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