diff --git a/app/controllers/download.go b/app/controllers/download.go new file mode 100644 index 0000000..1d132df --- /dev/null +++ b/app/controllers/download.go @@ -0,0 +1,148 @@ +package controllers + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" + "github.com/samber/lo" + "github.com/spf13/viper" + "go.fifitido.net/ytdl-web/app" + "go.fifitido.net/ytdl-web/app/models" + "go.fifitido.net/ytdl-web/pkg/htmx" + "go.fifitido.net/ytdl-web/pkg/httpx" + "go.fifitido.net/ytdl-web/pkg/server" + "go.fifitido.net/ytdl-web/pkg/view" + "go.fifitido.net/ytdl-web/ytdl" + "go.fifitido.net/ytdl-web/ytdl/metadata" + "golang.org/x/exp/slog" +) + +type DownloadController struct { + ytdl ytdl.Ytdl +} + +var _ server.Controller = (*DownloadController)(nil) + +func NewDownloadController(ytdl ytdl.Ytdl) *DownloadController { + return &DownloadController{ + ytdl: ytdl, + } +} + +func (c *DownloadController) Router(r chi.Router) { + r.Get("/", c.ListDownloadLinks) + r.Head("/proxy", c.ProxyDownload) + r.Get("/proxy", c.ProxyDownload) +} + +func (c *DownloadController) getUrlParam(r *http.Request) (string, bool) { + urlRaw, err := httpx.Query(r, "url") + if err != nil { + return "", false + } + + urlBytes, err := url.QueryUnescape(urlRaw) + if err != nil || len(urlBytes) < 1 { + return "", false + } + + return string(urlBytes), true +} + +func (c *DownloadController) ListDownloadLinks(w http.ResponseWriter, r *http.Request) { + hx := htmx.New(w, r) + layout := []string{} + if !hx.IsHtmxRequest() { + layout = append(layout, "layouts/main") + } + + videoUrl, ok := c.getUrlParam(r) + if !ok { + app.Views.Render(w, "index", view.Data{ + "BasePath": viper.GetString("base_path"), + "Error": view.Data{ + "Message": "Invalid URL", + }, + }, layout...) + return + } + + meta, err := c.ytdl.GetMetadata(videoUrl) + if err != nil { + app.Views.Render(w, "index", view.Data{ + "BasePath": viper.GetString("base_path"), + "Error": view.Data{ + "Message": "Could not find a video at that url", + "RetryUrl": videoUrl, + }, + }, layout...) + return + } + + if hx.IsHtmxRequest() { + hx.PushUrl("/download?url=" + url.QueryEscape(videoUrl)) + } + + app.Views.Render(w, "download", view.Data{ + "BasePath": viper.GetString("base_path"), + "Url": videoUrl, + "Meta": meta, + "Videos": models.GetVideosFromMetadata(meta), + }, layout...) +} + +func (c *DownloadController) ProxyDownload(w http.ResponseWriter, r *http.Request) { + videoUrl, ok := c.getUrlParam(r) + if !ok { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + formatId, err := httpx.Query(r, "format") + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + meta, err := c.ytdl.GetMetadata(videoUrl) + if err != nil { + slog.Error("Failed to get metadata", slog.String("error", err.Error())) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + videos := models.GetVideosFromMetadata(meta) + + index, err := httpx.QueryInt(r, "index") + if err != nil || index < 0 || index >= len(videos) { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + video := videos[index] + format, ok := lo.Find(video.Formats, func(format metadata.Format) bool { + return format.FormatID == formatId + }) + if !ok { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.%s\"", meta.ID, format.Resolution, format.Ext)) + if format.Filesize != nil { + w.Header().Set("Content-Length", fmt.Sprint(*format.Filesize)) + } else if format.FilesizeApprox != nil { + w.Header().Set("Content-Length", fmt.Sprint(*format.FilesizeApprox)) + } + + if len(videos) == 1 { + index = -1 + } + + if err := c.ytdl.Download(w, videoUrl, format.FormatID, index); err != nil { + slog.Error("Failed to download", slog.String("error", err.Error())) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } +} diff --git a/app/controllers/home.go b/app/controllers/home.go new file mode 100644 index 0000000..90b3fca --- /dev/null +++ b/app/controllers/home.go @@ -0,0 +1,52 @@ +package controllers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/spf13/viper" + "go.fifitido.net/ytdl-web/app" + "go.fifitido.net/ytdl-web/pkg/htmx" + "go.fifitido.net/ytdl-web/pkg/server" + "go.fifitido.net/ytdl-web/pkg/view" + "go.fifitido.net/ytdl-web/version" + "go.fifitido.net/ytdl-web/ytdl" +) + +type HomeController struct { + ytdl ytdl.Ytdl +} + +var _ server.Controller = (*HomeController)(nil) + +func NewHomeController(ytdl ytdl.Ytdl) *HomeController { + return &HomeController{ + ytdl: ytdl, + } +} + +func (c *HomeController) Router(r chi.Router) { + r.Get("/", c.Index) +} + +func (c *HomeController) Index(w http.ResponseWriter, r *http.Request) { + hx := htmx.New(w, r) + + if hx.IsHtmxRequest() { + hx.PushUrl("/") + + app.Views.Render(w, "index", view.Data{ + "BasePath": viper.GetString("base_path"), + "Version": version.Version, + "Build": version.Build, + "BinaryVersion": c.ytdl.Version(), + }) + } else { + app.Views.Render(w, "index", view.Data{ + "BasePath": viper.GetString("base_path"), + "Version": version.Version, + "Build": version.Build, + "BinaryVersion": c.ytdl.Version(), + }, "layouts/main") + } +} diff --git a/app/formats.go b/app/models/video.go similarity index 83% rename from app/formats.go rename to app/models/video.go index 1b15f85..4231abe 100644 --- a/app/formats.go +++ b/app/models/video.go @@ -1,4 +1,4 @@ -package app +package models import ( "github.com/samber/lo" @@ -10,10 +10,10 @@ type Video struct { Formats []metadata.Format } -func GetVideos(meta *metadata.Metadata) []Video { +func GetVideosFromMetadata(meta *metadata.Metadata) []Video { if meta.IsPlaylist() { return lo.Map(meta.Entries, func(video metadata.Metadata, _ int) Video { - return GetVideos(&video)[0] + return GetVideosFromMetadata(&video)[0] }) } diff --git a/app/router.go b/app/router.go deleted file mode 100644 index 75c1807..0000000 --- a/app/router.go +++ /dev/null @@ -1,167 +0,0 @@ -package app - -import ( - "fmt" - "net/http" - "net/url" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/samber/lo" - "github.com/spf13/viper" - "go.fifitido.net/ytdl-web/pkg/httpx" - "go.fifitido.net/ytdl-web/pkg/middlewarex" - "go.fifitido.net/ytdl-web/pkg/view" - "go.fifitido.net/ytdl-web/version" - "go.fifitido.net/ytdl-web/ytdl" - "go.fifitido.net/ytdl-web/ytdl/metadata" - "golang.org/x/exp/slog" -) - -func Router(ytdl ytdl.Ytdl) http.Handler { - r := chi.NewRouter() - - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middlewarex.SlogRequestLogger(slog.Default().With("module", "app"))) - r.Use(middleware.Recoverer) - - r.Get("/", indexHandler(ytdl)) - r.Get("/home", homeIndex) - - r.Route("/download", func(r chi.Router) { - r.Get("/", listDownloads(ytdl)) - r.Head("/proxy", proxyDownload(ytdl)) - r.Get("/proxy", proxyDownload(ytdl)) - }) - - return r -} - -func indexHandler(ytdl ytdl.Ytdl) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - views.Render(w, "index", view.Data{ - "BasePath": viper.GetString("base_path"), - "Version": version.Version, - "Build": version.Build, - "BinaryVersion": ytdl.Version(), - }, "layouts/main") - } -} - -func homeIndex(w http.ResponseWriter, r *http.Request) { - views.Render(w, "index", view.Data{ - "BasePath": viper.GetString("base_path"), - }) -} - -func listDownloads(ytdl ytdl.Ytdl) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - urlBytes, err := url.QueryUnescape(r.URL.Query()["url"][0]) - if err != nil { - views.Render(w, "index", view.Data{ - "BasePath": viper.GetString("base_path"), - "Error": view.Data{ - "Message": "Invalid URL", - }, - }) - return - } - url := string(urlBytes) - if len(url) < 1 { - views.Render(w, "index", view.Data{ - "BasePath": viper.GetString("base_path"), - "Error": view.Data{ - "Message": "Invalid URL", - }, - }) - return - } - - meta, err := ytdl.GetMetadata(url) - if err != nil { - views.Render(w, "index", view.Data{ - "BasePath": viper.GetString("base_path"), - "Error": view.Data{ - "Message": "Could not find a video at that url, maybe try again?", - }, - }) - return - } - - views.Render(w, "download", view.Data{ - "BasePath": viper.GetString("base_path"), - "Url": url, - "Meta": meta, - "Videos": GetVideos(meta), - }) - } -} - -func proxyDownload(ytdl ytdl.Ytdl) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - rawUrl, err := httpx.Query(r, "url") - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - urlBytes, err := url.QueryUnescape(rawUrl) - if err != nil { - slog.Error("Failed to decode url param", slog.String("error", err.Error())) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - url := string(urlBytes) - if len(url) < 1 { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - formatId, err := httpx.Query(r, "format") - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - meta, err := ytdl.GetMetadata(url) - if err != nil { - slog.Error("Failed to get metadata", slog.String("error", err.Error())) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - videos := GetVideos(meta) - - index, err := httpx.QueryInt(r, "index") - if err != nil || index < 0 || index >= len(videos) { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - video := videos[index] - format, ok := lo.Find(video.Formats, func(format metadata.Format) bool { - return format.FormatID == formatId - }) - if !ok { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.%s\"", meta.ID, format.Resolution, format.Ext)) - if format.Filesize != nil { - w.Header().Set("Content-Length", fmt.Sprint(*format.Filesize)) - } else if format.FilesizeApprox != nil { - w.Header().Set("Content-Length", fmt.Sprint(*format.FilesizeApprox)) - } - - if len(videos) == 1 { - index = -1 - } - - if err := ytdl.Download(w, url, format.FormatID, index); err != nil { - slog.Error("Failed to download", slog.String("error", err.Error())) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } -} diff --git a/app/serve.go b/app/serve.go deleted file mode 100644 index 69a7bd8..0000000 --- a/app/serve.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "fmt" - "net/http" - - "github.com/dgraph-io/badger/v2" - "go.fifitido.net/ytdl-web/config" - "go.fifitido.net/ytdl-web/utils" - "go.fifitido.net/ytdl-web/ytdl" - "go.fifitido.net/ytdl-web/ytdl/cache" - "golang.org/x/exp/slog" -) - -func Serve(cfg *config.Config) error { - logger := slog.Default() - - db, err := badger.Open( - badger. - DefaultOptions(cfg.Cache.DirPath). - WithLogger(utils.NewBadgerLogger(slog.With("module", "badger"))), - ) - if err != nil { - return err - } - defer db.Close() - - cache := cache.NewDefaultMetadataCache(db) - ytdl := ytdl.NewYtdl(cfg, slog.Default(), cache) - router := Router(ytdl) - - listenAddr := fmt.Sprintf("%s:%d", cfg.HTTP.Listen, cfg.HTTP.Port) - logger.Info("Starting HTTP server", slog.String("host", cfg.HTTP.Listen), slog.Int("port", cfg.HTTP.Port)) - return http.ListenAndServe(listenAddr, router) -} diff --git a/app/views.go b/app/views.go index 58d6403..f0f0a87 100644 --- a/app/views.go +++ b/app/views.go @@ -14,7 +14,7 @@ import ( //go:embed views/* var viewsfs embed.FS -var views = html.New( +var Views = html.New( viewsfs, html.DefaultOptions(). WithBaseDir("views"). @@ -43,7 +43,7 @@ var views = html.New( ) func init() { - if err := views.Load(); err != nil { + if err := Views.Load(); err != nil { panic(err) } } diff --git a/app/views/download.html b/app/views/download.html index fc28ef7..4702586 100644 --- a/app/views/download.html +++ b/app/views/download.html @@ -2,15 +2,16 @@

Download Video

{{.Meta.Title}}

{{.Url}}

- + {{$root := .}} {{range $vidIndex, $video := .Videos}} {{if not (eq $vidIndex diff --git a/app/views/index.html b/app/views/index.html index 60d8042..fc57eae 100644 --- a/app/views/index.html +++ b/app/views/index.html @@ -61,7 +61,29 @@ - {{if .Error}} -
{{.Error.Message}}
- {{end}} + +{{if .Error}} + +{{end}} diff --git a/cmd/root.go b/cmd/root.go index 15c9161..e93d4ce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,10 +6,15 @@ package cmd import ( "os" + "github.com/dgraph-io/badger/v2" "github.com/spf13/cobra" "github.com/spf13/viper" - "go.fifitido.net/ytdl-web/app" + "go.fifitido.net/ytdl-web/app/controllers" "go.fifitido.net/ytdl-web/config" + "go.fifitido.net/ytdl-web/pkg/server" + "go.fifitido.net/ytdl-web/utils" + "go.fifitido.net/ytdl-web/ytdl" + "go.fifitido.net/ytdl-web/ytdl/cache" "golang.org/x/exp/slog" ) @@ -24,10 +29,33 @@ var ( A web application that grabs the links to videos from over a thousand websites using the yt-dlp project under the hood.`, - Run: func(cmd *cobra.Command, args []string) { - if err := app.Serve(cfg); err != nil { - slog.Error("Error when serving website", slog.String("error", err.Error())) + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.Default() + + db, err := badger.Open( + badger. + DefaultOptions(cfg.Cache.DirPath). + WithLogger(utils.NewBadgerLogger(logger.With("module", "badger"))), + ) + if err != nil { + return err } + defer db.Close() + + cache := cache.NewDefaultMetadataCache(db) + ytdl := ytdl.NewYtdl(cfg, slog.Default(), cache) + + s := server.New( + server.DefaultServerOptions(). + WithListenAddr(viper.GetString("http.listen")). + WithListenPort(viper.GetInt("http.port")). + WithLogger(logger.With("module", "server")), + ) + + s.MountController("/", controllers.NewHomeController(ytdl)) + s.MountController("/download", controllers.NewDownloadController(ytdl)) + + return s.ListenAndServe() }, } ) @@ -44,7 +72,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $XDG_CONFIG_HOME/ytdl-web/config.yml)") - rootCmd.PersistentFlags().IntP("port", "p", 0, "port to listen on") + rootCmd.PersistentFlags().IntP("port", "p", 8080, "port to listen on") rootCmd.PersistentFlags().StringP("listen", "l", "", "address to listen on") rootCmd.PersistentFlags().StringP("base-path", "b", "", "the base path, used when behind reverse proxy") rootCmd.PersistentFlags().StringP("ytdlp-path", "y", "", "the path to the yt-dlp binary, used when it is not in $PATH") diff --git a/pkg/htmx/htmx.go b/pkg/htmx/htmx.go new file mode 100644 index 0000000..b242ab8 --- /dev/null +++ b/pkg/htmx/htmx.go @@ -0,0 +1,120 @@ +package htmx + +import "net/http" + +type HTMX struct { + w http.ResponseWriter + r *http.Request +} + +func New(w http.ResponseWriter, r *http.Request) *HTMX { + return &HTMX{ + w: w, + r: r, + } +} + +// +// Request header methods +// + +// True when the request is an HTMX request +func (h *HTMX) IsHtmxRequest() bool { + return h.r.Header.Get("HX-Request") == "true" +} + +// Indicates that the request is via an element using hx-boost +func (h *HTMX) IsBoosted() bool { + return h.r.Header.Get("HX-Boosted") == "true" +} + +// The current URL of the browser +func (h *HTMX) CurrentUrl() string { + return h.r.Header.Get("HX-Current-URL") +} + +// If the request is for history restoration after a miss in the local history cache +func (h *HTMX) IsHistoryRestore() bool { + return h.r.Header.Get("HX-History-Restore") == "true" +} + +// The id of the element that triggered the request +func (h *HTMX) TriggerId() string { + return h.r.Header.Get("HX-Trigger") +} + +// The name of the element that triggered the request +func (h *HTMX) TriggerName() string { + return h.r.Header.Get("HX-Trigger-Name") +} + +// The id of the target element +func (h *HTMX) TargetId() string { + return h.r.Header.Get("HX-Target") +} + +// The value entered by the user when prompted via hx-prompt +func (h *HTMX) Prompt() string { + return h.r.Header.Get("HX-Prompt") +} + +// +// Response header methods +// + +// Pushe a new url into the history stack +func (h *HTMX) PushUrl(url string) { + h.w.Header().Set("HX-Push", url) +} + +// Trigger a client-side redirect to a new location +func (h *HTMX) RedirectTo(url string) { + h.w.Header().Set("HX-Redirect", url) +} + +// Triggers a client-side redirect to a new location that acts as a swap +func (h *HTMX) Location(url string) { + h.w.Header().Set("Location", url) +} + +// Replace the current URL in the location bar +func (h *HTMX) ReplaceUrl(url string) { + h.w.Header().Set("HX-Replace-Url", url) +} + +// Sets refresh to true causing the client side to do a full refresh of the page +func (h *HTMX) Refresh() { + h.w.Header().Set("HX-Refresh", "true") +} + +// Trigger client side events +func (h *HTMX) Trigger(name string) { + h.w.Header().Set("HX-Trigger", name) +} + +// Trigger client side events after the swap step +func (h *HTMX) TriggerAfterSwap(name string) { + h.w.Header().Set("HX-Trigger-After-Swap", name) +} + +// Trigger client side events after the settle step +func (h *HTMX) TriggerAfterSettle(name string) { + h.w.Header().Set("HX-Trigger-After-Settle", name) +} + +// Specify how the response will be swapped. See hx-swap for possible values +func (h *HTMX) Reswap(value string) { + h.w.Header().Set("HX-Reswap", value) +} + +// Update the target of the content update to a different element on the page +// Value should be a CSS selector. +func (h *HTMX) Retarget(value string) { + h.w.Header().Set("HX-Retarget", value) +} + +// Choose which part of the response is used to be swapped in. Overrides an existing hx-select on the triggering element. +// Value should be a CSS selector. +func (h *HTMX) Reselect(value string) { + h.w.Header().Set("HX-Reselect", value) +} diff --git a/pkg/server/controller.go b/pkg/server/controller.go new file mode 100644 index 0000000..d1cb610 --- /dev/null +++ b/pkg/server/controller.go @@ -0,0 +1,7 @@ +package server + +import "github.com/go-chi/chi/v5" + +type Controller interface { + Router(r chi.Router) +} diff --git a/pkg/server/options.go b/pkg/server/options.go new file mode 100644 index 0000000..7b03910 --- /dev/null +++ b/pkg/server/options.go @@ -0,0 +1,32 @@ +package server + +import "golang.org/x/exp/slog" + +type ServerOptions struct { + ListenAddr string + ListenPort int + Logger *slog.Logger +} + +func DefaultServerOptions() *ServerOptions { + return &ServerOptions{ + ListenAddr: "127.0.0.1", + ListenPort: 8080, + Logger: slog.Default(), + } +} + +func (o *ServerOptions) WithListenAddr(addr string) *ServerOptions { + o.ListenAddr = addr + return o +} + +func (o *ServerOptions) WithListenPort(port int) *ServerOptions { + o.ListenPort = port + return o +} + +func (o *ServerOptions) WithLogger(logger *slog.Logger) *ServerOptions { + o.Logger = logger + return o +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..90119cb --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,54 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "go.fifitido.net/ytdl-web/pkg/middlewarex" + "golang.org/x/exp/slog" +) + +type Server interface { + MountController(path string, controller Controller) + ListenAndServe() error +} + +type DefaultServer struct { + r chi.Router + opts *ServerOptions +} + +var _ Server = (*DefaultServer)(nil) + +func New(options ...*ServerOptions) *DefaultServer { + var opts *ServerOptions + if len(options) > 0 { + opts = options[0] + } else { + opts = DefaultServerOptions() + } + + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middlewarex.SlogRequestLogger(opts.Logger)) + r.Use(middleware.Recoverer) + + return &DefaultServer{ + r: r, + opts: opts, + } +} + +func (s *DefaultServer) MountController(path string, controller Controller) { + s.r.Route(path, controller.Router) +} + +func (s *DefaultServer) ListenAndServe() error { + listenAddr := fmt.Sprintf("%s:%d", s.opts.ListenAddr, s.opts.ListenPort) + s.opts.Logger.Info("Starting HTTP server", slog.String("addr", s.opts.ListenAddr), slog.Int("port", s.opts.ListenPort)) + return http.ListenAndServe(listenAddr, s.r) +}