diff --git a/.gitignore b/.gitignore index 1f8b8aa..caffbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ bin/ -all.md5 ytproxy log.txt -config.json +all.md5 +config.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d04fde..0d333d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## 2.0.0 - 2024-09-20 +app refactored & reworked +### Added +- per site settings +- direct/no extractor (returning same url) +- json format logs +- disabling logs +- force https links to extractor options +- host setting in config +- stripping (bad) http(s) prefix in url +- os signals catching +- config reload (SIGHUP) +### Changed +- default config name from "config.json" to "config.jsonc" + ## 1.6.0 - 2024-09-13 ### Added - ignoring comment strings in config file diff --git a/README.md b/README.md index 9c36f91..cda6c3e 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,11 @@ ### What is this repository for? ### -This is part of another project: https://github.com/mesb1/xupnpd_youtube +This is yt-dlp based video restreamer, part of another project: https://github.com/mesb1/xupnpd_youtube ### Build ### -`cd src && go build` +`cd cmd && go build` ### Options ### -Run with `--help` - -### Exit codes ### - - - 0 - OK - - 1 - config read/parse error - - 2 - logger create error - - 3 - extractor create error - - 4 - streamer create error - - 5 - web server error - - 6 - links cache error - -### Config explained ### -do not copypaste, comments are not allowed in this json. -use config.default.json instead. - -```jsonc -{ - // web server listen port - "port": 8080, - // restreamer config - "streamer": { - // show errors in headers (insecure) - "error-headers": false, - // do not strictly check video headers - "ignore-missing-headers": false, - // do not check video server certificate (insecure) - "ignore-ssl-errors": false, - // video file that will be shown on errors - "error-video": "corrupted.mp4", - // audio file that will be played on errors - // dwnlded here youtu.be/_b8KPiT1PxI (suggest your options) - "error-audio": "failed.m4a", - // how to set streamer's user-agent - // request - set from user's request (old default) - // extractor - set from extractor on app start (default) - // config - set from config - "set-user-agent": "extractor", - // custom user agent used if "set-user-agent" set to "config" - "user-agent": "Mozilla", - // proxy for restreamer - // empty - no proxy - // "env" - read proxy from environment variables (e.g. HTTP_PROXY="http://127.0.0.1:3128") - // proxy url - e.g. "socks5://127.0.0.1:9999" - "proxy": "env", - // min TLS version: "TLS 1.3", "TLS 1.2", etc. - "min-tls-version": "TLS 1.2 " - }, - // media extractor config - "extractor": { - // file path - "path": "yt-dlp", - // arguments for extractor - // args separator is ",,", not space - // {{.HEIGHT}} will be replaced with requested height (360/480/720) - // {{.URL}} will be replace with requested url - // also you can use {{.FORMAT}} - requested format (now - only mp4 or m4a) - "mp4": "-f,,(mp4)[height<={{.HEIGHT}}],,-g,,{{.URL}}", - // same for m4a - "m4a": "-f,,(m4a),,-g,,{{.URL}}", - // args for getting user-agent - "get-user-agent": "--dump-user-agent", - // custom options list to extractor, like proxy, etc. - // same rules as mp4/m4a - // HEIGHT/URL/.. templates also can be used - "custom-options": [ - "--option1,,value1", - "--option2", - "value2", - "--option3", - "very long value 3" - ] - }, - // logger config - "log": { - // log level - // debug/info/warning/error/nothing - "level": "info", - // log destination - // stdout/file/both - "output": "stdout", - // filename if writing to file - "filename": "log.txt" - }, - // links cache config - "cache": { - // links expire time - // time units are "s", "m", "h", e.g. "1h10m10s", "10h", "1s" - // "0s" will disable cache - "expire-time": "3h" - } -} +Run with `--help` \ No newline at end of file diff --git a/build.go b/build.go index 9357999..a5582fa 100644 --- a/build.go +++ b/build.go @@ -9,15 +9,15 @@ import ( ) const ( - binDir = "bin" - filePrefix = "yt-proxy" - goBin = "go" - myos = "linux" - myarch = "amd64" + binDir = "bin" + goBin = "go" + myos = "linux" + myarch = "amd64" ) func main() { total := len(knownArch) * len(knownOS) + filePrefix := os.Args[1] for os_ := range knownOS { for arch := range knownArch { file := fmt.Sprintf("%s-%s-%s", filePrefix, os_, arch) diff --git a/build.sh b/build.sh index f087f98..b7754b6 100755 --- a/build.sh +++ b/build.sh @@ -1,15 +1,16 @@ #!/bin/bash +set -e + BinDir="bin" Md5File="all.md5" +FilePrefix="yt-proxy" date >${Md5File} -mkdir -p ${BinDir} || exit -rm ${BinDir}/${FilePrefix}* -rf || exit -cd src || exit -go run ../build.go || exit -cd ../$BinDir || exit -for i in $(ls -1 *); do - md5sum $i >>../${Md5File} || exit -done +mkdir -p ${BinDir} +rm ${BinDir}/${FilePrefix}* -rf +cd cmd +go run ../build.go ${FilePrefix} +cd ../$BinDir +md5sum -b ${FilePrefix}* >${Md5File} cd .. diff --git a/cmd/go.mod b/cmd/go.mod new file mode 100644 index 0000000..13fdaa5 --- /dev/null +++ b/cmd/go.mod @@ -0,0 +1,7 @@ +module ytproxy + +go 1.22.6 + +replace lib => ../lib + +require lib v0.0.0-00010101000000-000000000000 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..255586a --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + + app "lib/app" + cache "lib/cache" + config "lib/config" + extractor "lib/extractor" + logger "lib/logger" + streamer "lib/streamer" +) + +const appVersion = "2.0.0" + +type flagsT struct { + version bool + config string +} + +const ( + NoError = iota + SomeError +) + +func parseCLIFlags() flagsT { + var f flagsT + flag.BoolVar(&f.version, "version", false, "prints current yt-proxy version") + flag.StringVar(&f.config, "config", "config.jsonc", "config file path") + flag.Parse() + return f +} + +func main() { + flags := parseCLIFlags() + if flags.version { + os.Stdout.WriteString(fmt.Sprintf("%s\n", appVersion)) + os.Exit(NoError) + } + startApp(flags.config) +} + +func startApp(conf_file string) { + conf, def, opts, log, err := readConfig(conf_file) + if err != nil { + os.Stderr.WriteString(fmt.Sprintf("Config read error: %s\n", err)) + os.Exit(SomeError) + } + app := app.New( + logger.NewLayer(log, "App"), + def, opts) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.LogInfo("Bad request", r.RemoteAddr, r.RequestURI) + log.LogDebug("Bad request", r) + http.NotFound(w, r) + }) + http.HandleFunc("/play/", func(w http.ResponseWriter, r *http.Request) { + log.LogInfo("Play request", r.RemoteAddr, r.RequestURI) + log.LogDebug("User request", r) + app.Run(w, r) + log.LogInfo("Player disconnected", r.RemoteAddr) + }) + s := &http.Server{ + Addr: fmt.Sprintf("%s:%d", conf.Host, conf.PortInt), + } + go signalsCatcher(conf_file, log, app, s) + + log.LogInfo("Starting web server", "host", conf.Host, "port", conf.PortInt) + if err = s.ListenAndServe(); err == http.ErrServerClosed { + log.LogInfo("HTTP server closed") + os.Exit(NoError) + } else { + log.LogError("HTTP server", err) + log.Close() + os.Exit(SomeError) + } +} + +func readConfig(conf_file string) (config.ConfigT, app.Option, []app.Option, + logger.T, error) { + conf, err := config.Read(conf_file) + if err != nil { + return config.ConfigT{}, app.Option{}, nil, nil, err + } + + log, err := logger.New(conf.Log) + if err != nil { + return config.ConfigT{}, app.Option{}, nil, nil, err + } + + defapp, err := getNewApp(log, config.SubConfigT{ + ConfigT: config.ConfigT{ + Streamer: conf.Streamer, + Extractor: conf.Extractor, + Cache: conf.Cache, + }, + Name: "default", + }) + if err != nil { + return config.ConfigT{}, app.Option{}, nil, nil, err + } + + opts := make([]app.Option, 0) + for _, v := range conf.SubConfig { + opt, err := getNewApp(log, v) + if err != nil { + return config.ConfigT{}, app.Option{}, nil, nil, err + } + opts = append(opts, opt) + } + return conf, defapp, opts, log, nil +} + +func signalsCatcher(conf_file string, log logger.T, app *app.AppLogic, s *http.Server) { + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) + for { + switch <-sigint { + case syscall.SIGHUP: + log.LogWarning("Config reloading") + _, def, opts, lognew, err := readConfig(conf_file) + if err != nil { + log.LogError("Config reload error", err) + } else { + app.ReloadConfig(logger.NewLayer(lognew, "App"), def, opts) + log = lognew + } + case syscall.SIGINT: + fallthrough + case syscall.SIGTERM: + log.LogWarning("Exiting") + app.Shutdown() + if err := s.Shutdown(context.Background()); err != nil { + // Error from closing listeners, or context timeout: + log.LogError("HTTP server Shutdown", err) + } + return + } + } +} + +func getNewApp(log logger.T, v config.SubConfigT) (app.Option, error) { + texts := [3]string{ + "Extractor", + "Cache", + "Streamer", + } + + newname := func(name string) string { + return fmt.Sprintf("[%s] %s", v.Name, name) + } + nameerr := func(name string, err error) error { + return fmt.Errorf("%s: %s", newname(name), err) + } + xtr, err := extractor.New(v.Extractor, + logger.NewLayer(log, newname(texts[0]))) + if err != nil { + return app.Option{}, nameerr(texts[0], err) + } + cch, err := cache.New(v.Cache, + logger.NewLayer(log, newname(texts[1]))) + if err != nil { + return app.Option{}, nameerr(texts[1], err) + } + strm, err := streamer.New(v.Streamer, + logger.NewLayer(log, newname(texts[2])), xtr) + if err != nil { + return app.Option{}, nameerr(texts[2], err) + } + return app.Option{ + Name: v.Name, + Sites: v.Sites, + X: xtr, + S: strm, + C: cch, + L: logger.NewLayer(log, fmt.Sprintf("[%s] app", v.Name)), + }, nil +} diff --git a/config.default.json b/config.default.json deleted file mode 100644 index 22b7e7d..0000000 --- a/config.default.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "port": 8080, - "streamer": { - "error-headers": false, - "ignore-missing-headers": false, - "ignore-ssl-errors": false, - "error-video": "corrupted.mp4", - "error-audio": "failed.m4a", - "set-user-agent": "extractor", - "user-agent": "Mozilla", - "proxy": "env", - "min-tls-version": "TLS 1.2" - }, - "extractor": { - "path": "yt-dlp", - "mp4": "-f,,(mp4)[height<={{.HEIGHT}}],,-g,,{{.URL}}", - "m4a": "-f,,(m4a),,-g,,{{.URL}}", - "get-user-agent": "--dump-user-agent", - "custom-options": [] - }, - "log": { - "level": "info", - "output": "stdout", - "filename": "log.txt" - }, - "cache": { - "expire-time": "3h" - } -} \ No newline at end of file diff --git a/config.example.jsonc b/config.example.jsonc new file mode 100644 index 0000000..068707b --- /dev/null +++ b/config.example.jsonc @@ -0,0 +1,148 @@ +// example config for the app. +// only full string comments are supported, +// do not use /* comment */ and comment after keys. +// delete string to set key to the DEFAULT value. +{ + // web server listen host. + // DEFAULT "0.0.0.0" + "host": "127.0.0.1", + // web server listen port. + // DEFAULT 8080 + "port": 8080, + // logger config + "log": { + // log level + // debug/info/warning/error/nothing + // DEFAULT "info" + "level": "info", + // log destination + // stdout/file/both + // DEFAULT "stdout" + "output": "stdout", + // filename if writing to file + // DEFAULT "log.txt" + "filename": "log.txt", + // set output to json format + // DEFAULT false + "json": false + }, + // default restreamer config. + // restreamer takes https stream and restream it as http. + "streamer": { + // show errors in headers (insecure). + // streaming errors will be sent as Error-Header-xx header. + // DEFAULT false + "error-headers": false, + // do not strictly check video headers. + // if true, streamer will ignore incorrect "Content-Length" and "Content-Type" header. + // DEFAULT false + "ignore-missing-headers": false, + // do not check video server certificate (insecure) + // DEFAULT false + "ignore-ssl-errors": false, + // video file that will be shown on video stream errors + // DEFAULT "corrupted.mp4" + "error-video": "corrupted.mp4", + // audio file that will be played on audio stream errors + // dwnlded here youtu.be/_b8KPiT1PxI (suggest your options) + // DEFAULT "failed.m4a" + "error-audio": "failed.m4a", + // how to set streamer's user-agent + // request - set from user's request (old default) + // extractor - set from extractor on app start (default) + // config - set from config + // DEFAULT "extractor" + "set-user-agent": "extractor", + // custom user agent used if "set-user-agent" set to "config" + // DEFAULT "Mozilla" + "user-agent": "Mozilla", + // proxy for restreamer + // empty - no proxy + // "env" - read proxy from environment variables (e.g. HTTP_PROXY="http://127.0.0.1:3128") + // proxy url - e.g. "socks5://127.0.0.1:9999" + // DEFAULT "env" + "proxy": "env", + // min TLS version: "TLS 1.3", "TLS 1.2", etc. + // DEFAULT "TLS 1.2" + "min-tls-version": "TLS 1.2" + }, + // default media extractor config + "extractor": { + // file path + // "direct" - do not use extractor, just pass url to streamer + // DEFAULT "yt-dlp" + "path": "yt-dlp", + // arguments for extractor + // args separator is ",,", not space + // {{.HEIGHT}} will be replaced with requested height (360/480/720) + // {{.URL}} will be replace with requested url + // also you can use {{.FORMAT}} - requested format (now - only mp4 or m4a) + // DEFAULT "-f,,(mp4)[height<={{.HEIGHT}}],,-g,,{{.URL}}", + "mp4": "-f,,(mp4)[height<={{.HEIGHT}}],,-g,,{{.URL}}", + // same for m4a + // DEFAULT "-f,,(m4a),,-g,,{{.URL}}", + "m4a": "-f,,(m4a),,-g,,{{.URL}}", + // args for getting user-agent + // DEFAULT "--dump-user-agent" + "get-user-agent": "--dump-user-agent", + // add "https://" to links passed to extractor + // DEFAULT true + "force-https": true, + // custom options list to extractor, like proxy, etc. + // same rules as mp4/m4a + // HEIGHT/URL/.. templates also can be used + // DEFAULT [] + "custom-options": [ + "--option1,,value1", + "--option2", + "value2", + "--option3", + "very long value 3", + "--option4,,very long value 4" + ] + }, + // default links cache config + "cache": { + // links expire time + // time units are "s", "m", "h", e.g. "1h10m10s", "10h", "1s" + // "0s" will disable cache + // DEFAULT "3h" + "expire-time": "3h" + }, + // per site configs for streamer, extractor and cache. + // absent options will be set from default part. + // only exact matching domains will be affected. + // e.g. "site.com/video" matching "site.com" + // but "www.site.com/video" is not + // DEFAULT [] + "sub-config": [ + { + // sub config name. displayed in logs + // cannot be empty + "name": "some site", + // sites list + "sites": [ + "site.com", + "a.site.com" + ], + "extractor": { + "path": "my-extractor", + "mp4": "{{.URL}}" + } + }, + { + "name": "my stream", + "sites": [ + "my.streamer.example" + ], + "extractor": { + "path": "direct" + }, + "streamer": { + "error-headers": true, + "ignore-missing-headers": true, + "ignore-ssl-errors": true + } + } + ] +} \ No newline at end of file diff --git a/lib/app/app.go b/lib/app/app.go new file mode 100644 index 0000000..2994fe3 --- /dev/null +++ b/lib/app/app.go @@ -0,0 +1,211 @@ +package app + +import ( + cache "lib/cache" + extractor "lib/extractor" + extractor_config "lib/extractor/config" + logger "lib/logger" + streamer "lib/streamer" + "slices" + "sync" + + "net/http" + "net/url" + "strings" + "time" +) + +const ( + defaultVideoHeight = "720" + defaultVideoFormat = "mp4" +) + +type app struct { + cache cache.T + extractor extractor.T + streamer streamer.T + name string + sites []string + log logger.T +} + +type AppLogic struct { + mu sync.RWMutex + log logger.T + defaultApp app + appList []app +} + +type Option struct { + Name string + Sites []string + X extractor.T + S streamer.T + C cache.T + L logger.T +} + +func New(log logger.T, def Option, opts []Option) *AppLogic { + var t AppLogic + t.set(log, def, opts) + return &t +} + +func (t *AppLogic) set(log logger.T, def Option, opts []Option) { + t.log = log + t.defaultApp = app{ + log: def.L, + name: "default", + cache: def.C, + extractor: def.X, + streamer: def.S, + } + + t.appList = make([]app, 0) + for _, v := range opts { + t.appList = append(t.appList, app{ + cache: v.C, + extractor: v.X, + streamer: v.S, + name: v.Name, + sites: v.Sites, + log: v.L, + }) + } +} + +func (t *AppLogic) selectApp(rawURL string) app { + host, err := parseUrlHost(rawURL) + if err == nil { + for _, v := range t.appList { + if slices.Contains(v.sites, host) { + return v + } + } + } + return t.defaultApp +} + +func parseUrlHost(rawURL string) (string, error) { + u, err := url.Parse("https://" + rawURL) + return u.Host, err +} + +func (t *AppLogic) Run(w http.ResponseWriter, r *http.Request) { + t.mu.RLock() + defer t.mu.RUnlock() + printExpired := func(links []extractor_config.RequestT) { + if len(links) > 0 { + t.log.LogDebug("Expired links", links) + } + } + now := time.Now() + req := parseQuery(r.RequestURI) + resapp := t.selectApp(req.URL) + t.log.LogInfo("Request", req, "app", resapp.name) + if res, ok, expired := resapp.cacheCheck(req, now); ok { + printExpired(expired) + t.log.LogDebug("Link already cached", res) + resapp.play(w, r, req, res, t.log) + } else { + printExpired(expired) + res, err := resapp.extractor.Extract(req) + if err != nil { + t.log.LogError("URL extract error", err) + resapp.playError(w, req, err, t.log) + return + } + t.log.LogDebug("Extractor returned", res) + resapp.cacheAdd(req, res, now, t.log) + resapp.play(w, r, req, res, t.log) + } +} + +func (t *app) play( + w http.ResponseWriter, + r *http.Request, + req extractor_config.RequestT, + res extractor_config.ResultT, + logger logger.T, +) { + if err := t.streamer.Play(w, r, req, res); err != nil { + logger.LogError("Restream error", err) + t.playError(w, req, err, logger) + } +} + +func (t *app) playError( + w http.ResponseWriter, + req extractor_config.RequestT, + err error, + logger logger.T, +) { + if err := t.streamer.PlayError(w, req, err); err != nil { + logger.LogError("Error occured while playing error video", err) + } +} + +func (t *app) cacheCheck(req extractor_config.RequestT, now time.Time) (extractor_config.ResultT, bool, []extractor_config.RequestT) { + expired := t.cache.CleanExpired(now) + res, ok := t.cache.Get(req) + return res, ok, expired +} + +func (t *app) cacheAdd( + req extractor_config.RequestT, + res extractor_config.ResultT, + now time.Time, + logger logger.T, +) { + logger.LogDebug("Cache add", res) + t.cache.Add(req, res, now) +} + +func remove_http(url string) string { + url = strings.TrimPrefix(url, "http:/") + url = strings.TrimPrefix(url, "https:/") + url = strings.TrimLeft(url, "/") + return url +} + +func parseQuery(query string) extractor_config.RequestT { + var req extractor_config.RequestT + query = strings.TrimSpace(strings.TrimPrefix(query, "/play/")) + splitted := strings.Split(query, "?/?") + req.URL = remove_http(splitted[0]) + req.HEIGHT = defaultVideoHeight + req.FORMAT = defaultVideoFormat + if len(splitted) != 2 { + return req + } + tOpts, tErr := url.ParseQuery(splitted[1]) + if tErr == nil { + if tvh, ok := tOpts["vh"]; ok { + if tvh[0] == "360" || tvh[0] == "480" || tvh[0] == "720" { + req.HEIGHT = tvh[0] + } + } + if tvf, ok := tOpts["vf"]; ok { + if tvf[0] == "mp4" || tvf[0] == "m4a" { + req.FORMAT = tvf[0] + } + } + } + return req +} + +func (t *AppLogic) Shutdown() { + t.mu.Lock() // locking app forever + t.log.LogInfo("Exiting") + t.log.Close() +} + +func (t *AppLogic) ReloadConfig(log logger.T, def Option, opts []Option) { + t.log.LogDebug("Waiting for clients disconnect to reload app") + t.mu.Lock() + defer t.mu.Unlock() + t.log.LogInfo("Reloading app") + t.log.Close() + t.set(log, def, opts) + t.log.LogInfo("Reloading complete") +} diff --git a/lib/app/app_test.go b/lib/app/app_test.go new file mode 100644 index 0000000..816245e --- /dev/null +++ b/lib/app/app_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "testing" + + extractor_config "lib/extractor/config" +) + +func TestParseQuery(t *testing.T) { + var testPairs = map[string]extractor_config.RequestT{ + "/play/youtu.be/jNQXAC9IVRw?/?vh=360&vf=mp4": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: "360", + FORMAT: "mp4", + }, + "/play/youtu.be/jNQXAC9IVRw?/?vh=720?vf=avi": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: "720", + FORMAT: defaultVideoFormat, + }, + "/play/youtu.be/jNQXAC9IVRw": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: defaultVideoHeight, + FORMAT: defaultVideoFormat, + }, + "/play/youtu.be/jNQXAC9IVRw?/?": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: defaultVideoHeight, + FORMAT: defaultVideoFormat, + }, + "/play/youtu.be/jNQXAC9IVRw?/?vf=avi": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: defaultVideoHeight, + FORMAT: defaultVideoFormat, + }, + "/play/youtu.be/jNQXAC9IVRw?/?vf=mp4": { + URL: "youtu.be/jNQXAC9IVRw", + HEIGHT: defaultVideoHeight, + FORMAT: "mp4", + }, + } + for k, v := range testPairs { + if r := parseQuery(k); r != v { + t.Error("For", k, "expected", v, "got", r) + } + } +} + +func TestRemoveHttp(t *testing.T) { + for _, v := range []struct { + link string + want string + }{ + {link: "youtu.be/", want: "youtu.be/"}, + {link: "https:////youtu.be/", want: "youtu.be/"}, + {link: "https:///youtu.be/", want: "youtu.be/"}, + {link: "https://youtu.be/", want: "youtu.be/"}, + {link: "https:/youtu.be/", want: "youtu.be/"}, + {link: "http:////www.youtu.be/", want: "www.youtu.be/"}, + {link: "http:///www.youtu.be/", want: "www.youtu.be/"}, + {link: "http://www.youtu.be/", want: "www.youtu.be/"}, + {link: "http:/www.youtu.be/", want: "www.youtu.be/"}, + } { + if r := remove_http(v.link); r != v.want { + t.Error("For", v.link, "expected", v.want, "got", r) + } + } +} + +func TestParseHost(t *testing.T) { + for _, v := range []struct { + link string + want string + }{ + {link: "youtu.be/1", want: "youtu.be"}, + {link: "youtu.be:443/?param=1&b=c", want: "youtu.be:443"}, + {link: "www.yyy.youtu.be/", want: "www.yyy.youtu.be"}, + {link: "youtu.be", want: "youtu.be"}, + } { + if r, err := parseUrlHost(v.link); r != v.want || err != nil { + t.Error("For", v.link, "expected", v.want, "got", r, err) + } + } + +} diff --git a/lib/cache/cache.go b/lib/cache/cache.go new file mode 100644 index 0000000..65d6e2b --- /dev/null +++ b/lib/cache/cache.go @@ -0,0 +1,34 @@ +package cache + +import ( + "fmt" + "time" + + cache_default "lib/cache/impl/default" + cache_empty "lib/cache/impl/empty" + extractor_config "lib/extractor/config" + logger "lib/logger" +) + +type T interface { + Add(extractor_config.RequestT, extractor_config.ResultT, time.Time) + Get(extractor_config.RequestT) (extractor_config.ResultT, bool) + CleanExpired(time.Time) []extractor_config.RequestT +} + +type ConfigT struct { + ExpireTime *string `json:"expire-time"` +} + +func New(conf ConfigT, log logger.T) (T, error) { + t, err := time.ParseDuration(*conf.ExpireTime) + if err != nil { + return cache_default.New(0), err + } + if t.Seconds() < 1 { + log.LogDebug("", "disabled by config") + return cache_empty.New(), nil + } + log.LogDebug("", fmt.Sprintf("expire time set to %s", t)) + return cache_default.New(0), nil +} diff --git a/lib/cache/impl/default/default.go b/lib/cache/impl/default/default.go new file mode 100644 index 0000000..edfb18a --- /dev/null +++ b/lib/cache/impl/default/default.go @@ -0,0 +1,49 @@ +package cache + +import ( + "sync" + "time" + + extractor_config "lib/extractor/config" +) + +func New(t time.Duration) *defaultCache { + return &defaultCache{ + cache: make(map[extractor_config.RequestT]extractor_config.ResultT), + expireTime: t, + } +} + +type defaultCache struct { + sync.Mutex + cache map[extractor_config.RequestT]extractor_config.ResultT + expireTime time.Duration +} + +func (t *defaultCache) Add(req extractor_config.RequestT, res extractor_config.ResultT, + now time.Time) { + res.Expire = now.Add(t.expireTime) + t.Lock() + t.cache[req] = res + t.Unlock() +} + +func (t *defaultCache) Get(req extractor_config.RequestT) (extractor_config.ResultT, bool) { + t.Lock() + defer t.Unlock() + v, ok := t.cache[req] + return v, ok +} + +func (t *defaultCache) CleanExpired(now time.Time) []extractor_config.RequestT { + deleted := make([]extractor_config.RequestT, 0) + t.Lock() + for k, v := range t.cache { + if v.Expire.Before(now) { + delete(t.cache, k) + deleted = append(deleted, k) + } + } + t.Unlock() + return deleted +} diff --git a/lib/cache/impl/empty/empty.go b/lib/cache/impl/empty/empty.go new file mode 100644 index 0000000..21ba3a7 --- /dev/null +++ b/lib/cache/impl/empty/empty.go @@ -0,0 +1,25 @@ +package cache + +import ( + "time" + + extractor_config "lib/extractor/config" +) + +func New() *emptyCache { + return &emptyCache{} +} + +type emptyCache struct{} + +func (t *emptyCache) Add(req extractor_config.RequestT, res extractor_config.ResultT, + now time.Time) { +} + +func (t *emptyCache) Get(req extractor_config.RequestT) (extractor_config.ResultT, bool) { + return extractor_config.ResultT{}, false +} + +func (t *emptyCache) CleanExpired(now time.Time) []extractor_config.RequestT { + return []extractor_config.RequestT{} +} diff --git a/lib/config/config.go b/lib/config/config.go new file mode 100644 index 0000000..5e60e48 --- /dev/null +++ b/lib/config/config.go @@ -0,0 +1,195 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + cache "lib/cache" + extractor_config "lib/extractor/config" + logger_config "lib/logger/config" + streamer "lib/streamer" +) + +type ConfigT struct { + PortInt uint16 `json:"port"` + Host string `json:"host"` + Streamer streamer.ConfigT `json:"streamer"` + Extractor extractor_config.ConfigT `json:"extractor"` + Log logger_config.ConfigT `json:"log"` + Cache cache.ConfigT `json:"cache"` + SubConfig []SubConfigT `json:"sub-config"` +} + +type SubConfigT struct { + Name string `json:"name"` + Sites []string `json:"sites"` + ConfigT +} + +func defaultConfig() ConfigT { + fls := false + tru := true + ext := streamer.Extractor + tv := streamer.TlsVersion(0) + var s = [4]string{"corrupted.mp4", + "failed.m4a", + "Mozilla", + "env", + } + var e = [4]string{"yt-dlp", + "-f,,(mp4)[height<={{.HEIGHT}}],,-g,,{{.URL}}", + "-f,,(m4a),,-g,,{{.URL}}", + "--dump-user-agent", + } + co := make([]string, 0) + ll := logger_config.Info + lo := logger_config.Stdout + lf := "log.txt" + exp := "3h" + return ConfigT{ + PortInt: 8080, + Host: "0.0.0.0", + Streamer: streamer.ConfigT{ + EnableErrorHeaders: &fls, + IgnoreMissingHeaders: &fls, + IgnoreSSLErrors: &fls, + ErrorVideoPath: &s[0], + ErrorAudioPath: &s[1], + SetUserAgent: &ext, + UserAgent: &s[2], + Proxy: &s[3], + MinTlsVersion: &tv, + }, + Extractor: extractor_config.ConfigT{ + Path: &e[0], + MP4: &e[1], + M4A: &e[2], + GetUserAgent: &e[3], + CustomOptions: &co, + ForceHttps: &tru, + }, + Log: logger_config.ConfigT{ + Level: &ll, + Json: &fls, + Output: &lo, + FileName: &lf, + }, + Cache: cache.ConfigT{ + ExpireTime: &exp, + }, + } +} + +// add second config options to first +func appendConfig(src ConfigT, dst ConfigT) ConfigT { + // general options + if dst.PortInt == 0 { + dst.PortInt = src.PortInt + } + if dst.Host == "" { + dst.Host = src.Host + } + // streamer + if dst.Streamer.EnableErrorHeaders == nil { + dst.Streamer.EnableErrorHeaders = src.Streamer.EnableErrorHeaders + } + if dst.Streamer.IgnoreMissingHeaders == nil { + dst.Streamer.IgnoreMissingHeaders = src.Streamer.IgnoreMissingHeaders + } + if dst.Streamer.IgnoreSSLErrors == nil { + dst.Streamer.IgnoreSSLErrors = src.Streamer.IgnoreSSLErrors + } + if dst.Streamer.ErrorVideoPath == nil { + dst.Streamer.ErrorVideoPath = src.Streamer.ErrorVideoPath + } + if dst.Streamer.ErrorAudioPath == nil { + dst.Streamer.ErrorAudioPath = src.Streamer.ErrorAudioPath + } + if dst.Streamer.SetUserAgent == nil { + dst.Streamer.SetUserAgent = src.Streamer.SetUserAgent + } + if dst.Streamer.UserAgent == nil { + dst.Streamer.UserAgent = src.Streamer.UserAgent + } + if dst.Streamer.Proxy == nil { + dst.Streamer.Proxy = src.Streamer.Proxy + } + if dst.Streamer.MinTlsVersion == nil { + dst.Streamer.MinTlsVersion = src.Streamer.MinTlsVersion + } + // extractor + if dst.Extractor.Path == nil { + dst.Extractor.Path = src.Extractor.Path + } + if dst.Extractor.MP4 == nil { + dst.Extractor.MP4 = src.Extractor.MP4 + } + if dst.Extractor.M4A == nil { + dst.Extractor.M4A = src.Extractor.M4A + } + if dst.Extractor.GetUserAgent == nil { + dst.Extractor.GetUserAgent = src.Extractor.GetUserAgent + } + if dst.Extractor.CustomOptions == nil { + dst.Extractor.CustomOptions = src.Extractor.CustomOptions + } + if dst.Extractor.ForceHttps == nil { + dst.Extractor.ForceHttps = src.Extractor.ForceHttps + } + // logger + if dst.Log.Level == nil { + dst.Log.Level = src.Log.Level + } + if dst.Log.Json == nil { + dst.Log.Json = src.Log.Json + } + if dst.Log.Output == nil { + dst.Log.Output = src.Log.Output + } + if dst.Log.FileName == nil { + dst.Log.FileName = src.Log.FileName + } + // cache + if dst.Cache.ExpireTime == nil { + dst.Cache.ExpireTime = src.Cache.ExpireTime + } + return dst +} + +func Read(path string) (ConfigT, error) { + var c ConfigT + b, err := os.ReadFile(path) + if err != nil { + return c, err + } + func() { + strs := make([]string, 0) + for _, s := range strings.Split(string(b[:]), "\n") { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "//") { + strs = append(strs, s) + } + } + str := strings.Join(strs, "\n") + b = b[:0] + b = []byte(str) + + }() + err = json.Unmarshal(b, &c) + if err != nil { + return c, err + } + c = appendConfig(defaultConfig(), c) + for k, v := range c.SubConfig { + if v.Name == "" { + return c, fmt.Errorf("sub-config name empty") + } + if len(v.Sites) == 0 { + return c, fmt.Errorf("sub-config sites empty") + } + c.SubConfig[k].ConfigT = appendConfig(c, v.ConfigT) + } + return c, nil +} diff --git a/lib/extractor/config/config.go b/lib/extractor/config/config.go new file mode 100644 index 0000000..218fd60 --- /dev/null +++ b/lib/extractor/config/config.go @@ -0,0 +1,22 @@ +package extractor + +import "time" + +type ConfigT struct { + Path *string `json:"path"` + MP4 *string `json:"mp4"` + M4A *string `json:"m4a"` + GetUserAgent *string `json:"get-user-agent"` + CustomOptions *[]string `json:"custom-options"` + ForceHttps *bool `json:"force-https"` +} + +type ResultT struct { + URL string + Expire time.Time +} +type RequestT struct { + URL string + HEIGHT string + FORMAT string +} diff --git a/lib/extractor/extractor.go b/lib/extractor/extractor.go new file mode 100644 index 0000000..ee82f6a --- /dev/null +++ b/lib/extractor/extractor.go @@ -0,0 +1,70 @@ +package extractor + +import ( + "strings" + + extractor_config "lib/extractor/config" + extractor_default "lib/extractor/impl/default" + extractor_direct "lib/extractor/impl/direct" + logger "lib/logger" +) + +const separator = ",," + +type T interface { + Extract(extractor_config.RequestT) (extractor_config.ResultT, error) + GetUserAgent() (string, error) +} + +func New(c extractor_config.ConfigT, log logger.T) (T, error) { + var ( + ext layer + err error + ) + ext.force_http = *c.ForceHttps + if ext.force_http { + log.LogDebug("", "force-http", true) + } + ext.impl, err = real_new(c, log) + return &ext, err +} + +type layer struct { + impl T + force_http bool +} + +func (t *layer) Extract(req extractor_config.RequestT) (extractor_config.ResultT, error) { + if t.force_http { + req.URL = "https://" + req.URL + } + return t.impl.Extract(req) +} + +func (t *layer) GetUserAgent() (string, error) { + return t.impl.GetUserAgent() +} + +func real_new(c extractor_config.ConfigT, log logger.T) (T, error) { + switch *c.Path { + case "direct": + return extractor_direct.New() + default: + co := make([]string, 0) + for _, v := range *c.CustomOptions { + co = append(co, split(v)...) + } + return extractor_default.New( + *c.Path, + split(*c.MP4), + split(*c.M4A), + *c.GetUserAgent, + co, + log, + ) + } +} + +func split(s string) []string { + return strings.Split(s, separator) +} diff --git a/lib/extractor/impl/default/default.go b/lib/extractor/impl/default/default.go new file mode 100644 index 0000000..1ac11d2 --- /dev/null +++ b/lib/extractor/impl/default/default.go @@ -0,0 +1,126 @@ +package extractor + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + "sync" + "text/template" + + extractor_config "lib/extractor/config" + logger "lib/logger" +) + +func New(path string, mp4, m4a []string, get_user_agent string, + custom_options []string, log logger.T) (*defaultExtractor, error) { + var ( + e defaultExtractor + err error + ) + read := func(list []string) ([]*template.Template, error) { + res := make([]*template.Template, 0) + for _, v := range list { + t, err := template.New("").Parse(v) + if err != nil { + return res, err + } + res = append(res, t) + } + return res, nil + } + e.m4a, err = read(m4a) + if err != nil { + return &e, err + } + e.mp4, err = read(mp4) + if err != nil { + return &e, err + } + e.customOptions, err = read(custom_options) + if err != nil { + return &e, err + } + e.getUserAgent = get_user_agent + e.path = path + e.logger = log + return &e, nil +} + +type defaultExtractor struct { + sync.Mutex + path string + mp4 []*template.Template + m4a []*template.Template + customOptions []*template.Template + getUserAgent string + logger logger.T +} + +func (t *defaultExtractor) GetUserAgent() (string, error) { + return t.runCmd([]string{t.getUserAgent}) +} + +func (t *defaultExtractor) Extract(req extractor_config.RequestT, +) (extractor_config.ResultT, error) { + var ( + buf []string + bufOptions []string + err error + ) + execute := func(list []*template.Template) ([]string, error) { + buf := make([]string, 0) + for _, v := range list { + var b bytes.Buffer + err = v.Execute(&b, req) + if err != nil { + return buf, err + } + buf = append(buf, bytesToString(b)) + } + return buf, nil + } + switch req.FORMAT { + case "m4a": + buf, err = execute(t.m4a) + case "mp4": + fallthrough + default: + buf, err = execute(t.mp4) + } + if err != nil { + return extractor_config.ResultT{}, err + } + bufOptions, err = execute(t.customOptions) + if err != nil { + return extractor_config.ResultT{}, err + } + bufOptions = append(bufOptions, buf...) + out, err := t.runCmd(bufOptions) + if err != nil { + return extractor_config.ResultT{}, err + } + return extractor_config.ResultT{URL: out}, err +} + +func (t *defaultExtractor) runCmd(args []string) (string, error) { + t.Lock() + defer t.Unlock() + cmd := exec.Command(t.path, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + t.logger.LogDebug("Running", "path", t.path, "args", strings.Join(args, " ")) + err := cmd.Run() + outStr, errStr := bytesToString(stdout), bytesToString(stderr) + if err != nil { + combinedErrStr := fmt.Sprintf("%s\n%s\n%s", err.Error(), outStr, errStr) + return "", errors.New(combinedErrStr) + } + return outStr, nil +} + +func bytesToString(s bytes.Buffer) string { + return strings.TrimSpace(s.String()) +} diff --git a/lib/extractor/impl/direct/direct.go b/lib/extractor/impl/direct/direct.go new file mode 100644 index 0000000..c25ecd9 --- /dev/null +++ b/lib/extractor/impl/direct/direct.go @@ -0,0 +1,21 @@ +package extractor + +import ( + extractor_config "lib/extractor/config" +) + +func New() (*directExtractor, error) { + return &directExtractor{}, nil +} + +type directExtractor struct { +} + +func (t *directExtractor) GetUserAgent() (string, error) { + return "Mozilla", nil +} + +func (t *directExtractor) Extract(req extractor_config.RequestT, +) (extractor_config.ResultT, error) { + return extractor_config.ResultT{URL: req.URL}, nil +} diff --git a/lib/go.mod b/lib/go.mod new file mode 100644 index 0000000..43732d1 --- /dev/null +++ b/lib/go.mod @@ -0,0 +1,3 @@ +module lib + +go 1.22.6 diff --git a/lib/logger/config/config.go b/lib/logger/config/config.go new file mode 100644 index 0000000..6a5fff0 --- /dev/null +++ b/lib/logger/config/config.go @@ -0,0 +1,73 @@ +package logger + +import ( + "encoding/json" + "fmt" +) + +type ConfigT struct { + Level *LevelT `json:"level"` + Json *bool `json:"json"` + Output *OutputT `json:"output"` + FileName *string `json:"filename"` +} + +type LevelT uint8 + +const ( + Debug LevelT = iota + Info + Warning + Error + Nothing +) + +func (l *LevelT) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + switch s { + case "debug": + *l = Debug + case "info": + *l = Info + case "warning": + *l = Warning + case "error": + *l = Error + case "nothing": + *l = Nothing + default: + return fmt.Errorf("cannot unmarshal %s as log level", b) + } + return nil +} + +type OutputT uint8 + +const ( + Stdout OutputT = iota + File + Both +) + +func (o *OutputT) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + switch s { + case "stdout": + *o = Stdout + case "file": + *o = File + case "both": + *o = Both + default: + return fmt.Errorf("cannot unmarshal %s as log output", b) + } + return nil +} diff --git a/lib/logger/impl/default/log.go b/lib/logger/impl/default/log.go new file mode 100644 index 0000000..eb88b37 --- /dev/null +++ b/lib/logger/impl/default/log.go @@ -0,0 +1,114 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "sync" + + l "lib/logger/config" +) + +type loggerT struct { + mu sync.RWMutex + lvl *l.LevelT + lgr *log.Logger + outputs []*os.File +} + +func (t *loggerT) print(str string, s string, args []any) { + var ( + fmtstr string + arglen = len(args) + k int + ) + add := func(a string) { + if len(a) > 0 { + fmtstr = fmt.Sprintf("%s %s", fmtstr, a) + } + } + for k < arglen { + if k+1 < arglen { + add(fmt.Sprintf("%s:%+v", args[k], args[k+1])) + k = k + 2 + } else { + add(fmt.Sprintf("%+v", args[k])) + k++ + } + } + t.lgr.Println(fmt.Sprintf("%-7s %s.", str, s) + fmtstr) +} + +func (t *loggerT) checkAndPrint(lvl l.LevelT, str string, s string, i []any) { + if *t.lvl <= lvl { + t.print(str, s, i) + } +} + +func (t *loggerT) LogError(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.checkAndPrint(l.Error, "ERROR", s, i) +} +func (t *loggerT) LogWarning(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.checkAndPrint(l.Warning, "WARNING", s, i) +} +func (t *loggerT) LogDebug(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.checkAndPrint(l.Debug, "DEBUG", s, i) + +} +func (t *loggerT) LogInfo(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.checkAndPrint(l.Info, "INFO", s, i) +} + +func (t *loggerT) Close() { + t.mu.Lock() + defer t.mu.Unlock() + for _, v := range t.outputs { + v.Close() + } + t.lgr = log.Default() +} + +func New(conf l.ConfigT) (*loggerT, error) { + var ( + logger loggerT + lgr *log.Logger = log.Default() + ) + open := func() (*os.File, error) { + return os.OpenFile( + *conf.FileName, + os.O_APPEND|os.O_WRONLY|os.O_CREATE, + 0664) + } + logger.outputs = make([]*os.File, 0) + switch *conf.Output { + case l.Stdout: + lgr.SetOutput(os.Stdout) + case l.File: + f, err := open() + if err != nil { + return &logger, err + } + logger.outputs = append(logger.outputs, f) + lgr.SetOutput(f) + case l.Both: + f, err := open() + if err != nil { + return &logger, err + } + logger.outputs = append(logger.outputs, f) + out := io.MultiWriter(os.Stdout, f) + lgr.SetOutput(out) + } + logger.lgr = lgr + logger.lvl = conf.Level + return &logger, nil +} diff --git a/lib/logger/impl/empty/empty.go b/lib/logger/impl/empty/empty.go new file mode 100644 index 0000000..47da36e --- /dev/null +++ b/lib/logger/impl/empty/empty.go @@ -0,0 +1,14 @@ +package logger + +type loggerT struct { +} + +func (t *loggerT) LogError(string, ...any) {} +func (t *loggerT) LogWarning(string, ...any) {} +func (t *loggerT) LogDebug(string, ...any) {} +func (t *loggerT) LogInfo(string, ...any) {} +func (t *loggerT) Close() {} + +func New() (*loggerT, error) { + return &loggerT{}, nil +} diff --git a/lib/logger/impl/slog/slog.go b/lib/logger/impl/slog/slog.go new file mode 100644 index 0000000..b56ceb4 --- /dev/null +++ b/lib/logger/impl/slog/slog.go @@ -0,0 +1,104 @@ +package logger + +import ( + "io" + "log/slog" + "os" + "sync" + + l "lib/logger/config" +) + +type loggerT struct { + mu sync.RWMutex + lgr *slog.Logger + outputs []*os.File +} + +func (t *loggerT) LogError(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.lgr.Error(s, i...) +} +func (t *loggerT) LogWarning(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.lgr.Warn(s, i...) +} +func (t *loggerT) LogDebug(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.lgr.Debug(s, i...) + +} +func (t *loggerT) LogInfo(s string, i ...any) { + t.mu.RLock() + defer t.mu.RUnlock() + t.lgr.Info(s, i...) +} + +func (t *loggerT) Close() { + t.mu.Lock() + defer t.mu.Unlock() + for _, v := range t.outputs { + v.Close() + } + t.lgr = slog.Default() +} + +func New(conf l.ConfigT) (*loggerT, error) { + open := func() (*os.File, error) { + return os.OpenFile( + *conf.FileName, + os.O_APPEND|os.O_WRONLY|os.O_CREATE, + 0664) + } + var ( + lvl slog.Level + lgr *slog.Logger + ) + switch *conf.Level { + case l.Debug: + lvl = slog.LevelDebug + case l.Info: + lvl = slog.LevelInfo + case l.Warning: + lvl = slog.LevelWarn + case l.Error: + lvl = slog.LevelError + } + mkLogger := func(dst1, dst2 *os.File) { + var dst io.Writer + if dst2 == nil { + dst = dst2 + } else { + dst = io.MultiWriter(dst1, dst2) + } + lgr = slog.New( + slog.NewJSONHandler(dst, + &slog.HandlerOptions{Level: lvl})) + } + outputs := make([]*os.File, 0) + switch *conf.Output { + case l.Stdout: + mkLogger(os.Stdout, nil) + case l.File: + f, err := open() + if err != nil { + return &loggerT{}, err + } + outputs = append(outputs, f) + mkLogger(f, nil) + case l.Both: + f, err := open() + if err != nil { + return &loggerT{}, err + } + outputs = append(outputs, f) + mkLogger(os.Stdout, f) + } + return &loggerT{ + lgr: lgr, + outputs: outputs, + }, nil +} diff --git a/lib/logger/logger.go b/lib/logger/logger.go new file mode 100644 index 0000000..b77e0d3 --- /dev/null +++ b/lib/logger/logger.go @@ -0,0 +1,65 @@ +package logger + +import ( + "fmt" + config "lib/logger/config" + logger_default "lib/logger/impl/default" + logger_empty "lib/logger/impl/empty" + logger_slog "lib/logger/impl/slog" +) + +func New(conf config.ConfigT) (T, error) { + if *conf.Level == config.Nothing { + return logger_empty.New() + } + if *conf.Json { + return logger_slog.New(conf) + } else { + return logger_default.New(conf) + } +} + +type T interface { + LogError(string, ...any) + LogWarning(string, ...any) + LogDebug(string, ...any) + LogInfo(string, ...any) + Close() +} + +type loggerLayer struct { + impl T + logger_name string +} + +func NewLayer(impl T, name_str string) T { + return &loggerLayer{ + impl: impl, + logger_name: name_str, + } +} + +func (t *loggerLayer) f(s string) string { + switch s { + case "": + return t.logger_name + default: + return fmt.Sprintf("%s. %s", t.logger_name, s) + } +} + +func (t *loggerLayer) LogError(s string, i ...any) { + t.impl.LogError(t.f(s), i...) +} +func (t *loggerLayer) LogWarning(s string, i ...any) { + t.impl.LogWarning(t.f(s), i...) +} +func (t *loggerLayer) LogDebug(s string, i ...any) { + t.impl.LogDebug(t.f(s), i...) +} +func (t *loggerLayer) LogInfo(s string, i ...any) { + t.impl.LogInfo(t.f(s), i...) +} +func (t *loggerLayer) Close() { + t.impl.Close() +} diff --git a/src/streamer/streamer.go b/lib/streamer/streamer.go similarity index 79% rename from src/streamer/streamer.go rename to lib/streamer/streamer.go index eae51e5..b42ac86 100644 --- a/src/streamer/streamer.go +++ b/lib/streamer/streamer.go @@ -11,31 +11,35 @@ import ( "os" "strings" - extractor "ytproxy-extractor" - logger "ytproxy-logger" + extractor "lib/extractor" + extractor_config "lib/extractor/config" + logger "lib/logger" ) const defaultErrorHeader = "Error-Header-" type ConfigT struct { - EnableErrorHeaders bool `json:"error-headers"` - IgnoreMissingHeaders bool `json:"ignore-missing-headers"` - IgnoreSSLErrors bool `json:"ignore-ssl-errors"` - ErrorVideoPath string `json:"error-video"` - ErrorAudioPath string `json:"error-audio"` - SetUserAgent SetUserAgentT `json:"set-user-agent"` - UserAgent string `json:"user-agent"` - Proxy string `json:"proxy"` - MinTlsVersion tlsVersion `json:"min-tls-version"` + EnableErrorHeaders *bool `json:"error-headers"` + IgnoreMissingHeaders *bool `json:"ignore-missing-headers"` + IgnoreSSLErrors *bool `json:"ignore-ssl-errors"` + ErrorVideoPath *string `json:"error-video"` + ErrorAudioPath *string `json:"error-audio"` + SetUserAgent *SetUserAgentT `json:"set-user-agent"` + UserAgent *string `json:"user-agent"` + Proxy *string `json:"proxy"` + MinTlsVersion *TlsVersion `json:"min-tls-version"` } -type tlsVersion uint16 +type TlsVersion uint16 -func (u *tlsVersion) UnmarshalJSON(b []byte) error { +func (u *TlsVersion) UnmarshalJSON(b []byte) error { var ( s string i uint16 ) + if u == nil { + return nil + } if err := json.Unmarshal(b, &s); err != nil { return err } @@ -47,7 +51,7 @@ func (u *tlsVersion) UnmarshalJSON(b []byte) error { } for i = 0; i < math.MaxUint16; i++ { if eq(tls.VersionName(i)) { - *u = tlsVersion(i) + *u = TlsVersion(i) return nil } } @@ -82,8 +86,8 @@ func (u *SetUserAgentT) UnmarshalJSON(b []byte) error { } type T interface { - Play(http.ResponseWriter, *http.Request, extractor.RequestT, extractor.ResultT) error - PlayError(http.ResponseWriter, extractor.RequestT, error) error + Play(http.ResponseWriter, *http.Request, extractor_config.RequestT, extractor_config.ResultT) error + PlayError(http.ResponseWriter, extractor_config.RequestT, error) error } type streamer struct { @@ -93,7 +97,7 @@ type streamer struct { sendErrorFile sendErrorFileF setHeaders func(http.ResponseWriter, *http.Response) error setStreamerUserAgent func(*http.Request) string - log *logger.T + log logger.T } type ( @@ -107,18 +111,18 @@ type fileT struct { contentLength int64 } -func New(conf ConfigT, log *logger.T, xt extractor.T) (T, error) { +func New(conf ConfigT, log logger.T, xt extractor.T) (T, error) { var ( s streamer err error logs []string ) - s.errorVideoFile, err = readFile(conf.ErrorVideoPath) + s.errorVideoFile, err = readFile(*conf.ErrorVideoPath) if err != nil { return &s, err } s.errorVideoFile.contentType = "video/mp4" - s.errorAudioFile, err = readFile(conf.ErrorAudioPath) + s.errorAudioFile, err = readFile(*conf.ErrorAudioPath) if err != nil { return &s, err } @@ -143,8 +147,8 @@ func New(conf ConfigT, log *logger.T, xt extractor.T) (T, error) { func (t *streamer) Play( w http.ResponseWriter, req *http.Request, - reqst extractor.RequestT, - rest extractor.ResultT, + reqst extractor_config.RequestT, + rest extractor_config.ResultT, ) error { // t.log.LogDebug("Streamer request", rest) // fail := func(str string, err error) { @@ -178,7 +182,8 @@ func (t *streamer) Play( return nil } -func (t *streamer) PlayError(w http.ResponseWriter, req extractor.RequestT, err error) error { +func (t *streamer) PlayError(w http.ResponseWriter, req extractor_config.RequestT, + err error) error { var file *fileT if req.FORMAT == "mp4" { file = &t.errorVideoFile @@ -229,7 +234,7 @@ func makeDoRequestFunc(conf ConfigT) (doRequestF, []string, error) { tr := &http.Transport{} logs := make([]string, 0) func() { - mintls := uint16(conf.MinTlsVersion) + mintls := uint16(*conf.MinTlsVersion) tr.TLSClientConfig = &tls.Config{MinVersion: mintls} if mintls > 0 { logs = append(logs, @@ -237,19 +242,19 @@ func makeDoRequestFunc(conf ConfigT) (doRequestF, []string, error) { tls.VersionName(mintls))) } }() - if conf.IgnoreSSLErrors { + if *conf.IgnoreSSLErrors { tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} logs = append(logs, "ignoring SSL errors") } - switch conf.Proxy { + switch *conf.Proxy { case "": logs = append(logs, "no proxy set") case "env": tr.Proxy = http.ProxyFromEnvironment logs = append(logs, "proxy set to environment") default: - logs = append(logs, fmt.Sprintf("proxy set to '%s'", conf.Proxy)) - u, err := url.Parse(conf.Proxy) + logs = append(logs, fmt.Sprintf("proxy set to '%s'", *conf.Proxy)) + u, err := url.Parse(*conf.Proxy) if err != nil { return func(r *http.Request) (*http.Response, error) { return &http.Response{}, nil @@ -267,7 +272,7 @@ func makeSendErrorVideoFunc(conf ConfigT) sendErrorFileF { return func(w http.ResponseWriter, err error, file fileT) error { w.Header().Set("Content-Length", fmt.Sprintf("%d", file.contentLength)) w.Header().Set("Content-Type", file.contentType) - if conf.EnableErrorHeaders { + if *conf.EnableErrorHeaders { hdrs, errs := errorToHeaders(err) for i := range hdrs { w.Header().Set(hdrs[i], errs[i]) @@ -279,7 +284,7 @@ func makeSendErrorVideoFunc(conf ConfigT) sendErrorFileF { } func makeSetHeaders(conf ConfigT) func(http.ResponseWriter, *http.Response) error { - headersStrictCheck := !conf.IgnoreMissingHeaders + headersStrictCheck := !*conf.IgnoreMissingHeaders return func(w http.ResponseWriter, res *http.Response) error { h1, ok := res.Header["Content-Length"] if !ok && headersStrictCheck { @@ -311,24 +316,24 @@ func makeSetHeaders(conf ConfigT) func(http.ResponseWriter, *http.Response) erro } } -func makeSetStreamerUserAgent(conf ConfigT, xt extractor.T, log *logger.T) (func(*http.Request) string, error) { - switch conf.SetUserAgent { +func makeSetStreamerUserAgent(conf ConfigT, xt extractor.T, log logger.T) (func(*http.Request) string, error) { + switch *conf.SetUserAgent { case Request: - log.LogDebug("Streamer User-Agent set to request-set") + log.LogDebug("", "User-Agent", "request-set") return func(r *http.Request) string { return r.UserAgent() }, nil case Extractor: ua, err := xt.GetUserAgent() - log.LogDebug("Streamer User-Agent set to", ua) + log.LogDebug("", "User-Agent", ua) return func(r *http.Request) string { return ua }, err case Config: ua := conf.UserAgent - log.LogDebug("Streamer User-Agent set to", ua) + log.LogDebug("", "User-Agent", ua) return func(r *http.Request) string { - return ua + return *ua }, nil default: return func(r *http.Request) string { return "" }, diff --git a/src/streamer/streamer_test.go b/lib/streamer/streamer_test.go similarity index 100% rename from src/streamer/streamer_test.go rename to lib/streamer/streamer_test.go diff --git a/src/cache/cache.go b/src/cache/cache.go deleted file mode 100644 index 27f1bcd..0000000 --- a/src/cache/cache.go +++ /dev/null @@ -1,93 +0,0 @@ -package cache - -import ( - "sync" - "time" - - extractor "ytproxy-extractor" - logger "ytproxy-logger" -) - -type T interface { - Add(extractor.RequestT, extractor.ResultT, time.Time) - Get(extractor.RequestT) (extractor.ResultT, bool) - CleanExpired(time.Time) []extractor.ResultT -} - -type ConfigT struct { - ExpireTime *string `json:"expire-time"` -} - -const defaultExpireTime = 3 * time.Hour - -func New(conf ConfigT, log *logger.T) (T, error) { - defCache := func(t time.Duration) *defaultCache { - return &defaultCache{ - cache: make(map[extractor.RequestT]extractor.ResultT), - expireTime: t, - } - } - switch { - case conf.ExpireTime == nil: - log.LogDebug("cache", "no expire time set in config, using default 3h") - return defCache(defaultExpireTime), nil - default: - t, err := time.ParseDuration(*conf.ExpireTime) - if err != nil { - return &defaultCache{}, err - } - if t.Seconds() < 1 { - log.LogDebug("cache", "disabled by config") - return &emptyCache{}, nil - } - return defCache(t), nil - } -} - -type defaultCache struct { - sync.Mutex - cache map[extractor.RequestT]extractor.ResultT - expireTime time.Duration -} - -func (t *defaultCache) Add(req extractor.RequestT, res extractor.ResultT, - now time.Time) { - res.Expire = now.Add(t.expireTime) - t.Lock() - t.cache[req] = res - t.Unlock() -} - -func (t *defaultCache) Get(req extractor.RequestT) (extractor.ResultT, bool) { - t.Lock() - defer t.Unlock() - v, ok := t.cache[req] - return v, ok -} - -func (t *defaultCache) CleanExpired(now time.Time) []extractor.ResultT { - deleted := make([]extractor.ResultT, 0) - t.Lock() - for k, v := range t.cache { - if v.Expire.Before(now) { - delete(t.cache, k) - deleted = append(deleted, v) - } - } - t.Unlock() - return deleted -} - -type emptyCache struct{} - -func (t *emptyCache) Add(req extractor.RequestT, res extractor.ResultT, - now time.Time) { -} - -func (t *emptyCache) Get(req extractor.RequestT) (extractor.ResultT, bool) { - return extractor.ResultT{}, false -} - -func (t *emptyCache) CleanExpired(now time.Time) []extractor.ResultT { - return []extractor.ResultT{} -} diff --git a/src/cache/go.mod b/src/cache/go.mod deleted file mode 100644 index dcd2b6c..0000000 --- a/src/cache/go.mod +++ /dev/null @@ -1,12 +0,0 @@ -module ytproxy-linkscache - -go 1.22 - -replace ytproxy-extractor => ../extractor - -replace ytproxy-logger => ../logger - -require ( - ytproxy-extractor v0.0.0-00010101000000-000000000000 - ytproxy-logger v0.0.0-00010101000000-000000000000 -) diff --git a/src/config/config.go b/src/config/config.go deleted file mode 100644 index c612cc4..0000000 --- a/src/config/config.go +++ /dev/null @@ -1,43 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "strings" - - extractor "ytproxy-extractor" - cache "ytproxy-linkscache" - logger "ytproxy-logger" - streamer "ytproxy-streamer" -) - -type configT struct { - PortInt uint16 `json:"port"` - Streamer streamer.ConfigT `json:"streamer"` - Extractor extractor.ConfigT `json:"extractor"` - Log logger.ConfigT `json:"log"` - Cache cache.ConfigT `json:"cache"` -} - -func Read(path string) (configT, error) { - var c configT - b, err := os.ReadFile(path) - if err != nil { - return c, err - } - func() { - strs := make([]string, 0) - for _, s := range strings.Split(string(b[:]), "\n") { - s = strings.TrimSpace(s) - if !strings.HasPrefix(s, "//") { - strs = append(strs, s) - } - } - str := strings.Join(strs, "\n") - b = b[:0] - b = []byte(str) - - }() - err = json.Unmarshal(b, &c) - return c, err -} diff --git a/src/config/go.mod b/src/config/go.mod deleted file mode 100644 index 1dd0b67..0000000 --- a/src/config/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module ytproxy-config - -go 1.22 - -replace ( - ytproxy-extractor => ../extractor - ytproxy-linkscache => ../cache - ytproxy-logger => ../logger - ytproxy-streamer => ../streamer -) - -require ( - ytproxy-extractor v0.0.0-00010101000000-000000000000 - ytproxy-linkscache v0.0.0-00010101000000-000000000000 - ytproxy-logger v0.0.0-00010101000000-000000000000 - ytproxy-streamer v0.0.0-00010101000000-000000000000 -) diff --git a/src/extractor/extractor.go b/src/extractor/extractor.go deleted file mode 100644 index 97193df..0000000 --- a/src/extractor/extractor.go +++ /dev/null @@ -1,140 +0,0 @@ -package extractor - -import ( - "bytes" - "errors" - "fmt" - "os/exec" - "strings" - "sync" - "text/template" - "time" - - logger "ytproxy-logger" -) - -const separator = ",," - -type ConfigT struct { - Path string `json:"path"` - MP4 string `json:"mp4"` - M4A string `json:"m4a"` - GetUserAgent string `json:"get-user-agent"` - CustomOptions []string `json:"custom-options"` -} - -type ResultT struct { - URL string - Expire time.Time -} - -type T interface { - Extract(RequestT) (ResultT, error) - GetUserAgent() (string, error) -} - -type defaultExtractor struct { - sync.Mutex - path string - mp4 *template.Template - m4a *template.Template - customOptions []*template.Template - getUserAgent string - logger *logger.T -} - -type RequestT struct { - URL string - HEIGHT string - FORMAT string -} - -func New(c ConfigT, log *logger.T) (T, error) { - var ( - e defaultExtractor - err error - ) - e.m4a, err = template.New("").Parse(c.M4A) - if err != nil { - return &e, err - } - e.mp4, err = template.New("").Parse(c.MP4) - if err != nil { - return &e, err - } - e.customOptions = make([]*template.Template, 0) - for _, v := range c.CustomOptions { - b, err := template.New("").Parse(v) - if err != nil { - return &e, err - } - e.customOptions = append(e.customOptions, b) - } - e.getUserAgent = c.GetUserAgent - e.path = c.Path - e.logger = log - return &e, nil -} - -func (t *defaultExtractor) GetUserAgent() (string, error) { - return t.runCmd(t.getUserAgent) -} - -func (t *defaultExtractor) Extract(req RequestT) (ResultT, error) { - var ( - buf bytes.Buffer - err error - ) - switch req.FORMAT { - case "m4a": - err = t.m4a.Execute(&buf, req) - case "mp4": - fallthrough - default: - err = t.mp4.Execute(&buf, req) - } - if err != nil { - return ResultT{}, err - } - bufOptions := make([]string, 0) - for _, v := range t.customOptions { - var b bytes.Buffer - err = v.Execute(&b, req) - if err != nil { - return ResultT{}, err - } - bufOptions = append(bufOptions, bytesToString(b)) - } - bufOptions = append(bufOptions, bytesToString(buf)) - out, err := t.runCmd(strings.Join(bufOptions, separator)) - if err != nil { - return ResultT{}, err - } - return ResultT{URL: out}, err -} - -func (t *defaultExtractor) runCmd(args string) (string, error) { - realargs := split(args) - t.Lock() - defer t.Unlock() - cmd := exec.Command(t.path, realargs...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - t.logger.LogDebug("Running", t.path, strings.Join(realargs, " ")) - err := cmd.Run() - outStr, errStr := bytesToString(stdout), bytesToString(stderr) - if err != nil { - combinedErrStr := fmt.Sprintf("%s\n%s\n%s", err.Error(), outStr, errStr) - return "", errors.New(combinedErrStr) - } - return outStr, nil -} - -func split(s string) []string { - return strings.Split(s, separator) -} - -func bytesToString(s bytes.Buffer) string { - return strings.TrimSpace(s.String()) -} diff --git a/src/extractor/go.mod b/src/extractor/go.mod deleted file mode 100644 index 1896994..0000000 --- a/src/extractor/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module ytproxy-extractor - -go 1.22 - -replace ytproxy-logger => ../logger - -require ytproxy-logger v0.0.0-00010101000000-000000000000 diff --git a/src/go.mod b/src/go.mod deleted file mode 100644 index 0f08f86..0000000 --- a/src/go.mod +++ /dev/null @@ -1,22 +0,0 @@ -module ytproxy - -go 1.22 - -replace ( - ytproxy-config => ./config - ytproxy-extractor => ./extractor - ytproxy-linkscache => ./cache - ytproxy-logger => ./logger - ytproxy-streamer => ./streamer -) - -require ( - ytproxy-config v0.0.0-00010101000000-000000000000 - ytproxy-extractor v0.0.0-00010101000000-000000000000 - ytproxy-linkscache v0.0.0-00010101000000-000000000000 -) - -require ( - ytproxy-logger v0.0.0-00010101000000-000000000000 - ytproxy-streamer v0.0.0-00010101000000-000000000000 -) diff --git a/src/logger/go.mod b/src/logger/go.mod deleted file mode 100644 index b6c2b67..0000000 --- a/src/logger/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module ytproxy-logger - -go 1.22 diff --git a/src/logger/logger.go b/src/logger/logger.go deleted file mode 100644 index 4747ddb..0000000 --- a/src/logger/logger.go +++ /dev/null @@ -1,141 +0,0 @@ -package logger - -import ( - "encoding/json" - "fmt" - "io" - "log" - "os" - "strings" -) - -type logFuncT func(string, ...interface{}) - -type T struct { - LogError logFuncT - LogWarning logFuncT - LogDebug logFuncT - LogInfo logFuncT -} - -type ConfigT struct { - Level LevelT `json:"level"` - Output OutputT `json:"output"` - FileName string `json:"filename"` -} - -type LevelT uint8 - -const ( - Debug LevelT = iota - Info - Warning - Error - Nothing -) - -func (l *LevelT) UnmarshalJSON(b []byte) error { - var s string - err := json.Unmarshal(b, &s) - if err != nil { - return err - } - switch s { - case "debug": - *l = Debug - case "info": - *l = Info - case "warning": - *l = Warning - case "error": - *l = Error - case "nothing": - *l = Nothing - default: - return fmt.Errorf("cannot unmarshal %s as log level", b) - } - return nil -} - -type OutputT uint8 - -const ( - Stdout OutputT = iota - File - Both -) - -func (o *OutputT) UnmarshalJSON(b []byte) error { - var s string - err := json.Unmarshal(b, &s) - if err != nil { - return err - } - switch s { - case "stdout": - *o = Stdout - case "file": - *o = File - case "both": - *o = Both - default: - return fmt.Errorf("cannot unmarshal %s as log output", b) - } - return nil -} - -func New(conf ConfigT) (*T, error) { - var logger = T{ - LogError: func(s string, i ...interface{}) {}, - LogWarning: func(s string, i ...interface{}) {}, - LogDebug: func(s string, i ...interface{}) {}, - LogInfo: func(s string, i ...interface{}) {}, - } - if conf.Level == Nothing { - return &logger, nil - } - var ( - l *log.Logger = log.Default() - ) - open := func() (*os.File, error) { - return os.OpenFile( - // will never close this file :| - // should trap exit - conf.FileName, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0664) - } - switch conf.Output { - case Stdout: - l.SetOutput(os.Stdout) - case File: - f, err := open() - if err != nil { - return &logger, err - } - l.SetOutput(f) - case Both: - f, err := open() - if err != nil { - return &logger, err - } - l.SetOutput(io.MultiWriter(os.Stdout, f)) - } - print := func(str string, s string, i []interface{}) { - l.Printf( - fmt.Sprintf("[ %s ] %s:", str, s) + - fmt.Sprintf(strings.Repeat(" %+v", len(i)), i...)) - } - switch conf.Level { - case Debug: - logger.LogDebug = func(s string, i ...interface{}) { print("DEBUG", s, i) } - fallthrough - case Info: - logger.LogInfo = func(s string, i ...interface{}) { print("INFO", s, i) } - fallthrough - case Warning: - logger.LogWarning = func(s string, i ...interface{}) { print("WARNING", s, i) } - fallthrough - case Error: - logger.LogError = func(s string, i ...interface{}) { print("ERROR", s, i) } - } - return &logger, nil -} diff --git a/src/main.go b/src/main.go deleted file mode 100644 index abe7f43..0000000 --- a/src/main.go +++ /dev/null @@ -1,166 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "time" - - config "ytproxy-config" - extractor "ytproxy-extractor" - cache "ytproxy-linkscache" - logger "ytproxy-logger" - streamer "ytproxy-streamer" -) - -const appVersion = "1.6.0" - -const ( - defaultVideoHeight = "720" - defaultVideoFormat = "mp4" - defaultExpireTime = 3 * 60 * 60 -) - -type flagsT struct { - version bool - config string -} - -const ( - NoError = iota - ConfigError - LoggerError - ExtractorError - StreamerError - WebServerError - CacheError -) - -func parseCLIFlags() flagsT { - var f flagsT - flag.BoolVar(&f.version, "version", false, "prints current yt-proxy version") - flag.StringVar(&f.config, "config", "config.json", "config file path") - flag.Parse() - return f -} - -func main() { - stdout := func(s string) { os.Stdout.WriteString(fmt.Sprintf("%s\n", s)) } - stderr := func(s string) { os.Stderr.WriteString(fmt.Sprintf("[ ERROR ] %s\n", s)) } - flags := parseCLIFlags() - if flags.version { - stdout(appVersion) - os.Exit(NoError) - } - checkOrExit := func(err error, name string, errorcode int) { - if err != nil { - stderr(fmt.Sprintf("%s create error.", name)) - stderr(err.Error()) - os.Exit(errorcode) - } - } - conf, err := config.Read(flags.config) - checkOrExit(err, "Config", ConfigError) - - log, err := logger.New(conf.Log) - checkOrExit(err, "Logger", LoggerError) - log.LogDebug("logger created") - - extr, err := extractor.New(conf.Extractor, log) - checkOrExit(err, "Extractor", ExtractorError) - log.LogDebug("extractor created") - - cache, err := cache.New(conf.Cache, log) - checkOrExit(err, "Cache", CacheError) - log.LogDebug("cache created") - - restreamer, err := streamer.New(conf.Streamer, log, extr) - checkOrExit(err, "Streamer", StreamerError) - log.LogDebug("streamer created") - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - log.LogInfo("Bad request", r.RemoteAddr, r.RequestURI) - log.LogDebug("Bad request", r) - http.NotFound(w, r) - }) - http.HandleFunc("/play/", func(w http.ResponseWriter, r *http.Request) { - log.LogInfo("Play request", r.RemoteAddr, r.RequestURI) - log.LogDebug("User request", r) - req, res, err := getLink(r.RequestURI, log, cache, extr) - if err != nil { - log.LogError("URL extract error", err) - restreamer.PlayError(w, req, err) - log.LogInfo("URL extract failed. Disconnecting", r.RemoteAddr) - return - } - err = restreamer.Play(w, r, req, res) - if err != nil { - log.LogError("Restream error", err) - restreamer.PlayError(w, req, err) - log.LogInfo("URL Restream failed. Disconnecting", r.RemoteAddr) - return - } - log.LogInfo("Player disconnected", r.RemoteAddr) - }) - port := fmt.Sprintf("%d", conf.PortInt) - s := &http.Server{ - Addr: ":" + port, - } - s.SetKeepAlivesEnabled(true) - log.LogInfo("Starting web server", port) - err = s.ListenAndServe() - if err != nil { - log.LogError("HTTP server start failed: ", err) - os.Exit(WebServerError) - } -} - -func getLink(query string, log *logger.T, cache cache.T, - extractor extractor.T) (extractor.RequestT, extractor.ResultT, error) { - now := time.Now() - req := parseQuery(query) - for _, v := range cache.CleanExpired(now) { - log.LogDebug("Clean expired cache", v) - } - log.LogInfo("Request", req) - if lnk, ok := cache.Get(req); ok { - return req, lnk, nil - } - res, err := extractor.Extract(req) - log.LogDebug("Not cached. Extractor returned", res) - if err != nil { - return req, res, err - } - cache.Add(req, res, now) - log.LogDebug("Cache add", res) - return req, res, nil -} - -func parseQuery(query string) extractor.RequestT { - var req extractor.RequestT - query = strings.TrimSpace(strings.TrimPrefix(query, "/play/")) - splitted := strings.Split(query, "?/?") - req.URL = splitted[0] - req.HEIGHT = defaultVideoHeight - req.FORMAT = defaultVideoFormat - if len(splitted) != 2 { - return req - } - tOpts, tErr := url.ParseQuery(splitted[1]) - if tErr == nil { - if tvh, ok := tOpts["vh"]; ok { - if tvh[0] == "360" || tvh[0] == "480" || tvh[0] == "720" { - req.HEIGHT = tvh[0] - } - } - if tvf, ok := tOpts["vf"]; ok { - if tvf[0] == "mp4" || tvf[0] == "m4a" { - req.FORMAT = tvf[0] - } - } - } - return req -} diff --git a/src/main_test.go b/src/main_test.go deleted file mode 100644 index de9bd53..0000000 --- a/src/main_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "testing" - - extractor "ytproxy-extractor" -) - -var testPairs = map[string]extractor.RequestT{ - "/play/youtu.be/jNQXAC9IVRw?/?vh=360&vf=mp4": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: "360", - FORMAT: "mp4", - }, - "/play/youtu.be/jNQXAC9IVRw?/?vh=720?vf=avi": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: "720", - FORMAT: defaultVideoFormat, - }, - "/play/youtu.be/jNQXAC9IVRw": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: defaultVideoHeight, - FORMAT: defaultVideoFormat, - }, - "/play/youtu.be/jNQXAC9IVRw?/?": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: defaultVideoHeight, - FORMAT: defaultVideoFormat, - }, - "/play/youtu.be/jNQXAC9IVRw?/?vf=avi": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: defaultVideoHeight, - FORMAT: defaultVideoFormat, - }, - "/play/youtu.be/jNQXAC9IVRw?/?vf=mp4": { - URL: "youtu.be/jNQXAC9IVRw", - HEIGHT: defaultVideoHeight, - FORMAT: "mp4", - }, -} - -func TestParseQuery(t *testing.T) { - for k, v := range testPairs { - if r := parseQuery(k); r != v { - t.Error("For", k, "expected", v, "got", r) - } - } -} diff --git a/src/streamer/go.mod b/src/streamer/go.mod deleted file mode 100644 index 07fa94b..0000000 --- a/src/streamer/go.mod +++ /dev/null @@ -1,13 +0,0 @@ -module ytproxy-streamer - -go 1.22 - -replace ( - ytproxy-extractor => ../extractor - ytproxy-logger => ../logger -) - -require ( - ytproxy-extractor v0.0.0-00010101000000-000000000000 - ytproxy-logger v0.0.0-00010101000000-000000000000 -)