all repos — fixyoutube-go @ a2037dceb0c6ef1588c64eea205c9f909399c8e1

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

switch to rabbitpipe
Marco Andronaco andronacomarco@gmail.com
Sat, 23 Nov 2024 23:37:59 +0100
commit

a2037dceb0c6ef1588c64eea205c9f909399c8e1

parent

3c079d33ea2b15dcb629056d376effa72a8377cc

10 files changed, 97 insertions(+), 455 deletions(-)

jump to
M fixyoutube.gofixyoutube.go

@@ -4,9 +4,7 @@ import (

"net/http" "os" "strconv" - "time" - "github.com/BiRabittoh/fixyoutube-go/invidious" "github.com/joho/godotenv" "github.com/sirupsen/logrus" "golang.org/x/time/rate"

@@ -62,26 +60,13 @@ apiKey = getenvDefault("API_KEY", "itsme")

port := getenvDefault("PORT", "3000") burstTokens := getenvDefaultParse("BURST_TOKENS", "3") rateLimit := getenvDefaultParse("RATE_LIMIT", "1") - cacheDuration := getenvDefaultParse("CACHE_DURATION_MINUTES", "5") - timeoutDuration := getenvDefaultParse("TIMEOUT_DURATION_MINUTES", "10") - cleanupInterval := getenvDefaultParse("CLEANUP_INTERVAL_SECONDS", "30") - maxSizeMB := getenvDefaultParse("MAX_SIZE_MB", "20") - - myClient := &http.Client{Timeout: 10 * time.Second} - options := invidious.ClientOptions{ - CacheDuration: time.Duration(cacheDuration) * time.Minute, - TimeoutDuration: time.Duration(timeoutDuration) * time.Minute, - CleanupInterval: time.Duration(cleanupInterval) * time.Second, - MaxSizeBytes: int64(maxSizeMB * 1000000), - } - videoapi := invidious.NewClient(myClient, options) r := http.NewServeMux() r.HandleFunc("/", indexHandler) r.HandleFunc("/clear", clearHandler) - r.HandleFunc("/watch", watchHandler(videoapi)) - r.HandleFunc("/proxy/{videoId}", proxyHandler(videoapi)) - r.HandleFunc("/{videoId}", shortHandler(videoapi)) + r.HandleFunc("/watch", watchHandler) + r.HandleFunc("/proxy/{videoId}", proxyHandler) + r.HandleFunc("/{videoId}", shortHandler) var serveMux http.Handler if debugSwitch {
M go.modgo.mod

@@ -1,12 +1,17 @@

-module github.com/BiRabittoh/fixyoutube-go +module github.com/birabittoh/fixyoutube-go + +go 1.23.2 -go 1.22 +toolchain go1.23.3 require github.com/joho/godotenv v1.5.1 -require github.com/mattn/go-sqlite3 v1.14.22 +require ( + github.com/birabittoh/rabbitpipe v0.0.2 + golang.org/x/time v0.5.0 +) -require golang.org/x/time v0.5.0 +require github.com/birabittoh/myks v0.0.2 require ( github.com/sirupsen/logrus v1.9.3
M go.sumgo.sum

@@ -1,10 +1,12 @@

+github.com/birabittoh/myks v0.0.2 h1:EBukMUsAflwiqdNo4LE7o2WQdEvawty5ewCZWY+IXSU= +github.com/birabittoh/myks v0.0.2/go.mod h1:klNWaeUWm7TmhnBHBMt9vALwCHW11/Xw1BpCNkCx7hs= +github.com/birabittoh/rabbitpipe v0.0.2 h1:4ptBS4Ai9NJH9gv3uG5TZBp1H5gfgEabEw6XldSjUx0= +github.com/birabittoh/rabbitpipe v0.0.2/go.mod h1:6cEDb0WpwrRm2vt5IO3s2gPjzhZZLP7gYx+l9e3gx1k= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
M handlers.gohandlers.go

@@ -10,7 +10,7 @@ "regexp"

"strconv" "text/template" - "github.com/BiRabittoh/fixyoutube-go/invidious" + "github.com/birabittoh/fixyoutube-go/invidious" ) const templatesDirectory = "templates/"

@@ -56,37 +56,28 @@ http.Error(w, "Wrong or missing API key.", http.StatusForbidden)

return } - invidious.ClearDB() + // rabbitpipe.ClearDB() logger.Info("Cache cleared.") http.Error(w, "Done.", http.StatusOK) } -func videoHandler(videoId string, invidiousClient *invidious.Client, w http.ResponseWriter, r *http.Request) { - url := "https://www.youtube.com/watch?v=" + videoId - /* - userAgent := r.UserAgent() - res := userAgentRegex.MatchString(userAgent) - if !res { - logger.Debug("Regex did not match. Redirecting. UA:", userAgent) - http.Redirect(w, r, url, http.StatusFound) - return - } - */ +func videoHandler(videoID string, w http.ResponseWriter, r *http.Request) { + url := "https://www.youtube.com/watch?v=" + videoID - if !videoRegex.MatchString(videoId) { - logger.Info("Invalid video ID: ", videoId) + if !videoRegex.MatchString(videoID) { + logger.Info("Invalid video ID: ", videoID) http.Error(w, "Invalid video ID.", http.StatusBadRequest) return } - video, err := invidiousClient.GetVideo(videoId, true) - if err != nil { - logger.Info("Wrong video ID: ", videoId) + video, err := invidious.RP.GetVideo(videoID) + if err != nil || video == nil { + logger.Info("Wrong video ID: ", videoID) http.Error(w, "Wrong video ID.", http.StatusNotFound) return } - if video.Url == "" { + if invidious.GetVideoURL(*video) == "" { logger.Debug("No URL available. Redirecting.") http.Redirect(w, r, url, http.StatusFound) return

@@ -102,47 +93,43 @@ }

buf.WriteTo(w) } -func watchHandler(invidiousClient *invidious.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - u, err := url.Parse(r.URL.String()) - if err != nil { - logger.Error("Failed to parse URL: ", r.URL.String()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - q := u.Query() - videoId := q.Get("v") - videoHandler(videoId, invidiousClient, w, r) +func watchHandler(w http.ResponseWriter, r *http.Request) { + u, err := url.Parse(r.URL.String()) + if err != nil { + logger.Error("Failed to parse URL: ", r.URL.String()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } + + q := u.Query() + videoId := q.Get("v") + videoHandler(videoId, w, r) } -func shortHandler(invidiousClient *invidious.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - videoId := r.PathValue("videoId") - videoHandler(videoId, invidiousClient, w, r) - } +func shortHandler(w http.ResponseWriter, r *http.Request) { + videoId := r.PathValue("videoId") + videoHandler(videoId, w, r) + return } -func proxyHandler(invidiousClient *invidious.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - videoId := r.PathValue("videoId") +func proxyHandler(w http.ResponseWriter, r *http.Request) { + videoId := r.PathValue("videoId") - vb, s := invidiousClient.ProxyVideoId(videoId) - if s != http.StatusOK { - logger.Error("proxyHandler() failed. Final code: ", s) - http.Error(w, http.StatusText(s), s) - return - } - if !vb.ValidateLength() { - logger.Error("Buffer length is inconsistent.") - status := http.StatusInternalServerError - http.Error(w, http.StatusText(status), status) - return - } - h := w.Header() - h.Set("Status", "200") - h.Set("Content-Type", "video/mp4") - h.Set("Content-Length", strconv.FormatInt(vb.Length, 10)) - io.Copy(w, vb.Buffer) + vb, s := invidious.ProxyVideoId(videoId) + if s != http.StatusOK { + logger.Error("proxyHandler() failed. Final code: ", s) + http.Error(w, http.StatusText(s), s) + return } + if !vb.ValidateLength() { + logger.Error("Buffer length is inconsistent.") + status := http.StatusInternalServerError + http.Error(w, http.StatusText(status), status) + return + } + h := w.Header() + h.Set("Status", "200") + h.Set("Content-Type", "video/mp4") + h.Set("Content-Length", strconv.FormatInt(vb.Length, 10)) + io.Copy(w, vb.Buffer) }
D invidious/api.go

@@ -1,125 +0,0 @@

-package invidious - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "time" -) - -type Format struct { - Name string `json:"qualityLabel"` - Url string `json:"url"` - Container string `json:"container"` - Size string `json:"size"` - Itag string `json:"itag"` -} - -type VideoThumbnail struct { - Quality string `json:"quality"` - URL string `json:"url"` - Width int `json:"width"` - Height int `json:"height"` -} - -func (c *Client) fetchVideo(videoId string) (*Video, int) { - endpoint := fmt.Sprintf(videosEndpoint, c.Instance, url.QueryEscape(videoId)) - resp, err := c.http.Get(endpoint) - if err != nil { - logger.Error(err) - return nil, http.StatusInternalServerError - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, http.StatusNotFound - } - - if resp.StatusCode != http.StatusOK { - logger.Warn("Invidious gave the following status code: ", resp.StatusCode) - return nil, resp.StatusCode - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - logger.Error(err) - return nil, http.StatusInternalServerError - } - - res := &Video{} - err = json.Unmarshal(body, res) - if err != nil { - logger.Error(err) - return nil, http.StatusInternalServerError - } - - if len(res.VideoThumbnails) > 0 { - res.Thumbnail = res.VideoThumbnails[0].URL - } - - mp4Test := func(f Format) bool { return f.Itag == "18" } - res.Formats = filter(res.Formats, mp4Test) - - expireString := expireRegex.FindStringSubmatch(res.Formats[0].Url) - expireTimestamp, err := strconv.ParseInt(expireString[1], 10, 64) - if err != nil { - logger.Error(err) - return nil, http.StatusInternalServerError - } - res.Expire = time.Unix(expireTimestamp, 0) - - vb, i := c.findCompatibleFormat(res) - if i < 0 { - logger.Warn("No compatible formats found for video.") - res.Url = "" - } else { - videoBuffer := vb.Clone() - c.buffers.Set(videoId, videoBuffer) - res.Url = res.Formats[i].Url - } - - return res, http.StatusOK -} - -func (c *Client) NewInstance() error { - if c.Instance != "" { - err := fmt.Errorf("generic error") - c.timeouts.Set(c.Instance, &err) - } - - resp, err := c.http.Get(instancesEndpoint) - if err != nil { - return err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP error: %d", resp.StatusCode) - } - - var jsonArray [][]interface{} - err = json.Unmarshal(body, &jsonArray) - if err != nil { - logger.Error("Could not unmarshal JSON response for instances.") - return err - } - - for i := range jsonArray { - instance := jsonArray[i][0].(string) - if !c.timeouts.Has(instance) { - c.Instance = instance - logger.Info("Using new instance: ", c.Instance) - return nil - } - } - - return fmt.Errorf("cannot find a valid instance") -}
D invidious/cache.go

@@ -1,96 +0,0 @@

-package invidious - -import ( - "database/sql" - "fmt" - "time" - - _ "github.com/mattn/go-sqlite3" -) - -const dbConnectionString = "file:cache.sqlite?cache=shared&mode=" - -func getDb(mode string) *sql.DB { - db, err := sql.Open("sqlite3", dbConnectionString+mode) - if err != nil { - logger.Error("Could not open DB:", err) - return nil - } - db.SetMaxOpenConns(1) - return db -} - -func InitDB() { - db := getDb("rwc") - defer db.Close() - - _, err := db.Exec(createQueryVideos) - if err != nil { - logger.Errorf("%q: %s\n", err, createQueryVideos) - return - } -} - -func CacheVideoDB(v Video) error { - db := getDb("rw") - defer db.Close() - - cacheVideo, err := db.Prepare(cacheVideoQuery) - if err != nil { - logger.Error("Could not cache video: ", err) - return err - } - defer cacheVideo.Close() - - _, err = cacheVideo.Exec(v.VideoId, v.Title, v.Description, v.Uploader, v.Duration, v.Url, v.Thumbnail, v.Expire) - if err != nil { - logger.Error("Could not cache video: ", err) - return err - } - - return nil -} - -func GetVideoDB(videoId string) (*Video, error) { - db := getDb("ro") - defer db.Close() - - getVideo, err := db.Prepare(getVideoQuery) - if err != nil { - logger.Error("Could not get video: ", err) - return nil, err - } - defer getVideo.Close() - - v := &Video{} - err = getVideo.QueryRow(videoId).Scan(&v.VideoId, &v.Title, &v.Description, &v.Uploader, &v.Duration, &v.Url, &v.Thumbnail, &v.Expire) - if err != nil { - logger.Debug("Could not get video:", err) - return nil, err - } - - if time.Now().After(v.Expire) { - logger.Info("Video has expired.") - return nil, fmt.Errorf("expired") - } - - return v, nil -} - -func ClearDB() { - db := getDb("rw") - defer db.Close() - - stmt, err := db.Prepare(clearQuery) - if err != nil { - logger.Error("Could not clear DB:", err) - return - } - defer stmt.Close() - - _, err = stmt.Exec() - if err != nil { - logger.Error("Could not clear DB:", err) - return - } -}
M invidious/invidious.goinvidious/invidious.go

@@ -2,60 +2,29 @@ package invidious

import ( "bytes" - "net/http" - "regexp" "time" - "github.com/BiRabittoh/fixyoutube-go/volatile" + "github.com/birabittoh/myks" + "github.com/birabittoh/rabbitpipe" "github.com/sirupsen/logrus" ) -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 expireRegex = regexp.MustCompile(`(?i)expire=(\d+)`) var logger = logrus.New() - -type ClientOptions struct { - CacheDuration time.Duration - TimeoutDuration time.Duration - CleanupInterval time.Duration - MaxSizeBytes int64 -} +var buffers = myks.New[VideoBuffer](time.Minute) +var RP = rabbitpipe.New() type VideoBuffer struct { Buffer *bytes.Buffer Length int64 } -type Client struct { - http *http.Client - timeouts *volatile.Volatile[string, error] - buffers *volatile.Volatile[string, VideoBuffer] - Instance string - Options ClientOptions -} - -type Video struct { - VideoId string `json:"videoId"` - Title string `json:"title"` - Description string `json:"description"` - Uploader string `json:"author"` - VideoThumbnails []VideoThumbnail `json:"videoThumbnails"` - Duration int `json:"lengthSeconds"` - Formats []Format `json:"formatStreams"` - Expire time.Time - Url string - Thumbnail string -} - -func filter[T any](ss []T, test func(T) bool) (ret []T) { - for _, s := range ss { - if test(s) { - ret = append(ret, s) +func GetVideoURL(video rabbitpipe.Video) string { + for _, format := range video.FormatStreams { + if format.Itag == "18" { + return format.URL } } - return + return "" } func NewVideoBuffer(b *bytes.Buffer, l int64) *VideoBuffer {

@@ -75,59 +44,3 @@

func (vb *VideoBuffer) ValidateLength() bool { return vb.Length > 0 && vb.Length == int64(vb.Buffer.Len()) } - -func (c *Client) GetVideo(videoId string, fromCache bool) (*Video, error) { - logger.Info("Video https://youtu.be/", videoId, " was requested.") - - var video *Video - var err error - - if fromCache { - video, err = GetVideoDB(videoId) - if err == nil { - logger.Info("Found a valid cache entry.") - return video, nil - } - } - - video, httpErr := c.fetchVideo(videoId) - - switch httpErr { - case http.StatusOK: - logger.Info("Retrieved by API.") - case http.StatusNotFound: - logger.Debug("Video does not exist or can't be retrieved.") - return nil, err - default: - err = c.NewInstance() - if err != nil { - logger.Error("Could not get a new instance: ", err) - time.Sleep(10 * time.Second) - } - return c.GetVideo(videoId, true) - } - - err = CacheVideoDB(*video) - if err != nil { - logger.Warn("Could not cache video id: ", videoId) - logger.Warn(err) - } - return video, nil -} - -func NewClient(httpClient *http.Client, options ClientOptions) *Client { - InitDB() - timeouts := volatile.NewVolatile[string, error](options.TimeoutDuration, options.CleanupInterval) - buffers := volatile.NewVolatile[string, VideoBuffer](options.CacheDuration, options.CleanupInterval) - client := &Client{ - http: httpClient, - timeouts: timeouts, - buffers: buffers, - Options: options, - } - err := client.NewInstance() - if err != nil { - logger.Fatal(err) - } - return client -}
M invidious/proxy.goinvidious/proxy.go

@@ -4,9 +4,14 @@ import (

"bytes" "io" "net/http" + "time" + + "github.com/birabittoh/rabbitpipe" ) -func (c *Client) urlToBuffer(url string) (*VideoBuffer, int) { +const MaxSizeBytes = int64(20 * 1024 * 1024) + +func urlToBuffer(url string) (*VideoBuffer, int) { if url == "" { return nil, http.StatusBadRequest }

@@ -17,7 +22,7 @@ logger.Error(err) // bad request

return nil, http.StatusInternalServerError } - resp, err := c.http.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { logger.Error(err) // request failed return nil, http.StatusGone

@@ -31,7 +36,7 @@ if resp.ContentLength == 0 {

return nil, http.StatusNoContent } - if resp.ContentLength > c.Options.MaxSizeBytes { + if resp.ContentLength > MaxSizeBytes { logger.Debug("Content-Length exceeds max size.") return nil, http.StatusBadRequest }

@@ -47,29 +52,14 @@

return &VideoBuffer{b, l}, http.StatusOK } -func (c *Client) findCompatibleFormat(video *Video) (*VideoBuffer, int) { - for i := len(video.Formats) - 1; i >= 0; i-- { - url := video.Formats[i].Url - logger.Debug(url) - vb, httpStatus := c.urlToBuffer(url) - if httpStatus == http.StatusOK { - videoBuffer := vb.Clone() - c.buffers.Set(video.VideoId, videoBuffer) - return vb, i - } - logger.Debug("Format ", i, "failed with status code ", httpStatus) - } - return nil, -1 -} - -func (c *Client) getBuffer(video Video) (*VideoBuffer, int) { - vb, err := c.buffers.Get(video.VideoId) +func getBuffer(video rabbitpipe.Video) (*VideoBuffer, int) { + vb, err := buffers.Get(video.VideoID) if err != nil { // no cache entry - vb, s := c.urlToBuffer(video.Url) + vb, s := urlToBuffer(GetVideoURL(video)) if vb != nil { if s == http.StatusOK && vb.Length > 0 { - c.buffers.Set(video.VideoId, vb.Clone()) + buffers.Set(video.VideoID, *vb, 5*time.Minute) return vb, s } }

@@ -80,11 +70,11 @@ videoBuffer := vb.Clone()

return videoBuffer, http.StatusOK } -func (c *Client) ProxyVideoId(videoId string) (*VideoBuffer, int) { - video, err := GetVideoDB(videoId) +func ProxyVideoId(videoID string) (*VideoBuffer, int) { + video, err := RP.GetVideo(videoID) if err != nil { - logger.Info("Cannot proxy a video that is not cached: https://youtu.be/", videoId) + logger.Info("Cannot get video: https://youtu.be/", videoID) return nil, http.StatusBadRequest } - return c.getBuffer(*video) + return getBuffer(*video) }
D invidious/queries.go

@@ -1,19 +0,0 @@

-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, - url TEXT NOT NULL, - thumbnail TEXT, - expire DATETIME NOT NULL -);` - -const getVideoQuery = "SELECT * FROM videos WHERE videoId = (?);" - -const cacheVideoQuery = "INSERT OR REPLACE INTO videos (videoId, title, description, uploader, duration, url, thumbnail, expire) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" - -const clearQuery = "DELETE FROM videos;"
M templates/video.htmltemplates/video.html

@@ -15,24 +15,24 @@ <head>

<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ .Title }} - FixYouTube</title> - <link rel="canonical" href="https://www.youtube.com/watch?v={{ .VideoId }}" /> - <meta property="og:url" content="https://www.youtube.com/watch?v={{ .VideoId }}" /> + <link rel="canonical" href="https://www.youtube.com/watch?v={{ .VideoID }}" /> + <meta property="og:url" content="https://www.youtube.com/watch?v={{ .VideoID }}" /> <meta property="theme-color" content="0000FF" /> <meta property="twitter:card" content="player" /> - <meta property="twitter:site" content="{{ .Uploader }}" /> - <meta property="twitter:creator" content="{{ .Uploader }}" /> + <meta property="twitter:site" content="{{ .Author }}" /> + <meta property="twitter:creator" content="{{ .Author }}" /> <meta property="twitter:title" content="{{ .Title }}" /> <meta property="og:title" content="{{ .Title }}" /> <meta property="og:description" content="{{ .Description }}" /> - <meta property="og:site_name" content="FixYouTube ({{ .Uploader }})" /> - <meta property="twitter:image" content="{{ .Thumbnail }}" /> + <meta property="og:site_name" content="FixYouTube ({{ .Author }})" /> + {{ if gt (len .VideoThumbnails) 0 }} + <meta property="twitter:image" content="{{ (index .VideoThumbnails 0).URL }}" /> + {{ end }} <meta property="twitter:player:stream:content_type" content="video/mp4" /> - {{ if .Url }} - <meta property="og:video" content="/proxy/{{ .VideoId }}" /> - <meta property="og:video:secure_url" content="/proxy/{{ .VideoId }}" /> - <meta property="og:video:duration" content="{{ .Duration }}"> + <meta property="og:video" content="/proxy/{{ .VideoID }}" /> + <meta property="og:video:secure_url" content="/proxy/{{ .VideoID }}" /> + <meta property="og:video:duration" content="{{ .LengthSeconds }}"> <meta property="og:video:type" content="video/mp4" /> - {{ end }} <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text fill=%22white%22 y=%22.9em%22 font-size=%2290%22>🛠</text></svg>"> <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css"> </head>

@@ -40,12 +40,12 @@

<body> <main class="container" style="max-width: 35rem"> <video style="width: 100%" autoplay controls> - <source src="/proxy/{{ .VideoId }}" type="video/mp4" /> + <source src="/proxy/{{ .VideoID }}" type="video/mp4" /> </video> <h2>{{ .Title }}</h2> - <h3>&gt; {{ .Uploader }}</h3> + <h3>&gt; {{ .Author }}</h3> <pre style="white-space: pre-wrap">{{ .Description }}</pre> - <a href="https://www.youtube.com/watch?v={{ .VideoId }}">Watch on YouTube</a> + <a href="https://www.youtube.com/watch?v={{ .VideoID }}">Watch on YouTube</a> <br /> <a href="/">What is this?</a> <hr>