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}