From 91be8274c4b42bd509600a93c81c3ee1bc351978 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 16 Mar 2025 18:11:34 +0100 Subject: [PATCH] UI is largely working --- go.mod | 3 +- go.sum | 6 +- html_debug.go | 4 +- viewmodels.go | 2 + web/frag-header.go.html | 8 + web/frag-index.go.html | 32 +++ web/index.go.html | 11 + web/index.gohtml | 28 --- web/{video.gohtml => video.go.html} | 6 +- webview.go | 369 ++++++++++++++++++---------- 10 files changed, 298 insertions(+), 171 deletions(-) create mode 100644 web/frag-header.go.html create mode 100644 web/frag-index.go.html create mode 100644 web/index.go.html delete mode 100644 web/index.gohtml rename web/{video.gohtml => video.go.html} (88%) diff --git a/go.mod b/go.mod index 989b898..47388c0 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,8 @@ go 1.24 toolchain go1.24.0 require ( - gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0 + gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0 + github.com/donseba/go-htmx v1.12.0 github.com/playwright-community/playwright-go v0.4902.0 ) diff --git a/go.sum b/go.sum index 24ade2a..71347c2 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,12 @@ -gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0 h1:Tqo9jnPXBDzIiMh+CrrFd5epITbG8UWqVfpLk21R/C0= -gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0 h1:gq75Ce7QTQ5Rj5fzS/6eeOA/enyV0oDMVt5mejwX14Y= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.7.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k= github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE= +github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= diff --git a/html_debug.go b/html_debug.go index da03c80..77c1942 100644 --- a/html_debug.go +++ b/html_debug.go @@ -12,9 +12,9 @@ func staticFileServer() http.Handler { } func renderTemplate(wr io.Writer, templateName string, viewModel any) error { - templates, err := template.ParseGlob("web/*.gohtml") + templates, err := template.ParseGlob("web/*.go.html") if err != nil { return err } - return templates.ExecuteTemplate(wr, templateName+".gohtml", viewModel) + return templates.ExecuteTemplate(wr, templateName+".go.html", viewModel) } diff --git a/viewmodels.go b/viewmodels.go index e7242ad..df7047d 100644 --- a/viewmodels.go +++ b/viewmodels.go @@ -2,9 +2,11 @@ package main type VideoInfoVM struct { ID int + PreviousID int Title string Description string Thumbnail string + IsWatched bool } type VideoPlayerVM struct { diff --git a/web/frag-header.go.html b/web/frag-header.go.html new file mode 100644 index 0000000..f422c4e --- /dev/null +++ b/web/frag-header.go.html @@ -0,0 +1,8 @@ +{{define "frag-header"}} + + + Viva++ + + + +{{end}} diff --git a/web/frag-index.go.html b/web/frag-index.go.html new file mode 100644 index 0000000..9aeef0c --- /dev/null +++ b/web/frag-index.go.html @@ -0,0 +1,32 @@ +{{- /*gotype: vivaplusdl.VideoInfoVM*/}} +{{define "frag-index"}} +
+ +
+

{{.Title}}

+

{{.Description}}

+
+ Watch Now +
+ +
+ {{if ne .PreviousID 0}} + Show Previous + {{end}} +
+
+
+{{end}} diff --git a/web/index.go.html b/web/index.go.html new file mode 100644 index 0000000..7bd934b --- /dev/null +++ b/web/index.go.html @@ -0,0 +1,11 @@ +{{- /*gotype: vivaplusdl.VideoInfoVM*/}} + + +{{template "frag-header" .}} + +

Viva++

+
+ {{template "frag-index" .}} +
+ + diff --git a/web/index.gohtml b/web/index.gohtml deleted file mode 100644 index 26079c7..0000000 --- a/web/index.gohtml +++ /dev/null @@ -1,28 +0,0 @@ -{{- /*gotype: vivaplusdl.VideoInfoVM*/}} - - - - - Viva++ - - - -

Viva++

-
-
- -
-

{{.Title}}

-

{{.Description}}

-
-
-
- - diff --git a/web/video.gohtml b/web/video.go.html similarity index 88% rename from web/video.gohtml rename to web/video.go.html index 7ee3f50..99dba47 100644 --- a/web/video.gohtml +++ b/web/video.go.html @@ -1,11 +1,7 @@ {{- /*gotype: vivaplusdl.VideoPlayerVM*/}} - - - {{.Title}} - Viva++ - - + {{template "frag-header" .}}

Viva++

diff --git a/webview.go b/webview.go index e187652..83019f1 100644 --- a/webview.go +++ b/webview.go @@ -10,17 +10,29 @@ import ( "strings" "gitea.seeseepuff.be/seeseemelk/mysqlite" + "github.com/donseba/go-htmx" ) +type App struct { + db *mysqlite.Db + htmx *htmx.HTMX +} + func serveWebview(db *mysqlite.Db) { addr := "localhost:8081" + app := &App{ + db: db, + htmx: htmx.New(), + } + log.Printf("Listening on %s", addr) http.Handle("/static/", http.StripPrefix("/static/", staticFileServer())) - http.HandleFunc("/video/", serveVideo(db)) - http.HandleFunc("/stream/", serveStream(db)) - http.HandleFunc("/", serveIndex(db)) - // http.HandleFunc("/video/", handleVideoActions(db)) + http.HandleFunc("/video/", app.serveVideo) + http.HandleFunc("/stream/", app.serveStream) + http.HandleFunc("/mark-watched/", app.handleMarkWatched) + http.HandleFunc("/mark-unwatched/", app.handleMarkUnwatched) + http.HandleFunc("/", app.serveIndex) err := http.ListenAndServe(addr, nil) if err != nil { @@ -28,152 +40,243 @@ func serveWebview(db *mysqlite.Db) { } } -func serveIndex(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - // Query the database for the oldest unwatched episode - var id int - var title, description, thumbnail string - err := db.Query(`SELECT id, title, description, thumbnail FROM videos WHERE (watch_state IS NULL OR watch_state != 'watched') ORDER BY upload_date, episode LIMIT 1`).ScanSingle(&id, &title, &description, &thumbnail) - if err != nil { - http.Error(w, "No unwatched episodes found", http.StatusNotFound) - return - } - - vm := VideoInfoVM{ - ID: id, - Title: title, - Description: description, - Thumbnail: thumbnail, - } - err = renderTemplate(w, "index", vm) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } +func (a *App) renderTemplate(w http.ResponseWriter, name string, viewmodel interface{}) { + err := renderTemplate(w, "index", viewmodel) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } } -func serveVideo(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } +func (a *App) getViewModelForVideo(id int) (*VideoInfoVM, error) { + // Query the database for the requested video + var title, description, thumbnail string + var watchState *string + err := a.db.Query(`SELECT title, description, thumbnail, watch_state FROM videos WHERE id = ? LIMIT 1`).Bind(id).ScanSingle(&title, &description, &thumbnail, &watchState) + if err != nil { + return nil, err + } - // Parse the video ID from the URL - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 3 { + // Find the previous video ID based on upload date and episode number + var previousID int + err = a.db.Query(` + SELECT id FROM videos + WHERE (upload_date < (SELECT upload_date FROM videos WHERE id = ?)) + OR (upload_date = (SELECT upload_date FROM videos WHERE id = ?) AND episode < (SELECT episode FROM videos WHERE id = ?)) + ORDER BY upload_date DESC, episode DESC + LIMIT 1 + `).Bind(id, id, id).ScanSingle(&previousID) + if err != nil { + // If there's no previous video, set previousID to 0 or -1 to indicate no previous video + previousID = 0 + } + + // Determine if the video has been watched + isWatched := watchState != nil && *watchState == "watched" + + return &VideoInfoVM{ + ID: id, + PreviousID: previousID, + Title: title, + Description: description, + Thumbnail: thumbnail, + IsWatched: isWatched, + }, nil +} + +func (a *App) getViewModelForUnwachtedVideo() (*VideoInfoVM, error) { + // Query the database for the oldest unwatched episode + var id int + err := a.db.Query(`SELECT id FROM videos WHERE (watch_state IS NULL OR watch_state != 'watched') ORDER BY upload_date, episode LIMIT 1`).ScanSingle(&id) + if err != nil { + return nil, err + } + return a.getViewModelForVideo(id) +} + +func (a *App) serveIndex(w http.ResponseWriter, r *http.Request) { + var vm *VideoInfoVM + var err error + + // Check if a specific video ID was requested + videoIDParam := r.URL.Query().Get("video") + if videoIDParam != "" { + videoID, parseErr := strconv.Atoi(videoIDParam) + if parseErr == nil { + vm, err = a.getViewModelForVideo(videoID) + } else { http.Error(w, "Invalid video ID", http.StatusBadRequest) return } + } else { + // Fall back to the default behavior - get the oldest unwatched video + vm, err = a.getViewModelForUnwachtedVideo() + } - videoIDStr := pathParts[2] - videoID, err := strconv.Atoi(videoIDStr) + if err != nil { + log.Printf("Failed to find video: %v", err) + http.Error(w, "Failed to find video", http.StatusInternalServerError) + return + } + a.renderTemplate(w, "index", vm) +} + +func (a *App) serveVideo(w http.ResponseWriter, r *http.Request) { + // Parse the video ID from the URL + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) < 3 { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + videoIDStr := pathParts[2] + videoID, err := strconv.Atoi(videoIDStr) + if err != nil { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + // Query the database for the video with the given ID + var title, description string + err = a.db.Query(`SELECT title, description FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&title, &description) + if err != nil { + log.Printf("Failed to find video: %v", err) + http.Error(w, "Video not found", http.StatusNotFound) + return + } + + vm := VideoPlayerVM{ + ID: videoID, + Title: title, + Description: description, + } + a.renderTemplate(w, "video", vm) +} + +func (a *App) serveStream(w http.ResponseWriter, r *http.Request) { + // Parse the video ID from the URL + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) < 3 { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + videoIDStr := pathParts[2] + videoID, err := strconv.Atoi(videoIDStr) + if err != nil { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + // Get video metadata from database + var year, episode int + err = a.db.Query(`SELECT year, episode FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&year, &episode) + if err != nil { + http.Error(w, "Video not found", http.StatusNotFound) + return + } + + // Determine the video file path + downloadDir := os.Getenv("VIVAPLUS_DESTINATION") + if downloadDir == "" { + downloadDir = "./downloads" + } + + seasonDir := filepath.Join(downloadDir, fmt.Sprintf("Season %d", year)) + videoFilename := fmt.Sprintf("S%dE%03d.mp4", year, episode) + videoPath := filepath.Join(seasonDir, videoFilename) + + // Check if the file exists + log.Printf("Streaming video: %s", videoPath) + _, err = os.Stat(videoPath) + if os.IsNotExist(err) { + http.Error(w, "Video file not found", http.StatusNotFound) + return + } + + // Serve the file + http.ServeFile(w, r, videoPath) +} + +func (a *App) handleMarkWatched(w http.ResponseWriter, r *http.Request) { + // Only handle POST requests + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse the video ID from the URL + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) < 3 { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + videoIDStr := pathParts[2] + videoID, err := strconv.Atoi(videoIDStr) + if err != nil { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + // Update the watch state in the database + err = a.db.Query(`UPDATE videos SET watch_state = NULL WHERE id = ?`).Bind(videoID).Exec() + if err != nil { + http.Error(w, "Failed to update watch state", http.StatusInternalServerError) + return + } + + h := a.htmx.NewHandler(w, r) + if h.RenderPartial() { + vm, err := a.getViewModelForVideo(videoID) if err != nil { - http.Error(w, "Invalid video ID", http.StatusBadRequest) + http.Error(w, "Failed to find video", http.StatusInternalServerError) return } - - // Query the database for the video with the given ID - var title, description string - err = db.Query(`SELECT title, description FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&title, &description) - if err != nil { - http.Error(w, "Video not found", http.StatusNotFound) - return - } - - vm := VideoPlayerVM{ - ID: videoID, - Title: title, - Description: description, - } - err = renderTemplate(w, "video", vm) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + a.renderTemplate(w, "frag-index", vm) + } else { + // Redirect back to the homepage + http.Redirect(w, r, "/", http.StatusSeeOther) } } -func serveStream(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - // Parse the video ID from the URL - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 3 { - http.Error(w, "Invalid video ID", http.StatusBadRequest) - return - } +func (a *App) handleMarkUnwatched(w http.ResponseWriter, r *http.Request) { + // Only handle POST requests + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } - videoIDStr := pathParts[2] - videoID, err := strconv.Atoi(videoIDStr) + // Parse the video ID from the URL + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) < 3 { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + videoIDStr := pathParts[2] + videoID, err := strconv.Atoi(videoIDStr) + if err != nil { + http.Error(w, "Invalid video ID", http.StatusBadRequest) + return + } + + // Update the watch state in the database + err = a.db.Query(`UPDATE videos SET watch_state = 'unwatched' WHERE id = ?`).Bind(videoID).Exec() + if err != nil { + http.Error(w, "Failed to update watch state", http.StatusInternalServerError) + return + } + + h := a.htmx.NewHandler(w, r) + if h.RenderPartial() { + vm, err := a.getViewModelForVideo(videoID) if err != nil { - http.Error(w, "Invalid video ID", http.StatusBadRequest) + http.Error(w, "Failed to find video", http.StatusInternalServerError) return } - - // Get video metadata from database - var year, episode int - err = db.Query(`SELECT year, episode FROM videos WHERE id = ?`).Bind(videoID).ScanSingle(&year, &episode) - if err != nil { - http.Error(w, "Video not found", http.StatusNotFound) - return - } - - // Determine the video file path - downloadDir := os.Getenv("VIVAPLUS_DESTINATION") - if downloadDir == "" { - downloadDir = "./downloads" - } - - seasonDir := filepath.Join(downloadDir, fmt.Sprintf("Season %d", year)) - videoFilename := fmt.Sprintf("S%dE%03d.mp4", year, episode) - videoPath := filepath.Join(seasonDir, videoFilename) - - // Check if the file exists - log.Printf("Streaming video: %s", videoPath) - _, err = os.Stat(videoPath) - if os.IsNotExist(err) { - http.Error(w, "Video file not found", http.StatusNotFound) - return - } - - // Serve the file - http.ServeFile(w, r, videoPath) + a.renderTemplate(w, "frag-index", vm) + } else { + // Redirect back to the homepage + http.Redirect(w, r, "/", http.StatusSeeOther) } } - -// func handleVideoActions(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) { -// return func(w http.ResponseWriter, r *http.Request) { -// // Only handle POST requests for actions -// if r.Method != http.MethodPost { -// http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -// return -// } - -// pathParts := strings.Split(r.URL.Path, "/") -// if len(pathParts) < 4 { -// http.Error(w, "Invalid URL format", http.StatusBadRequest) -// return -// } - -// videoIDStr := pathParts[2] -// action := pathParts[3] -// videoID, err := strconv.Atoi(videoIDStr) -// if err != nil { -// http.Error(w, "Invalid video ID", http.StatusBadRequest) -// return -// } - -// switch action { -// case "mark-watched": -// err = db.Query(`UPDATE videos SET watch_state = 'watched' WHERE id = ?`).Bind(videoID).Exec() -// if err != nil { -// http.Error(w, "Failed to update watch state", http.StatusInternalServerError) -// return -// } -// // Redirect back to the homepage -// http.Redirect(w, r, "/", http.StatusSeeOther) -// default: -// http.Error(w, "Unknown action", http.StatusBadRequest) -// return -// } -// } -// }