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