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