all repos — fixyoutube-go @ 4bab611266395d2ba4e664ebf8fd2bec3467c99b

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 maxSizeBytes = 20000000 // 20 MB
 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
 75type HTTPError struct {
 76	StatusCode int
 77}
 78
 79func (e HTTPError) Error() string {
 80	return fmt.Sprintf("HTTP error: %d", e.StatusCode)
 81}
 82
 83func (c *Client) fetchVideo(videoId string) (*Video, error) {
 84	if c.Instance == "" {
 85		err := c.NewInstance()
 86		if err != nil {
 87			logger.Fatal(err, "Could not get a new instance.")
 88		}
 89	}
 90	endpoint := fmt.Sprintf(videosEndpoint, c.Instance, url.QueryEscape(videoId))
 91	resp, err := c.http.Get(endpoint)
 92	if err != nil {
 93		return nil, err
 94	}
 95	defer resp.Body.Close()
 96
 97	body, err := io.ReadAll(resp.Body)
 98	if err != nil {
 99		return nil, err
100	}
101
102	if resp.StatusCode != http.StatusOK {
103		return nil, HTTPError{resp.StatusCode}
104	}
105
106	res := &Video{}
107	err = json.Unmarshal(body, res)
108	if err != nil {
109		return nil, err
110	}
111
112	mp4Test := func(f Format) bool { return f.Container == "mp4" }
113	res.Formats = filter(res.Formats, mp4Test)
114
115	expireString := expireRegex.FindStringSubmatch(res.Formats[0].Url)
116	expireTimestamp, err := strconv.ParseInt(expireString[1], 10, 64)
117	if err != nil {
118		fmt.Println("Error:", err)
119		return nil, err
120	}
121	res.Expire = time.Unix(expireTimestamp, 0)
122	return res, nil
123}
124
125func (c *Client) GetVideo(videoId string, fromCache bool) (*Video, error) {
126	logger.Info("Video https://youtu.be/", videoId, " was requested.")
127
128	var video *Video
129	var err error
130
131	if fromCache {
132		video, err = GetVideoDB(videoId)
133		if err == nil {
134			logger.Info("Found a valid cache entry.")
135			return video, nil
136		}
137	}
138
139	video, err = c.fetchVideo(videoId)
140
141	if err != nil {
142		if httpErr, ok := err.(HTTPError); ok {
143			// handle HTTPError
144			s := httpErr.StatusCode
145			if s == http.StatusNotFound || s == http.StatusInternalServerError {
146				logger.Debug("Video does not exist.")
147				return nil, err
148			}
149			logger.Debug("Invidious HTTP error: ", httpErr.StatusCode)
150		}
151		// handle generic error
152		logger.Error(err)
153		err = c.NewInstance()
154		if err != nil {
155			logger.Error("Could not get a new instance: ", err)
156			time.Sleep(10 * time.Second)
157		}
158		return c.GetVideo(videoId, true)
159	}
160	logger.Info("Retrieved by API.")
161
162	err = CacheVideoDB(*video)
163	if err != nil {
164		logger.Warn("Could not cache video id: ", videoId)
165		logger.Warn(err)
166	}
167	return video, nil
168}
169
170func (c *Client) isNotTimedOut(instance string) bool {
171	for i := range c.timeouts {
172		cur := c.timeouts[i]
173		if instance == cur.Instance {
174			return false
175		}
176	}
177	return true
178}
179
180func (c *Client) NewInstance() error {
181	now := time.Now()
182
183	timeoutsTest := func(t Timeout) bool { return now.Sub(t.Timestamp) < timeoutDuration }
184	c.timeouts = filter(c.timeouts, timeoutsTest)
185
186	timeout := Timeout{c.Instance, now}
187	c.timeouts = append(c.timeouts, timeout)
188
189	resp, err := c.http.Get(instancesEndpoint)
190	if err != nil {
191		return err
192	}
193	defer resp.Body.Close()
194
195	body, err := io.ReadAll(resp.Body)
196	if err != nil {
197		return err
198	}
199
200	if resp.StatusCode != http.StatusOK {
201		return HTTPError{resp.StatusCode}
202	}
203
204	var jsonArray [][]interface{}
205	err = json.Unmarshal(body, &jsonArray)
206	if err != nil {
207		logger.Error("Could not unmarshal JSON response for instances.")
208		return err
209	}
210
211	for i := range jsonArray {
212		instance := jsonArray[i][0].(string)
213		instanceTest := func(t Timeout) bool { return t.Instance == instance }
214		result := filter(c.timeouts, instanceTest)
215		if len(result) == 0 {
216			c.Instance = instance
217			logger.Info("Using new instance: ", c.Instance)
218			return nil
219		}
220	}
221	logger.Error("Cannot find a valid instance.")
222	return err
223}
224
225func (c *Client) ProxyVideo(url string, formatIndex int) (*bytes.Buffer, int64, int) {
226
227	req, err := http.NewRequest(http.MethodGet, url, nil)
228	if err != nil {
229		logger.Error(err) // bad request
230		return nil, 0, http.StatusInternalServerError
231	}
232
233	resp, err := c.http.Do(req)
234	if err != nil {
235		logger.Error(err) // request failed
236		return nil, 0, http.StatusGone
237	}
238
239	if resp.ContentLength > maxSizeBytes {
240		logger.Debug("Format ", formatIndex, ": Content-Length exceeds max size.")
241		return nil, 0, http.StatusBadRequest
242	}
243	defer resp.Body.Close()
244
245	b := new(bytes.Buffer)
246	l, err := io.Copy(b, resp.Body)
247	if l != resp.ContentLength {
248		return nil, 0, http.StatusBadRequest
249	}
250
251	return b, l, http.StatusOK
252}
253
254func NewClient(httpClient *http.Client) *Client {
255	InitDB()
256	return &Client{
257		http:     httpClient,
258		timeouts: []Timeout{},
259		Instance: "",
260	}
261}