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.
HTTPS
https://zanvex.de/git/mini-video-library.gitinternal/web/web.go
Zum Verzeichnispackage 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"
}