package handler import ( "bytes" "fmt" "html/template" "net/http" "path/filepath" "strconv" "strings" "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 mediaSvc *service.MediaService settingsSvc *service.SettingsService workerPool *worker.Pool } func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, mediaSvc *service.MediaService, 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, mediaSvc: mediaSvc, 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, "presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") }, }) return tmpl.ParseGlob("web/templates/*.html") } var presetSvcGlobal *service.PresetService 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]) } type PageData struct { Title string ActiveTab string Data interface{} BaseURL string AutoRefresh bool RefreshSec int Theme string } const cookieMaxAge = 365 * 24 * 60 * 60 // 1 year 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") // Render content first 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 } // Then render layout with content 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) } } // Library handlers 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) } media, folders, err := h.mediaSvc.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 { Media []*models.Media Folders []string Path string SortBy string Filter string }{ Media: media, Folders: folders, Path: path, SortBy: sortBy, Filter: filter, }, }) } func (h *Handler) LibraryDetail(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 } media, err := h.mediaSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } h.renderWithRequest(w, r, "library_detail", PageData{ Title: media.Title, ActiveTab: "library", Data: media, }) } func (h *Handler) ServeMedia(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 } media, err := h.mediaSvc.GetByID(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } http.ServeFile(w, r, media.Filepath) } // Queue handlers 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") download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags) 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) } // Settings handlers 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"), OutputTemplate: r.FormValue("output_template"), 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.OutputTemplate = r.FormValue("output_template") 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") http.Redirect(w, r, "/settings", http.StatusSeeOther) } // Download form page 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 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"), Formats: formats, ShowFormats: r.URL.Query().Get("list_formats") == "1", }, }) } // API handlers for HTMX-style interactions 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)) } // Theme handler 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) // Redirect back to the referring page, or home referer := r.Header.Get("Referer") if referer == "" { referer = "/" } http.Redirect(w, r, referer, http.StatusSeeOther) } // Scan library func (h *Handler) ScanLibrary(w http.ResponseWriter, r *http.Request) { if err := h.mediaSvc.ScanLibrary(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/library", http.StatusSeeOther) } // Delete media func (h *Handler) DeleteMedia(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.mediaSvc.Delete(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/library", http.StatusSeeOther) }