From c36a533d178ad89e5a91e1deab6fbe91774f9fb7 Mon Sep 17 00:00:00 2001
From: Sebastiaan de Schaetzen <sebastiaan.de.schaetzen@gmail.com>
Date: Sun, 16 Mar 2025 11:50:45 +0100
Subject: [PATCH] Can view some videos

---
 episodes.go      |   6 +-
 go.mod           |   3 +-
 go.sum           |   6 +-
 html_debug.go    |   2 +-
 main.go          |  15 ++--
 static/style.css | 186 +++++++++++++++++++++++++++++++++++++++++++++++
 viewmodels.go    |   7 ++
 web/index.gohtml |  19 ++++-
 web/video.gohtml |  35 +++++++++
 webview.go       | 138 ++++++++++++++++++++++++++++++++++-
 10 files changed, 394 insertions(+), 23 deletions(-)
 create mode 100644 web/video.gohtml

diff --git a/episodes.go b/episodes.go
index 05a5a8e..35ec40c 100644
--- a/episodes.go
+++ b/episodes.go
@@ -2,8 +2,9 @@ package main
 
 import (
 	"fmt"
-	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 	"log"
+
+	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 )
 
 func CalculateEpisodeNumbers(db *mysqlite.Db) error {
@@ -46,8 +47,9 @@ func CalculateEpisodeNumbers(db *mysqlite.Db) error {
 			}
 
 			// Set the episode ID
+			log.Printf("Setting episode ID for %d to %d", episodeId, lastEpisode)
 			lastEpisode++
-			err = tx.Query("update videos set episode = ? where id = ?").Bind(&lastEpisode, &episodeId).Exec()
+			err = tx.Query("update videos set episode = ? where id = ?").Bind(lastEpisode, episodeId).Exec()
 			if err != nil {
 				return fmt.Errorf("error updating episode id: %w", err)
 			}
diff --git a/go.mod b/go.mod
index f86bbda..989b898 100644
--- a/go.mod
+++ b/go.mod
@@ -5,13 +5,12 @@ go 1.24
 toolchain go1.24.0
 
 require (
-	gitea.seeseepuff.be/seeseemelk/mysqlite v0.4.0
+	gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0
 	github.com/playwright-community/playwright-go v0.4902.0
 )
 
 require (
 	github.com/deckarep/golang-set/v2 v2.7.0 // indirect
-	github.com/donseba/go-htmx v1.12.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/go-jose/go-jose/v3 v3.0.3 // indirect
 	github.com/go-stack/stack v1.8.1 // indirect
diff --git a/go.sum b/go.sum
index e51182c..24ade2a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,10 @@
-gitea.seeseepuff.be/seeseemelk/mysqlite v0.4.0 h1:jBj4qsSJCz7XOBakh2Rl4Pggvv5hPpR8iLcvxwdQ4NQ=
-gitea.seeseepuff.be/seeseemelk/mysqlite v0.4.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
+gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.0 h1:Tqo9jnPXBDzIiMh+CrrFd5epITbG8UWqVfpLk21R/C0=
+gitea.seeseepuff.be/seeseemelk/mysqlite v0.6.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 712015a..da03c80 100644
--- a/html_debug.go
+++ b/html_debug.go
@@ -16,5 +16,5 @@ func renderTemplate(wr io.Writer, templateName string, viewModel any) error {
 	if err != nil {
 		return err
 	}
-	return templates.Execute(wr, viewModel)
+	return templates.ExecuteTemplate(wr, templateName+".gohtml", viewModel)
 }
diff --git a/main.go b/main.go
index 0eb1310..986db27 100644
--- a/main.go
+++ b/main.go
@@ -2,8 +2,9 @@ package main
 
 import (
 	"flag"
-	"github.com/playwright-community/playwright-go"
 	"log"
+
+	"github.com/playwright-community/playwright-go"
 )
 
 func main() {
@@ -57,12 +58,12 @@ func main() {
 	//	if err != nil {
 	//		panic(err)
 	//	}
-	//
-	//	err = CalculateEpisodeNumbers(db)
-	//	if err != nil {
-	//		panic(err)
-	//	}
-	//
+
+	err = CalculateEpisodeNumbers(db)
+	if err != nil {
+		panic(err)
+	}
+
 	//	err = DownloadAllVideos(db)
 	//	if err != nil {
 	//		panic(err)
diff --git a/static/style.css b/static/style.css
index 6e2bb06..7e08fad 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2,12 +2,198 @@ body {
 	background-color: black;
 	color: white;
 	font-family: sans-serif;
+	margin: 0;
+	padding: 20px;
 }
 
 h1 {
 	text-transform: uppercase;
+	text-align: center;
+	color: #e50914;
+	margin-bottom: 30px;
+}
+
+.video-container {
+	max-width: 1000px;
+	margin: 0 auto;
 }
 
 .video-box {
 	border: 1px solid white;
+	border-radius: 8px;
+	background-color: #1e1e1e;
+	padding: 15px;
+	display: flex;
+	overflow: hidden;
+	box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+	gap: 20px;
+}
+
+.thumbnail-container {
+	flex: 0 0 auto;
+	max-width: 40%;
+	margin-right: 20px;
+	position: relative;
+}
+
+.thumbnail-container a {
+	display: block;
+	cursor: pointer;
+	position: relative;
+	overflow: hidden;
+	transition: opacity 0.2s;
+}
+
+.thumbnail-container a:hover {
+	opacity: 0.8;
+}
+
+.thumbnail-container a:hover .play-button {
+	opacity: 1;
+}
+
+.play-button {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	background-color: rgba(0, 0, 0, 0.5);
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	opacity: 0;
+	transition: opacity 0.3s ease;
+}
+
+.play-symbol {
+	color: white;
+	font-size: 50px;
+	text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+}
+
+.video-thumbnail {
+	width: 100%;
+	height: auto;
+	border-radius: 4px;
+	display: block;
+}
+
+.video-info {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+}
+
+.video-title {
+	font-size: 24px;
+	margin-top: 0;
+	margin-bottom: 15px;
+	color: #ffffff;
+}
+
+.video-description {
+	flex: 1;
+	font-size: 16px;
+	line-height: 1.5;
+	color: #aaaaaa;
+	margin-bottom: 20px;
+}
+
+.video-actions {
+	display: flex;
+	gap: 10px;
+}
+
+.watch-button {
+	background-color: #e50914;
+	color: white;
+	border: none;
+	padding: 10px 20px;
+	border-radius: 4px;
+	cursor: pointer;
+	font-size: 16px;
+	font-weight: bold;
+}
+
+.watch-button:hover {
+	background-color: #f40612;
+}
+
+.mark-watched {
+	background-color: transparent;
+	color: #aaaaaa;
+	border: 1px solid #555;
+	padding: 10px 15px;
+	border-radius: 4px;
+	cursor: pointer;
+	font-size: 16px;
+}
+
+.mark-watched:hover {
+	background-color: #333;
+	color: #ffffff;
+}
+
+/* Video Player Styles */
+.video-player-container {
+	max-width: 1200px;
+	margin: 0 auto;
+}
+
+.video-player-box {
+	border: 1px solid white;
+	border-radius: 8px;
+	background-color: #1e1e1e;
+	padding: 20px;
+	overflow: hidden;
+	box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+}
+
+.video-player {
+	margin: 20px 0;
+	width: 100%;
+	display: flex;
+	justify-content: center;
+}
+
+.video-player video {
+	width: 100%;
+	max-height: 70vh;
+	background-color: black;
+}
+
+.video-player-info {
+	margin-top: 20px;
+}
+
+.back-button-container {
+	max-width: 1200px;
+	margin: 0 auto 20px;
+}
+
+.back-button {
+	display: inline-block;
+	color: #aaaaaa;
+	text-decoration: none;
+	padding: 8px 16px;
+	border: 1px solid #555;
+	border-radius: 4px;
+}
+
+.back-button:hover {
+	background-color: #333;
+	color: #ffffff;
+}
+
+@media (max-width: 768px) {
+	.video-box {
+		flex-direction: column;
+	}
+	
+	.thumbnail-container {
+		max-width: 100%;
+		margin-bottom: 15px;
+		flex: auto;
+	}
 }
diff --git a/viewmodels.go b/viewmodels.go
index f7685d1..e7242ad 100644
--- a/viewmodels.go
+++ b/viewmodels.go
@@ -1,7 +1,14 @@
 package main
 
 type VideoInfoVM struct {
+	ID          int
 	Title       string
 	Description string
 	Thumbnail   string
 }
+
+type VideoPlayerVM struct {
+	ID          int
+	Title       string
+	Description string
+}
diff --git a/web/index.gohtml b/web/index.gohtml
index 0c2945a..26079c7 100644
--- a/web/index.gohtml
+++ b/web/index.gohtml
@@ -8,10 +8,21 @@
 </head>
 <body>
 <h1>Viva++</h1>
-<div class="video-box">
-	<h2>{{.Title}}</h2>
-	<div>{{.Description}}</div>
-	<img src="{{.Thumbnail}}" alt="Thumbnail" />
+<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>
diff --git a/web/video.gohtml b/web/video.gohtml
new file mode 100644
index 0000000..7ee3f50
--- /dev/null
+++ b/web/video.gohtml
@@ -0,0 +1,35 @@
+{{- /*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>
+<body>
+    <h1>Viva++</h1>
+    <div class="back-button-container">
+        <a href="/" class="back-button">← Back to Home</a>
+    </div>
+    <div class="video-player-container">
+        <div class="video-player-box">
+            <h2 class="video-title">{{.Title}}</h2>
+            <div class="video-player">
+                <video controls autoplay src="/stream/{{.ID}}">
+                    Your browser does not support the video tag.
+                </video>
+            </div>
+            <div class="video-player-info">
+                <p class="video-description">{{.Description}}</p>
+				<!--
+                <div class="video-actions">
+                    <form method="post" action="/video/{{.ID}}/mark-watched">
+                        <button type="submit" class="mark-watched">Mark as Watched</button>
+                    </form>
+                </div>
+				-->
+            </div>
+        </div>
+    </div>
+</body>
+</html>
diff --git a/webview.go b/webview.go
index 4c12a9f..e187652 100644
--- a/webview.go
+++ b/webview.go
@@ -1,9 +1,15 @@
 package main
 
 import (
-	"gitea.seeseepuff.be/seeseemelk/mysqlite"
+	"fmt"
 	"log"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"gitea.seeseepuff.be/seeseemelk/mysqlite"
 )
 
 func serveWebview(db *mysqlite.Db) {
@@ -11,8 +17,10 @@ func serveWebview(db *mysqlite.Db) {
 
 	log.Printf("Listening on %s", addr)
 	http.Handle("/static/", http.StripPrefix("/static/", staticFileServer()))
-	http.HandleFunc("/", serveIndex(db))
 	http.HandleFunc("/video/", serveVideo(db))
+	http.HandleFunc("/stream/", serveStream(db))
+	http.HandleFunc("/", serveIndex(db))
+	// http.HandleFunc("/video/", handleVideoActions(db))
 
 	err := http.ListenAndServe(addr, nil)
 	if err != nil {
@@ -23,14 +31,16 @@ 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 title, description, thumbnail FROM videos WHERE (watch_state IS NULL OR watch_state != 'watched') ORDER BY upload_date, episode LIMIT 1`).ScanSingle(&title, &description, &thumbnail)
+		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,
@@ -43,5 +53,127 @@ func serveIndex(db *mysqlite.Db) func(w http.ResponseWriter, r *http.Request) {
 }
 
 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
+		}
 
+		// 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 = 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)
+		}
+	}
 }
+
+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
+		}
+
+		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 = 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 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
+// 		}
+// 	}
+// }