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}