UI is largely working
All checks were successful
Build / build (push) Successful in 3m11s

This commit is contained in:
Sebastiaan de Schaetzen 2025-03-16 18:11:34 +01:00
parent c36a533d17
commit 91be8274c4
10 changed files with 298 additions and 171 deletions

3
go.mod
View File

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

6
go.sum
View File

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

View File

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

View File

@ -2,9 +2,11 @@ package main
type VideoInfoVM struct {
ID int
PreviousID int
Title string
Description string
Thumbnail string
IsWatched bool
}
type VideoPlayerVM struct {

8
web/frag-header.go.html Normal file
View File

@ -0,0 +1,8 @@
{{define "frag-header"}}
<head>
<meta charset="UTF-8">
<title>Viva++</title>
<link href="/static/style.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
{{end}}

32
web/frag-index.go.html Normal file
View File

@ -0,0 +1,32 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
{{define "frag-index"}}
<div class="video-box">
<div class="thumbnail-container">
<a href="/video/{{.ID}}">
<img src="{{.Thumbnail}}" alt="Thumbnail" class="video-thumbnail" />
<div class="play-button">
<span class="play-symbol"></span>
</div>
</a>
</div>
<div class="video-info">
<h2 class="video-title">{{.Title}}</h2>
<p class="video-description">{{.Description}}</p>
<div class="video-actions">
<a href="/video/{{.ID}}" class="watch-button">Watch Now</a>
<form hx-boost="true" method="post" action="{{if .IsWatched}}/mark-unwatched/{{.ID}}{{else}}/mark-watched/{{.ID}}{{end}}">
<button type="submit" class="mark-watched">
{{if .IsWatched}}
Mark as Unwatched
{{else}}
Mark as Watched
{{end}}
</button>
</form>
{{if ne .PreviousID 0}}
<a href="/?video={{.PreviousID}}" class="mark-watched">Show Previous</a>
{{end}}
</div>
</div>
</div>
{{end}}

11
web/index.go.html Normal file
View File

@ -0,0 +1,11 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
<!DOCTYPE html>
<html lang="en">
{{template "frag-header" .}}
<body>
<h1>Viva++</h1>
<div class="video-container">
{{template "frag-index" .}}
</div>
</body>
</html>

View File

@ -1,28 +0,0 @@
{{- /*gotype: vivaplusdl.VideoInfoVM*/}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Viva++</title>
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<h1>Viva++</h1>
<div class="video-container">
<div class="video-box">
<div class="thumbnail-container">
<a href="/video/{{.ID}}">
<img src="{{.Thumbnail}}" alt="Thumbnail" class="video-thumbnail" />
<div class="play-button">
<span class="play-symbol">▶</span>
</div>
</a>
</div>
<div class="video-info">
<h2 class="video-title">{{.Title}}</h2>
<p class="video-description">{{.Description}}</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,11 +1,7 @@
{{- /*gotype: vivaplusdl.VideoPlayerVM*/}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{.Title}} - Viva++</title>
<link href="/static/style.css" rel="stylesheet">
</head>
{{template "frag-header" .}}
<body>
<h1>Viva++</h1>
<div class="back-button-container">

View File

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