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 maxSizeMB = 50
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 Client struct {
25 http *http.Client
26 Instance string
27}
28
29type Format struct {
30 VideoId string
31 Name string `json:"qualityLabel"`
32 Height int
33 Width int
34 Url string `json:"url"`
35 Container string `json:"container"`
36 Size string `json:"size"`
37}
38
39type Video struct {
40 VideoId string `json:"videoId"`
41 Title string `json:"title"`
42 Description string `json:"description"`
43 Uploader string `json:"author"`
44 Duration int `json:"lengthSeconds"`
45 Formats []Format `json:"formatStreams"`
46 Timestamp time.Time
47 Expire time.Time
48 FormatIndex int
49}
50
51func filter[T any](ss []T, test func(T) bool) (ret []T) {
52 for _, s := range ss {
53 if test(s) {
54 ret = append(ret, s)
55 }
56 }
57 return
58}
59
60func parseOrZero(number string) int {
61 res, err := strconv.Atoi(number)
62 if err != nil {
63 return 0
64 }
65 return res
66}
67
68func (c *Client) fetchVideo(videoId string) (*Video, error) {
69 if c.Instance == "" {
70 err := c.NewInstance()
71 if err != nil {
72 logger.Fatal(err, "Could not get a new instance.")
73 }
74 }
75 endpoint := fmt.Sprintf(videosEndpoint, c.Instance, url.QueryEscape(videoId))
76 resp, err := c.http.Get(endpoint)
77 if err != nil {
78 return nil, err
79 }
80 defer resp.Body.Close()
81
82 body, err := io.ReadAll(resp.Body)
83 if err != nil {
84 return nil, err
85 }
86
87 if resp.StatusCode != http.StatusOK {
88 return nil, fmt.Errorf(string(body))
89 }
90
91 res := &Video{}
92 err = json.Unmarshal(body, res)
93 if err != nil {
94 return nil, err
95 }
96
97 mp4Test := func(f Format) bool { return f.Container == "mp4" }
98 res.Formats = filter(res.Formats, mp4Test)
99
100 expireString := expireRegex.FindStringSubmatch(res.Formats[0].Url)
101 expireTimestamp, err := strconv.ParseInt(expireString[1], 10, 64)
102 if err != nil {
103 fmt.Println("Error:", err)
104 return nil, err
105 }
106 res.Expire = time.Unix(expireTimestamp, 0)
107
108 return res, err
109}
110
111func (c *Client) GetVideo(videoId string) (*Video, error) {
112 logger.Info("Video https://youtu.be/", videoId, " was requested.")
113
114 video, err := GetVideoDB(videoId)
115 if err == nil {
116 logger.Info("Found a valid cache entry.")
117 return video, nil
118 }
119
120 video, err = c.fetchVideo(videoId)
121
122 if err != nil {
123 if err.Error() == "{}" {
124 return nil, err
125 }
126 logger.Error(err)
127 err = c.NewInstance()
128 if err != nil {
129 logger.Error("Could not get a new instance: ", err)
130 time.Sleep(10 * time.Second)
131 }
132 return c.GetVideo(videoId)
133 }
134 logger.Info("Retrieved by API.")
135
136 CacheVideoDB(*video)
137 return video, nil
138}
139
140func (c *Client) NewInstance() error {
141 resp, err := c.http.Get(instancesEndpoint)
142 if err != nil {
143 return err
144 }
145 defer resp.Body.Close()
146
147 body, err := io.ReadAll(resp.Body)
148 if err != nil {
149 return err
150 }
151
152 if resp.StatusCode != http.StatusOK {
153 return fmt.Errorf(string(body))
154 }
155
156 var jsonArray [][]interface{}
157 err = json.Unmarshal(body, &jsonArray)
158 if err != nil {
159 return err
160 }
161
162 c.Instance = jsonArray[0][0].(string)
163 logger.Info("Using new instance:", c.Instance)
164 return nil
165}
166
167func (c *Client) ProxyVideo(w http.ResponseWriter, videoId string, formatIndex int) error {
168 video, err := GetVideoDB(videoId)
169 if err != nil {
170 logger.Debug("Cannot proxy a video that is not cached: https://youtu.be/", videoId)
171 http.Error(w, "Bad Request", http.StatusBadRequest)
172 return err
173 }
174
175 fmtAmount := len(video.Formats)
176 idx := formatIndex % fmtAmount
177 url := video.Formats[fmtAmount-1-idx].Url
178 req, err := http.NewRequest(http.MethodGet, url, nil)
179 if err != nil {
180 logger.Error(err)
181 new_video, err := c.fetchVideo(videoId)
182 if err != nil {
183 logger.Error("Url for", videoId, "expired:", err)
184 return err
185 }
186 return c.ProxyVideo(w, new_video.VideoId, formatIndex)
187 }
188
189 req.Header.Add("Range", fmt.Sprintf("bytes=0-%d000000", maxSizeMB))
190 resp, err := c.http.Do(req)
191 if err != nil {
192 logger.Error(err)
193 http.Error(w, err.Error(), http.StatusInternalServerError)
194 return err
195 }
196 defer resp.Body.Close()
197
198 w.Header().Set("content-type", "video/mp4")
199 w.Header().Set("Status", "200")
200
201 temp := bytes.NewBuffer(nil)
202 _, err = io.Copy(temp, resp.Body)
203 if err == nil { // done
204 _, err = io.Copy(w, temp)
205 return err
206 }
207
208 newIndex := formatIndex + 1
209 if newIndex < fmtAmount {
210 return c.ProxyVideo(w, videoId, newIndex)
211 }
212 _, err = io.Copy(w, temp)
213 return err
214}
215
216func NewClient(httpClient *http.Client) *Client {
217 InitDB()
218 return &Client{httpClient, ""}
219}