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) }