all repos — fixyoutube-go @ f4aee526df2825f57da964fff12ea902c5979782

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 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}