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