handler.go
⎇
Raw
1package handler
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "path/filepath"
9 "strconv"
10 "strings"
11
12 "github.com/gabriel-vasile/mimetype"
13 "github.com/go-chi/chi/v5"
14
15 "vidarchive/internal/config"
16 "vidarchive/internal/models"
17 "vidarchive/internal/service"
18 "vidarchive/internal/worker"
19)
20
21type Handler struct {
22 cfg *config.Config
23 templates *template.Template
24 presetSvc *service.PresetService
25 downloadSvc *service.DownloadService
26 mediaSvc *service.MediaService
27 settingsSvc *service.SettingsService
28 workerPool *worker.Pool
29}
30
31func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, mediaSvc *service.MediaService, settingsSvc *service.SettingsService, workerPool *worker.Pool) (*Handler, error) {
32 presetSvcGlobal = presetSvc
33 tmpl, err := loadTemplates()
34 if err != nil {
35 return nil, fmt.Errorf("load templates: %w", err)
36 }
37
38 return &Handler{
39 cfg: cfg,
40 templates: tmpl,
41 presetSvc: presetSvc,
42 downloadSvc: downloadSvc,
43 mediaSvc: mediaSvc,
44 settingsSvc: settingsSvc,
45 workerPool: workerPool,
46 }, nil
47}
48
49func loadTemplates() (*template.Template, error) {
50 tmpl := template.New("").Funcs(template.FuncMap{
51 "formatDuration": formatDuration,
52 "formatFileSize": formatFileSize,
53 "splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
54 "baseName": filepath.Base,
55 "ext": filepath.Ext,
56 "mimeType": mimeTypeFromFile,
57 "presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
58 })
59
60 return tmpl.ParseGlob("web/templates/*.html")
61}
62
63var presetSvcGlobal *service.PresetService
64
65func formatDuration(seconds int) string {
66 if seconds == 0 {
67 return "--:--"
68 }
69 m := seconds / 60
70 s := seconds % 60
71 h := m / 60
72 m = m % 60
73 if h > 0 {
74 return fmt.Sprintf("%d:%02d:%02d", h, m, s)
75 }
76 return fmt.Sprintf("%d:%02d", m, s)
77}
78
79func formatFileSize(bytes int64) string {
80 const unit = 1024
81 if bytes < unit {
82 return fmt.Sprintf("%d B", bytes)
83 }
84 div, exp := int64(unit), 0
85 for n := bytes / unit; n >= unit; n /= unit {
86 div *= unit
87 exp++
88 }
89 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
90}
91
92func mimeTypeFromFile(path string) string {
93 mtype, err := mimetype.DetectFile(path)
94 if err != nil || mtype == nil {
95 return "application/octet-stream"
96 }
97 return mtype.String()
98}
99
100type PageData struct {
101 Title string
102 ActiveTab string
103 Data interface{}
104 BaseURL string
105 AutoRefresh bool
106 RefreshSec int
107 Theme string
108}
109
110const cookieMaxAge = 365 * 24 * 60 * 60 // 1 year
111
112func getCookie(r *http.Request, name, defaultVal string) string {
113 cookie, err := r.Cookie(name)
114 if err != nil {
115 return defaultVal
116 }
117 return cookie.Value
118}
119
120func setCookie(w http.ResponseWriter, name, value string) {
121 http.SetCookie(w, &http.Cookie{
122 Name: name,
123 Value: value,
124 Path: "/",
125 MaxAge: cookieMaxAge,
126 HttpOnly: true,
127 SameSite: http.SameSiteLaxMode,
128 })
129}
130
131func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
132 data.BaseURL = h.cfg.BaseURL
133 if data.Theme == "" {
134 data.Theme = getCookie(r, "theme", "auto")
135 }
136 w.Header().Set("Content-Type", "text/html; charset=utf-8")
137
138 // Render content first
139 var contentBuf bytes.Buffer
140 if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
141 http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
142 return
143 }
144
145 // Then render layout with content
146 layoutData := struct {
147 PageData
148 Content template.HTML
149 }{
150 PageData: data,
151 Content: template.HTML(contentBuf.String()),
152 }
153
154 if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
155 http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
156 }
157}
158
159// Library handlers
160func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
161 path := r.URL.Query().Get("path")
162 sortBy := r.URL.Query().Get("sort")
163 filter := r.URL.Query().Get("filter")
164
165 if sortBy == "" {
166 sortBy = getCookie(r, "library_sort", "date")
167 } else {
168 setCookie(w, "library_sort", sortBy)
169 }
170
171 media, folders, err := h.mediaSvc.GetAll(path, sortBy, filter)
172 if err != nil {
173 http.Error(w, err.Error(), http.StatusInternalServerError)
174 return
175 }
176
177 settings, _ := h.settingsSvc.GetAll()
178
179 h.renderWithRequest(w, r, "library", PageData{
180 Title: "Library",
181 ActiveTab: "library",
182 AutoRefresh: settings.AutoRefreshLibrary,
183 RefreshSec: settings.RefreshInterval,
184 Data: struct {
185 Media []*models.Media
186 Folders []string
187 Path string
188 SortBy string
189 Filter string
190 }{
191 Media: media,
192 Folders: folders,
193 Path: path,
194 SortBy: sortBy,
195 Filter: filter,
196 },
197 })
198}
199
200func (h *Handler) LibraryDetail(w http.ResponseWriter, r *http.Request) {
201 idStr := chi.URLParam(r, "id")
202 id, err := strconv.ParseInt(idStr, 10, 64)
203 if err != nil {
204 http.Error(w, "Invalid ID", http.StatusBadRequest)
205 return
206 }
207
208 media, err := h.mediaSvc.GetByID(id)
209 if err != nil {
210 http.Error(w, "Not found", http.StatusNotFound)
211 return
212 }
213
214 meta, _ := h.mediaSvc.GetMetadata(id)
215 subtitles, _ := h.mediaSvc.GetSubtitles(id)
216
217 h.renderWithRequest(w, r, "library_detail", PageData{
218 Title: media.Title,
219 ActiveTab: "library",
220 Data: struct {
221 Media *models.Media
222 Metadata *service.MediaMetadata
223 Subtitles []models.SubtitleTrack
224 }{
225 Media: media,
226 Metadata: meta,
227 Subtitles: subtitles,
228 },
229 })
230}
231
232func (h *Handler) ServeMedia(w http.ResponseWriter, r *http.Request) {
233 idStr := chi.URLParam(r, "id")
234 id, err := strconv.ParseInt(idStr, 10, 64)
235 if err != nil {
236 http.Error(w, "Invalid ID", http.StatusBadRequest)
237 return
238 }
239
240 media, err := h.mediaSvc.GetByID(id)
241 if err != nil {
242 http.Error(w, "Not found", http.StatusNotFound)
243 return
244 }
245
246 http.ServeFile(w, r, media.Filepath)
247}
248
249func (h *Handler) ServeThumbnail(w http.ResponseWriter, r *http.Request) {
250 idStr := chi.URLParam(r, "id")
251 id, err := strconv.ParseInt(idStr, 10, 64)
252 if err != nil {
253 http.Error(w, "Invalid ID", http.StatusBadRequest)
254 return
255 }
256
257 thumbPath, err := h.mediaSvc.ExtractThumbnail(id)
258 if err != nil {
259 // Serve fallback icon instead of 404 so <img> always has a valid source
260 media, mediaErr := h.mediaSvc.GetByID(id)
261 if mediaErr == nil && media.IsAudio {
262 http.ServeFile(w, r, "web/static/icons/audio-icon.svg")
263 } else {
264 http.ServeFile(w, r, "web/static/icons/video-icon.svg")
265 }
266 return
267 }
268
269 http.ServeFile(w, r, thumbPath)
270}
271
272func (h *Handler) ServeSubtitles(w http.ResponseWriter, r *http.Request) {
273 idStr := chi.URLParam(r, "id")
274 id, err := strconv.ParseInt(idStr, 10, 64)
275 if err != nil {
276 http.Error(w, "Invalid ID", http.StatusBadRequest)
277 return
278 }
279
280 lang := chi.URLParam(r, "lang")
281 if lang == "" {
282 http.Error(w, "Missing language", http.StatusBadRequest)
283 return
284 }
285
286 // Validate language to prevent path traversal
287 if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
288 http.Error(w, "Invalid language", http.StatusBadRequest)
289 return
290 }
291
292 subtitlePath := h.mediaSvc.GetSubtitlePath(id, lang)
293
294 w.Header().Set("Content-Type", "text/vtt")
295 http.ServeFile(w, r, subtitlePath)
296}
297
298// Queue handlers
299func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
300 status := r.URL.Query().Get("status")
301 sortBy := r.URL.Query().Get("sort")
302
303 if sortBy == "" {
304 sortBy = getCookie(r, "queue_sort", "date")
305 } else {
306 setCookie(w, "queue_sort", sortBy)
307 }
308
309 downloads, err := h.downloadSvc.GetAll(status, sortBy)
310 if err != nil {
311 http.Error(w, err.Error(), http.StatusInternalServerError)
312 return
313 }
314
315 settings, _ := h.settingsSvc.GetAll()
316
317 h.renderWithRequest(w, r, "queue", PageData{
318 Title: "Queue",
319 ActiveTab: "queue",
320 AutoRefresh: settings.AutoRefreshDownloads,
321 RefreshSec: settings.RefreshInterval,
322 Data: struct {
323 Items []*models.Download
324 Status string
325 SortBy string
326 }{
327 Items: downloads,
328 Status: status,
329 SortBy: sortBy,
330 },
331 })
332}
333
334func (h *Handler) DownloadDetail(w http.ResponseWriter, r *http.Request) {
335 idStr := chi.URLParam(r, "id")
336 id, err := strconv.ParseInt(idStr, 10, 64)
337 if err != nil {
338 http.Error(w, "Invalid ID", http.StatusBadRequest)
339 return
340 }
341
342 download, err := h.downloadSvc.GetByID(id)
343 if err != nil {
344 http.Error(w, "Not found", http.StatusNotFound)
345 return
346 }
347
348 h.renderWithRequest(w, r, "queue_detail", PageData{
349 Title: "Queue Details",
350 ActiveTab: "queue",
351 Data: download,
352 })
353}
354
355func (h *Handler) CreateDownload(w http.ResponseWriter, r *http.Request) {
356 if err := r.ParseForm(); err != nil {
357 http.Error(w, err.Error(), http.StatusBadRequest)
358 return
359 }
360
361 url := r.FormValue("url")
362 if url == "" {
363 http.Error(w, "URL is required", http.StatusBadRequest)
364 return
365 }
366
367 var presetID *int64
368 if pid := r.FormValue("preset_id"); pid != "" {
369 id, err := strconv.ParseInt(pid, 10, 64)
370 if err == nil {
371 presetID = &id
372 }
373 }
374
375 formatOverride := r.FormValue("format_override")
376 customFlags := r.FormValue("custom_flags")
377 outputDir := r.FormValue("output_dir")
378
379 download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
380 if err != nil {
381 http.Error(w, err.Error(), http.StatusInternalServerError)
382 return
383 }
384
385 h.workerPool.Submit(download)
386
387 http.Redirect(w, r, "/queue", http.StatusSeeOther)
388}
389
390func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) {
391 url := r.URL.Query().Get("url")
392 if url == "" {
393 http.Error(w, "URL is required", http.StatusBadRequest)
394 return
395 }
396
397 formats, err := h.downloadSvc.ListFormats(url)
398 if err != nil {
399 http.Error(w, err.Error(), http.StatusInternalServerError)
400 return
401 }
402
403 h.renderWithRequest(w, r, "formats", PageData{
404 Title: "Available Formats",
405 ActiveTab: "download",
406 Data: struct {
407 URL string
408 Formats []*models.FormatInfo
409 }{
410 URL: url,
411 Formats: formats,
412 },
413 })
414}
415
416func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
417 idStr := chi.URLParam(r, "id")
418 id, err := strconv.ParseInt(idStr, 10, 64)
419 if err != nil {
420 http.Error(w, "Invalid ID", http.StatusBadRequest)
421 return
422 }
423
424 if err := h.downloadSvc.Delete(id); err != nil {
425 http.Error(w, err.Error(), http.StatusInternalServerError)
426 return
427 }
428
429 http.Redirect(w, r, "/queue", http.StatusSeeOther)
430}
431
432func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
433 if err := h.downloadSvc.DeleteAll(); err != nil {
434 http.Error(w, err.Error(), http.StatusInternalServerError)
435 return
436 }
437
438 http.Redirect(w, r, "/queue", http.StatusSeeOther)
439}
440
441// Settings handlers
442func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
443 presets, err := h.presetSvc.GetAll()
444 if err != nil {
445 http.Error(w, err.Error(), http.StatusInternalServerError)
446 return
447 }
448
449 settings, err := h.settingsSvc.GetAll()
450 if err != nil {
451 http.Error(w, err.Error(), http.StatusInternalServerError)
452 return
453 }
454
455 h.renderWithRequest(w, r, "settings", PageData{
456 Title: "Settings",
457 ActiveTab: "settings",
458 Data: struct {
459 Presets []*models.Preset
460 Settings *models.Settings
461 }{
462 Presets: presets,
463 Settings: settings,
464 },
465 })
466}
467
468func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
469 if err := r.ParseForm(); err != nil {
470 http.Error(w, err.Error(), http.StatusBadRequest)
471 return
472 }
473
474 preset := &models.Preset{
475 Name: r.FormValue("name"),
476 Description: r.FormValue("description"),
477 FormatMode: r.FormValue("format_mode"),
478 Format: r.FormValue("format"),
479 Quality: r.FormValue("quality"),
480 CustomFormat: r.FormValue("custom_format"),
481 AudioFormat: r.FormValue("audio_format"),
482 SubLangs: r.FormValue("sub_langs"),
483 OutputTemplate: r.FormValue("output_template"),
484 CustomFlags: r.FormValue("custom_flags"),
485 }
486
487 preset.IsDefault = r.FormValue("is_default") == "1"
488 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
489 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
490 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
491 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
492 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
493
494 if err := h.presetSvc.Create(preset); err != nil {
495 http.Error(w, err.Error(), http.StatusInternalServerError)
496 return
497 }
498
499 http.Redirect(w, r, "/settings", http.StatusSeeOther)
500}
501
502func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
503 idStr := chi.URLParam(r, "id")
504 id, err := strconv.ParseInt(idStr, 10, 64)
505 if err != nil {
506 http.Error(w, "Invalid ID", http.StatusBadRequest)
507 return
508 }
509
510 if err := r.ParseForm(); err != nil {
511 http.Error(w, err.Error(), http.StatusBadRequest)
512 return
513 }
514
515 preset, err := h.presetSvc.GetByID(id)
516 if err != nil {
517 http.Error(w, "Not found", http.StatusNotFound)
518 return
519 }
520
521 preset.Name = r.FormValue("name")
522 preset.Description = r.FormValue("description")
523 preset.FormatMode = r.FormValue("format_mode")
524 preset.Format = r.FormValue("format")
525 preset.Quality = r.FormValue("quality")
526 preset.CustomFormat = r.FormValue("custom_format")
527 preset.AudioFormat = r.FormValue("audio_format")
528 preset.SubLangs = r.FormValue("sub_langs")
529 preset.OutputTemplate = r.FormValue("output_template")
530 preset.CustomFlags = r.FormValue("custom_flags")
531 preset.IsDefault = r.FormValue("is_default") == "1"
532 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
533 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
534 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
535 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
536 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
537
538 if err := h.presetSvc.Update(preset); err != nil {
539 http.Error(w, err.Error(), http.StatusInternalServerError)
540 return
541 }
542
543 http.Redirect(w, r, "/settings", http.StatusSeeOther)
544}
545
546func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
547 idStr := chi.URLParam(r, "id")
548 id, err := strconv.ParseInt(idStr, 10, 64)
549 if err != nil {
550 http.Error(w, "Invalid ID", http.StatusBadRequest)
551 return
552 }
553
554 if err := h.presetSvc.Delete(id); err != nil {
555 http.Error(w, err.Error(), http.StatusInternalServerError)
556 return
557 }
558
559 http.Redirect(w, r, "/settings", http.StatusSeeOther)
560}
561
562func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
563 if err := r.ParseForm(); err != nil {
564 http.Error(w, err.Error(), http.StatusBadRequest)
565 return
566 }
567
568 if interval := r.FormValue("refresh_interval"); interval != "" {
569 h.settingsSvc.SetRefreshInterval(interval)
570 }
571
572 h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1")
573 h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1")
574 h.settingsSvc.SetCookies(r.FormValue("cookies"))
575
576 http.Redirect(w, r, "/settings", http.StatusSeeOther)
577}
578
579// Download form page
580func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
581 presets, err := h.presetSvc.GetAll()
582 if err != nil {
583 http.Error(w, err.Error(), http.StatusInternalServerError)
584 return
585 }
586
587 defaultPreset, _ := h.presetSvc.GetDefault()
588
589 url := r.URL.Query().Get("url")
590 var formats []*models.FormatInfo
591 if r.URL.Query().Get("list_formats") == "1" && url != "" {
592 formats, _ = h.downloadSvc.ListFormats(url)
593 }
594
595 h.renderWithRequest(w, r, "download_form", PageData{
596 Title: "Download",
597 ActiveTab: "download",
598 Data: struct {
599 Presets []*models.Preset
600 DefaultPreset *models.Preset
601 URL string
602 FormatOverride string
603 CustomFlags string
604 OutputDir string
605 Formats []*models.FormatInfo
606 ShowFormats bool
607 }{
608 Presets: presets,
609 DefaultPreset: defaultPreset,
610 URL: url,
611 FormatOverride: r.URL.Query().Get("format_override"),
612 CustomFlags: r.URL.Query().Get("custom_flags"),
613 OutputDir: r.URL.Query().Get("output_dir"),
614 Formats: formats,
615 ShowFormats: r.URL.Query().Get("list_formats") == "1",
616 },
617 })
618}
619
620// API handlers for HTMX-style interactions
621func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
622 idStr := r.URL.Query().Get("id")
623 id, err := strconv.ParseInt(idStr, 10, 64)
624 if err != nil {
625 http.Error(w, "Invalid ID", http.StatusBadRequest)
626 return
627 }
628
629 preset, err := h.presetSvc.GetByID(id)
630 if err != nil {
631 http.Error(w, "Not found", http.StatusNotFound)
632 return
633 }
634
635 flags := h.presetSvc.EffectiveFlags(preset, "", "")
636 w.Header().Set("Content-Type", "text/plain")
637 w.Write([]byte(flags))
638}
639
640// Theme handler
641func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
642 if err := r.ParseForm(); err != nil {
643 http.Error(w, err.Error(), http.StatusBadRequest)
644 return
645 }
646
647 theme := r.FormValue("theme")
648 if theme == "" {
649 theme = "auto"
650 }
651
652 setCookie(w, "theme", theme)
653
654 // Redirect back to the referring page, or home
655 referer := r.Header.Get("Referer")
656 if referer == "" {
657 referer = "/"
658 }
659 http.Redirect(w, r, referer, http.StatusSeeOther)
660}
661
662// Scan library
663func (h *Handler) ScanLibrary(w http.ResponseWriter, r *http.Request) {
664 if err := h.mediaSvc.ScanLibrary(); err != nil {
665 http.Error(w, err.Error(), http.StatusInternalServerError)
666 return
667 }
668 http.Redirect(w, r, "/library", http.StatusSeeOther)
669}
670
671// Delete media
672func (h *Handler) DeleteMedia(w http.ResponseWriter, r *http.Request) {
673 idStr := chi.URLParam(r, "id")
674 id, err := strconv.ParseInt(idStr, 10, 64)
675 if err != nil {
676 http.Error(w, "Invalid ID", http.StatusBadRequest)
677 return
678 }
679
680 if err := h.mediaSvc.Delete(id); err != nil {
681 http.Error(w, err.Error(), http.StatusInternalServerError)
682 return
683 }
684
685 http.Redirect(w, r, "/library", http.StatusSeeOther)
686}
687