package main import ( "fmt" "log" "net/http" "os" "path/filepath" "strconv" "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/", 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 { panic(err) } } 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 (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 } // 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() } 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, "Failed to find video", http.StatusInternalServerError) return } a.renderTemplate(w, "frag-index", vm) } else { // Redirect back to the homepage http.Redirect(w, r, "/", http.StatusSeeOther) } } 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 } // 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, "Failed to find video", http.StatusInternalServerError) return } a.renderTemplate(w, "frag-index", vm) } else { // Redirect back to the homepage http.Redirect(w, r, "/", http.StatusSeeOther) } }