diff --git a/README.md b/README.md index 116621c..cec483e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ for container hostnames, volume mounts and Remote Path Mappings. Open `http://:8484/` and sign in with the API key to see the queue and history, to pause, resume or remove downloads, and to correct matches -with overrides. See [Web interface](docs/usage.md#web-interface). +with overrides. Its Setup page lists the values to enter in Sonarr and Radarr. +See [Web interface](docs/usage.md#web-interface). ## Help and contributing diff --git a/cmd/magnetowid/main.go b/cmd/magnetowid/main.go index facc578..6d80baa 100644 --- a/cmd/magnetowid/main.go +++ b/cmd/magnetowid/main.go @@ -115,7 +115,8 @@ func run(log *slog.Logger, level *slog.LevelVar) error { mux.Handle("/{provider}/api", &newznab.Handler{Providers: providers, APIKey: *apiKey, Probe: prober, Log: log}) mux.Handle("/api", &sabnzbd.Handler{Queue: queue, APIKey: *apiKey, Categories: cats, Log: log}) (&overrides.Handler{Store: overrideStore, APIKey: *apiKey, Log: log}).Register(mux) - (&web.Handler{Queue: queue, Overrides: overrideStore, APIKey: *apiKey, Version: version, Log: log}).Register(mux) + (&web.Handler{Queue: queue, Overrides: overrideStore, Providers: providers, Categories: cats, APIKey: *apiKey, + Version: version, Log: log}).Register(mux) mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, "OK\n") }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/docs/usage.md b/docs/usage.md index d6cb3a4..04ff3cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -104,6 +104,9 @@ Sonarr/Radarr must be able to read the download directory. If they see it at a d ## Sonarr / Radarr setup +The [web interface](#web-interface)'s **Setup** page lists these values for +your install. + 1. **Download client:** Settings → Download Clients → **SABnzbd**. - Name: `magnetowid`. - Host and port: magnetowid's host and port. @@ -181,6 +184,13 @@ import a download removed from history. add, change and remove them for series and films. A form that can't be saved says why beside the field at fault. +**Setup** shows what to enter in Sonarr and Radarr: the download client's host, +port and category, and each site's indexer URL, API path and categories. The +addresses are the ones the page was opened with, so use another host if the +apps reach magnetowid [differently](#docker-networking-and-shared-downloads). +It also shows the version, the download folder, and each site's state: working, +or unreachable with the error and the time of the next attempt. + Signing in lasts 30 days. Changing `MAGNETOWID_API_KEY` signs every browser out. Search for titles in Sonarr/Radarr. diff --git a/internal/downloader/queue.go b/internal/downloader/queue.go index 0f9bc0b..ea53ed3 100644 --- a/internal/downloader/queue.go +++ b/internal/downloader/queue.go @@ -107,6 +107,10 @@ func (j Job) TimeLeft(now time.Time) time.Duration { return time.Duration(float64(now.Sub(j.Started)) * (1 - j.Fraction) / gained) } +// Unreachable reports whether the job's last attempt met its provider's +// outage, which Error then describes. +func (j Job) Unreachable() bool { return strings.HasPrefix(j.Error, errUnreachable.Error()+": ") } + // HistoryRetention is how long finished jobs are kept. const HistoryRetention = 30 * 24 * time.Hour diff --git a/internal/downloader/queue_test.go b/internal/downloader/queue_test.go index fc63a1c..ec27732 100644 --- a/internal/downloader/queue_test.go +++ b/internal/downloader/queue_test.go @@ -272,6 +272,10 @@ func TestOutagePausesProvider(t *testing.T) { if (j.ID == a || j.ID == b) && (j.Status != StatusQueued || j.Attempts != 0) { t.Errorf("job %s: status = %s, attempts = %d", j.Name, j.Status, j.Attempts) } + // Only a met the outage. + if (j.ID == a || j.ID == b) && j.Unreachable() != (j.ID == a) { + t.Errorf("job %s: unreachable = %v, error = %q", j.Name, j.Unreachable(), j.Error) + } } if until, ok := q.Outages()["fake"]; !ok || time.Until(until) < 50*time.Minute { t.Errorf("outages = %v, want fake held back for about an hour", q.Outages()) diff --git a/internal/newznab/newznab.go b/internal/newznab/newznab.go index e649533..cd87555 100644 --- a/internal/newznab/newznab.go +++ b/internal/newznab/newznab.go @@ -43,6 +43,15 @@ const ( catTVHD = 5040 ) +// Categories returns the categories an indexer offers a kind's releases in, +// for Sonarr's or Radarr's indexer settings. +func Categories(kind provider.Kind) []int { + if kind == provider.Movie { + return []int{catMovies, catMoviesHD} + } + return []int{catTV, catTVHD} +} + // Handler serves the Newznab API. Mount it at "/{provider}/api". type Handler struct { Providers *provider.Registry @@ -505,8 +514,13 @@ func (h *Handler) nzbLink(r *http.Request, p provider.Provider, id string, secs return h.baseURL(r, p) + "?" + v.Encode() } -// Honor reverse-proxy headers when constructing the public URL. func (h *Handler) baseURL(r *http.Request, p provider.Provider) string { + return BaseURL(r) + "/" + p.Name() + "/api" +} + +// BaseURL is the address r reached magnetowid at, such as +// "http://nas:8484". It honors reverse-proxy headers. +func BaseURL(r *http.Request) string { scheme := "http" if r.TLS != nil || strings.EqualFold(firstValue(r.Header.Get("X-Forwarded-Proto")), "https") { scheme = "https" @@ -515,7 +529,7 @@ func (h *Handler) baseURL(r *http.Request, p provider.Provider) string { if fh := firstValue(r.Header.Get("X-Forwarded-Host")); fh != "" { host = fh } - return scheme + "://" + host + "/" + p.Name() + "/api" + return scheme + "://" + host } func firstValue(v string) string { diff --git a/internal/newznab/newznab_test.go b/internal/newznab/newznab_test.go index 4ab3aba..8509af6 100644 --- a/internal/newznab/newznab_test.go +++ b/internal/newznab/newznab_test.go @@ -613,6 +613,24 @@ func TestLinksBehindProxy(t *testing.T) { } } +func TestBaseURL(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://nas:8484/ui/setup", nil) + if got := BaseURL(r); got != "http://nas:8484" { + t.Errorf("BaseURL = %q", got) + } + r.Header.Set("X-Forwarded-Proto", "https, http") + r.Header.Set("X-Forwarded-Host", "magnetowid.example.com, internal:8484") + if got := BaseURL(r); got != "https://magnetowid.example.com" { + t.Errorf("BaseURL behind a proxy = %q", got) + } +} + +func TestCategories(t *testing.T) { + if tv, films := Categories(provider.Episode), Categories(provider.Movie); !slices.Equal(tv, []int{5000, 5040}) || !slices.Equal(films, []int{2000, 2040}) { + t.Errorf("categories = %v and %v", tv, films) + } +} + func TestSearchByTVDBID(t *testing.T) { fp := &fakeTVDBProvider{title: "Days of Honor"} fp.items = []provider.Item{ diff --git a/internal/web/setup.go b/internal/web/setup.go new file mode 100644 index 0000000..d222480 --- /dev/null +++ b/internal/web/setup.go @@ -0,0 +1,156 @@ +package web + +import ( + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/combor/magnetowid/internal/downloader" + "github.com/combor/magnetowid/internal/newznab" + "github.com/combor/magnetowid/internal/provider" +) + +// setupView is everything the setup page shows: what to enter in Sonarr and +// Radarr, and how this magnetowid is doing. +type setupView struct { + chrome + // The download client's address, as the browser reached this page. + Host, Port string + SSL bool + Categories []setupValue // the download client's, by app if known + Indexers []setupValue // each site's URL + Newznab []setupValue // the indexers' categories, by app + Sites []siteStatus + Dir string // the download folder + Offered string // the download categories, as configured +} + +// setupValue is a value to enter, and where if that isn't everywhere. +type setupValue struct { + Text string + For string +} + +// siteStatus is how downloads from a site are going. +type siteStatus struct { + Name string + Status string + Tone string // badge colour: neutral, ok or danger + Facts []string // the status's details, then what the site supports + Error string // why the site is unreachable +} + +// newSetupView takes base as newznab.BaseURL returns it. +func newSetupView(c chrome, base string, providers *provider.Registry, categories []string, dir string, + jobs []downloader.Job, outages map[string]time.Time, now time.Time) setupView { + v := setupView{chrome: c, Host: base, Dir: dir, Offered: strings.Join(categories, ", ")} + if u, err := url.Parse(base); err == nil && u.Hostname() != "" { + v.Host, v.Port, v.SSL = u.Hostname(), u.Port(), u.Scheme == "https" + if v.Port == "" { + v.Port = "80" + if v.SSL { + v.Port = "443" + } + } + } + // Only the default categories say which app they are for. + if slices.Contains(categories, "tv") && slices.Contains(categories, "movies") { + v.Categories = []setupValue{{"tv", "in Sonarr"}, {"movies", "in Radarr"}} + } else { + for _, c := range categories { + v.Categories = append(v.Categories, setupValue{Text: c}) + } + } + v.Newznab = []setupValue{ + {numbers(newznab.Categories(provider.Episode)), "in Sonarr"}, + {numbers(newznab.Categories(provider.Movie)), "in Radarr"}, + } + for _, name := range providers.Names() { + p, _ := providers.Get(name) + v.Indexers = append(v.Indexers, setupValue{base + "/" + name, name}) + v.Sites = append(v.Sites, newSiteStatus(p, jobs, outages[name], c.Paused, now)) + } + return v +} + +func (v setupView) Title() string { return "Setup — magnetowid" } + +// ByApp reports whether the download categories name their apps. +func (v setupView) ByApp() bool { return len(v.Categories) > 0 && v.Categories[0].For != "" } + +func numbers(ns []int) string { + s := make([]string, len(ns)) + for i, n := range ns { + s[i] = strconv.Itoa(n) + } + return strings.Join(s, ", ") +} + +// newSiteStatus takes when the site's outage lets its jobs run again, zero if +// it has none. Only downloads tell whether a site is reachable. +func newSiteStatus(p provider.Provider, jobs []downloader.Job, outage time.Time, paused bool, now time.Time) siteStatus { + v := siteStatus{Name: p.Name(), Status: "No downloads yet", Tone: "neutral"} + var waiting int + var last time.Time + var unreachable string + for _, j := range jobs { + if j.Ref.Provider != p.Name() { + continue + } + switch j.Status { + case downloader.StatusQueued, downloader.StatusDownloading: + if j.Paused { + continue + } + waiting++ + if unreachable == "" && j.Unreachable() { + unreachable = j.Error + } + case downloader.StatusCompleted: + if j.Finished.After(last) { + last = j.Finished + } + } + } + switch { + // An outage outlasts its jobs' removal, but only until its next attempt. + case !outage.IsZero() && (outage.After(now) || waiting > 0): + v.Status, v.Tone, v.Error = "Unreachable", "danger", unreachable + switch { + case paused: + v.Facts = append(v.Facts, "tries again when the queue resumes") + case outage.After(now): + v.Facts = append(v.Facts, "trying again in "+until(outage.Sub(now))) + default: + v.Facts = append(v.Facts, "trying again now") + } + switch waiting { + case 0: + case 1: + v.Facts = append(v.Facts, "1 download waiting") + default: + v.Facts = append(v.Facts, fmt.Sprintf("%d downloads waiting", waiting)) + } + case !last.IsZero(): + v.Status, v.Tone = "Working", "ok" + v.Facts = append(v.Facts, "last download "+ago(now.Sub(last))) + } + if _, ok := p.(provider.TVDBSearcher); ok { + v.Facts = append(v.Facts, "TVDB ID search") + } + if _, ok := p.(provider.RecentLister); ok { + v.Facts = append(v.Facts, "RSS") + } + if _, ok := p.(provider.Overridable); ok { + v.Facts = append(v.Facts, "overrides") + } + return v +} + +func (h *Handler) setup(w http.ResponseWriter, r *http.Request) { + h.show(w, r, http.StatusOK, "setup", "") +} diff --git a/internal/web/setup_test.go b/internal/web/setup_test.go new file mode 100644 index 0000000..02273f8 --- /dev/null +++ b/internal/web/setup_test.go @@ -0,0 +1,179 @@ +package web + +import ( + "bytes" + "context" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/combor/magnetowid/internal/downloader" + "github.com/combor/magnetowid/internal/nzb" + "github.com/combor/magnetowid/internal/provider" +) + +func TestSetupPage(t *testing.T) { + srv, q, _ := newUI(t) + if r := get(t, srv, "/ui/setup", nil); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/login" { + t.Fatalf("signed out = %d to %q", r.status, r.header.Get("Location")) + } + page, _ := signedIn(t, srv) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + r := page("/ui/setup") + if missing := lacks(r.body, + "Setup — magnetowid", + `

Setup

`, + `Setup`, + `hx-get="/ui/setup" hx-trigger="every 5s"`, + ``+u.Hostname()+``, + ``+u.Port()+``, + `tvin Sonarr`, + `moviesin Radarr`, + ``+srv.URL+`/fakefake`, + `/api`, + `5000, 5040in Sonarr`, + `2000, 2040in Radarr`, + `
  • `, + "No downloads yet", + "overrides", + "
    v1.2.3
    ", + ``+q.Dir()+``, + "
    tv, movies
    ", + ); r.status != http.StatusOK || missing != nil { + t.Errorf("setup page = %d, lacks %q:\n%s", r.status, missing, r.body) + } + // The page names the API key without showing it. + if strings.Contains(r.body, `"key"`) || strings.Contains(r.body, ">key<") || strings.Contains(r.body, "Use SSL") { + t.Errorf("setup page shows the API key or SSL:\n%s", r.body) + } + + r = page("/ui/setup", "X-Forwarded-Proto", "https", "X-Forwarded-Host", "magnetowid.example.com") + if missing := lacks(r.body, + `magnetowid.example.com`, + `443`, + "
    Use SSL
    ", + `https://magnetowid.example.com/fake`, + ); missing != nil { + t.Errorf("setup page behind a proxy lacks %q:\n%s", missing, r.body) + } + + add(t, q, "Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-FAKE", "a") + finish(t, q) + r = page("/ui/setup", "HX-Request", "true") + if missing := lacks(r.body, "Setup — magnetowid", `Working`, + "last download just now"); missing != nil || strings.Contains(r.body, "Unreachable`, + "provider unreachable: dial tcp: <no route>", + `series
  • films
  • `, + "One for each app.", + `80`, + ); missing != nil { + t.Errorf("setup page lacks %q:\n%s", missing, b.String()) + } +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css index ab23d02..8282d15 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -90,8 +90,8 @@ body { -moz-osx-font-smoothing: grayscale; } -h1, h2, p { margin: 0; } -h1, h2 { text-wrap: balance; } +h1, h2, h3, p { margin: 0; } +h1, h2, h3 { text-wrap: balance; } p { text-wrap: pretty; } code { @@ -917,7 +917,8 @@ code { border-top: 1px solid var(--border); } -.fieldset-about h2 { +.fieldset-about h2, +.fieldset-about h3 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; @@ -1037,6 +1038,102 @@ code { } } +/* Setup */ + +.guide { padding: 28px 28px 0; } + +.guide-head { + display: grid; + gap: 6px; + padding-bottom: 24px; +} + +.guide-head h2 { + font-size: 20px; + font-weight: 700; + line-height: 1.3; + letter-spacing: -0.02em; +} + +.guide-head .lead { + max-width: 62ch; + margin-top: 0; +} + +/* One label width, so the lists of a page line up. */ +.values { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + align-content: start; + align-items: baseline; + gap: 10px 20px; + margin: 0; + font-size: 14px; +} + +.values dt { color: var(--text-muted); } + +.values dd { + min-width: 0; + margin: 0; +} + +/* One click selects a value whole, to copy it. */ +.value { + -webkit-user-select: all; + user-select: all; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + overflow-wrap: anywhere; + color: var(--text); + font-size: 13px; +} + +.alts { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.alts li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 8px; +} + +.for { + color: var(--text-muted); + font-size: 13px; +} + +p.for { margin-top: 6px; } + +.guide-note { + padding: 18px 0 22px; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 13px; +} + +.guide-note a { + color: inherit; + font-weight: 600; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +.guide-note a:hover { color: var(--text); } + +.site-name { + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.server { padding: 22px 28px; } + /* Removal confirmation */ .confirm { @@ -1356,7 +1453,26 @@ code { .percent { font-size: 32px; } .stats { font-size: 13px; } - .tab { padding: 0 12px; } + /* The tabs take the row, and a page's control goes under them. Tabs too + wide for the row scroll. */ + .toolbar { min-width: 0; } + + .tabs { + display: flex; + flex: 1 1 100%; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + + .tab { + flex: 1 1 auto; + justify-content: center; + padding: 0 4px; + white-space: nowrap; + } + + .toolbar > form { margin-left: auto; } .wide { display: none; } .row { @@ -1385,6 +1501,18 @@ code { } .form-card { padding: 22px 18px 18px; } + .guide { padding: 22px 18px 0; } + .guide-head { padding-bottom: 20px; } + .server { padding: 18px; } + + /* Labels go above their values, which need the width. */ + .values { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + } + + .values dt { font-size: 12.5px; } + .values dd + dt { margin-top: 10px; } .form-head { padding-bottom: 20px; } .form-head h1 { font-size: 21px; } diff --git a/internal/web/templates/app.html b/internal/web/templates/app.html index 2fa8347..bc7d7f5 100644 --- a/internal/web/templates/app.html +++ b/internal/web/templates/app.html @@ -33,6 +33,7 @@ Queue{{if .Queued}}{{.Queued}}{{end}} History{{if .Finished}}{{.Finished}}{{end}} Overrides + Setup {{- block "actions" .}}{{end}} diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html new file mode 100644 index 0000000..6059bea --- /dev/null +++ b/internal/web/templates/setup.html @@ -0,0 +1,95 @@ +{{define "page" -}} +
    +
    +

    Connect Sonarr and Radarr

    +

    Add magnetowid to each app as a download client, then as an indexer for every site. Both take the API key you sign in with.

    +
    +
    +
    +

    Download client

    +

    Settings → Download Clients → SABnzbd

    +
    +
    +
    Host
    +
    {{.Host}}
    + {{- with .Port}} +
    Port
    +
    {{.}}
    + {{- end}} + {{- if .SSL}} +
    Use SSL
    +
    On
    + {{- end}} +
    API key
    +
    The key you sign in with
    +
    Category
    +
    {{template "values" .Categories}}{{if not .ByApp}}

    One for each app.

    {{end}}
    +
    +
    +
    +
    +

    Indexers

    +

    Settings → Indexers → Newznab, one for each site

    +
    +
    +
    URL
    +
    {{template "values" .Indexers}}
    +
    API path
    +
    /api
    +
    API key
    +
    The same key
    +
    Categories
    +
    {{template "values" .Newznab}}
    +
    Download client
    +
    The one added for magnetowid
    +
    +
    +

    The addresses are the ones this page was opened with. An app that reaches magnetowid another way, such as by a Docker service name, takes that host instead. See Docker networking and shared downloads.

    +
    + + {{- if .Sites}} +
    +
    +

    Sites

    + {{len .Sites}} +
    + +
    + {{- end}} + +
    +
    +

    This server

    +
    +
    +
    + {{- with .Version}} +
    Version
    +
    {{.}}
    + {{- end}} +
    Download folder
    +
    {{.Dir}}

    Sonarr and Radarr read downloads from here. If they see the folder at another path, add a Remote Path Mapping.

    + {{- with .Offered}} +
    Categories
    +
    {{.}}
    + {{- end}} +
    +
    +
    +{{- end}} + +{{define "values" -}} + +{{- end}} diff --git a/internal/web/view.go b/internal/web/view.go index 1d7d090..c5b7c57 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -14,7 +14,7 @@ import ( // chrome is the queue summary every page shows. type chrome struct { Version string - Page string // queue, history or overrides + Page string // queue, history, overrides or setup Paused bool Queued int // unfinished jobs Finished int @@ -73,6 +73,8 @@ func (c chrome) Heading() string { return "Download history" case c.Page == "overrides": return "Overrides" + case c.Page == "setup": + return "Setup" } return "Download queue" } @@ -104,6 +106,8 @@ func pagePath(page string) string { return "/ui/history" case "overrides": return "/ui/overrides" + case "setup": + return "/ui/setup" } return "/ui/" } diff --git a/internal/web/web.go b/internal/web/web.go index 77a3923..65a776e 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,5 +1,5 @@ // Package web serves the browser interface: sign-in, live queue and history -// pages with download controls, and forms for overrides. +// pages with download controls, forms for overrides, and a setup page. // // static/htmx-4.0.0.min.js is dist/htmx.min.js from the htmx.org 4.0.0 npm // package, under the Zero-Clause BSD license. @@ -21,7 +21,9 @@ import ( "time" "github.com/combor/magnetowid/internal/downloader" + "github.com/combor/magnetowid/internal/newznab" "github.com/combor/magnetowid/internal/overrides" + "github.com/combor/magnetowid/internal/provider" ) //go:embed templates static @@ -36,6 +38,7 @@ var ( overridesPage = parse(app, "templates/overrides.html") seriesPage = parse(app, "templates/override.html", "templates/series.html") filmPage = parse(app, "templates/override.html", "templates/film.html") + setupPage = parse(app, "templates/setup.html") ) func parse(base *template.Template, names ...string) *template.Template { @@ -49,11 +52,13 @@ const ( // Handler serves the interface under /ui/. Signing in takes the API key. type Handler struct { - Queue *downloader.Queue - Overrides *overrides.Store - APIKey string - Version string - Log *slog.Logger + Queue *downloader.Queue + Overrides *overrides.Store + Providers *provider.Registry + Categories []string // the download categories + APIKey string + Version string + Log *slog.Logger } // Register adds the interface's routes to mux, which must not have a @@ -85,6 +90,7 @@ func (h *Handler) Register(mux *http.ServeMux) { post("/ui/overrides/{site}/films", h.saveFilm) post("/ui/overrides/{site}/films/{year}/{title}", h.saveFilm) post("/ui/overrides/{site}/films/{year}/{title}/delete", h.deleteFilm) + get("/ui/setup", h.setup) mux.Handle("GET /ui/login", secure(http.HandlerFunc(h.loginForm))) mux.Handle("POST /ui/login", secure(csrf.Handler(http.HandlerFunc(h.login)))) mux.Handle("POST /ui/logout", secure(csrf.Handler(http.HandlerFunc(h.logout)))) @@ -236,6 +242,11 @@ func (h *Handler) show(w http.ResponseWriter, r *http.Request, status int, page, ov := newOverridesView(h.Overrides, newChrome(jobs, paused, h.Version, page)) ov.Notice = notice t, v = overridesPage, ov + case "setup": + sv := newSetupView(newChrome(jobs, paused, h.Version, page), newznab.BaseURL(r), h.Providers, h.Categories, + h.Queue.Dir(), jobs, h.Queue.Outages(), now) + sv.Notice = notice + t, v = setupPage, sv default: qv := newQueueView(jobs, paused, h.Queue.Outages(), h.Version, now) qv.Notice = notice diff --git a/internal/web/web_test.go b/internal/web/web_test.go index e2377ab..dbd272a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -82,7 +82,8 @@ func newUI(t *testing.T) (*httptest.Server, *downloader.Queue, *bolt.DB) { mux.Handle("/api", http.NotFoundHandler()) mux.HandleFunc("GET /overrides", http.NotFound) mux.HandleFunc("GET /health", http.NotFound) - (&Handler{Queue: q, Overrides: o, APIKey: "key", Version: "1.2.3", Log: slog.New(slog.DiscardHandler)}).Register(mux) + (&Handler{Queue: q, Overrides: o, Providers: providers, Categories: []string{"tv", "movies"}, APIKey: "key", + Version: "1.2.3", Log: slog.New(slog.DiscardHandler)}).Register(mux) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, q, db