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