package handler
import (
"bytes"
"fmt"
"html/template"
"net/http"
"path/filepath"
"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
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,
"mimeType": mimeTypeFromFile,
"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])
}
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 // 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
}
meta, _ := h.mediaSvc.GetMetadata(id)
subtitles, _ := h.mediaSvc.GetSubtitles(id)
h.renderWithRequest(w, r, "library_detail", PageData{
Title: media.Title,
ActiveTab: "library",
Data: struct {
Media *models.Media
Metadata *service.MediaMetadata
Subtitles []models.SubtitleTrack
}{
Media: media,
Metadata: meta,
Subtitles: subtitles,
},
})
}
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)
}
func (h *Handler) ServeThumbnail(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
}
thumbPath, err := h.mediaSvc.ExtractThumbnail(id)
if err != nil {
// Serve fallback icon instead of 404 so
always has a valid source
media, mediaErr := h.mediaSvc.GetByID(id)
if mediaErr == nil && media.IsAudio {
http.ServeFile(w, r, "web/static/icons/audio-icon.svg")
} else {
http.ServeFile(w, r, "web/static/icons/video-icon.svg")
}
return
}
http.ServeFile(w, r, thumbPath)
}
func (h *Handler) ServeSubtitles(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
}
lang := chi.URLParam(r, "lang")
if lang == "" {
http.Error(w, "Missing language", http.StatusBadRequest)
return
}
// Validate language to prevent path traversal
if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
http.Error(w, "Invalid language", http.StatusBadRequest)
return
}
subtitlePath := h.mediaSvc.GetSubtitlePath(id, lang)
w.Header().Set("Content-Type", "text/vtt")
http.ServeFile(w, r, subtitlePath)
}
// 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")
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)
}
// 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")
h.settingsSvc.SetCookies(r.FormValue("cookies"))
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
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",
},
})
}
// 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)
}