all repos — fixyoutube-go @ 2e2bbcb2dcdeba398ad0be06a90f662ed3a12796

A better way to embed YouTube videos everywhere (inspired by FixTweet).

invidious/invidious.go (view raw)

  1package invidious
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"net/url"
  9	"regexp"
 10	"strconv"
 11	"time"
 12
 13	"github.com/sirupsen/logrus"
 14)
 15
 16const timeoutDuration = 10 * time.Minute
 17const maxSizeBytes = 20000000 // 20 MB
 18const instancesEndpoint = "https://api.invidious.io/instances.json?sort_by=api,type"
 19const videosEndpoint = "https://%s/api/v1/videos/%s?fields=videoId,title,description,author,lengthSeconds,size,formatStreams"
 20
 21var expireRegex = regexp.MustCompile(`(?i)expire=(\d+)`)
 22var logger = logrus.New()
 23
 24type Timeout struct {
 25	Instance  string
 26	Timestamp time.Time
 27}
 28
 29type Client struct {
 30	http     *http.Client
 31	timeouts []Timeout
 32	Instance string
 33}
 34
 35type Format struct {
 36	VideoId   string
 37	Name      string `json:"qualityLabel"`
 38	Height    int
 39	Width     int
 40	Url       string `json:"url"`
 41	Container string `json:"container"`
 42	Size      string `json:"size"`
 43}
 44
 45type Video struct {
 46	VideoId     string   `json:"videoId"`
 47	Title       string   `json:"title"`
 48	Description string   `json:"description"`
 49	Uploader    string   `json:"author"`
 50	Duration    int      `json:"lengthSeconds"`
 51	Formats     []Format `json:"formatStreams"`
 52	Timestamp   time.Time
 53	Expire      time.Time
 54	FormatIndex int
 55}
 56
 57func filter[T any](ss []T, test func(T) bool) (ret []T) {
 58	for _, s := range ss {
 59		if test(s) {
 60			ret = append(ret, s)
 61		}
 62	}
 63	return
 64}
 65
 66func parseOrZero(number string) int {
 67	res, err := strconv.Atoi(number)
 68	if err != nil {
 69		return 0
 70	}
 71	return res
 72}
 73
 74type HTTPError struct {
 75	StatusCode int
 76}
 77
 78func (e HTTPError) Error() string {
 79	return fmt.Sprintf("HTTP error: %d", e.StatusCode)
 80}
 81
 82func (c *Client) fetchVideo(videoId string) (*Video, error) {
 83	if c.Instance == "" {
 84		err := c.NewInstance()
 85		if err != nil {
 86			logger.Fatal(err, "Could not get a new instance.")
 87		}
 88	}
 89	endpoint := fmt.Sprintf(videosEndpoint, c.Instance, url.QueryEscape(videoId))
 90	resp, err := c.http.Get(endpoint)
 91	if err != nil {
 92		return nil, err
 93	}
 94	defer resp.Body.Close()
 95
 96	body, err := io.ReadAll(resp.Body)
 97	if err != nil {
 98		return nil, err
 99	}
100
101	if resp.StatusCode != http.StatusOK {
102		return nil, HTTPError{resp.StatusCode}
103	}
104
105	res := &Video{}
106	err = json.Unmarshal(body, res)
107	if err != nil {
108		return nil, err
109	}
110
111	mp4Test := func(f Format) bool { return f.Container == "mp4" }
112	res.Formats = filter(res.Formats, mp4Test)
113
114	expireString := expireRegex.FindStringSubmatch(res.Formats[0].Url)
115	expireTimestamp, err := strconv.ParseInt(expireString[1], 10, 64)
116	if err != nil {
117		fmt.Println("Error:", err)
118		return nil, err
119	}
120	res.Expire = time.Unix(expireTimestamp, 0)
121	return res, nil
122}
123
124func (c *Client) GetVideo(videoId string, fromCache bool) (*Video, error) {
125	logger.Info("Video https://youtu.be/", videoId, " was requested.")
126
127	var video *Video
128	var err error
129
130	if fromCache {
131		video, err = GetVideoDB(videoId)
132		if err == nil {
133			logger.Info("Found a valid cache entry.")
134			return video, nil
135		}
136	}
137
138	video, err = c.fetchVideo(videoId)
139
140	if err != nil {
141		if httpErr, ok := err.(HTTPError); ok {
142			// handle HTTPError
143			s := httpErr.StatusCode
144			if s == http.StatusNotFound || s == http.StatusInternalServerError {
145				logger.Debug("Video does not exist.")
146				return nil, err
147			}
148			logger.Debug("Invidious HTTP error: ", httpErr.StatusCode)
149		}
150		// handle generic error
151		logger.Error(err)
152		err = c.NewInstance()
153		if err != nil {
154			logger.Error("Could not get a new instance: ", err)
155			time.Sleep(10 * time.Second)
156		}
157		return c.GetVideo(videoId, true)
158	}
159	logger.Info("Retrieved by API.")
160
161	err = CacheVideoDB(*video)
162	if err != nil {
163		logger.Warn("Could not cache video id: ", videoId)
164		logger.Warn(err)
165	}
166	return video, nil
167}
168
169func (c *Client) isNotTimedOut(instance string) bool {
170	for i := range c.timeouts {
171		cur := c.timeouts[i]
172		if instance == cur.Instance {
173			return false
174		}
175	}
176	return true
177}
178
179func (c *Client) NewInstance() error {
180	now := time.Now()
181
182	timeoutsTest := func(t Timeout) bool { return now.Sub(t.Timestamp) < timeoutDuration }
183	c.timeouts = filter(c.timeouts, timeoutsTest)
184
185	timeout := Timeout{c.Instance, now}
186	c.timeouts = append(c.timeouts, timeout)
187
188	resp, err := c.http.Get(instancesEndpoint)
189	if err != nil {
190		return err
191	}
192	defer resp.Body.Close()
193
194	body, err := io.ReadAll(resp.Body)
195	if err != nil {
196		return err
197	}
198
199	if resp.StatusCode != http.StatusOK {
200		return HTTPError{resp.StatusCode}
201	}
202
203	var jsonArray [][]interface{}
204	err = json.Unmarshal(body, &jsonArray)
205	if err != nil {
206		logger.Error("Could not unmarshal JSON response for instances.")
207		return err
208	}
209
210	for i := range jsonArray {
211		instance := jsonArray[i][0].(string)
212		instanceTest := func(t Timeout) bool { return t.Instance == instance }
213		result := filter(c.timeouts, instanceTest)
214		if len(result) == 0 {
215			c.Instance = instance
216			logger.Info("Using new instance: ", c.Instance)
217			return nil
218		}
219	}
220	logger.Error("Cannot find a valid instance.")
221	return err
222}
223
224func (c *Client) ProxyVideo(w http.ResponseWriter, r *http.Request, videoId string, formatIndex int) int {
225	video, err := GetVideoDB(videoId)
226	if err != nil {
227		logger.Warn("Cannot proxy a video that is not cached: https://youtu.be/", videoId)
228		return http.StatusBadRequest
229	}
230
231	fmtAmount := len(video.Formats)
232	idx := formatIndex % fmtAmount
233	url := video.Formats[fmtAmount-1-idx].Url
234	req, err := http.NewRequest(http.MethodGet, url, nil)
235	if err != nil {
236		logger.Error(err)
237		new_video, err := c.GetVideo(videoId, false)
238		if err != nil {
239			logger.Error("Cannot get new data for video ", videoId, ":", err)
240			return http.StatusInternalServerError
241		}
242		return c.ProxyVideo(w, r, new_video.VideoId, formatIndex)
243	}
244
245	resp, err := c.http.Do(req) // send video request
246	if err != nil {
247		logger.Error(err)
248		return http.StatusInternalServerError
249	}
250
251	if resp.ContentLength > maxSizeBytes {
252		newIndex := formatIndex + 1
253		if newIndex < fmtAmount {
254			logger.Debug("Format ", newIndex, ": Content-Length exceeds max size. Trying another format.")
255			return c.ProxyVideo(w, r, videoId, newIndex)
256		}
257		logger.Error("Could not find a suitable format.")
258		return http.StatusBadRequest
259	}
260	defer resp.Body.Close()
261
262	w.Header().Set("Content-Type", "video/mp4")
263	w.Header().Set("Status", "200")
264	_, err = io.Copy(w, resp.Body)
265	return http.StatusOK
266}
267
268func NewClient(httpClient *http.Client) *Client {
269	InitDB()
270	return &Client{
271		http:     httpClient,
272		timeouts: []Timeout{},
273		Instance: "",
274	}
275}