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