handler.go
⎇
Raw
1package handler
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "log"
8 "net/http"
9 "net/url"
10 "path/filepath"
11 "strconv"
12 "strings"
13
14 "github.com/gabriel-vasile/mimetype"
15 "github.com/go-chi/chi/v5"
16
17 "vidarchive"
18 "vidarchive/internal/config"
19 "vidarchive/internal/models"
20 "vidarchive/internal/service"
21 "vidarchive/internal/worker"
22)
23
24type Handler struct {
25 cfg *config.Config
26 templates *template.Template
27 presetSvc *service.PresetService
28 downloadSvc *service.DownloadService
29 librarySvc *service.LibraryService
30 settingsSvc *service.SettingsService
31 subscriptionSvc *service.SubscriptionService
32 workerPool *worker.Pool
33}
34
35func New(cfg *config.Config, presetSvc *service.PresetService, downloadSvc *service.DownloadService, librarySvc *service.LibraryService, settingsSvc *service.SettingsService, subscriptionSvc *service.SubscriptionService, workerPool *worker.Pool) (*Handler, error) {
36 presetSvcGlobal = presetSvc
37 tmpl, err := loadTemplates()
38 if err != nil {
39 return nil, fmt.Errorf("load templates: %w", err)
40 }
41
42 return &Handler{
43 cfg: cfg,
44 templates: tmpl,
45 presetSvc: presetSvc,
46 downloadSvc: downloadSvc,
47 librarySvc: librarySvc,
48 settingsSvc: settingsSvc,
49 subscriptionSvc: subscriptionSvc,
50 workerPool: workerPool,
51 }, nil
52}
53
54func loadTemplates() (*template.Template, error) {
55 tmpl := template.New("").Funcs(template.FuncMap{
56 "formatDuration": formatDuration,
57 "formatFileSize": formatFileSize,
58 "splitPath": func(p string) []string { return strings.Split(strings.Trim(p, "/"), "/") },
59 "baseName": filepath.Base,
60 "ext": filepath.Ext,
61 "mimeType": mimeTypeFromFile,
62 "presetFlags": func(p *models.Preset) string { return presetSvcGlobal.EffectiveFlags(p, "", "") },
63 "urlEncode": template.URLQueryEscaper,
64 "urlEncodePath": func(p string) string {
65 parts := strings.Split(strings.Trim(p, "/"), "/")
66 for i, part := range parts {
67 parts[i] = url.PathEscape(part)
68 }
69 return strings.Join(parts, "/")
70 },
71 "sub": func(a, b int) int { return a - b },
72 // emptyPreset / newSubscription supply a zero value so the shared create and
73 // edit form partials can be rendered from the create page too. newSubscription
74 // carries the create-time defaults (overwrite mode, daily schedule).
75 "emptyPreset": func() *models.Preset { return nil },
76 "newSubscription": func() *models.Subscription {
77 return &models.Subscription{RefreshMode: "overwrite", ScheduleKind: "daily"}
78 },
79 // dict builds a map from alternating key/value args, so a template can pass
80 // more than one value into a sub-template (e.g. the subscription form needs
81 // both the subscription and the preset list).
82 "dict": func(values ...interface{}) (map[string]interface{}, error) {
83 if len(values)%2 != 0 {
84 return nil, fmt.Errorf("dict expects an even number of arguments")
85 }
86 m := make(map[string]interface{}, len(values)/2)
87 for i := 0; i < len(values); i += 2 {
88 key, ok := values[i].(string)
89 if !ok {
90 return nil, fmt.Errorf("dict keys must be strings")
91 }
92 m[key] = values[i+1]
93 }
94 return m, nil
95 },
96 // isLongText reports whether text spans more than ~2 lines, so the detail
97 // view can make long descriptions collapsible. Uses rune count (not bytes)
98 // so CJK text isn't flagged early.
99 "isLongText": func(s string) bool {
100 return strings.Count(s, "\n") >= 2 || len([]rune(s)) > 180
101 },
102 })
103
104 return tmpl.ParseFS(vidarchive.TemplatesFS, "web/templates/*.html")
105}
106
107var presetSvcGlobal *service.PresetService
108
109func formatDuration(seconds int) string {
110 if seconds <= 0 {
111 return "--:--"
112 }
113 m := seconds / 60
114 s := seconds % 60
115 h := m / 60
116 m = m % 60
117 if h > 0 {
118 return fmt.Sprintf("%d:%02d:%02d", h, m, s)
119 }
120 return fmt.Sprintf("%d:%02d", m, s)
121}
122
123func formatFileSize(bytes int64) string {
124 const unit = 1024
125 if bytes < unit {
126 return fmt.Sprintf("%d B", bytes)
127 }
128 div, exp := int64(unit), 0
129 for n := bytes / unit; n >= unit; n /= unit {
130 div *= unit
131 exp++
132 }
133 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
134}
135
136func mimeTypeFromFile(path string) string {
137 mtype, err := mimetype.DetectFile(path)
138 if err != nil || mtype == nil {
139 return "application/octet-stream"
140 }
141 return mtype.String()
142}
143
144type PageData struct {
145 Title string
146 ActiveTab string
147 Data interface{}
148 BaseURL string
149 AutoRefresh bool
150 RefreshSec int
151 Theme string
152 Flash *Flash
153}
154
155// Flash is a one-shot message shown to the user after a redirect (the
156// Post/Redirect/Get pattern). Kind is "success" or "error".
157type Flash struct {
158 Kind string
159 Message string
160}
161
162const cookieMaxAge = 365 * 24 * 60 * 60
163
164const flashCookie = "flash"
165
166// setFlash stashes a one-shot message in a short-lived cookie. The next rendered
167// page reads and clears it (see consumeFlash), so the message appears once after
168// the redirect and never again.
169func setFlash(w http.ResponseWriter, kind, message string) {
170 http.SetCookie(w, &http.Cookie{
171 Name: flashCookie,
172 Value: url.QueryEscape(kind + "|" + message),
173 Path: "/",
174 MaxAge: 30,
175 HttpOnly: true,
176 SameSite: http.SameSiteLaxMode,
177 })
178}
179
180func flashSuccess(w http.ResponseWriter, message string) { setFlash(w, "success", message) }
181func flashError(w http.ResponseWriter, message string) { setFlash(w, "error", message) }
182
183// consumeFlash reads the flash cookie (if any) and immediately expires it, so a
184// message is shown exactly once.
185func consumeFlash(w http.ResponseWriter, r *http.Request) *Flash {
186 c, err := r.Cookie(flashCookie)
187 if err != nil || c.Value == "" {
188 return nil
189 }
190 http.SetCookie(w, &http.Cookie{
191 Name: flashCookie,
192 Value: "",
193 Path: "/",
194 MaxAge: -1,
195 HttpOnly: true,
196 SameSite: http.SameSiteLaxMode,
197 })
198 decoded, err := url.QueryUnescape(c.Value)
199 if err != nil {
200 return nil
201 }
202 kind, message, ok := strings.Cut(decoded, "|")
203 if !ok {
204 return nil
205 }
206 // The cookie is client-editable, so don't let an arbitrary kind flow into the
207 // banner's class name — clamp it to the two we render.
208 if kind != "success" {
209 kind = "error"
210 }
211 return &Flash{Kind: kind, Message: message}
212}
213
214func getCookie(r *http.Request, name, defaultVal string) string {
215 cookie, err := r.Cookie(name)
216 if err != nil {
217 return defaultVal
218 }
219 return cookie.Value
220}
221
222func setCookie(w http.ResponseWriter, name, value string) {
223 http.SetCookie(w, &http.Cookie{
224 Name: name,
225 Value: value,
226 Path: "/",
227 MaxAge: cookieMaxAge,
228 HttpOnly: true,
229 SameSite: http.SameSiteLaxMode,
230 })
231}
232
233func (h *Handler) renderWithRequest(w http.ResponseWriter, r *http.Request, contentTmpl string, data PageData) {
234 data.BaseURL = h.cfg.BaseURL
235 if data.Theme == "" {
236 data.Theme = getCookie(r, "theme", "auto")
237 }
238 if data.Flash == nil {
239 data.Flash = consumeFlash(w, r)
240 }
241 w.Header().Set("Content-Type", "text/html; charset=utf-8")
242
243 var contentBuf bytes.Buffer
244 if err := h.templates.ExecuteTemplate(&contentBuf, contentTmpl, data); err != nil {
245 http.Error(w, fmt.Sprintf("render content: %v", err), http.StatusInternalServerError)
246 return
247 }
248
249 layoutData := struct {
250 PageData
251 Content template.HTML
252 }{
253 PageData: data,
254 Content: template.HTML(contentBuf.String()),
255 }
256
257 if err := h.templates.ExecuteTemplate(w, "layout", layoutData); err != nil {
258 http.Error(w, fmt.Sprintf("render layout: %v", err), http.StatusInternalServerError)
259 }
260}
261
262// settingsOrDefault loads settings for page rendering, falling back to sane
263// defaults (rather than a nil deref) if the store can't be read.
264func (h *Handler) settingsOrDefault() *models.Settings {
265 settings, err := h.settingsSvc.GetAll()
266 if err != nil {
267 log.Printf("settings load failed, using defaults: %v", err)
268 return &models.Settings{RefreshInterval: 5, AutoRefreshDownloads: true}
269 }
270 return settings
271}
272
273func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
274 path := r.URL.Query().Get("path")
275 sortBy := r.URL.Query().Get("sort")
276 filter := r.URL.Query().Get("filter")
277
278 if sortBy == "" {
279 sortBy = getCookie(r, "library_sort", "date")
280 } else {
281 setCookie(w, "library_sort", sortBy)
282 }
283
284 items, folders, err := h.librarySvc.GetAll(path, sortBy, filter)
285 if err != nil {
286 http.Error(w, err.Error(), http.StatusInternalServerError)
287 return
288 }
289
290 settings := h.settingsOrDefault()
291
292 h.renderWithRequest(w, r, "library", PageData{
293 Title: "Library",
294 ActiveTab: "library",
295 AutoRefresh: settings.AutoRefreshLibrary,
296 RefreshSec: settings.RefreshInterval,
297 Data: struct {
298 Items []*models.LibraryItem
299 Folders []string
300 Path string
301 SortBy string
302 Filter string
303 }{
304 Items: items,
305 Folders: folders,
306 Path: path,
307 SortBy: sortBy,
308 Filter: filter,
309 },
310 })
311}
312
313func normalizeRelPath(r *http.Request) string {
314 // chi gives the raw, still-encoded wildcard. Decode it as a URL path, where
315 // '+' is a literal plus (only query strings treat '+' as space) — so an item
316 // directory named "a+b" round-trips correctly.
317 relPath := chi.URLParam(r, "*")
318 relPath = strings.Trim(relPath, "/")
319 if decoded, err := url.PathUnescape(relPath); err == nil {
320 relPath = decoded
321 } else {
322 log.Printf("normalizeRelPath: undecodable path %q: %v", relPath, err)
323 }
324 return relPath
325}
326
327func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
328 relPath := normalizeRelPath(r)
329 if r.Method == "POST" && strings.HasSuffix(relPath, "/delete") {
330 relPath = strings.TrimSuffix(relPath, "/delete")
331 h.deleteMedia(relPath, w, r)
332 return
333 }
334 h.libraryDetail(relPath, w, r)
335}
336
337func (h *Handler) libraryDetail(relPath string, w http.ResponseWriter, r *http.Request) {
338
339 ctx := r.Context()
340 item, err := h.librarySvc.GetByRelPath(ctx, relPath)
341 if err != nil {
342 http.Error(w, "Not found", http.StatusNotFound)
343 return
344 }
345
346 selectedFilename := r.URL.Query().Get("file")
347 if selectedFilename == "" && len(item.MediaFiles) > 0 {
348 selectedFilename = item.MediaFiles[0].Filename
349 }
350
351 meta, err := h.librarySvc.GetMetadata(ctx, relPath, selectedFilename)
352 if err != nil {
353 log.Printf("libraryDetail %q: metadata unavailable: %v", relPath, err)
354 }
355 subtitles, err := h.librarySvc.GetSubtitles(ctx, relPath)
356 if err != nil {
357 log.Printf("libraryDetail %q: subtitles unavailable: %v", relPath, err)
358 }
359
360 h.renderWithRequest(w, r, "library_detail", PageData{
361 Title: item.Name,
362 ActiveTab: "library",
363 Data: struct {
364 Item *models.LibraryItem
365 SelectedFilename string
366 Metadata *service.MediaMetadata
367 Subtitles []models.SubtitleTrack
368 }{
369 Item: item,
370 SelectedFilename: selectedFilename,
371 Metadata: meta,
372 Subtitles: subtitles,
373 },
374 })
375}
376
377func (h *Handler) ServeMediaItem(w http.ResponseWriter, r *http.Request) {
378 relPath := normalizeRelPath(r)
379 if strings.HasSuffix(relPath, "/thumbnail") {
380 h.serveThumbnail(strings.TrimSuffix(relPath, "/thumbnail"), w, r)
381 return
382 }
383 // Subtitle tracks are addressed as <item>/subtitles/<lang>, with the language
384 // as a trailing path segment (see LibraryService.GetSubtitles).
385 if i := strings.LastIndex(relPath, "/subtitles/"); i >= 0 {
386 item := relPath[:i]
387 lang := relPath[i+len("/subtitles/"):]
388 h.serveSubtitles(item, lang, w, r)
389 return
390 }
391 h.serveMedia(relPath, w, r)
392}
393
394func (h *Handler) serveMedia(relPath string, w http.ResponseWriter, r *http.Request) {
395
396 filename := r.URL.Query().Get("file")
397 if filename == "" {
398 http.Error(w, "Missing file", http.StatusBadRequest)
399 return
400 }
401
402 mediaPath, err := h.librarySvc.GetMediaFile(r.Context(), relPath, filename)
403 if err != nil {
404 http.Error(w, "Not found", http.StatusNotFound)
405 return
406 }
407
408 http.ServeFile(w, r, mediaPath)
409}
410
411func (h *Handler) serveThumbnail(relPath string, w http.ResponseWriter, r *http.Request) {
412 ctx := r.Context()
413 filename := r.URL.Query().Get("file")
414
415 if thumb, ok := h.librarySvc.ThumbnailForFile(ctx, relPath, filename); ok {
416 http.ServeFile(w, r, thumb)
417 return
418 }
419
420 // Fall back to an icon, matched to the requested file's type (or the item's
421 // primary file when no specific file was requested).
422 icon := "video-icon.svg"
423 if item, err := h.librarySvc.GetByRelPath(ctx, relPath); err == nil {
424 if isAudioFile(item, filename) {
425 icon = "audio-icon.svg"
426 }
427 }
428 data, err := vidarchive.StaticFS.ReadFile("web/static/icons/" + icon)
429 if err != nil {
430 http.Error(w, "icon not found", http.StatusInternalServerError)
431 return
432 }
433 w.Header().Set("Content-Type", "image/svg+xml")
434 w.Write(data)
435}
436
437// isAudioFile reports whether the named file (or, if unnamed, the first media
438// file) of an item is audio.
439func isAudioFile(item *models.LibraryItem, filename string) bool {
440 if filename != "" {
441 for _, mf := range item.MediaFiles {
442 if mf.Filename == filename {
443 return mf.IsAudio
444 }
445 }
446 return false
447 }
448 return len(item.MediaFiles) > 0 && item.MediaFiles[0].IsAudio
449}
450
451func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r *http.Request) {
452
453 if lang == "" {
454 http.Error(w, "Missing language", http.StatusBadRequest)
455 return
456 }
457
458 if strings.Contains(lang, "/") || strings.Contains(lang, "..") || strings.Contains(lang, "\\") {
459 http.Error(w, "Invalid language", http.StatusBadRequest)
460 return
461 }
462
463 subtitlePath := h.librarySvc.GetSubtitlePath(relPath, lang)
464
465 w.Header().Set("Content-Type", "text/vtt")
466 http.ServeFile(w, r, subtitlePath)
467}
468
469func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
470
471 if err := h.librarySvc.Delete(relPath); err != nil {
472 http.Error(w, err.Error(), http.StatusInternalServerError)
473 return
474 }
475
476 http.Redirect(w, r, "/library", http.StatusSeeOther)
477}
478
479func (h *Handler) Downloads(w http.ResponseWriter, r *http.Request) {
480 status := r.URL.Query().Get("status")
481 sortBy := r.URL.Query().Get("sort")
482
483 if sortBy == "" {
484 sortBy = getCookie(r, "queue_sort", "date")
485 } else {
486 setCookie(w, "queue_sort", sortBy)
487 }
488
489 downloads, err := h.downloadSvc.GetAll(status, sortBy)
490 if err != nil {
491 http.Error(w, err.Error(), http.StatusInternalServerError)
492 return
493 }
494
495 settings := h.settingsOrDefault()
496
497 h.renderWithRequest(w, r, "queue", PageData{
498 Title: "Queue",
499 ActiveTab: "queue",
500 AutoRefresh: settings.AutoRefreshDownloads,
501 RefreshSec: settings.RefreshInterval,
502 Data: struct {
503 Items []*models.Download
504 Status string
505 SortBy string
506 }{
507 Items: downloads,
508 Status: status,
509 SortBy: sortBy,
510 },
511 })
512}
513
514func (h *Handler) DownloadDetail(w http.ResponseWriter, r *http.Request) {
515 idStr := chi.URLParam(r, "id")
516 id, err := strconv.ParseInt(idStr, 10, 64)
517 if err != nil {
518 http.Error(w, "Invalid ID", http.StatusBadRequest)
519 return
520 }
521
522 download, err := h.downloadSvc.GetByID(id)
523 if err != nil {
524 http.Error(w, "Not found", http.StatusNotFound)
525 return
526 }
527
528 h.renderWithRequest(w, r, "queue_detail", PageData{
529 Title: "Queue Details",
530 ActiveTab: "queue",
531 Data: download,
532 })
533}
534
535func (h *Handler) CreateDownload(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 url := r.FormValue("url")
542 if url == "" {
543 flashError(w, "A URL is required to start a download.")
544 http.Redirect(w, r, "/download", http.StatusSeeOther)
545 return
546 }
547
548 var presetID *int64
549 if pid := r.FormValue("preset_id"); pid != "" {
550 id, err := strconv.ParseInt(pid, 10, 64)
551 if err == nil {
552 presetID = &id
553 }
554 }
555
556 formatOverride := r.FormValue("format_override")
557 customFlags := r.FormValue("custom_flags")
558 outputDir := r.FormValue("output_dir")
559
560 download, err := h.downloadSvc.Create(url, presetID, formatOverride, customFlags, outputDir)
561 if err != nil {
562 flashError(w, "Couldn't queue download: "+err.Error())
563 http.Redirect(w, r, "/download", http.StatusSeeOther)
564 return
565 }
566
567 h.workerPool.Submit(download)
568
569 flashSuccess(w, "Download queued.")
570 http.Redirect(w, r, "/queue", http.StatusSeeOther)
571}
572
573func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) {
574 url := r.URL.Query().Get("url")
575 if url == "" {
576 http.Error(w, "URL is required", http.StatusBadRequest)
577 return
578 }
579
580 formats, err := h.downloadSvc.ListFormats(url)
581 if err != nil {
582 http.Error(w, err.Error(), http.StatusInternalServerError)
583 return
584 }
585
586 h.renderWithRequest(w, r, "formats", PageData{
587 Title: "Available Formats",
588 ActiveTab: "download",
589 Data: struct {
590 URL string
591 Formats []*models.FormatInfo
592 }{
593 URL: url,
594 Formats: formats,
595 },
596 })
597}
598
599func (h *Handler) DeleteDownload(w http.ResponseWriter, r *http.Request) {
600 idStr := chi.URLParam(r, "id")
601 id, err := strconv.ParseInt(idStr, 10, 64)
602 if err != nil {
603 http.Error(w, "Invalid ID", http.StatusBadRequest)
604 return
605 }
606
607 if err := h.downloadSvc.Delete(id); err != nil {
608 flashError(w, "Couldn't remove download: "+err.Error())
609 http.Redirect(w, r, "/queue", http.StatusSeeOther)
610 return
611 }
612
613 flashSuccess(w, "Download removed.")
614 http.Redirect(w, r, "/queue", http.StatusSeeOther)
615}
616
617func (h *Handler) ClearAllDownloads(w http.ResponseWriter, r *http.Request) {
618 if err := h.downloadSvc.DeleteAll(); err != nil {
619 flashError(w, "Couldn't clear the queue: "+err.Error())
620 http.Redirect(w, r, "/queue", http.StatusSeeOther)
621 return
622 }
623
624 flashSuccess(w, "Queue cleared.")
625 http.Redirect(w, r, "/queue", http.StatusSeeOther)
626}
627
628func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
629 presets, err := h.presetSvc.GetAll()
630 if err != nil {
631 http.Error(w, err.Error(), http.StatusInternalServerError)
632 return
633 }
634
635 settings, err := h.settingsSvc.GetAll()
636 if err != nil {
637 http.Error(w, err.Error(), http.StatusInternalServerError)
638 return
639 }
640
641 h.renderWithRequest(w, r, "settings", PageData{
642 Title: "Settings",
643 ActiveTab: "settings",
644 Data: struct {
645 Presets []*models.Preset
646 Settings *models.Settings
647 }{
648 Presets: presets,
649 Settings: settings,
650 },
651 })
652}
653
654func (h *Handler) CreatePreset(w http.ResponseWriter, r *http.Request) {
655 if err := r.ParseForm(); err != nil {
656 http.Error(w, err.Error(), http.StatusBadRequest)
657 return
658 }
659
660 preset := &models.Preset{
661 Name: r.FormValue("name"),
662 Description: r.FormValue("description"),
663 FormatMode: r.FormValue("format_mode"),
664 Format: r.FormValue("format"),
665 Quality: r.FormValue("quality"),
666 CustomFormat: r.FormValue("custom_format"),
667 AudioFormat: r.FormValue("audio_format"),
668 SubLangs: r.FormValue("sub_langs"),
669 CustomFlags: r.FormValue("custom_flags"),
670 }
671
672 preset.IsDefault = r.FormValue("is_default") == "1"
673 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
674 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
675 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
676 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
677 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
678
679 if err := h.presetSvc.Create(preset); err != nil {
680 flashError(w, "Couldn't create preset: "+err.Error())
681 http.Redirect(w, r, "/settings", http.StatusSeeOther)
682 return
683 }
684
685 flashSuccess(w, "Preset created.")
686 http.Redirect(w, r, "/settings", http.StatusSeeOther)
687}
688
689func (h *Handler) UpdatePreset(w http.ResponseWriter, r *http.Request) {
690 idStr := chi.URLParam(r, "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 if err := r.ParseForm(); err != nil {
698 http.Error(w, err.Error(), http.StatusBadRequest)
699 return
700 }
701
702 preset, err := h.presetSvc.GetByID(id)
703 if err != nil {
704 http.Error(w, "Not found", http.StatusNotFound)
705 return
706 }
707
708 preset.Name = r.FormValue("name")
709 preset.Description = r.FormValue("description")
710 preset.FormatMode = r.FormValue("format_mode")
711 preset.Format = r.FormValue("format")
712 preset.Quality = r.FormValue("quality")
713 preset.CustomFormat = r.FormValue("custom_format")
714 preset.AudioFormat = r.FormValue("audio_format")
715 preset.SubLangs = r.FormValue("sub_langs")
716 preset.CustomFlags = r.FormValue("custom_flags")
717 preset.IsDefault = r.FormValue("is_default") == "1"
718 preset.ExtractAudio = r.FormValue("extract_audio") == "1"
719 preset.EmbedSubs = r.FormValue("embed_subs") == "1"
720 preset.EmbedThumbnail = r.FormValue("embed_thumbnail") == "1"
721 preset.EmbedMetadata = r.FormValue("embed_metadata") == "1"
722 preset.WriteInfoJSON = r.FormValue("write_info_json") == "1"
723
724 if err := h.presetSvc.Update(preset); err != nil {
725 flashError(w, "Couldn't update preset: "+err.Error())
726 http.Redirect(w, r, "/settings", http.StatusSeeOther)
727 return
728 }
729
730 flashSuccess(w, "Preset updated.")
731 http.Redirect(w, r, "/settings", http.StatusSeeOther)
732}
733
734func (h *Handler) DeletePreset(w http.ResponseWriter, r *http.Request) {
735 idStr := chi.URLParam(r, "id")
736 id, err := strconv.ParseInt(idStr, 10, 64)
737 if err != nil {
738 http.Error(w, "Invalid ID", http.StatusBadRequest)
739 return
740 }
741
742 if err := h.presetSvc.Delete(id); err != nil {
743 flashError(w, "Couldn't delete preset: "+err.Error())
744 http.Redirect(w, r, "/settings", http.StatusSeeOther)
745 return
746 }
747
748 flashSuccess(w, "Preset deleted.")
749 http.Redirect(w, r, "/settings", http.StatusSeeOther)
750}
751
752func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
753 if err := r.ParseForm(); err != nil {
754 http.Error(w, err.Error(), http.StatusBadRequest)
755 return
756 }
757
758 var firstErr error
759 record := func(err error) {
760 if err != nil && firstErr == nil {
761 firstErr = err
762 }
763 }
764
765 if interval := r.FormValue("refresh_interval"); interval != "" {
766 record(h.settingsSvc.SetRefreshInterval(interval))
767 }
768 record(h.settingsSvc.SetAutoRefreshLibrary(r.FormValue("auto_refresh_library") == "1"))
769 record(h.settingsSvc.SetAutoRefreshDownloads(r.FormValue("auto_refresh_downloads") == "1"))
770 record(h.settingsSvc.SetCookies(r.FormValue("cookies")))
771
772 if firstErr != nil {
773 log.Printf("UpdateSettings: %v", firstErr)
774 flashError(w, "Some settings couldn't be saved: "+firstErr.Error())
775 } else {
776 flashSuccess(w, "Settings saved.")
777 }
778 http.Redirect(w, r, "/settings", http.StatusSeeOther)
779}
780
781func (h *Handler) DownloadForm(w http.ResponseWriter, r *http.Request) {
782 presets, err := h.presetSvc.GetAll()
783 if err != nil {
784 http.Error(w, err.Error(), http.StatusInternalServerError)
785 return
786 }
787
788 // A missing default preset is normal (the user may not have set one); the
789 // template handles a nil DefaultPreset, so this isn't surfaced as an error.
790 defaultPreset, _ := h.presetSvc.GetDefault()
791
792 url := r.URL.Query().Get("url")
793 var formats []*models.FormatInfo
794 var flash *Flash
795 if r.URL.Query().Get("list_formats") == "1" && url != "" {
796 var err error
797 if formats, err = h.downloadSvc.ListFormats(url); err != nil {
798 log.Printf("DownloadForm: listing formats for %q failed: %v", url, err)
799 flash = &Flash{Kind: "error", Message: "Couldn't list formats: " + err.Error()}
800 }
801 }
802
803 h.renderWithRequest(w, r, "download_form", PageData{
804 Title: "Download",
805 ActiveTab: "download",
806 Flash: flash,
807 Data: struct {
808 Presets []*models.Preset
809 DefaultPreset *models.Preset
810 URL string
811 FormatOverride string
812 CustomFlags string
813 OutputDir string
814 Formats []*models.FormatInfo
815 ShowFormats bool
816 }{
817 Presets: presets,
818 DefaultPreset: defaultPreset,
819 URL: url,
820 FormatOverride: r.URL.Query().Get("format_override"),
821 CustomFlags: r.URL.Query().Get("custom_flags"),
822 OutputDir: r.URL.Query().Get("output_dir"),
823 Formats: formats,
824 ShowFormats: r.URL.Query().Get("list_formats") == "1",
825 },
826 })
827}
828
829func (h *Handler) GetPresetFlags(w http.ResponseWriter, r *http.Request) {
830 idStr := r.URL.Query().Get("id")
831 id, err := strconv.ParseInt(idStr, 10, 64)
832 if err != nil {
833 http.Error(w, "Invalid ID", http.StatusBadRequest)
834 return
835 }
836
837 preset, err := h.presetSvc.GetByID(id)
838 if err != nil {
839 http.Error(w, "Not found", http.StatusNotFound)
840 return
841 }
842
843 flags := h.presetSvc.EffectiveFlags(preset, "", "")
844 w.Header().Set("Content-Type", "text/plain")
845 w.Write([]byte(flags))
846}
847
848func (h *Handler) Theme(w http.ResponseWriter, r *http.Request) {
849 if err := r.ParseForm(); err != nil {
850 http.Error(w, err.Error(), http.StatusBadRequest)
851 return
852 }
853
854 theme := r.FormValue("theme")
855 if theme == "" {
856 theme = "auto"
857 }
858
859 setCookie(w, "theme", theme)
860
861 referer := r.Header.Get("Referer")
862 if referer == "" {
863 referer = "/"
864 }
865 http.Redirect(w, r, referer, http.StatusSeeOther)
866}
867