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