Git Repository

Mini Video Library

Mini Video Library scannt lokale Medienordner, verwaltet Metadaten und Vorschaubilder und streamt kompatible Videos direkt per HTTP an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/mini-video-library.git

internal/web/web.go

Zum Verzeichnis
package web

import (
	"bytes"
	"context"
	"database/sql"
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"html/template"
	"io"
	"io/fs"
	"log/slog"
	"minivideolib/internal/config"
	"minivideolib/internal/ffmpeginstall"
	"minivideolib/internal/i18n"
	"minivideolib/internal/library"
	"minivideolib/internal/logging"
	"minivideolib/internal/plugins"
	"minivideolib/internal/scanner"
	"minivideolib/internal/sidecar"
	"minivideolib/internal/themes"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"
)

//go:embed templates/*.html static/*
var assets embed.FS

type App struct {
	lib        *library.Service
	db         *sql.DB
	scanner    *scanner.Scanner
	cfg        config.Config
	configPath string
	log        *slog.Logger
	templates  *template.Template
	installMu  sync.RWMutex
	installing bool
	installErr string
	installed  bool
	logs       *logging.Store
	plugins    *plugins.Service
	themes     *themes.Manager
}

func New(lib *library.Service, db *sql.DB, scan *scanner.Scanner, c config.Config, configPath string, log *slog.Logger, logs *logging.Store, pluginService *plugins.Service) (http.Handler, error) {
	var a *App
	language := func() string {
		if a == nil {
			return "de"
		}
		return a.cfg.Language
	}
	funcs := template.FuncMap{"bytes": formatBytes, "duration": func(v float64) string { return formatDurationLanguage(v, language()) }, "date": formatDate, "json": func(s string) template.JS { return template.JS(s) }, "compat": func(v library.Video) string { return compatibilityLanguage(v, language()) }, "t": func(key string) string {
		language := "de"
		if a != nil {
			language = a.cfg.Language
		}
		return i18n.T(language, key)
	}}
	t, e := template.New("").Funcs(funcs).ParseFS(assets, "templates/*.html")
	if e != nil {
		return nil, e
	}
	c.Language = i18n.Normalize(c.Language)
	a = &App{lib: lib, db: db, scanner: scan, cfg: c, configPath: configPath, log: log, templates: t, logs: logs, plugins: pluginService, themes: themes.New("data/themes")}
	r := chi.NewRouter()
	r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, a.accessLog)
	static, _ := fs.Sub(assets, "static")
	r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
	r.Get("/theme-assets/*", a.themeAsset)
	r.Get("/", a.home)
	r.Get("/collections", a.collections)
	r.Get("/video/{id}", a.detail)
	r.Get("/watch/{id}", a.watch)
	r.Get("/stream/{id}", a.stream)
	r.Get("/thumbnail/{id}", a.thumbnail)
	r.Get("/subtitles/{id}/{index}", a.externalSubtitle)
	r.Get("/settings", a.settings)
	r.Post("/settings", a.saveSettings)
	r.Post("/api/preferences", a.savePreferences)
	r.Post("/api/themes/install", a.installTheme)
	r.Post("/api/scan", a.scanNow)
	r.Post("/api/scan/cancel", a.cancelScan)
	r.Post("/api/scan/full", a.fullScan)
	r.Post("/api/scan/thumbnails", a.rebuildThumbnails)
	r.Get("/api/scan/status", a.scanStatus)
	r.Get("/api/logs", a.getLogs)
	r.Post("/api/ffmpeg/install", a.installFFmpeg)
	r.Get("/api/ffmpeg/status", a.ffmpegStatus)
	r.Put("/api/videos/{id}/progress", a.progress)
	r.Post("/api/videos/{id}/favorite", a.favorite)
	r.Post("/api/videos/{id}/metadata", a.updateMetadata)
	r.Post("/api/videos/{id}/thumbnail", a.regenerateThumbnail)
	r.Post("/api/videos/{id}/reveal", a.revealVideo)
	r.Post("/api/videos/{id}/poster", a.uploadPoster)
	r.Post("/api/videos/{id}/reset", a.resetProgress)
	r.Get("/api/problems", a.problems)
	r.Post("/api/problems/ignore", a.ignoreProblem)
	r.Post("/api/problems/retry", a.retryProblems)
	r.Get("/api/stats", a.stats)
	r.Get("/api/duplicates", a.duplicates)
	r.Get("/api/diagnostics", a.diagnostics)
	r.Get("/api/plugins", a.listPlugins)
	r.Post("/api/plugins/online/settings", a.pluginSettings)
	r.Get("/api/plugins/tmdb/search", a.tmdbSearch)
	r.Post("/api/plugins/tmdb/apply", a.tmdbApply)
	r.Get("/api/plugins/anidb/{aid}", a.anidbLookup)
	r.Post("/api/plugins/anidb/apply", a.anidbApply)
	r.Post("/api/plugins/nfo/{id}/read", a.nfoRead)
	r.Post("/api/plugins/nfo/{id}/write", a.nfoWrite)
	r.Post("/api/plugins/nfo/write-all", a.nfoWriteAll)
	r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]any{"ok": true, "scanning": scan.Running()})
	})
	return r, nil
}
func (a *App) installFFmpeg(w http.ResponseWriter, r *http.Request) {
	a.installMu.Lock()
	if a.installing {
		a.installMu.Unlock()
		writeJSON(w, http.StatusConflict, map[string]string{"error": "installation already running"})
		return
	}
	a.installing, a.installed, a.installErr = true, false, ""
	a.installMu.Unlock()
	go func() {
		destination, _ := filepath.Abs("ffmpeg")
		result, err := ffmpeginstall.Install(context.Background(), destination)
		a.installMu.Lock()
		defer a.installMu.Unlock()
		a.installing = false
		if err != nil {
			a.installErr = err.Error()
			a.log.Error("install FFmpeg", "error", err)
			return
		}
		a.cfg.FFmpegPath, a.cfg.FFprobePath = result.FFmpeg, result.FFprobe
		if err = config.Save(a.configPath, a.cfg); err != nil {
			a.installErr = err.Error()
			return
		}
		a.scanner.SetBinaries(result.FFmpeg, result.FFprobe)
		a.installed = true
		a.log.Info("FFmpeg installed", "directory", destination)
	}()
	writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
}
func (a *App) ffmpegStatus(w http.ResponseWriter, r *http.Request) {
	a.installMu.RLock()
	defer a.installMu.RUnlock()
	writeJSON(w, http.StatusOK, map[string]any{"installing": a.installing, "installed": a.installed, "error": a.installErr})
}
func (a *App) accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
		next.ServeHTTP(ww, r)
		if r.URL.Path == "/api/logs" {
			return
		}
		a.log.Info("http", "method", r.Method, "path", r.URL.Path, "status", ww.Status(), "duration", time.Since(start))
	})
}

type page struct {
	Title             string
	Videos            []library.Video
	Video             library.Video
	Query             library.Query
	Config            config.Config
	Scanning          bool
	Saved             bool
	CurrentPage       int
	TotalPages        int
	TotalVideos       int
	PrevURL           string
	NextURL           string
	Plugin            plugins.OnlineSettings
	AniDB             *plugins.AniDBResult
	Language          string
	ActiveTheme       string
	Themes            []themes.Manifest
	ExternalSubtitles []sidecar.Subtitle
	Collections       []library.SmartCollection
}

func (a *App) render(w http.ResponseWriter, name string, p page) {
	p.Language, p.ActiveTheme = a.cfg.Language, a.cfg.ActiveTheme
	p.Config = a.cfg
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if e := a.templates.ExecuteTemplate(w, name, p); e != nil {
		a.log.Error("template", "error", e)
	}
}
func (a *App) home(w http.ResponseWriter, r *http.Request) {
	q := library.Query{Search: r.URL.Query().Get("q"), Resolution: r.URL.Query().Get("resolution"), Codec: r.URL.Query().Get("codec"), State: r.URL.Query().Get("state"), Sort: r.URL.Query().Get("sort"), Collection: r.URL.Query().Get("collection")}
	current, _ := strconv.Atoi(r.URL.Query().Get("page"))
	if current < 1 {
		current = 1
	}
	q.Limit, q.Offset = 60, (current-1)*60
	v, e := a.lib.List(r.Context(), q)
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	total, e := a.lib.Count(r.Context(), q)
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	pages := (total + q.Limit - 1) / q.Limit
	if pages > 0 && current > pages {
		http.Redirect(w, r, pageURL(r, pages), http.StatusSeeOther)
		return
	}
	p := page{Title: "Bibliothek", Videos: v, Query: q, Scanning: a.scanner.Running(), CurrentPage: current, TotalPages: pages, TotalVideos: total}
	if current > 1 {
		p.PrevURL = pageURL(r, current-1)
	}
	if current < pages {
		p.NextURL = pageURL(r, current+1)
	}
	a.render(w, "home.html", p)
}
func (a *App) collections(w http.ResponseWriter, r *http.Request) {
	items, err := a.lib.SmartCollections(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	a.render(w, "collections.html", page{Title: i18n.T(a.cfg.Language, "collections.title"), Collections: items})
}
func pageURL(r *http.Request, page int) string {
	q := r.URL.Query()
	q.Set("page", strconv.Itoa(page))
	return "/?" + q.Encode()
}
func (a *App) getVideo(w http.ResponseWriter, r *http.Request) (library.Video, bool) {
	id, e := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if e != nil {
		http.NotFound(w, r)
		return library.Video{}, false
	}
	v, e := a.lib.Get(r.Context(), id)
	if errors.Is(e, sql.ErrNoRows) {
		http.NotFound(w, r)
		return v, false
	}
	if e != nil {
		http.Error(w, e.Error(), 500)
		return v, false
	}
	return v, true
}
func (a *App) detail(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if ok {
		var anidb *plugins.AniDBResult
		var metadata plugins.AniDBResult
		if json.Unmarshal([]byte(v.AniDBJSON), &metadata) == nil && metadata.ID != 0 {
			anidb = &metadata
		}
		externalSubtitles, _ := sidecar.Find(v.Path)
		a.render(w, "detail.html", page{Title: v.Title, Video: v, AniDB: anidb, ExternalSubtitles: externalSubtitles})
	}
}
func (a *App) watch(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if ok {
		externalSubtitles, _ := sidecar.Find(v.Path)
		a.render(w, "watch.html", page{Title: v.Title, Video: v, ExternalSubtitles: externalSubtitles})
	}
}

func (a *App) externalSubtitle(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	index, err := strconv.Atoi(chi.URLParam(r, "index"))
	items, findErr := sidecar.Find(v.Path)
	if err != nil || findErr != nil || index < 0 || index >= len(items) || !items[index].Playable {
		http.NotFound(w, r)
		return
	}
	data, err := sidecar.WebVTT(items[index])
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
	w.Header().Set("Cache-Control", "no-cache")
	_, _ = w.Write(data)
}
func (a *App) stream(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	f, e := os.Open(v.Path)
	if e != nil {
		http.Error(w, "video unavailable", http.StatusNotFound)
		return
	}
	defer f.Close()
	st, e := f.Stat()
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.Header().Set("Accept-Ranges", "bytes")
	w.Header().Set("Content-Type", mimeFor(v.Container))
	http.ServeContent(w, r, v.Filename, st.ModTime(), f)
}
func (a *App) thumbnail(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	path := v.Thumbnail
	if r.URL.Query().Has("poster") {
		path = v.Poster
	}
	if path == "" {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Cache-Control", "public, max-age=86400")
	http.ServeFile(w, r, path)
}
func (a *App) progress(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	var p struct {
		Position float64 `json:"position"`
		Watched  *bool   `json:"watched"`
	}
	if e := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&p); e != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if p.Position < 0 || p.Position > v.Duration+30 {
		http.Error(w, "invalid position", 400)
		return
	}
	if e := a.lib.UpdateProgress(r.Context(), v.ID, p.Position, p.Watched); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
func (a *App) favorite(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	if e := a.lib.ToggleFavorite(r.Context(), v.ID); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	http.Redirect(w, r, "/video/"+strconv.FormatInt(v.ID, 10), http.StatusSeeOther)
}
func (a *App) updateMetadata(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	if e := r.ParseForm(); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	if e := a.lib.UpdateMetadata(r.Context(), v.ID, r.FormValue("title"), r.FormValue("description"), r.FormValue("tags")); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	http.Redirect(w, r, "/video/"+strconv.FormatInt(v.ID, 10), http.StatusSeeOther)
}
func (a *App) regenerateThumbnail(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	if _, e := a.scanner.RegenerateThumbnail(r.Context(), v.ID, v.Path, v.Duration); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	http.Redirect(w, r, "/video/"+strconv.FormatInt(v.ID, 10), http.StatusSeeOther)
}
func (a *App) revealVideo(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	if e := exec.Command("explorer.exe", "/select,", v.Path).Start(); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
func (a *App) uploadPoster(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 12<<20)
	if e := r.ParseMultipartForm(10 << 20); e != nil {
		http.Error(w, "Bild ungültig oder zu groß", 400)
		return
	}
	f, h, e := r.FormFile("poster")
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	defer f.Close()
	ext := strings.ToLower(filepath.Ext(h.Filename))
	if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
		http.Error(w, "Nur JPG, PNG oder WebP", 400)
		return
	}
	dir := "data/posters"
	if e = os.MkdirAll(dir, 0755); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	path := filepath.Join(dir, strconv.FormatInt(v.ID, 10)+ext)
	out, e := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	_, e = io.Copy(out, f)
	out.Close()
	if e == nil {
		_, e = a.db.ExecContext(r.Context(), "UPDATE videos SET poster=? WHERE id=?", path, v.ID)
	}
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	http.Redirect(w, r, "/video/"+strconv.FormatInt(v.ID, 10), 303)
}
func (a *App) resetProgress(w http.ResponseWriter, r *http.Request) {
	v, ok := a.getVideo(w, r)
	if !ok {
		return
	}
	if e := a.lib.ResetProgress(r.Context(), v.ID); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	http.Redirect(w, r, "/video/"+strconv.FormatInt(v.ID, 10), 303)
}
func (a *App) problems(w http.ResponseWriter, r *http.Request) {
	x, e := a.lib.Problems(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, 200, x)
}
func (a *App) ignoreProblem(w http.ResponseWriter, r *http.Request) {
	if e := r.ParseForm(); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	if e := a.lib.IgnoreProblem(r.Context(), r.FormValue("path")); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(204)
}
func (a *App) retryProblems(w http.ResponseWriter, r *http.Request) {
	if a.scanner.Running() {
		writeJSON(w, 409, map[string]string{"error": "scan already running"})
		return
	}
	if e := a.lib.ClearProblems(r.Context()); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	go a.scanner.Run(context.Background())
	writeJSON(w, 202, map[string]bool{"started": true})
}
func (a *App) stats(w http.ResponseWriter, r *http.Request) {
	x, e := a.lib.Stats(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, 200, x)
}
func (a *App) duplicates(w http.ResponseWriter, r *http.Request) {
	x, e := a.lib.Duplicates(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, 200, x)
}
func (a *App) diagnostics(w http.ResponseWriter, r *http.Request) {
	x, e := a.lib.Diagnostics(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, 200, x)
}
func (a *App) listPlugins(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, 200, a.plugins.List(r.Context()))
}
func (a *App) pluginSettings(w http.ResponseWriter, r *http.Request) {
	if e := r.ParseMultipartForm(1 << 20); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	values := map[string]string{"anidb_client": r.FormValue("anidb_client"), "anidb_clientver": r.FormValue("anidb_clientver")}
	if token := strings.TrimSpace(r.FormValue("tmdb_token")); token != "" {
		values["tmdb_token"] = token
	}
	e := a.plugins.SaveSettings(r.Context(), "online-metadata", values)
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(204)
}
func (a *App) tmdbSearch(w http.ResponseWriter, r *http.Request) {
	x, e := a.plugins.TMDBSearch(r.Context(), r.URL.Query().Get("q"))
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	writeJSON(w, 200, x)
}
func (a *App) tmdbApply(w http.ResponseWriter, r *http.Request) {
	var x struct {
		VideoID int64              `json:"video_id"`
		Result  plugins.TMDBResult `json:"result"`
	}
	if e := json.NewDecoder(r.Body).Decode(&x); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	if e := a.plugins.ApplyTMDB(r.Context(), x.VideoID, x.Result); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(204)
}
func (a *App) anidbLookup(w http.ResponseWriter, r *http.Request) {
	aid, e := strconv.Atoi(chi.URLParam(r, "aid"))
	if e != nil {
		http.Error(w, "invalid AID", 400)
		return
	}
	x, e := a.plugins.AniDBLookup(r.Context(), aid)
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	if videoID := videoIDFromReferer(r); videoID != 0 && x.Picture != "" {
		if _, e = a.plugins.DownloadAniDBPoster(r.Context(), videoID, x.Picture); e != nil {
			http.Error(w, "AniDB-Daten wurden geladen, aber das Cover konnte nicht gespeichert werden: "+e.Error(), 502)
			return
		}
	}
	writeJSON(w, 200, x)
}

func videoIDFromReferer(r *http.Request) int64 {
	u, e := url.Parse(r.Referer())
	if e != nil || u.Host != "" && u.Host != r.Host {
		return 0
	}
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	if len(parts) != 2 || parts[0] != "video" {
		return 0
	}
	id, _ := strconv.ParseInt(parts[1], 10, 64)
	return id
}
func (a *App) anidbApply(w http.ResponseWriter, r *http.Request) {
	var x struct {
		VideoID int64               `json:"video_id"`
		Result  plugins.AniDBResult `json:"result"`
	}
	if e := json.NewDecoder(r.Body).Decode(&x); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	if e := a.plugins.ApplyAniDB(r.Context(), x.VideoID, x.Result); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	w.WriteHeader(204)
}
func (a *App) nfoRead(w http.ResponseWriter, r *http.Request) {
	id, e := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if e == nil {
		e = a.plugins.ReadNFO(r.Context(), id)
	}
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	w.WriteHeader(204)
}
func (a *App) nfoWrite(w http.ResponseWriter, r *http.Request) {
	id, e := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if e == nil {
		e = a.plugins.WriteNFO(r.Context(), id)
	}
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	w.WriteHeader(204)
}
func (a *App) nfoWriteAll(w http.ResponseWriter, r *http.Request) {
	n, e := a.plugins.WriteAllNFO(r.Context())
	if e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	writeJSON(w, 200, map[string]int{"written": n})
}
func (a *App) scanNow(w http.ResponseWriter, r *http.Request) {
	if a.scanner.Running() {
		writeJSON(w, http.StatusConflict, map[string]string{"error": "scan already running"})
		return
	}
	go func() {
		if e := a.scanner.Run(context.Background()); e != nil {
			a.log.Error("manual scan", "error", e)
		}
	}()
	writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
}
func (a *App) cancelScan(w http.ResponseWriter, r *http.Request) {
	if !a.scanner.Cancel() {
		writeJSON(w, http.StatusConflict, map[string]string{"error": "no scan running"})
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]bool{"cancelled": true})
}
func (a *App) fullScan(w http.ResponseWriter, r *http.Request) {
	if e := a.scanner.ForceRescan(r.Context(), false); e != nil {
		http.Error(w, e.Error(), 409)
		return
	}
	go a.scanner.Run(context.Background())
	writeJSON(w, 202, map[string]bool{"started": true})
}
func (a *App) rebuildThumbnails(w http.ResponseWriter, r *http.Request) {
	if e := a.scanner.ForceRescan(r.Context(), true); e != nil {
		http.Error(w, e.Error(), 409)
		return
	}
	go a.scanner.Run(context.Background())
	writeJSON(w, 202, map[string]bool{"started": true})
}
func (a *App) scanStatus(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, a.scanner.Status())
}
func (a *App) getLogs(w http.ResponseWriter, r *http.Request) {
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	writeJSON(w, http.StatusOK, a.logs.List(limit))
}
func (a *App) settings(w http.ResponseWriter, r *http.Request) {
	installedThemes, _ := a.themes.List()
	a.render(w, "settings.html", page{Title: i18n.T(a.cfg.Language, "nav.settings"), Config: a.cfg, Plugin: a.plugins.OnlineSettings(r.Context()), Scanning: a.scanner.Running(), Saved: r.URL.Query().Has("saved"), Themes: installedThemes})
}

func (a *App) savePreferences(w http.ResponseWriter, r *http.Request) {
	if e := r.ParseForm(); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	c := a.cfg
	if r.Form.Has("language") {
		c.Language = i18n.Normalize(r.FormValue("language"))
	}
	if r.Form.Has("theme") {
		themeID := strings.TrimSpace(r.FormValue("theme"))
		if themeID != "" {
			if _, e := a.themes.Asset(themeID, "theme.css"); e != nil {
				http.Error(w, "Theme nicht gefunden", 400)
				return
			}
		}
		c.ActiveTheme = themeID
	}
	if r.Form.Has("preferred_audio_language") {
		c.PreferredAudioLanguage = strings.TrimSpace(r.FormValue("preferred_audio_language"))
	}
	if r.Form.Has("preferred_subtitle_language") {
		c.PreferredSubtitleLanguage = strings.TrimSpace(r.FormValue("preferred_subtitle_language"))
	}
	if e := config.Save(a.configPath, c); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	a.cfg = c
	http.Redirect(w, r, "/settings#appearance", http.StatusSeeOther)
}

func (a *App) installTheme(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
	if e := r.ParseMultipartForm(20 << 20); e != nil {
		http.Error(w, "Theme-ZIP ist ungültig oder zu groß", 400)
		return
	}
	file, header, e := r.FormFile("theme")
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	defer file.Close()
	data, e := io.ReadAll(io.LimitReader(file, (20<<20)+1))
	if e != nil || len(data) > 20<<20 {
		http.Error(w, "Theme-ZIP ist zu groß", 400)
		return
	}
	if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
		http.Error(w, "Nur ZIP-Dateien sind erlaubt", 400)
		return
	}
	manifest, e := a.themes.Install(bytes.NewReader(data), int64(len(data)))
	if e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	a.log.Info("theme installed", "theme", manifest.ID, "version", manifest.Version)
	http.Redirect(w, r, "/settings#appearance", http.StatusSeeOther)
}

func (a *App) themeAsset(w http.ResponseWriter, r *http.Request) {
	if a.cfg.ActiveTheme == "" {
		http.NotFound(w, r)
		return
	}
	name := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
	path, e := a.themes.Asset(a.cfg.ActiveTheme, name)
	if e != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Cache-Control", "no-cache")
	http.ServeFile(w, r, path)
}
func (a *App) saveSettings(w http.ResponseWriter, r *http.Request) {
	if e := r.ParseForm(); e != nil {
		http.Error(w, e.Error(), 400)
		return
	}
	c := a.cfg
	c.Libraries = nil
	for _, line := range strings.Split(r.FormValue("libraries"), "\n") {
		if x := strings.TrimSpace(line); x != "" {
			if abs, e := filepath.Abs(x); e == nil {
				c.Libraries = append(c.Libraries, abs)
			}
		}
	}
	if x, e := strconv.Atoi(r.FormValue("thumbnail_width")); e == nil && x >= 160 && x <= 1920 {
		c.ThumbnailWidth = x
	}
	if d, e := time.ParseDuration(r.FormValue("scan_interval")); e == nil {
		c.ScanInterval = d
	}
	c.FFmpegPath = strings.TrimSpace(r.FormValue("ffmpeg_path"))
	c.FFprobePath = strings.TrimSpace(r.FormValue("ffprobe_path"))
	if e := config.Save(a.configPath, c); e != nil {
		http.Error(w, e.Error(), 500)
		return
	}
	a.cfg = c
	a.scanner.SetConfig(c)
	http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}
func formatBytes(n int64) string {
	const u = 1024
	if n < u {
		return fmt.Sprintf("%d B", n)
	}
	d := int64(u)
	i := 0
	for n/d >= u && i < 3 {
		d *= u
		i++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(d), "KMGT"[i])
}
func formatDuration(v float64) string {
	return formatDurationLanguage(v, "de")
}
func formatDurationLanguage(v float64, language string) string {
	d := time.Duration(v) * time.Second
	if d >= time.Hour {
		if language == "en" {
			return fmt.Sprintf("%d:%02d hrs", int(d.Hours()), int(d.Minutes())%60)
		}
		return fmt.Sprintf("%d:%02d Std.", int(d.Hours()), int(d.Minutes())%60)
	}
	if language == "en" {
		return fmt.Sprintf("%d min", int(d.Minutes()))
	}
	return fmt.Sprintf("%d Min.", int(d.Minutes()))
}
func formatDate(v int64) string {
	if v == 0 {
		return "–"
	}
	return time.Unix(v, 0).Format("02.01.2006")
}
func mimeFor(c string) string {
	switch strings.ToLower(c) {
	case "webm":
		return "video/webm"
	case "matroska":
		return "video/x-matroska"
	case "quicktime", "mov":
		return "video/quicktime"
	default:
		return "video/mp4"
	}
}
func compatibility(v library.Video) string {
	return compatibilityLanguage(v, "de")
}
func compatibilityLanguage(v library.Video, language string) string {
	codec := strings.ToLower(v.VideoCodec)
	container := strings.ToLower(v.Container)
	if (codec == "h264" || codec == "av1" || codec == "vp9") && (container == "mov" || container == "mp4" || container == "webm") {
		if language == "en" {
			return "Direct Play likely"
		}
		return "Direct Play wahrscheinlich"
	}
	if codec == "h264" || codec == "hevc" {
		if language == "en" {
			return "Remux may be required"
		}
		return "Remux könnte erforderlich sein"
	}
	if language == "en" {
		return "Browser compatibility uncertain"
	}
	return "Browser-Kompatibilität unsicher"
}