Restructure to an MVC structure

This commit is contained in:
Evan Fiordeliso 2023-08-14 18:14:08 -04:00
parent 3704bb2231
commit b428fb2de2
13 changed files with 480 additions and 218 deletions

148
app/controllers/download.go Normal file
View File

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

52
app/controllers/home.go Normal file
View File

@ -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")
}
}

View File

@ -1,4 +1,4 @@
package app package models
import ( import (
"github.com/samber/lo" "github.com/samber/lo"
@ -10,10 +10,10 @@ type Video struct {
Formats []metadata.Format Formats []metadata.Format
} }
func GetVideos(meta *metadata.Metadata) []Video { func GetVideosFromMetadata(meta *metadata.Metadata) []Video {
if meta.IsPlaylist() { if meta.IsPlaylist() {
return lo.Map(meta.Entries, func(video metadata.Metadata, _ int) Video { return lo.Map(meta.Entries, func(video metadata.Metadata, _ int) Video {
return GetVideos(&video)[0] return GetVideosFromMetadata(&video)[0]
}) })
} }

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import (
//go:embed views/* //go:embed views/*
var viewsfs embed.FS var viewsfs embed.FS
var views = html.New( var Views = html.New(
viewsfs, viewsfs,
html.DefaultOptions(). html.DefaultOptions().
WithBaseDir("views"). WithBaseDir("views").
@ -43,7 +43,7 @@ var views = html.New(
) )
func init() { func init() {
if err := views.Load(); err != nil { if err := Views.Load(); err != nil {
panic(err) panic(err)
} }
} }

View File

@ -2,15 +2,16 @@
<h1>Download Video</h1> <h1>Download Video</h1>
<h2 class="fs-4 text-muted text-center">{{.Meta.Title}}</h2> <h2 class="fs-4 text-muted text-center">{{.Meta.Title}}</h2>
<p style="font-size: 0.85rem">{{.Url}}</p> <p style="font-size: 0.85rem">{{.Url}}</p>
<button <a
hx-get="{{.BasePath}}/home" href="{{.BasePath}}/"
hx-get="{{.BasePath}}/"
hx-trigger="click" hx-trigger="click"
hx-target="#main-content" hx-target="#main-content"
class="btn btn-secondary btn-sm mt-3" class="btn btn-secondary btn-sm mt-3"
style="width: 30rem; max-width: 100%" style="width: 30rem; max-width: 100%"
> >
Download Another Video Download Another Video
</button> </a>
</div> </div>
{{$root := .}} {{range $vidIndex, $video := .Videos}} {{if not (eq $vidIndex {{$root := .}} {{range $vidIndex, $video := .Videos}} {{if not (eq $vidIndex

View File

@ -61,7 +61,29 @@
</div> </div>
</button> </button>
</div> </div>
{{if .Error}}
<div class="alert alert-danger mt-4" role="" alert>{{.Error.Message}}</div>
{{end}}
</form> </form>
{{if .Error}}
<div class="alert alert-danger mt-4" role="alert">
<span>{{.Error.Message}}</span>
{{if .Error.RetryUrl}}
<button
class="btn btn-link btn-sm pt-0 lh-base text-decoration-none"
hx-get="/download"
hx-trigger="click"
hx-target="#main-content"
hx-swap="innerHTML"
hx-vals='{"url": "{{.Error.RetryUrl}}"}'
>
<span class="text-decoration-underline">Try Again</span>
<div
class="spinner-border spinner-border-sm htmx-indicator ms-1"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
</button>
{{end}}
</div>
{{end}}

View File

@ -6,10 +6,15 @@ package cmd
import ( import (
"os" "os"
"github.com/dgraph-io/badger/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "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/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" "golang.org/x/exp/slog"
) )
@ -24,10 +29,33 @@ var (
A web application that grabs the links to videos from over a A web application that grabs the links to videos from over a
thousand websites using the yt-dlp project under the hood.`, thousand websites using the yt-dlp project under the hood.`,
Run: func(cmd *cobra.Command, args []string) { RunE: func(cmd *cobra.Command, args []string) error {
if err := app.Serve(cfg); err != nil { logger := slog.Default()
slog.Error("Error when serving website", slog.String("error", err.Error()))
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().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("listen", "l", "", "address to listen on")
rootCmd.PersistentFlags().StringP("base-path", "b", "", "the base path, used when behind reverse proxy") 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") rootCmd.PersistentFlags().StringP("ytdlp-path", "y", "", "the path to the yt-dlp binary, used when it is not in $PATH")

120
pkg/htmx/htmx.go Normal file
View File

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

7
pkg/server/controller.go Normal file
View File

@ -0,0 +1,7 @@
package server
import "github.com/go-chi/chi/v5"
type Controller interface {
Router(r chi.Router)
}

32
pkg/server/options.go Normal file
View File

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

54
pkg/server/server.go Normal file
View File

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