all repos — fixyoutube-go @ 913b5e94318ac196a2c6111e459ffe2ba801cd36

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

invidious/invidious.go (view raw)

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