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}