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}}
+
+
+
+{{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
-// }
-// }
-// }