From efc8f15c220f55919aff92ba4c834904eac3d1de Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:26:23 +0100 Subject: [PATCH] Add a setup page to the web interface A new Setup page shows what to enter in Sonarr and Radarr: the download client's host, port and category, and each site's indexer URL, the API path and the categories for each app. The addresses are the ones the page was opened with, behind a reverse proxy too, and Use SSL appears only over HTTPS. The API key is named, not shown. A click selects a value whole, since the clipboard API is not available over plain HTTP. The page also lists each site as working, with the time of its last download, or unreachable, with the error, the next attempt and the downloads waiting, and shows the version, the download folder and the download categories. newznab exports BaseURL and Categories so the page and the API agree, and Job.Unreachable tells a job held back by its provider's outage from one with an error of its own. Four tabs with their counts no longer fit beside a page's control on a phone, so there the tabs take the row, scroll if they are still too wide, and the control goes under them. --- README.md | 3 +- cmd/magnetowid/main.go | 3 +- docs/usage.md | 10 ++ internal/downloader/queue.go | 4 + internal/downloader/queue_test.go | 4 + internal/newznab/newznab.go | 18 ++- internal/newznab/newznab_test.go | 18 +++ internal/web/setup.go | 156 ++++++++++++++++++++++++++ internal/web/setup_test.go | 179 ++++++++++++++++++++++++++++++ internal/web/static/style.css | 136 ++++++++++++++++++++++- internal/web/templates/app.html | 1 + internal/web/templates/setup.html | 95 ++++++++++++++++ internal/web/view.go | 6 +- internal/web/web.go | 23 +++- internal/web/web_test.go | 3 +- 15 files changed, 643 insertions(+), 16 deletions(-) create mode 100644 internal/web/setup.go create mode 100644 internal/web/setup_test.go create mode 100644 internal/web/templates/setup.html 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