package handler import ( "bytes" "fmt" "html/template" "net/http" "net/url" "path/filepath" "runtime" "strconv" "strings" "github.com/gabriel-vasile/mimetype" "github.com/go-chi/chi/v5" "vidarchive/internal/config" "vidarchive/internal/models" "vidarchive/internal/service" "vidarchive/internal/worker" ) type Handler struct { cfg *config.Config templates *template.Template presetSvc *service.PresetService downloadSvc *service.DownloadService librarySvc *service.LibraryService settingsSvc *service.SettingsService workerPool *worker.Pool } func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, workerPool *worker.Pool) (*Handler, error) { presetSvcGlobal = presetSvc tmpl, err := loadTemplates() 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, workerPool: workerPool, }, nil } func loadTemplates() (*template.Template, error) { tmpl := template.New("").Funcs(template.FuncMap{ "formatDuration": formatDuration, "formatFileSize": formatFileSize, "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 presetSvcGlobal.EffectiveFlags(p, "", "") }, "urlEncode": template.URLQueryEscaper, "urlEncodePath": func(p string) string { parts := strings.Split(strings.Trim(p, "/"), "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") }, "sub": func(a, b int) int { return a - b }, // 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 }, }) if repoRoot == "" { return nil, fmt.Errorf("failed to determine template path") } return tmpl.ParseGlob(filepath.Join(repoRoot, "web", "templates", "*.html")) } var ( presetSvcGlobal *service.PresetService repoRoot string ) func init() { _, file, _, ok := runtime.Caller(0) if ok { repoRoot = filepath.Join(filepath.Dir(file), "..", "..") } } func formatDuration(seconds int) string { if seconds <= 0 { return "--:--" } m := seconds / 60 s := seconds % 60 h := m / 60 m = m % 60 if h > 0 { return fmt.Sprintf("%d:%02d:%02d", h, m, s) } return fmt.Sprintf("%d:%02d", m, s) } func formatFileSize(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } 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 } const cookieMaxAge = 365 * 24 * 60 * 60 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") } 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) } } func (h *Handler) Library(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") sortBy := r.URL.Query().Get("sort") filter := r.URL.Query().Get("filter") if sortBy == "" { sortBy = getCookie(r, "library_sort", "date") } else { setCookie(w, "library_sort", sortBy) } items, folders, err := h.librarySvc.GetAll(path, sortBy, filter) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } settings, _ := h.settingsSvc.GetAll() 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 { relPath := chi.URLParam(r, "*") relPath = strings.ReplaceAll(relPath, "+", " ") relPath = strings.Trim(relPath, "/") relPath, _ = url.PathUnescape(relPath) 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) { ctx := r.Context() item, err := h.librarySvc.GetByRelPath(ctx, 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, _ := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename) subtitles, _ := h.librarySvc.GetSubtitles(ctx, relPath) 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 } if strings.HasSuffix(relPath, "/subtitles") { h.serveSubtitles(strings.TrimSuffix(relPath, "/subtitles"), 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(r.Context(), 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) { ctx := r.Context() filename := r.URL.Query().Get("file") if thumb, ok := h.librarySvc.ThumbnailForFile(ctx, 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(ctx, relPath); err == nil { if isAudioFile(item, filename) { icon = "audio-icon.svg" } } http.ServeFile(w, r, filepath.Join(repoRoot, "web", "static", "icons", icon)) } // 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 string, w http.ResponseWriter, r *http.Request) { lang := r.URL.Query().Get("lang") 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 := h.librarySvc.GetSubtitlePath(relPath, lang) 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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/library", http.StatusSeeOther) } func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") sortBy := r.URL.Query().Get("sort") if sortBy == "" { sortBy = getCookie(r, "queue_sort", "date") } else { setCookie(w, "queue_sort", sortBy) } downloads, err := h.downloadSvc.GetAll(status, sortBy) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } settings, _ := h.settingsSvc.GetAll() 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) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) 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 == "" { http.Error(w, "URL is required", http.StatusBadRequest) 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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } h.workerPool.Submit(download) http.Redirect(w, r, "/queue", http.StatusSeeOther) } func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("url") if url == "" { http.Error(w, "URL is required", http.StatusBadRequest) return } formats, err := h.downloadSvc.ListFormats(url) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } h.renderWithRequest(w, r, "formats", PageData{ Title: "Available Formats", ActiveTab: "download", Data: struct { URL string Formats []*models.FormatInfo }{ URL: url, Formats: formats, }, }) } func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } if err := h.downloadSvc.Delete(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/queue", http.StatusSeeOther) } func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) { if err := h.downloadSvc.DeleteAll(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/queue", http.StatusSeeOther) } func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { presets, err := h.presetSvc.GetAll() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } settings, err := h.settingsSvc.GetAll() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) 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{ Name: r.FormValue("name"), Description: r.FormValue("description"), FormatMode: r.FormValue("format_mode"), Format: r.FormValue("format"), Quality: r.FormValue("quality"), CustomFormat: r.FormValue("custom_format"), AudioFormat: r.FormValue("audio_format"), SubLangs: r.FormValue("sub_langs"), CustomFlags: r.FormValue("custom_flags"), } preset.IsDefault = r.FormValue("is_default") == "1" preset.ExtractAudio = r.FormValue("extract_audio") == "1" preset.EmbedSubs = r.FormValue("embed_subs") == "1" preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1" preset.EmbedMetadata = r.FormValue("embed_metadata") == "1" preset.WriteInfoJSON = r.FormValue("write_info_json") == "1" if err := h.presetSvc.Create(preset); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) 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 } preset.Name = r.FormValue("name") preset.Description = r.FormValue("description") preset.FormatMode = r.FormValue("format_mode") preset.Format = r.FormValue("format") preset.Quality = r.FormValue("quality") preset.CustomFormat = r.FormValue("custom_format") preset.AudioFormat = r.FormValue("audio_format") preset.SubLangs = r.FormValue("sub_langs") preset.CustomFlags = r.FormValue("custom_flags") preset.IsDefault = r.FormValue("is_default") == "1" preset.ExtractAudio = r.FormValue("extract_audio") == "1" preset.EmbedSubs = r.FormValue("embed_subs") == "1" preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1" preset.EmbedMetadata = r.FormValue("embed_metadata") == "1" preset.WriteInfoJSON = r.FormValue("write_info_json") == "1" if err := h.presetSvc.Update(preset); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } if err := h.presetSvc.Delete(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if interval := r.FormValue("refresh_interval"); interval != "" { h.settingsSvc.SetRefreshInterval(interval) } h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1") h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1") h.settingsSvc.SetCookies(r.FormValue("cookies")) 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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } defaultPreset, _ := h.presetSvc.GetDefault() url := r.URL.Query().Get("url") var formats []*models.FormatInfo if r.URL.Query().Get("list_formats") == "1" && url != "" { formats, _ = h.downloadSvc.ListFormats(url) } h.renderWithRequest(w, r, "download_form", PageData{ Title: "Download", ActiveTab: "download", 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) }