all repos — fixyoutube-go @ a463db2dc3d1b2e9bbe391a4b2db4a173d21b347

A better way to embed YouTube videos everywhere (inspired by FixTweet).

add format choice
Marco Andronaco andronacomarco@gmail.com
Mon, 01 Jan 2024 21:55:25 +0100
commit

a463db2dc3d1b2e9bbe391a4b2db4a173d21b347

parent

7596dca40684a2cfd7176f6ec34e3360d020900a

5 files changed, 146 insertions(+), 70 deletions(-)

jump to
M fixyoutube.gofixyoutube.go

@@ -9,6 +9,7 @@ "net/url"

"os" "regexp" "slices" + "strconv" "time" "github.com/BiRabittoh/fixyoutube-go/invidious"

@@ -19,7 +20,7 @@

var templatesDirectory = "templates/" var indexTemplate = template.Must(template.ParseFiles(templatesDirectory + "index.html")) var videoTemplate = template.Must(template.ParseFiles(templatesDirectory + "video.html")) -var blacklist = []string{"favicon.ico", "robots.txt"} +var blacklist = []string{"favicon.ico", "robots.txt", "proxy"} var userAgentRegex = regexp.MustCompile(`(?i)bot|facebook|embed|got|firefox\/92|firefox\/38|curl|wget|go-http|yahoo|generator|whatsapp|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|cfnetwork|node`) var apiKey string

@@ -56,7 +57,7 @@ invidious.ClearDB()

http.Error(w, "Done.", http.StatusOK) } -func videoHandler(videoId string, invidiousClient *invidious.Client, w http.ResponseWriter, r *http.Request) { +func videoHandler(videoId string, formatIndex int, invidiousClient *invidious.Client, w http.ResponseWriter, r *http.Request) { userAgent := r.UserAgent() res := userAgentRegex.MatchString(userAgent) if !res {

@@ -72,6 +73,8 @@ http.Error(w, err.Error(), http.StatusInternalServerError)

return } + video.FormatIndex = formatIndex % len(video.Formats) + buf := &bytes.Buffer{} err = videoTemplate.Execute(buf, video) if err != nil {

@@ -90,28 +93,40 @@ return

} videoId := u.Query().Get("v") - videoHandler(videoId, invidiousClient, w, r) + videoHandler(videoId, 0, invidiousClient, w, r) } +} + +func parseFormatIndex(vars map[string]string) int { + formatIndex, err := strconv.Atoi(vars["formatIndex"]) + if err != nil { + log.Println("Error: could not parse formatIndex.") + return 0 + } + return formatIndex } func shortHandler(invidiousClient *invidious.Client) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - videoId := mux.Vars(r)["videoId"] + vars := mux.Vars(r) + videoId := vars["videoId"] + formatIndex := parseFormatIndex(vars) if slices.Contains(blacklist, videoId) { http.Error(w, "Not a valid ID.", http.StatusBadRequest) return } - videoHandler(videoId, invidiousClient, w, r) + videoHandler(videoId, formatIndex, invidiousClient, w, r) } } func proxyHandler(invidiousClient *invidious.Client) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - videoId := mux.Vars(r)["videoId"] - - invidiousClient.ProxyVideo(videoId, w) + vars := mux.Vars(r) + videoId := vars["videoId"] + formatIndex := parseFormatIndex(vars) + invidiousClient.ProxyVideo(w, videoId, formatIndex) } }

@@ -138,8 +153,10 @@ r := mux.NewRouter()

r.HandleFunc("/", indexHandler) r.HandleFunc("/clear", clearHandler) r.HandleFunc("/watch", watchHandler(videoapi)) - r.HandleFunc("/{videoId}", shortHandler(videoapi)) r.HandleFunc("/proxy/{videoId}", proxyHandler(videoapi)) + r.HandleFunc("/proxy/{videoId}/{formatIndex}", proxyHandler(videoapi)) + r.HandleFunc("/{videoId}", shortHandler(videoapi)) + r.HandleFunc("/{videoId}/{formatIndex}", shortHandler(videoapi)) /* // native go implementation (useless until february 2024) r := http.NewServeMux()
M invidious/cache.goinvidious/cache.go

@@ -3,26 +3,12 @@

import ( "database/sql" "log" + "time" _ "github.com/mattn/go-sqlite3" ) -var dbConnectionString = "file:cache.sqlite?cache=shared&mode=" -var createQuery = ` -CREATE TABLE IF NOT EXISTS videos ( - videoId TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - uploader TEXT NOT NULL, - duration int NOT NULL, - height TEXT NOT NULL, - width TEXT NOT NULL, - url TEXT, - timestamp DATETIME DEFAULT (datetime('now')) -);` -var getVideoQuery = "SELECT * FROM videos WHERE videoId = (?);" -var cacheVideoQuery = "INSERT OR REPLACE INTO videos (videoId, title, description, uploader, duration, height, width, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" -var clearQuery = "DELETE FROM videos;" +const dbConnectionString = "file:cache.sqlite?cache=shared&mode=" func getDb(mode string) *sql.DB { db, err := sql.Open("sqlite3", dbConnectionString+mode)

@@ -38,9 +24,14 @@ func InitDB() {

db := getDb("rwc") defer db.Close() - _, err := db.Exec(createQuery) + _, err := db.Exec(createQueryVideos) if err != nil { - log.Printf("%q: %s\n", err, createQuery) + log.Printf("%q: %s\n", err, createQueryVideos) + return + } + _, err = db.Exec(createQueryFormats) + if err != nil { + log.Printf("%q: %s\n", err, createQueryFormats) return } }

@@ -49,38 +40,79 @@ func CacheVideoDB(v Video) {

db := getDb("rw") defer db.Close() - stmt, err := db.Prepare(cacheVideoQuery) + cacheVideo, err := db.Prepare(cacheVideoQuery) if err != nil { log.Println("Could not cache video:", err) return } - defer stmt.Close() + defer cacheVideo.Close() - _, err = stmt.Exec(v.VideoId, v.Title, v.Description, v.Uploader, v.Duration, v.Height, v.Width, v.Url) + _, err = cacheVideo.Exec(v.VideoId, v.Title, v.Description, v.Uploader, v.Duration) if err != nil { log.Println("Could not cache video:", err) return } + + for _, f := range v.Formats { + cacheFormat, err := db.Prepare(cacheFormatQuery) + if err != nil { + log.Println("Could not cache format:", err) + return + } + defer cacheVideo.Close() + + _, err = cacheFormat.Exec(v.VideoId, f.Name, f.Height, f.Width, f.Url) + if err != nil { + log.Println("Could not cache format:", err) + return + } + } } func GetVideoDB(videoId string) (*Video, error) { db := getDb("ro") defer db.Close() - stmt, err := db.Prepare(getVideoQuery) + getVideo, err := db.Prepare(getVideoQuery) if err != nil { log.Println("Could not get video:", err) return nil, err } - defer stmt.Close() + defer getVideo.Close() - t := &Video{} - err = stmt.QueryRow(videoId).Scan(&t.VideoId, &t.Title, &t.Description, &t.Uploader, &t.Duration, &t.Height, &t.Width, &t.Url, &t.Timestamp) + v := &Video{} + sixHoursAgo := time.Now().Add(-6 * time.Hour) + err = getVideo.QueryRow(videoId, sixHoursAgo).Scan(&v.VideoId, &v.Title, &v.Description, &v.Uploader, &v.Duration, &sql.NullString{}) if err != nil { log.Println("Could not get video:", err) return nil, err } - return t, nil + + getFormat, err := db.Prepare(getFormatQuery) + if err != nil { + log.Println("Could not get video:", err) + return nil, err + } + defer getFormat.Close() + + response, err := getFormat.Query(videoId) + if err != nil { + log.Println("Could not get formats:", err) + return nil, err + } + defer response.Close() + + for response.Next() { + f := Format{} + err := response.Scan(&f.VideoId, &f.Name, &f.Height, &f.Width, &f.Url) + if err != nil { + log.Println("Could not get formats:", err) + return nil, err + } + v.Formats = append(v.Formats, f) + } + + return v, nil } func ClearDB() {
M invidious/invidious.goinvidious/invidious.go

@@ -13,10 +13,11 @@ "strings"

"time" ) -var instancesEndpoint = "https://api.invidious.io/instances.json?sort_by=api,type" -var videosEndpoint = "https://%s/api/v1/videos/%s?fields=videoId,title,description,author,lengthSeconds,size,formatStreams" +const maxSizeMB = 50 +const instancesEndpoint = "https://api.invidious.io/instances.json?sort_by=api,type" +const videosEndpoint = "https://%s/api/v1/videos/%s?fields=videoId,title,description,author,lengthSeconds,size,formatStreams" + var timeToLive, _ = time.ParseDuration("6h") -var maxSizeMB = 50 type Client struct { http *http.Client

@@ -24,22 +25,24 @@ Instance string

} type Format struct { + VideoId string + Name string `json:"qualityLabel"` + Height int + Width int Url string `json:"url"` Container string `json:"container"` Size string `json:"size"` } type Video struct { - VideoId string `json:"videoId"` - Title string `json:"title"` - Description string `json:"description"` - Uploader string `json:"author"` - Duration int `json:"lengthSeconds"` - FormatStreams []Format `json:"formatStreams"` - Url string - Height int - Width int - Timestamp time.Time + VideoId string `json:"videoId"` + Title string `json:"title"` + Description string `json:"description"` + Uploader string `json:"author"` + Duration int `json:"lengthSeconds"` + Formats []Format `json:"formatStreams"` + Timestamp time.Time + FormatIndex int } func filter[T any](ss []T, test func(T) bool) (ret []T) {

@@ -90,13 +93,13 @@ return nil, err

} mp4Test := func(f Format) bool { return f.Container == "mp4" } - mp4Formats := filter(res.FormatStreams, mp4Test) - myFormat := mp4Formats[len(mp4Formats)-1] - mySize := strings.Split(myFormat.Size, "x") + res.Formats = filter(res.Formats, mp4Test) - res.Url = myFormat.Url - res.Width = parseOrZero(mySize[0]) - res.Height = parseOrZero(mySize[1]) + for _, f := range res.Formats { + s := strings.Split(f.Size, "x") + f.Width = parseOrZero(s[0]) + f.Height = parseOrZero(s[1]) + } return res, err }

@@ -106,12 +109,8 @@ log.Println("Video", videoId, "was requested.")

video, err := GetVideoDB(videoId) if err == nil { - now := time.Now() - delta := now.Sub(video.Timestamp) - if delta < timeToLive { - log.Println("Found a valid cache entry from", delta, "ago.") - return video, nil - } + log.Println("Found a valid cache entry.") + return video, nil } video, err = c.fetchVideo(videoId)

@@ -157,14 +156,16 @@ log.Println("Using new instance:", c.Instance)

return nil } -func (c *Client) ProxyVideo(videoId string, w http.ResponseWriter) error { +func (c *Client) ProxyVideo(w http.ResponseWriter, videoId string, formatIndex int) error { video, err := GetVideoDB(videoId) if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) return err } - req, err := http.NewRequest(http.MethodGet, video.Url, nil) + idx := formatIndex % len(video.Formats) + url := video.Formats[len(video.Formats)-1-idx].Url + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { log.Fatal(err) new_video, err := c.fetchVideo(videoId)

@@ -172,7 +173,7 @@ if err != nil {

log.Fatal("Url for", videoId, "expired:", err) return err } - return c.ProxyVideo(new_video.VideoId, w) + return c.ProxyVideo(w, new_video.VideoId, formatIndex) } req.Header.Add("Range", fmt.Sprintf("bytes=0-%d000000", maxSizeMB))

@@ -185,7 +186,7 @@ }

defer resp.Body.Close() w.Header().Set("content-type", "video/mp4") - w.Header().Set("Status", "206") // Partial Content + w.Header().Set("Status", "200") i, err := io.Copy(w, resp.Body) fmt.Println(i, err)
A invidious/queries.go

@@ -0,0 +1,30 @@

+package invidious + +const createQueryVideos = ` +CREATE TABLE IF NOT EXISTS videos ( + videoId TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + uploader TEXT NOT NULL, + duration int NOT NULL, + timestamp_column DATETIME DEFAULT (datetime('now')) +);` + +const createQueryFormats = ` +CREATE TABLE IF NOT EXISTS formats ( + videoId TEXT, + name TEXT, + height TEXT NOT NULL, + width TEXT NOT NULL, + url TEXT, + PRIMARY KEY (videoId, name), + FOREIGN KEY(videoId) REFERENCES videos(videoId) +);` + +const getVideoQuery = "SELECT * FROM videos WHERE videoId = (?) AND timestamp_column > ?;" +const getFormatQuery = "SELECT * FROM formats WHERE videoId = (?)" + +const cacheVideoQuery = "INSERT OR REPLACE INTO videos (videoId, title, description, uploader, duration) VALUES (?, ?, ?, ?, ?);" +const cacheFormatQuery = "INSERT OR REPLACE INTO formats (videoId, name, height, width, url) VALUES (?, ?, ?, ?, ?);" + +const clearQuery = "DELETE FROM videos;"
M templates/video.htmltemplates/video.html

@@ -21,12 +21,8 @@ <meta property="og:description" content="{{ .Description }}" />

<meta property="og:site_name" content="FixYouTube ({{ .Uploader }})" /> <meta property="twitter:image" content="0" /> <meta property="twitter:player:stream:content_type" content="video/mp4" /> - <meta property="twitter:player:height" content="{{ .Height }}" /> - <meta property="twitter:player:width" content="{{ .Width }}" /> - <meta property="og:video" content="/proxy/{{ .VideoId }}" /> - <meta property="og:video:secure_url" content="/proxy/{{ .VideoId }}" /> - <meta property="og:video:height" content="{{ .Height }}" /> - <meta property="og:video:width" content="{{ .Width }}" /> + <meta property="og:video" content="/proxy/{{ .VideoId }}/{{ .FormatIndex }}" /> + <meta property="og:video:secure_url" content="/proxy/{{ .VideoId }}/{{ .FormatIndex }}" /> <meta property="og:video:duration" content="{{ .Duration }}"> <meta property="og:video:type" content="video/mp4" /> </head><body></body></html>