package handler
import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/go-chi/chi/v5"
"vidarchive"
"vidarchive/internal/config"
"vidarchive/internal/models"
"vidarchive/internal/service"
"vidarchive/internal/util"
"vidarchive/internal/worker"
)
type Handler struct {
cfg *config.Config
templates *template.Template
presetSvc *service.PresetService
downloadSvc *service.DownloadService
librarySvc *service.LibraryService
settingsSvc *service.SettingsService
subscriptionSvc *service.SubscriptionService
workerPool *worker.Pool
}
func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, subscriptionSvc *service.SubscriptionService, workerPool *worker.Pool) (*Handler, error) {
tmpl, err := loadTemplates(presetSvc)
if err != nil {
return nil, fmt.Errorf("load templates: %w", err)
}
return &Handler{
cfg: cfg,
templates: tmpl,
presetSvc: presetSvc,
downloadSvc: downloadSvc,
librarySvc: librarySvc,
settingsSvc: settingsSvc,
subscriptionSvc: subscriptionSvc,
workerPool: workerPool,
}, nil
}
// loadTemplates builds the template set.
func loadTemplates(presetSvc *service.PresetService) (*template.Template, error) {
tmpl := template.New("").Funcs(template.FuncMap{
"formatDuration": formatDuration,
"formatFileSize": util.FormatBytes,
"splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
"baseName": filepath.Base,
"ext": filepath.Ext,
"mimeType": mimeTypeFromFile,
"presetFlags": func(p *models.Preset) string { return presetSvc.EffectiveFlags(p, "", "") },
"urlEncode": template.URLQueryEscaper,
"urlEncodePath": util.URLEncodePath,
"sub": func(a, b int) int { return a - b },
// emptyPreset / newSubscription supply a zero value so the shared create and
// edit form partials can be rendered from the create page too. newSubscription
// carries the create-time defaults (overwrite mode, daily schedule).
"emptyPreset": func() *models.Preset { return nil },
"newSubscription": func() *models.Subscription {
return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"}
},
// dict builds a map from alternating key/value args, so a template can pass
// more than one value into a sub-template (e.g. the subscription form needs
// both the subscription and the preset list).
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict expects an even number of arguments")
}
m := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[key] = values[i+1]
}
return m, nil
},
// isLongText reports whether text spans more than ~2 lines, so the detail
// view can make long descriptions collapsible. Uses rune count (not bytes)
// so CJK text isn't flagged early.
"isLongText": func(s string) bool {
return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180
},
})
return tmpl.ParseFS(vidarchive.TemplatesFS, "web/templates/*.html")
}
func formatDuration(seconds int) string {
if seconds <= 0 {
return "--:--"
}
return util.FormatClock(seconds)
}
func mimeTypeFromFile(path string) string {
mtype, err := mimetype.DetectFile(path)
if err != nil || mtype == nil {
return "application/octet-stream"
}
return mtype.String()
}
type PageData struct {
Title string
ActiveTab string
Data interface{}
BaseURL string
AutoRefresh bool
RefreshSec int
Theme string
Flash *Flash
}
// Flash is a one-shot message shown to the user after a redirect (the
// Post/Redirect/Get pattern). Kind is "success" or "error".
type Flash struct {
Kind string
Message string
}
const cookieMaxAge = 365 * 24 * 60 * 60
const flashCookie = "flash"
// setFlash stashes a one-shot message in a short-lived cookie. The next rendered
// page reads and clears it (see consumeFlash), so the message appears once after
// the redirect and never again.
func setFlash(w http.ResponseWriter, kind, message string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: url.QueryEscape(kind + "|" + message),
Path: "/",
MaxAge: 30,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) }
func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) }
// consumeFlash reads the flash cookie (if any) and immediately expires it, so a
// message is shown exactly once.
func consumeFlash(w http.ResponseWriter, r *http.Request) *Flash {
c, err := r.Cookie(flashCookie)
if err != nil || c.Value == "" {
return nil
}
http.SetCookie(w, &http.Cookie{
Name: flashCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
decoded, err := url.QueryUnescape(c.Value)
if err != nil {
return nil
}
kind, message, ok := strings.Cut(decoded, "|")
if !ok {
return nil
}
// The cookie is client-editable, so don't let an arbitrary kind flow into the
// banner's class name — clamp it to the two we render.
if kind != "success" {
kind = "error"
}
return &Flash{Kind: kind, Message: message}
}
func getCookie(r *http.Request, name, defaultVal string) string {
cookie, err := r.Cookie(name)
if err != nil {
return defaultVal
}
return cookie.Value
}
func setCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
data.BaseURL = h.cfg.BaseURL
if data.Theme == "" {
data.Theme = getCookie(r, "theme", "auto")
}
if data.Flash == nil {
data.Flash = consumeFlash(w, r)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var contentBuf bytes.Buffer
if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
return
}
layoutData := struct {
PageData
Content template.HTML
}{
PageData: data,
Content: template.HTML(contentBuf.String()),
}
if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
}
}
// settingsOrDefault loads settings for page rendering, falling back to sane
// defaults (rather than a nil deref) if the store can't be read.
func (h *Handler) settingsOrDefault() *models.Settings {
settings, err := h.settingsSvc.GetAll()
if err != nil {
log.Printf("settings load failed, using defaults: %v", err)
return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true}
}
return settings
}
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
filter := r.URL.Query().Get("filter")
sortBy := sortFromRequest(w, r, "library_sort", "date")
items, folders, err := h.librarySvc.GetAll(path, sortBy, filter)
if err != nil {
h.serverError(w, r, "list library", err)
return
}
settings := h.settingsOrDefault()
h.renderWithRequest(w, r, "library", PageData{
Title: "Library",
ActiveTab: "library",
AutoRefresh: settings.AutoRefreshLibrary,
RefreshSec: settings.RefreshInterval,
Data: struct {
Items []*models.LibraryItem
Folders []string
Path string
SortBy string
Filter string
}{
Items: items,
Folders: folders,
Path: path,
SortBy: sortBy,
Filter: filter,
},
})
}
func normalizeRelPath(r *http.Request) string {
// chi gives the raw, still-encoded wildcard. Decode it as a URL path, where
// '+' is a literal plus (only query strings treat '+' as space) — so an item
// directory named "a+b" round-trips correctly.
relPath := chi.URLParam(r, "*")
relPath = strings.Trim(relPath, "/")
if decoded, err := url.PathUnescape(relPath); err == nil {
relPath = decoded
} else {
log.Printf("normalizeRelPath: undecodable path %q: %v", relPath, err)
}
return relPath
}
func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
relPath := normalizeRelPath(r)
if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
relPath = strings.TrimSuffix(relPath, "/delete")
h.deleteMedia(relPath, w, r)
return
}
h.libraryDetail(relPath, w, r)
}
func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
item, err := h.librarySvc.GetByRelPath(relPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
selectedFilename := r.URL.Query().Get("file")
if selectedFilename == "" && len(item.MediaFiles) > 0 {
selectedFilename = item.MediaFiles[0].Filename
}
meta, err := h.librarySvc.GetMetadata(relPath, selectedFilename)
if err != nil {
log.Printf("libraryDetail %q: metadata unavailable: %v", relPath, err)
}
subtitles, err := h.librarySvc.GetSubtitles(relPath)
if err != nil {
log.Printf("libraryDetail %q: subtitles unavailable: %v", relPath, err)
}
h.renderWithRequest(w, r, "library_detail", PageData{
Title: item.Name,
ActiveTab: "library",
Data: struct {
Item *models.LibraryItem
SelectedFilename string
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
}{
Item: item,
SelectedFilename: selectedFilename,
Metadata: meta,
Subtitles: subtitles,
},
})
}
func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
relPath := normalizeRelPath(r)
if strings.HasSuffix(relPath, "/thumbnail") {
h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
return
}
// Subtitle tracks are addressed as - /subtitles/, with the language
// as a trailing path segment (see LibraryService.GetSubtitles).
if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 {
item := relPath[:i]
lang := relPath[i+len("/subtitles/"):]
h.serveSubtitles(item, lang, w, r)
return
}
h.serveMedia(relPath, w, r)
}
func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
if filename == "" {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
mediaPath, err := h.librarySvc.GetMediaFile(relPath, filename)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, mediaPath)
}
func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
if thumb, ok := h.librarySvc.ThumbnailForFile(relPath, filename); ok {
http.ServeFile(w, r, thumb)
return
}
// Fall back to an icon, matched to the requested file's type (or the item's
// primary file when no specific file was requested).
icon := "video-icon.svg"
if item, err := h.librarySvc.GetByRelPath(relPath); err == nil {
if isAudioFile(item, filename) {
icon = "audio-icon.svg"
}
}
data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon)
if err != nil {
http.Error(w, "icon not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Write(data)
}
// isAudioFile reports whether the named file (or, if unnamed, the first media
// file) of an item is audio.
func isAudioFile(item *models.LibraryItem, filename string) bool {
if filename != "" {
for _, mf := range item.MediaFiles {
if mf.Filename == filename {
return mf.IsAudio
}
}
return false
}
return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
}
func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) {
if lang == "" {
http.Error(w, "Missing language", http.StatusBadRequest)
return
}
if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
http.Error(w, "Invalid language", http.StatusBadRequest)
return
}
subtitlePath, err := h.librarySvc.GetSubtitlePath(relPath, lang)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/vtt")
http.ServeFile(w, r, subtitlePath)
}
func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
if err := h.librarySvc.Delete(relPath); err != nil {
redirectWithError(w, r, "/library", "Couldn't delete this item.", err)
return
}
redirectWithSuccess(w, r, "/library", "Item deleted.")
}
func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
sortBy := sortFromRequest(w, r, "queue_sort", "date")
downloads, err := h.downloadSvc.GetAll(status, sortBy)
if err != nil {
h.serverError(w, r, "list downloads", err)
return
}
settings := h.settingsOrDefault()
h.renderWithRequest(w, r, "queue", PageData{
Title: "Queue",
ActiveTab: "queue",
AutoRefresh: settings.AutoRefreshDownloads,
RefreshSec: settings.RefreshInterval,
Data: struct {
Items []*models.Download
Status string
SortBy string
}{
Items: downloads,
Status: status,
SortBy: sortBy,
},
})
}
func (h *Handler) DownloadDetail(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
download, err := h.downloadSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
h.renderWithRequest(w, r, "queue_detail", PageData{
Title: "Queue Details",
ActiveTab: "queue",
Data: download,
})
}
func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
url := r.FormValue("url")
if url == "" {
flashError(w, "A URL is required to start a download.")
http.Redirect(w, r, "/download", http.StatusSeeOther)
return
}
var presetID *int64
if pid := r.FormValue("preset_id"); pid != "" {
id, err := strconv.ParseInt(pid, 10, 64)
if err == nil {
presetID = &id
}
}
formatOverride := r.FormValue("format_override")
customFlags := r.FormValue("custom_flags")
outputDir := r.FormValue("output_dir")
download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
if err != nil {
redirectWithError(w, r, "/download", "Couldn't queue this download.", err)
return
}
h.workerPool.Submit(download)
redirectWithSuccess(w, r, "/queue", "Download queued.")
}
func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if err := h.downloadSvc.Delete(id); err != nil {
redirectWithError(w, r, "/queue", "Couldn't remove this download.", err)
return
}
redirectWithSuccess(w, r, "/queue", "Download removed.")
}
func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
if err := h.downloadSvc.DeleteAll(); err != nil {
redirectWithError(w, r, "/queue", "Couldn't clear the queue.", err)
return
}
redirectWithSuccess(w, r, "/queue", "Queue cleared.")
}
func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
h.serverError(w, r, "list presets", err)
return
}
settings, err := h.settingsSvc.GetAll()
if err != nil {
h.serverError(w, r, "load settings", err)
return
}
h.renderWithRequest(w, r, "settings", PageData{
Title: "Settings",
ActiveTab: "settings",
Data: struct {
Presets []*models.Preset
Settings *models.Settings
}{
Presets: presets,
Settings: settings,
},
})
}
func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
preset := &models.Preset{}
if err := applyPresetForm(preset, r); err != nil {
redirectWithError(w, r, "/settings", err.Error(), nil)
return
}
if err := h.presetSvc.Create(preset); err != nil {
redirectWithError(w, r, "/settings", "Couldn't create this preset.", err)
return
}
redirectWithSuccess(w, r, "/settings", "Preset created.")
}
// applyPresetForm copies the preset form fields onto p and validates them. It is
// shared by create and update so the two can't drift apart as fields are added.
func applyPresetForm(p *models.Preset, r *http.Request) error {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
return fmt.Errorf("A preset needs a name.")
}
// Mirrors the radio options on the settings form; empty means "unspecified"
// and BuildArgs applies its own default.
formatMode := r.FormValue("format_mode")
switch formatMode {
case "", "default", "preset", "custom":
default:
return fmt.Errorf("Unknown format mode %q.", formatMode)
}
p.Name = name
p.Description = r.FormValue("description")
p.FormatMode = formatMode
p.Format = r.FormValue("format")
p.Quality = r.FormValue("quality")
p.CustomFormat = r.FormValue("custom_format")
p.AudioFormat = r.FormValue("audio_format")
p.SubLangs = r.FormValue("sub_langs")
p.CustomFlags = r.FormValue("custom_flags")
p.IsDefault = r.FormValue("is_default") == "1"
p.ExtractAudio = r.FormValue("extract_audio") == "1"
p.EmbedSubs = r.FormValue("embed_subs") == "1"
p.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
p.EmbedMetadata = r.FormValue("embed_metadata") == "1"
p.WriteInfoJSON = r.FormValue("write_info_json") == "1"
return nil
}
func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
preset, err := h.presetSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if err := applyPresetForm(preset, r); err != nil {
redirectWithError(w, r, "/settings", err.Error(), nil)
return
}
if err := h.presetSvc.Update(preset); err != nil {
redirectWithError(w, r, "/settings", "Couldn't update this preset.", err)
return
}
redirectWithSuccess(w, r, "/settings", "Preset updated.")
}
func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if err := h.presetSvc.Delete(id); err != nil {
redirectWithError(w, r, "/settings", "Couldn't delete this preset.", err)
return
}
redirectWithSuccess(w, r, "/settings", "Preset deleted.")
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var firstErr error
record := func(err error) {
if err != nil && firstErr == nil {
firstErr = err
}
}
if interval := r.FormValue("refresh_interval"); interval != "" {
record(h.settingsSvc.SetRefreshInterval(interval))
}
record(h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1"))
record(h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1"))
record(h.settingsSvc.SetCookies(r.FormValue("cookies")))
if firstErr != nil {
log.Printf("UpdateSettings: %v", firstErr)
flashError(w, "Some settings couldn't be saved: "+firstErr.Error())
} else {
flashSuccess(w, "Settings saved.")
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
presets, err := h.presetSvc.GetAll()
if err != nil {
h.serverError(w, r, "list presets", err)
return
}
// A missing default preset is normal (the user may not have set one); the
// template handles a nil DefaultPreset, so this isn't surfaced as an error.
defaultPreset, _ := h.presetSvc.GetDefault()
url := r.URL.Query().Get("url")
var formats []*models.FormatInfo
var flash *Flash
if r.URL.Query().Get("list_formats") == "1" && url != "" {
var err error
if formats, err = h.downloadSvc.ListFormats(url); err != nil {
log.Printf("DownloadForm: listing formats for %q failed: %v", url, err)
flash = &Flash{Kind: "error", Message: "Couldn't list formats: " + err.Error()}
}
}
h.renderWithRequest(w, r, "download_form", PageData{
Title: "Download",
ActiveTab: "download",
Flash: flash,
Data: struct {
Presets []*models.Preset
DefaultPreset *models.Preset
URL string
FormatOverride string
CustomFlags string
OutputDir string
Formats []*models.FormatInfo
ShowFormats bool
}{
Presets: presets,
DefaultPreset: defaultPreset,
URL: url,
FormatOverride: r.URL.Query().Get("format_override"),
CustomFlags: r.URL.Query().Get("custom_flags"),
OutputDir: r.URL.Query().Get("output_dir"),
Formats: formats,
ShowFormats: r.URL.Query().Get("list_formats") == "1",
},
})
}
func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
preset, err := h.presetSvc.GetByID(id)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
flags := h.presetSvc.EffectiveFlags(preset, "", "")
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(flags))
}
func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
theme := r.FormValue("theme")
if theme == "" {
theme = "auto"
}
setCookie(w, "theme", theme)
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
}