From f77aef7203ee878bbe70c0c6f71653ed995f50d8 Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:14:26 +0100 Subject: [PATCH 1/4] Add a cancel that leaves finished jobs alone Queue.Cancel deletes a job only while it is queued or downloading. A download that finishes as someone cancels it then stays in history for Sonarr or Radarr to import, instead of disappearing with its folder left behind. HistoryRetention is exported so the web interface can say how long finished jobs are kept. --- internal/downloader/queue.go | 18 ++++++++++++++--- internal/downloader/queue_test.go | 32 ++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/internal/downloader/queue.go b/internal/downloader/queue.go index 16f3406..e454d58 100644 --- a/internal/downloader/queue.go +++ b/internal/downloader/queue.go @@ -102,7 +102,8 @@ func (j Job) TimeLeft(now time.Time) time.Duration { return time.Duration(float64(now.Sub(j.Started)) * (1 - j.Fraction) / j.Fraction) } -const historyRetention = 30 * 24 * time.Hour +// HistoryRetention is how long finished jobs are kept. +const HistoryRetention = 30 * 24 * time.Hour const incompleteDir = ".incomplete" @@ -308,9 +309,20 @@ func (q *Queue) Jobs() []Job { // Delete cancels the job and optionally removes its files. It returns false for // unknown IDs; a save failure leaves the job unchanged. func (q *Queue) Delete(id string, deleteFiles bool) (bool, error) { + return q.discard(id, deleteFiles, false) +} + +// Cancel deletes a job that has not finished, so one that finishes as it is +// cancelled stays in history to be imported. It returns false for unknown and +// finished jobs. +func (q *Queue) Cancel(id string) (bool, error) { + return q.discard(id, false, true) +} + +func (q *Queue) discard(id string, deleteFiles, unfinishedOnly bool) (bool, error) { q.mu.Lock() job, ok := q.jobs[id] - if !ok { + if !ok || (unfinishedOnly && job.Status != StatusQueued && job.Status != StatusDownloading) { q.mu.Unlock() return false, nil } @@ -506,7 +518,7 @@ func (q *Queue) prune(now time.Time) { var old []string for _, id := range q.order { j := q.jobs[id] - if (j.Status == StatusCompleted || j.Status == StatusFailed) && now.Sub(j.Finished) > historyRetention { + if (j.Status == StatusCompleted || j.Status == StatusFailed) && now.Sub(j.Finished) > HistoryRetention { old = append(old, id) } } diff --git a/internal/downloader/queue_test.go b/internal/downloader/queue_test.go index 1641ce5..21cbf35 100644 --- a/internal/downloader/queue_test.go +++ b/internal/downloader/queue_test.go @@ -425,6 +425,36 @@ func TestPauseRunningJob(t *testing.T) { waitJob(t, q, id, func(j Job) bool { return j.Status == StatusDownloading && !j.Paused && j.Attempts == 1 }) } +func TestCancel(t *testing.T) { + e := &blockingEngine{started: make(chan string, 2)} + q := newQueue(t, t.TempDir(), &fakeProvider{}, e) + run(t, q) + running := add(t, q, "a", "tv", 0, nzb.Ref{Provider: "fake", ID: "a"}) + <-e.started + queued := add(t, q, "b", "tv", 0, nzb.Ref{Provider: "fake", ID: "b"}) + for _, id := range []string{queued, running} { + if ok, err := q.Cancel(id); !ok || err != nil { + t.Fatalf("Cancel(%s) = %v, %v", id, ok, err) + } + } + if jobs := q.Jobs(); len(jobs) != 0 { + t.Errorf("jobs after cancelling = %+v", jobs) + } + if ok, err := q.Cancel("SABnzbd_nzo_gone"); ok || err != nil { + t.Errorf("cancelling an unknown job = %v, %v", ok, err) + } + + // A job that finished before the cancel stays for Sonarr/Radarr to import. + q = startQueue(t, &fakeProvider{}, &fakeEngine{}) + done := waitFinished(t, q, add(t, q, "c", "tv", 0, nzb.Ref{Provider: "fake", ID: "c"})) + if ok, err := q.Cancel(done.ID); ok || err != nil { + t.Errorf("cancelling a finished job = %v, %v", ok, err) + } + if _, err := os.Stat(done.Storage); len(q.Jobs()) != 1 || err != nil { + t.Errorf("finished job after a cancel: %+v, files %v", q.Jobs(), err) + } +} + func TestPauseBeforeDownloadStarts(t *testing.T) { for name, pause := range map[string]func(q *Queue, id string) error{ "job": func(q *Queue, id string) error { _, err := q.PauseJobs(id); return err }, @@ -586,7 +616,7 @@ func TestPruneOldHistory(t *testing.T) { dir := t.TempDir() q := newQueue(t, dir, &fakeProvider{}, &fakeEngine{}) now := time.Now() - old := now.Add(-historyRetention - time.Hour) + old := now.Add(-HistoryRetention - time.Hour) set := func(name string, status Status, finished time.Time) string { id := add(t, q, name, "tv", 0, nzb.Ref{Provider: "fake", ID: name}) q.mu.Lock() From 01eee123e82a1c0a8c2a09b1c5ad195d15750abb Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:34:54 +0100 Subject: [PATCH 2/4] Control downloads and show history in the web interface The queue page can now pause and resume the whole queue or a single job, and remove a job after asking first. A new history page lists finished and failed downloads, newest first. Removing one from history can also delete its folder, and the confirmation shows which folder that is. The queue refreshes every second and history every five seconds. Every control is a plain form. htmx swaps in the page's new state at once, and without JavaScript the form returns to the page. Confirmations use the Popover API, which needs no script and keeps its open state, and a ticked box, through each refresh. A dialog's open attribute would be cleared by the morph instead. An action cancels a refresh already under way, so a refresh can't bring back the old state. If saving fails, a notice appears outside the refreshing part of the page, so the next refresh doesn't erase it. A download being paused is shown in the list at once rather than as still running. Removing from the queue uses Queue.Cancel, so a download that finishes at that moment stays in history to be imported. --- README.md | 7 +- docs/usage.md | 34 ++- internal/web/static/style.css | 372 +++++++++++++++++++++++++++- internal/web/templates/app.html | 85 +++++++ internal/web/templates/history.html | 54 ++++ internal/web/templates/queue.html | 90 +++---- internal/web/view.go | 185 +++++++++++--- internal/web/web.go | 124 ++++++++-- internal/web/web_test.go | 284 +++++++++++++++++++-- 9 files changed, 1092 insertions(+), 143 deletions(-) create mode 100644 internal/web/templates/app.html create mode 100644 internal/web/templates/history.html diff --git a/README.md b/README.md index 01bae8d..bdf8fc5 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,10 @@ for container hostnames, volume mounts and Remote Path Mappings. ## Web interface -Open `http://:8484/` and sign in with the API key to watch the -download queue: the current download's progress, what's next, and any retries. -See [Web interface](docs/usage.md#web-interface). +Open `http://:8484/` and sign in with the API key to watch and +manage downloads: the current download's progress, what's next, any retries, +and what has finished. Pause, resume or remove downloads, and delete finished +ones' files. See [Web interface](docs/usage.md#web-interface). ## Help and contributing diff --git a/docs/usage.md b/docs/usage.md index 3e48bd5..f170dac 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -148,8 +148,9 @@ default). Subtitle failures log a warning and keep the video. ### Pausing downloads -Use SABnzbd API commands to pause or resume the queue or individual jobs. -Sonarr and Radarr display paused jobs but cannot control pausing: +Pause or resume the queue or individual jobs in the +[web interface](#web-interface), or with SABnzbd API commands. Sonarr and +Radarr display paused jobs but cannot control pausing: ```sh curl 'http://localhost:8484/api?mode=pause&apikey=YOUR_API_KEY' @@ -163,13 +164,27 @@ interrupted downloads restart from the beginning when resumed. ## Web interface -Open `http://:8484/` in a browser and sign in with the API key. The page -shows the running download's progress and time left, the queued jobs in the -order they will run, and paused or retrying jobs with their last error. It -refreshes itself every second. +Open `http://:8484/` in a browser and sign in with the API key. + +**Queue** shows the running download's progress and time left, the queued +jobs in the order they will run, and paused or retrying jobs with their last +error. It refreshes itself every second. From here you can: + +- pause or resume the whole queue; +- pause or resume a single job; +- remove a job. Removing the running download stops it and deletes what it + has downloaded so far. + +A paused download restarts from the beginning when resumed. + +**History** lists finished and failed downloads, newest first, and refreshes +itself every five seconds. Sonarr and Radarr usually remove downloads they +have imported; the rest stay for 30 days. Removing a download from history can +also delete its folder. Remove only downloads that are already imported: +Sonarr and Radarr cannot import one that has gone from history. Signing in lasts 30 days. Changing `MAGNETOWID_API_KEY` signs every browser -out. Search for titles and manage downloads in Sonarr/Radarr. +out. Search for titles in Sonarr/Radarr. ## Correcting matches @@ -252,8 +267,9 @@ under **Settings → Download Clients**: ## Troubleshooting -Search for titles and manage downloads in Sonarr/Radarr. The -[web interface](#web-interface) shows magnetowid's download queue. +Search for titles in Sonarr/Radarr. The [web interface](#web-interface) shows +magnetowid's download queue and history, with each failed or retrying job's +last error. | Problem | What to check | |---|---| diff --git a/internal/web/static/style.css b/internal/web/static/style.css index ae2e18b..8003f5f 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -26,6 +26,8 @@ --warn-border: #f3d9a4; --danger: #b42318; --danger-soft: #fdecea; + --danger-solid: #c42b1f; /* under white text */ + --danger-solid-hover: #a82218; --ok: #0b7568; --ok-soft: #ddf4ef; @@ -33,7 +35,9 @@ --mark-window: #3a3a45; --mark-reel: #f4f4f6; + --raised: #ffffff; /* the selected tab */ --shadow: 0 1px 2px rgb(16 16 24 / 0.04), 0 10px 28px -12px rgb(16 16 24 / 0.1); + --shadow-float: 0 2px 6px rgb(16 16 24 / 0.06), 0 28px 64px -16px rgb(16 16 24 / 0.32); --radius: 16px; --radius-sm: 12px; /* Native UI fonts first; Linux's system-ui often maps to a Helvetica clone. */ @@ -64,7 +68,9 @@ --mark-window: #121317; --mark-reel: #e9e9ee; + --raised: #2a2b33; --shadow: 0 1px 0 rgb(255 255 255 / 0.02) inset; + --shadow-float: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 28px 64px -16px rgb(0 0 0 / 0.6); } } @@ -281,6 +287,83 @@ code { .btn-primary:hover { background: var(--accent-hover); } +.btn-small { + height: 36px; + padding: 0 14px; + font-size: 13.5px; +} + +.btn-small .icon { width: 16px; height: 16px; } + +.btn-quiet { + border-color: var(--border); + background: var(--surface); + color: var(--text); +} + +.btn-quiet:hover { + border-color: var(--border-strong); + background: var(--surface-2); +} + +.btn-accent { + background: var(--accent); + color: #fff; + box-shadow: 0 1px 0 rgb(255 255 255 / 0.18) inset, 0 6px 16px -8px var(--accent); +} + +.btn-accent:hover { background: var(--accent-hover); } + +.btn-strong { + background: var(--text); + color: var(--bg); +} + +.btn-strong:hover { background: color-mix(in srgb, var(--text) 84%, var(--bg)); } + +.btn-danger { + background: var(--danger-solid); + color: #fff; +} + +.btn-danger:hover { background: var(--danger-solid-hover); } + +.icon-btn { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background-color 0.15s, color 0.15s, transform 0.1s; +} + +.icon-btn .icon { width: 18px; height: 18px; } +.icon-btn:hover { background: var(--surface-2); color: var(--text); } +.icon-btn:active { transform: translateY(1px); } +.icon-btn-accent { color: var(--accent-text); } + +.icon-btn-accent:hover { + background: color-mix(in srgb, var(--accent-text) 11%, transparent); + color: var(--accent-text); +} + +.icon-btn-danger:hover { background: var(--danger-soft); color: var(--danger); } + +.actions { + grid-area: actions; + justify-self: end; + display: flex; + align-items: center; + gap: 2px; +} + +.actions > form { display: contents; } + /* Layout */ .container { @@ -291,6 +374,65 @@ code { padding: 32px 20px calc(64px + env(safe-area-inset-bottom)); } +/* Page tabs and the page's own controls */ + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tabs { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.tab { + display: inline-flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 14px; + border-radius: 9px; + color: var(--text-muted); + font-size: 14px; + font-weight: 600; + text-decoration: none; + transition: background-color 0.15s, color 0.15s, box-shadow 0.15s; +} + +.tab:hover { color: var(--text); } + +.tab[aria-current="page"] { + background: var(--raised); + color: var(--text); + box-shadow: 0 1px 2px rgb(16 16 24 / 0.08), 0 0 0 1px var(--border); +} + +.tab-count { + display: inline-grid; + place-items: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted) 15%, transparent); + font-size: 11.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.tab[aria-current="page"] .tab-count { + background: color-mix(in srgb, var(--accent-text) 13%, transparent); + color: var(--accent-text); +} + .card { background: var(--surface); border: 1px solid var(--border); @@ -335,6 +477,13 @@ code { margin-bottom: 14px; } +.hero-tools { + display: flex; + align-items: center; + gap: 12px; + margin: -4px -8px -4px 0; +} + .eyebrow { display: inline-flex; align-items: center; @@ -505,6 +654,7 @@ code { font-variant-numeric: tabular-nums; } +.badge-ok { background: var(--ok-soft); color: var(--ok); } .badge-warn { background: var(--warn-soft); color: var(--warn); } .badge-danger { background: var(--danger-soft); color: var(--danger); } @@ -547,14 +697,14 @@ code { .row { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto auto; grid-template-areas: - "pos title badge" - "pos meta badge" - ". error error"; + "pos title badge actions" + "pos meta badge actions" + ". error error error"; align-items: center; column-gap: 16px; - padding: 14px 18px 14px 14px; + padding: 12px 12px 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); @@ -581,6 +731,10 @@ code { font-variant-numeric: tabular-nums; } +.pos .icon { width: 16px; height: 16px; } +.pos-ok { background: var(--ok-soft); color: var(--ok); } +.pos-danger { background: var(--danger-soft); color: var(--danger); } + .row-title { grid-area: title; overflow: hidden; @@ -604,6 +758,29 @@ code { .added { margin-left: 2px; } +/* Every fact starts with a dot, and the clip hides the one that starts each + line. */ +.facts { + display: inline-flex; + flex-wrap: wrap; + margin-left: calc(2px - 1.1em); + clip-path: inset(0 0 0 1.1em); +} + +.facts span::before { + content: "·"; + display: inline-block; + width: 1.1em; + text-align: center; + opacity: 0.55; +} + +.footnote { + color: var(--text-muted); + font-size: 13px; + text-align: center; +} + .row-error { grid-area: error; display: flex; @@ -653,6 +830,155 @@ code { color: var(--text-muted); } +/* Asking before removing */ + +.confirm { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + margin: auto; + padding: 24px 24px 20px; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow-float); + opacity: 0; + transform: translateY(10px) scale(0.98); + transition: opacity 0.2s ease, transform 0.2s ease, overlay 0.2s allow-discrete, display 0.2s allow-discrete; +} + +/* Only while open: a display here would show closed popovers. */ +.confirm:popover-open { + display: grid; + gap: 6px; + opacity: 1; + transform: none; +} + +@starting-style { + .confirm:popover-open { opacity: 0; transform: translateY(10px) scale(0.98); } +} + +.confirm::backdrop { + background: rgb(10 10 14 / 0); + transition: background-color 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete; +} + +.confirm:popover-open::backdrop { + background: rgb(10 10 14 / 0.42); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +@starting-style { + .confirm:popover-open::backdrop { background: rgb(10 10 14 / 0); } +} + +.confirm-title { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.015em; +} + +.confirm-name { + font-weight: 600; + overflow-wrap: anywhere; +} + +.confirm-text { + color: var(--text-muted); + font-size: 14px; +} + +.confirm-form { + display: grid; + gap: 12px; + margin-top: 8px; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.confirm-form .confirm-actions { margin-top: 4px; } + +.check { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; +} + +.check input { + width: 18px; + height: 18px; + margin: 1px 0 0; + accent-color: var(--danger-solid); + cursor: pointer; +} + +.check:has(:checked) { + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + background: var(--danger-soft); +} + +.path { + display: block; + width: fit-content; + max-width: 100%; + margin-top: 6px; + background: var(--surface); + color: var(--text-muted); + font-weight: 500; + overflow-wrap: anywhere; +} + +/* The button says whether the files go too. */ +.if-deleted { display: none; } +.confirm:has(:checked) .if-kept { display: none; } +.confirm:has(:checked) .if-deleted { display: inline; } +.confirm:has(:checked) .btn-strong { background: var(--danger-solid); color: #fff; } +.confirm:has(:checked) .btn-strong:hover { background: var(--danger-solid-hover); } + +/* A failed action */ + +.notice { + position: fixed; + inset: auto 16px calc(20px + env(safe-area-inset-bottom)); + z-index: 30; + display: grid; + justify-items: center; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 10px; + max-width: 520px; + padding: 12px 16px; + border-radius: 12px; + background: var(--danger-solid); + color: #fff; + font-size: 14px; + font-weight: 550; + box-shadow: var(--shadow-float); + animation: toast 8s ease forwards; +} + +.toast .icon { margin-top: 1px; } + /* Sign in */ .auth { @@ -755,6 +1081,14 @@ code { 50% { transform: scaleY(1); } } +@keyframes toast { + 0% { opacity: 0; transform: translateY(8px); } + 3%, 94% { opacity: 1; transform: none; } + 100% { opacity: 0; visibility: hidden; } +} + +@keyframes toast-hide { to { visibility: hidden; } } + @keyframes sheen { 0% { background-position: -150% 0, 0 0; } 60%, 100% { background-position: 250% 0, 0 0; } @@ -770,6 +1104,9 @@ code { animation: none; transition: none; } + + /* Hiding a notice is not motion. */ + .toast { animation: toast-hide 8s step-end forwards !important; } } /* Phones */ @@ -785,21 +1122,42 @@ code { .container { gap: 20px; padding: 20px 16px calc(48px + env(safe-area-inset-bottom)); } .hero { padding: 20px 18px; } + + /* The controls share the eyebrow's line; the chips go below. */ + .hero-tools { display: contents; } + .hero .actions { order: 1; margin: -6px -8px -6px auto; } + .hero .chips { order: 2; flex-basis: 100%; } .hero-title { font-size: 20px; } .percent { font-size: 32px; } .stats { font-size: 13px; } + .tab { padding: 0 12px; } + .wide { display: none; } + .row { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "title badge" - "meta meta" + "meta actions" "error error"; column-gap: 12px; - padding: 13px 14px; + padding: 12px 10px 12px 14px; } + .row .actions { align-self: start; margin: 4px -4px 0 0; } .pos { display: none; } + /* A sheet at the bottom, within thumb reach. */ + .confirm { + width: auto; + margin: auto 10px calc(10px + env(safe-area-inset-bottom)); + padding: 22px 20px 18px; + } + + .confirm-actions .btn { + flex: 1 1 auto; + white-space: nowrap; + } + .auth-card { padding: 26px 22px 22px; } } diff --git a/internal/web/templates/app.html b/internal/web/templates/app.html new file mode 100644 index 0000000..2e69e4c --- /dev/null +++ b/internal/web/templates/app.html @@ -0,0 +1,85 @@ +{{/* What the queue and history pages share. */}} + +{{define "head"}} + + +{{- end}} + +{{define "content" -}} +
+{{template "app" .}} +
+
{{with .Notice}}{{template "notice" .}}{{end}}
+{{- end}} + +{{/* The refreshed part of the page, with the tab's title. */}} +{{define "update"}}{{.Title}} +{{template "app" .}}{{end}} + +{{define "notice" -}} + +{{- end}} + +{{define "app" -}} +
+
+ + {{template "mark"}} + magnetowid + + {{with .Version}}{{.}}{{end}} +
+ + + {{.StateLabel}} + +
+ +
+
+
+
+ +{{/* Actions swap in the page's new state, and cancel a refresh under way so it can't bring back the old one. */}} +
+

{{if eq .Page "history"}}Download history{{else}}Download queue{{end}}

+ + {{template "page" .}} +
+{{- end}} + +{{define "name" -}} +{{.Title}}{{with .Episode}} {{.}}{{end}} +{{- end}} + +{{define "chips" -}} +{{with .Category}}{{.}}{{end}}{{with .Provider}}{{.}}{{end}}{{with .Priority}}{{.}}{{end}} +{{- end}} + +{{define "row-error" -}} +

{{template "icon-alert"}}{{.}}

+{{- end}} + +{{/* Asks before removing a job; the page supplies the question and the form. */}} +{{define "remove-button" -}} + +{{- end}} + +{{define "keep-button" -}} + +{{- end}} + +{{define "icon-pause"}}{{end}} +{{define "icon-play"}}{{end}} +{{define "icon-trash"}}{{end}} +{{define "icon-check"}}{{end}} +{{define "icon-alert"}}{{end}} diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html new file mode 100644 index 0000000..30b16a8 --- /dev/null +++ b/internal/web/templates/history.html @@ -0,0 +1,54 @@ +{{define "page" -}} + {{- if .Jobs}} +
    + {{- range .Jobs}} +
  1. + +

    {{template "name" .}}

    + {{.Status}} +

    + {{- template "chips" .}} + + {{- with .Size}}{{.}}{{end -}} + {{- with .Took}}took {{.}}{{end -}} + {{- with .Attempt}}{{.}} attempts{{end -}} + finished {{.Finished}} + +

    +
    + {{template "remove-button" .}} + +
    + {{- with .Error}} + {{template "row-error" .}} + {{- end}} +
  2. + {{- end}} +
+

Finished downloads are kept for {{.KeptDays}} days, or until Sonarr or Radarr removes them.

+ {{- else}} +
+ {{template "mark"}} +

No finished downloads

+

Finished downloads show up here until Sonarr or Radarr imports them, for up to {{.KeptDays}} days.

+
+ {{- end}} +{{- end}} diff --git a/internal/web/templates/queue.html b/internal/web/templates/queue.html index bb8555e..a12c8a4 100644 --- a/internal/web/templates/queue.html +++ b/internal/web/templates/queue.html @@ -1,44 +1,16 @@ -{{define "head"}} - - +{{define "actions" -}} +{{if .Paused -}} +
+ +
+{{- else -}} +
+ +
{{- end}} - -{{define "content" -}} -
-{{template "app" .}} -
{{- end}} -{{/* The refreshed part of the page, with the tab's title. */}} -{{define "update"}}{{.Title}} -{{template "app" .}}{{end}} - -{{define "app" -}} -
-
- - {{template "mark"}} - magnetowid - - {{with .Version}}{{.}}{{end}} -
- - - {{.StateLabel}} - -
- -
-
-
-
- -
-

Download queue

- +{{define "page" -}} {{- if .Paused}}
{{- end}} -{{define "chips" -}} -{{with .Category}}{{.}}{{end}}{{with .Provider}}{{.}}{{end}}{{with .Priority}}{{.}}{{end}} +{{define "job-actions" -}} +
+ {{- if .Paused}} +
+ +
+ {{- else}} +
+ +
+ {{- end}} + {{template "remove-button" .}} + +
{{- end}} diff --git a/internal/web/view.go b/internal/web/view.go index ca5b4ad..c7fe615 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -11,13 +11,80 @@ import ( "github.com/combor/magnetowid/internal/downloader" ) +// chrome is what every page shows about the queue as a whole. +type chrome struct { + Version string + Page string // queue or history + Paused bool + Queued int // unfinished jobs + Finished int + Notice string // why an action failed + state string +} + +func newChrome(jobs []downloader.Job, paused bool, version, page string) chrome { + c := chrome{Version: displayVersion(version), Page: page, Paused: paused, state: "idle"} + for _, j := range jobs { + switch j.Status { + case downloader.StatusDownloading: + c.Queued++ + if !j.Paused { + c.state = "downloading" + } else if c.state == "idle" { + c.state = "waiting" + } + case downloader.StatusQueued: + c.Queued++ + if c.state == "idle" { + c.state = "waiting" + } + default: + c.Finished++ + } + } + if paused { + c.state = "paused" + } + return c +} + +// State names the queue's overall state for the header. +func (c chrome) State() string { return c.state } + +func (c chrome) StateLabel() string { + switch c.state { + case "paused": + return "Paused" + case "downloading": + return "Downloading" + case "waiting": + return "Waiting" + } + return "Idle" +} + +// Refresh is where the page gets its changing part. +func (c chrome) Refresh() string { + if c.Page == "history" { + return "/ui/history" + } + return "/ui/queue" +} + +// Every is how often the page refreshes. History changes less often. +func (c chrome) Every() string { + if c.Page == "history" { + return "5s" + } + return "1s" +} + // queueView is everything the queue page shows, so tests can render any // state without running downloads. type queueView struct { - Version string - Paused bool - Active *jobView - Next []jobView + chrome + Active *jobView + Next []jobView } type jobView struct { @@ -31,17 +98,25 @@ type jobView struct { Provider string Priority string // empty for normal priority Percent int - Size string // done of estimated total, or empty if unknown + Size string // done of estimated total, the final size, or empty if unknown Left string Added string Status string - Tone string // badge colour: neutral, warn or danger + Tone string // badge colour: neutral, ok, warn or danger Error string Attempt int // shown from the second attempt + Paused bool + Running bool + Took string // finished jobs only + Finished string + Storage string // a completed job's folder } +// Label names the job in controls and in the tab's title. +func (v jobView) Label() string { return strings.TrimSpace(v.Title + " " + v.Episode) } + func newQueueView(jobs []downloader.Job, paused bool, outages map[string]time.Time, version string, now time.Time) queueView { - v := queueView{Version: displayVersion(version), Paused: paused} + v := queueView{chrome: newChrome(jobs, paused, version, "queue")} type queued struct { job downloader.Job retry time.Time // including the provider's outage @@ -49,6 +124,10 @@ func newQueueView(jobs []downloader.Job, paused bool, outages map[string]time.Ti } var next []queued for _, j := range jobs { + // A paused download stops within moments; show it where it is going. + if j.Status == downloader.StatusDownloading && (paused || j.Paused) { + j.Status = downloader.StatusQueued + } switch j.Status { case downloader.StatusDownloading: jv := newJobView(j, time.Time{}, now) @@ -95,6 +174,8 @@ func newJobView(j downloader.Job, retry, now time.Time) jobView { Status: "Queued", Tone: "neutral", Error: j.Error, + Paused: j.Paused, + Running: j.Status == downloader.StatusDownloading, } v.Title, v.Episode, v.Specs = splitName(j.Name) switch { @@ -150,42 +231,66 @@ func splitName(name string) (title, episode string, specs []string) { return strings.ReplaceAll(m[1], ".", " "), m[2], specs } -// State names the queue's overall state for the header. -func (v queueView) State() string { - switch { - case v.Paused: - return "paused" - case v.Active != nil: - return "downloading" - case len(v.Next) > 0: - return "waiting" - } - return "idle" -} - -func (v queueView) StateLabel() string { - switch v.State() { - case "paused": - return "Paused" - case "downloading": - return "Downloading" - case "waiting": - return "Waiting" - } - return "Idle" -} - // Title shows a running download's progress in the browser tab. func (v queueView) Title() string { switch { case v.Active != nil: - return fmt.Sprintf("%d%% · %s — magnetowid", v.Active.Percent, strings.TrimSpace(v.Active.Title+" "+v.Active.Episode)) + return fmt.Sprintf("%d%% · %s — magnetowid", v.Active.Percent, v.Active.Label()) case v.Paused: return "Paused — magnetowid" } return "magnetowid" } +// historyView is everything the history page shows. +type historyView struct { + chrome + Jobs []jobView // newest first +} + +func newHistoryView(jobs []downloader.Job, paused bool, version string, now time.Time) historyView { + v := historyView{chrome: newChrome(jobs, paused, version, "history")} + var done []downloader.Job + for _, j := range jobs { + if j.Status == downloader.StatusCompleted || j.Status == downloader.StatusFailed { + done = append(done, j) + } + } + slices.SortStableFunc(done, func(a, b downloader.Job) int { return b.Finished.Compare(a.Finished) }) + for _, j := range done { + jv := jobView{ + ID: j.ID, + Name: j.Name, + Category: j.Category, + Provider: j.Ref.Provider, + Status: "Completed", + Tone: "ok", + Finished: ago(now.Sub(j.Finished)), + Storage: j.Storage, + } + jv.Title, jv.Episode, jv.Specs = splitName(j.Name) + if j.Status == downloader.StatusFailed { + jv.Status, jv.Tone, jv.Error = "Failed", "danger", j.Error + if j.Attempts > 1 { + jv.Attempt = j.Attempts + } + } else if j.Bytes > 0 { + jv.Size = size(j.Bytes) + } + // A failure's time is only its last attempt's. + if j.Status == downloader.StatusCompleted && !j.Started.IsZero() && j.Finished.After(j.Started) { + jv.Took = duration(j.Finished.Sub(j.Started)) + } + v.Jobs = append(v.Jobs, jv) + } + return v +} + +func (v historyView) Title() string { return "History — magnetowid" } + +// KeptDays is how long finished jobs stay in history. +func (v historyView) KeptDays() int { return int(downloader.HistoryRetention.Hours() / 24) } + // Release builds set a bare version number. func displayVersion(s string) string { if s != "" && s[0] >= '0' && s[0] <= '9' { @@ -211,15 +316,23 @@ func timeLeft(d time.Duration) string { return "estimating…" case d < time.Minute: return "less than a minute left" + } + return "about " + duration(d) + " left" +} + +func duration(d time.Duration) string { + switch { + case d < time.Minute: + return "less than a minute" case d < time.Hour: - return fmt.Sprintf("about %d min left", int(d.Round(time.Minute).Minutes())) + return fmt.Sprintf("%d min", int(d.Round(time.Minute).Minutes())) } d = d.Round(time.Minute) h, m := int(d.Hours()), int(d.Minutes())%60 if m == 0 { - return fmt.Sprintf("about %d h left", h) + return fmt.Sprintf("%d h", h) } - return fmt.Sprintf("about %d h %d min left", h, m) + return fmt.Sprintf("%d h %d min", h, m) } // Times are relative: the server's time zone is often not the viewer's. diff --git a/internal/web/web.go b/internal/web/web.go index 241e88a..499bee2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,5 +1,5 @@ -// Package web serves the browser interface: a sign-in page and a live view of -// the download queue. +// Package web serves the browser interface: a sign-in page, and live views of +// the download queue and history that can pause, resume and remove downloads. // // 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. @@ -27,13 +27,15 @@ import ( var files embed.FS var ( - layout = template.Must(template.ParseFS(files, "templates/layout.html")) - loginPage = page("templates/login.html") - queuePage = page("templates/queue.html") + layout = template.Must(template.ParseFS(files, "templates/layout.html")) + app = parse(layout, "templates/app.html") + loginPage = parse(layout, "templates/login.html") + queuePage = parse(app, "templates/queue.html") + historyPage = parse(app, "templates/history.html") ) -func page(name string) *template.Template { - return template.Must(template.Must(layout.Clone()).ParseFS(files, name)) +func parse(base *template.Template, name string) *template.Template { + return template.Must(template.Must(base.Clone()).ParseFS(files, name)) } const ( @@ -53,9 +55,18 @@ type Handler struct { // catch-all route. func (h *Handler) Register(mux *http.ServeMux) { csrf := http.NewCrossOriginProtection() + get := func(pattern string, f http.HandlerFunc) { mux.Handle("GET "+pattern, secure(h.auth(f))) } + post := func(pattern string, f http.HandlerFunc) { mux.Handle("POST "+pattern, secure(csrf.Handler(h.auth(f)))) } mux.Handle("GET /{$}", http.RedirectHandler("/ui/", http.StatusSeeOther)) - mux.Handle("GET /ui/{$}", secure(h.auth(h.queue))) - mux.Handle("GET /ui/queue", secure(h.auth(h.queueUpdate))) + get("/ui/{$}", h.queue) + get("/ui/queue", h.queue) + get("/ui/history", h.history) + post("/ui/queue/pause", h.pauseQueue(true)) + post("/ui/queue/resume", h.pauseQueue(false)) + post("/ui/queue/{id}/pause", h.pauseJob(true)) + post("/ui/queue/{id}/resume", h.pauseJob(false)) + post("/ui/queue/{id}/delete", h.cancelJob) + post("/ui/history/{id}/delete", h.deleteJob) 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)))) @@ -184,17 +195,98 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/login", http.StatusSeeOther) } -func (h *Handler) view() queueView { - return newQueueView(h.Queue.Jobs(), h.Queue.Paused(), h.Queue.Outages(), h.Version, time.Now()) +func (h *Handler) queue(w http.ResponseWriter, r *http.Request) { + h.show(w, r, http.StatusOK, "queue", "") } -func (h *Handler) queue(w http.ResponseWriter, _ *http.Request) { - h.render(w, http.StatusOK, queuePage, "layout", h.view()) +func (h *Handler) history(w http.ResponseWriter, r *http.Request) { + h.show(w, r, http.StatusOK, "history", "") } -// queueUpdate is the part of the page that refreshes itself. -func (h *Handler) queueUpdate(w http.ResponseWriter, _ *http.Request) { - h.render(w, http.StatusOK, queuePage, "update", h.view()) +// show renders a page with an optional notice, or for htmx only the part of +// it that refreshes. +func (h *Handler) show(w http.ResponseWriter, r *http.Request, status int, page, notice string) { + jobs, paused, now := h.Queue.Jobs(), h.Queue.Paused(), time.Now() + var t *template.Template + var v any + if page == "history" { + hv := newHistoryView(jobs, paused, h.Version, now) + hv.Notice = notice + t, v = historyPage, hv + } else { + qv := newQueueView(jobs, paused, h.Queue.Outages(), h.Version, now) + qv.Notice = notice + t, v = queuePage, qv + } + name := "layout" + if htmx(r) { + name = "update" + } + w.Header().Add("Vary", "HX-Request") + h.render(w, status, t, name, v) +} + +func htmx(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } + +func (h *Handler) pauseQueue(pause bool) http.HandlerFunc { + what := "resume the queue" + if pause { + what = "pause the queue" + } + return func(w http.ResponseWriter, r *http.Request) { + h.done(w, r, "queue", what, h.Queue.SetPaused(pause)) + } +} + +func (h *Handler) pauseJob(pause bool) http.HandlerFunc { + f, what := h.Queue.ResumeJobs, "resume the download" + if pause { + f, what = h.Queue.PauseJobs, "pause the download" + } + return func(w http.ResponseWriter, r *http.Request) { + _, err := f(r.PathValue("id")) + h.done(w, r, "queue", what, err) + } +} + +// A download that finished meanwhile stays in history. +func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) { + _, err := h.Queue.Cancel(r.PathValue("id")) + h.done(w, r, "queue", "remove the download", err) +} + +func (h *Handler) deleteJob(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + _, err := h.Queue.Delete(r.PathValue("id"), r.PostFormValue("files") == "1") + h.done(w, r, "history", "remove the download from history", err) +} + +// done answers an action on page. htmx gets the page's new state at once and +// plain forms go back to the page. Unknown jobs are not errors: the page +// shows they have gone. +func (h *Handler) done(w http.ResponseWriter, r *http.Request, page, what string, err error) { + if err != nil { + h.Log.Error("web interface action failed", "action", what, "err", err) + notice := "Couldn’t " + what + ". The log has the details." + if !htmx(r) { + h.show(w, r, http.StatusInternalServerError, page, notice) + return + } + // Outside the refreshing part, so the next refresh keeps it. + w.Header().Set("HX-Retarget", "#notice") + w.Header().Set("HX-Reswap", "innerHTML") + h.render(w, http.StatusInternalServerError, app, "notice", notice) + return + } + if htmx(r) { + h.show(w, r, http.StatusOK, page, "") + return + } + path := "/ui/" + if page == "history" { + path = "/ui/history" + } + http.Redirect(w, r, path, http.StatusSeeOther) } // Render fully before writing so a template error cannot send half a page. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index ad8d189..1e0a64f 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -9,7 +9,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -19,6 +21,7 @@ import ( "github.com/combor/magnetowid/internal/nzb" "github.com/combor/magnetowid/internal/provider" "github.com/combor/magnetowid/internal/store" + bolt "go.etcd.io/bbolt" ) type fakeProvider struct{} @@ -27,18 +30,24 @@ func (fakeProvider) Name() string { return "fake" } func (fakeProvider) Search(context.Context, provider.Query) ([]provider.Item, error) { return nil, nil } -func (fakeProvider) Resolve(context.Context, string) (provider.Stream, error) { + +// Resolving "gone" fails for good. +func (fakeProvider) Resolve(_ context.Context, id string) (provider.Stream, error) { + if id == "gone" { + return provider.Stream{}, provider.ErrUnavailable + } return provider.Stream{}, nil } type fakeEngine struct{} -func (fakeEngine) Download(context.Context, provider.Stream, string, func(time.Duration, int64)) error { - return nil +func (fakeEngine) Download(_ context.Context, _ provider.Stream, out string, progress func(time.Duration, int64)) error { + progress(time.Second, 5) + return os.WriteFile(out, []byte("media"), 0o666) } -// newUI serves the interface for a queue whose worker never runs. -func newUI(t *testing.T) (*httptest.Server, *downloader.Queue) { +// newUI serves the interface for a queue whose worker runs only in finish. +func newUI(t *testing.T) (*httptest.Server, *downloader.Queue, *bolt.DB) { t.Helper() db, err := store.Open(t.TempDir()) if err != nil { @@ -58,7 +67,44 @@ func newUI(t *testing.T) (*httptest.Server, *downloader.Queue) { (&Handler{Queue: q, APIKey: "key", Version: "1.2.3", Log: slog.New(slog.DiscardHandler)}).Register(mux) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - return srv, q + return srv, q, db +} + +// finish runs the worker until every job has finished. +func finish(t *testing.T, q *downloader.Queue) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { q.Run(ctx); close(done) }() + defer func() { cancel(); <-done }() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + if !slices.ContainsFunc(q.Jobs(), unfinished) { + return + } + } + t.Fatalf("jobs did not finish: %+v", q.Jobs()) +} + +func unfinished(j downloader.Job) bool { + return j.Status == downloader.StatusQueued || j.Status == downloader.StatusDownloading +} + +func add(t *testing.T, q *downloader.Queue, name, ref string) string { + t.Helper() + id, err := q.Add(name, name+".nzb", "tv", 0, false, nzb.Ref{Provider: "fake", ID: ref}) + if err != nil { + t.Fatal(err) + } + return id +} + +func job(q *downloader.Queue, id string) (downloader.Job, bool) { + for _, j := range q.Jobs() { + if j.ID == id { + return j, true + } + } + return downloader.Job{}, false } type response struct { @@ -122,7 +168,7 @@ func signIn(t *testing.T, srv *httptest.Server) *http.Cookie { } func TestSignIn(t *testing.T) { - srv, _ := newUI(t) + srv, _, _ := newUI(t) if r := get(t, srv, "/", nil); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/" { t.Errorf("GET / = %d to %q", r.status, r.header.Get("Location")) @@ -177,18 +223,10 @@ func TestSignIn(t *testing.T) { } func TestQueuePage(t *testing.T) { - srv, q := newUI(t) + srv, q, _ := newUI(t) c := signIn(t, srv) - add := func(name string) string { - t.Helper() - id, err := q.Add(name, name+".nzb", "tv", 0, false, nzb.Ref{Provider: "fake", ID: name}) - if err != nil { - t.Fatal(err) - } - return id - } - add("Tom & Jerry.S01E02.1080p.WEB-DL.AAC.H.264-FAKE") - if _, err := q.PauseJobs(add("Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-FAKE")); err != nil { + add(t, q, "Tom & Jerry.S01E02.1080p.WEB-DL.AAC.H.264-FAKE", "1") + if _, err := q.PauseJobs(add(t, q, "Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-FAKE", "2")); err != nil { t.Fatal(err) } if err := q.SetPaused(true); err != nil { @@ -217,13 +255,131 @@ func TestQueuePage(t *testing.T) { // Refreshes carry only the changing part and the tab's title. r = get(t, srv, "/ui/queue", c, "HX-Request", "true") if r.status != http.StatusOK || !strings.HasPrefix(r.body, "Paused — magnetowid") || - strings.Contains(r.body, "Paused — magnetowid") || + !strings.Contains(r.body, "Resume queue") || strings.Contains(r.body, "`) || !strings.Contains(r.body, "Couldn’t resume the queue.") { + t.Errorf("failed htmx resume = %d %v %s", r.status, r.header, r.body) + } + r = post(t, srv, "/ui/queue/resume", nil, "Cookie", cookie) + if r.status != http.StatusInternalServerError || !strings.Contains(r.body, "History — magnetowid", + `hx-get="/ui/history" hx-trigger="every 5s"`, + `History3`, + `Completed`, + `Failed`, + "content unavailable", + "Seksmisja", + `action="/ui/history/` + deleted + `/delete"`, + `name="files" value="1"`, + } { + if !strings.Contains(r.body, want) { + t.Errorf("history lacks %q:\n%s", want, r.body) + } + } + if strings.Contains(r.body, "Queued") { + t.Errorf("history shows a queued job:\n%s", r.body) + } + if r := get(t, srv, "/ui/history", c, "HX-Request", "true"); !strings.HasPrefix(r.body, "History") || strings.Contains(r.body, "<html") { + t.Errorf("history refresh = %d %s", r.status, r.body) + } + + storage := func(id string) string { j, _ := job(q, id); return j.Storage } + keptDir, deletedDir := storage(kept), storage(deleted) + r = post(t, srv, "/ui/history/"+kept+"/delete", nil, "Cookie", cookie) + if _, err := os.Stat(keptDir); r.status != http.StatusSeeOther || r.header.Get("Location") != "/ui/history" || err != nil { + t.Errorf("removing without files = %d to %q; files %v", r.status, r.header.Get("Location"), err) + } + r = post(t, srv, "/ui/history/"+deleted+"/delete", url.Values{"files": {"1"}}, "Cookie", cookie, "HX-Request", "true") + if _, err := os.Stat(deletedDir); r.status != http.StatusOK || !os.IsNotExist(err) || !strings.HasPrefix(r.body, "<title>History") { + t.Errorf("removing with files = %d, files %v, %s", r.status, err, r.body) + } + for _, id := range []string{kept, deleted} { + if _, ok := job(q, id); ok { + t.Errorf("job %s still in history", id) + } + } + if _, ok := job(q, failed); !ok { + t.Error("the failed job went too") } } func TestStaticFiles(t *testing.T) { - srv, _ := newUI(t) + srv, _, _ := newUI(t) r := get(t, srv, "/ui/static/htmx-4.0.0.min.js", nil) if r.status != http.StatusOK || !strings.HasPrefix(r.header.Get("Content-Type"), "text/javascript") || r.header.Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(r.body, "htmx") { @@ -240,6 +396,40 @@ func TestStaticFiles(t *testing.T) { } } +func TestHistoryView(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + finished := func(name string, status downloader.Status, ago, took time.Duration) downloader.Job { + return downloader.Job{ID: name, Name: name, Category: "tv", Status: status, Ref: nzb.Ref{Provider: "tvp"}, + Finished: now.Add(-ago), Started: now.Add(-ago - took), Bytes: 1_240_000_000, Attempts: 1} + } + older := finished("Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-TVP", downloader.StatusCompleted, 3*time.Hour, 14*time.Minute) + older.Storage = "/downloads/tv/Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-TVP" + failed := finished("failed", downloader.StatusFailed, 2*time.Hour, 30*time.Second) + failed.Attempts, failed.Error = 6, "content unavailable" + newer := finished("newer", downloader.StatusCompleted, time.Minute, 65*time.Minute) + running := downloader.Job{ID: "running", Status: downloader.StatusDownloading} + + v := newHistoryView([]downloader.Job{older, running, failed, newer}, false, "1.4.0", now) + var got []string + for _, j := range v.Jobs { + got = append(got, fmt.Sprintf("%s: %s %s, %q %q %q, %d attempts, %q", j.ID, j.Tone, j.Status, j.Size, j.Took, j.Finished, j.Attempt, j.Storage)) + } + want := []string{ + `newer: ok Completed, "1.2 GB" "1 h 5 min" "1 min ago", 0 attempts, ""`, + `failed: danger Failed, "" "" "2 h ago", 6 attempts, ""`, + `Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-TVP: ok Completed, "1.2 GB" "14 min" "3 h ago", 0 attempts, "/downloads/tv/Ranczo.S02E01.1080p.WEB-DL.AAC.H.264-TVP"`, + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("history:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if j := v.Jobs[2]; j.Label() != "Ranczo S02E01" || j.Error != "" { + t.Errorf("job = %+v", j) + } + if v.Queued != 1 || v.Finished != 3 || v.State() != "downloading" || v.Refresh() != "/ui/history" || v.Every() != "5s" || v.KeptDays() != 30 { + t.Errorf("chrome = %+v, state %q", v.chrome, v.State()) + } +} + func TestQueueView(t *testing.T) { now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) job := func(name string, status downloader.Status) downloader.Job { @@ -291,6 +481,18 @@ func TestQueueView(t *testing.T) { t.Errorf("state %q, title %q, version %q", v.State(), v.Title(), v.Version) } + // A paused download is shown where it will be once it has stopped. + stopping := running + stopping.Paused = true + for _, v := range []queueView{ + newQueueView([]downloader.Job{stopping, low}, false, nil, "", now), + newQueueView([]downloader.Job{running, low}, true, nil, "", now), + } { + if v.Active != nil || len(v.Next) != 2 || v.Next[0].Running || v.State() == "downloading" { + t.Errorf("stopping download: active %+v, next %+v, state %q", v.Active, v.Next, v.State()) + } + } + for _, tc := range []struct { v queueView state string @@ -298,6 +500,7 @@ func TestQueueView(t *testing.T) { {newQueueView(nil, false, nil, "dev", now), "idle"}, {newQueueView(nil, true, nil, "dev", now), "paused"}, {newQueueView([]downloader.Job{low}, false, nil, "dev", now), "waiting"}, + {newQueueView([]downloader.Job{paused}, false, nil, "dev", now), "waiting"}, } { if tc.v.State() != tc.state || tc.v.Version != "dev" { t.Errorf("state %q, version %q; want %q", tc.v.State(), tc.v.Version, tc.state) @@ -311,6 +514,7 @@ func TestRenderStates(t *testing.T) { running := downloader.Job{Name: "Hydrozagadka.1971.Polish.1080p.WEB-DL.AAC.H.264-TVP", Status: downloader.StatusDownloading, Started: now, Ref: nzb.Ref{Provider: "tvp"}} failing := downloader.Job{Name: "x", Status: downloader.StatusQueued, RetryAt: now.Add(time.Minute), Error: "<b>boom</b>"} + paused := downloader.Job{ID: "p1", Name: "Paused.S01E01.1080p-TVP", Status: downloader.StatusQueued, Paused: true} for _, tc := range []struct { v queueView want []string @@ -321,7 +525,12 @@ func TestRenderStates(t *testing.T) { "<span>Polish</span><span>1080p</span><span>WEB-DL</span><span>AAC</span><span>H.264</span>", `value="0"`, "estimating…"}}, {newQueueView([]downloader.Job{running, failing}, false, nil, "", now), []string{"Up next", "Retrying in", "<b>boom</b>"}}, - {newQueueView([]downloader.Job{failing}, false, nil, "", now), []string{"Queued</h2>", `data-state="waiting"`}}, + {newQueueView([]downloader.Job{failing}, false, nil, "", now), []string{"Queued</h2>", `data-state="waiting"`, + `action="/ui/queue/pause"`, "Pause<span", `action="/ui/queue//pause"`, "Remove it from the queue?"}}, + {newQueueView([]downloader.Job{running}, false, nil, "", now), []string{"Stop and remove this download?", + "the download restarts from the beginning"}}, + {newQueueView(nil, true, nil, "", now), []string{"Resume<span", `action="/ui/queue/resume"`}}, + {newQueueView([]downloader.Job{paused}, false, nil, "", now), []string{`action="/ui/queue/p1/resume"`, "Resume Paused S01E01"}}, } { var b bytes.Buffer if err := queuePage.ExecuteTemplate(&b, "layout", tc.v); err != nil { @@ -333,6 +542,34 @@ func TestRenderStates(t *testing.T) { } } } + + done := downloader.Job{ID: "d1", Name: "Cube.1997.1080p-TVP", Status: downloader.StatusCompleted, Storage: "/downloads/movies/Cube"} + gone := downloader.Job{ID: "f1", Name: "x", Status: downloader.StatusFailed, Error: "<b>boom</b>"} + for _, tc := range []struct { + v historyView + want []string + not []string + }{ + {newHistoryView(nil, false, "", now), []string{"No finished downloads", "for up to 30 days"}, nil}, + {newHistoryView([]downloader.Job{done}, false, "", now), []string{"Cube", "/downloads/movies/Cube", "hasn’t imported it yet"}, nil}, + {newHistoryView([]downloader.Job{gone}, false, "", now), []string{"<b>boom</b>", `action="/ui/history/f1/delete"`}, + []string{`name="files"`, "hasn’t imported"}}, + } { + var b bytes.Buffer + if err := historyPage.ExecuteTemplate(&b, "layout", tc.v); err != nil { + t.Fatal(err) + } + for _, want := range tc.want { + if !strings.Contains(b.String(), want) { + t.Errorf("history for %+v lacks %q", tc.v, want) + } + } + for _, not := range tc.not { + if strings.Contains(b.String(), not) { + t.Errorf("history for %+v has %q", tc.v, not) + } + } + } } func TestSplitName(t *testing.T) { @@ -362,6 +599,9 @@ func TestFormatting(t *testing.T) { {timeLeft(8*time.Minute + 20*time.Second), "about 8 min left"}, {timeLeft(2 * time.Hour), "about 2 h left"}, {timeLeft(65 * time.Minute), "about 1 h 5 min left"}, + {duration(20 * time.Second), "less than a minute"}, + {duration(14*time.Minute + 10*time.Second), "14 min"}, + {duration(71 * time.Minute), "1 h 11 min"}, {ago(10 * time.Second), "just now"}, {ago(5 * time.Minute), "5 min ago"}, {ago(3 * time.Hour), "3 h ago"}, From ff22d5011decfd721e90889e2e72f9e815299b0b Mon Sep 17 00:00:00 2001 From: Piotr Komborski <163394+combor@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:26:43 +0100 Subject: [PATCH 3/4] Keep confirmations usable while the page refreshes htmx focuses the first autofocus element after every swap, so the first confirmation on the page pulled focus back to Keep every second. A keyboard user who tabbed to Remove could then dismiss the confirmation instead. The confirmations no longer autofocus: focus stays on the remove button, and Tab moves into the confirmation. A failed removal showed its notice at the bottom of the page. On phones, the open confirmation covered it. The notice now appears inside the confirmation, and refreshes leave it there until the confirmation is closed. Actions no longer cancel each other or a refresh under way. The bundled htmx frees its request queue when a cancelled request ends, even though the request that replaced it is still running, so later requests could overlap and land out of order. Actions now wait in the same queue as the refreshes, so responses arrive in the order they were requested. --- internal/web/static/style.css | 17 +++++++++++++++-- internal/web/templates/app.html | 15 +++++++++++---- internal/web/templates/history.html | 1 + internal/web/templates/queue.html | 1 + internal/web/web.go | 29 ++++++++++++++++------------- internal/web/web_test.go | 12 ++++++++++++ 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 8003f5f..ddfa239 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -974,11 +974,24 @@ code { font-size: 14px; font-weight: 550; box-shadow: var(--shadow-float); - animation: toast 8s ease forwards; } +.notice .toast { animation: toast 8s ease forwards; } .toast .icon { margin-top: 1px; } +.confirm-notice:empty { display: none; } + +.confirm-notice .toast { + max-width: none; + margin-top: 6px; + padding: 10px 12px; + border-radius: 10px; + background: var(--danger-soft); + color: var(--danger); + font-size: 13.5px; + box-shadow: none; +} + /* Sign in */ .auth { @@ -1106,7 +1119,7 @@ code { } /* Hiding a notice is not motion. */ - .toast { animation: toast-hide 8s step-end forwards !important; } + .notice .toast { animation: toast-hide 8s step-end forwards !important; } } /* Phones */ diff --git a/internal/web/templates/app.html b/internal/web/templates/app.html index 2e69e4c..7b52adf 100644 --- a/internal/web/templates/app.html +++ b/internal/web/templates/app.html @@ -1,7 +1,8 @@ {{/* What the queue and history pages share. */}} +{{/* Refreshes keep a failed removal's notice while its confirmation is open. */}} {{define "head"}} -<meta name="htmx-config" content='{"includeIndicatorCSS": false}'> +<meta name="htmx-config" content='{"includeIndicatorCSS": false, "morphSkipChildren": "[hx-morph-skip-children], .confirm:popover-open .confirm-notice"}'> <script src="/ui/static/htmx-4.0.0.min.js" defer></script> {{- end}} @@ -43,8 +44,10 @@ </div> </header> -{{/* Actions swap in the page's new state, and cancel a refresh under way so it can't bring back the old one. */}} -<main class="container" hx-target:inherited="#app" hx-swap:inherited="innerMorph" hx-sync:inherited="#app:replace"> +{{/* Actions swap in the page's new state. They queue with the refreshes, so responses land in order and none brings back an + older state. Nothing is cancelled: the bundled htmx frees the queue when a cancelled request ends, while its + replacement still runs. */}} +<main class="container" hx-target:inherited="#app" hx-swap:inherited="innerMorph" hx-sync:inherited="#app:queue all"> <h1 class="visually-hidden">{{if eq .Page "history"}}Download history{{else}}Download queue{{end}}</h1> <div class="toolbar"> <nav class="tabs" aria-label="Pages"> @@ -74,8 +77,12 @@ <h1 class="visually-hidden">{{if eq .Page "history"}}Download history{{else}}Dow <button class="icon-btn icon-btn-danger" type="button" popovertarget="remove-{{.ID}}" aria-label="Remove {{.Label}}" title="Remove">{{template "icon-trash"}}</button> {{- end}} +{{/* A failed removal shows here: a notice below would be hidden by the open confirmation. */}} +{{define "remove-notice"}}<div class="confirm-notice" id="remove-{{.ID}}-notice" aria-live="assertive"></div>{{end}} + +{{/* No autofocus: htmx focuses the first one after every refresh. */}} {{define "keep-button" -}} -<button class="btn btn-quiet" type="button" popovertarget="remove-{{.ID}}" popovertargetaction="hide" autofocus>Keep</button> +<button class="btn btn-quiet" type="button" popovertarget="remove-{{.ID}}" popovertargetaction="hide">Keep</button> {{- end}} {{define "icon-pause"}}<svg class="icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><rect x="5" y="4" width="3.5" height="12" rx="1.2"/><rect x="11.5" y="4" width="3.5" height="12" rx="1.2"/></svg>{{end}} diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 30b16a8..02f0473 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -30,6 +30,7 @@ <h2 class="confirm-title" id="remove-{{.ID}}-title">Remove it from history?</h2> {{- if eq .Tone "ok"}} <p class="confirm-text">If Sonarr or Radarr hasn’t imported it yet, it won’t.</p> {{- end}} + {{template "remove-notice" .}} <div class="confirm-actions"> {{template "keep-button" .}} <button class="btn btn-strong" type="submit"><span class="if-kept">Remove</span><span class="if-deleted">Remove and delete</span></button> diff --git a/internal/web/templates/queue.html b/internal/web/templates/queue.html index a12c8a4..e6dfd67 100644 --- a/internal/web/templates/queue.html +++ b/internal/web/templates/queue.html @@ -90,6 +90,7 @@ <h2>The queue is empty</h2> <h2 class="confirm-title" id="remove-{{.ID}}-title">{{if .Running}}Stop and remove this download?{{else}}Remove it from the queue?{{end}}</h2> <p class="confirm-name">{{.Label}}</p> <p class="confirm-text">{{if .Running}}What it has downloaded so far is deleted.{{else}}It won’t be downloaded.{{end}}</p> + {{template "remove-notice" .}} <form class="confirm-actions" method="post" action="/ui/queue/{{.ID}}/delete" hx-post="/ui/queue/{{.ID}}/delete"> {{template "keep-button" .}} <button class="btn btn-danger" type="submit">Remove</button> diff --git a/internal/web/web.go b/internal/web/web.go index 499bee2..dd08d9c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -234,7 +234,7 @@ func (h *Handler) pauseQueue(pause bool) http.HandlerFunc { what = "pause the queue" } return func(w http.ResponseWriter, r *http.Request) { - h.done(w, r, "queue", what, h.Queue.SetPaused(pause)) + h.done(w, r, "queue", what, "#notice", h.Queue.SetPaused(pause)) } } @@ -245,37 +245,40 @@ func (h *Handler) pauseJob(pause bool) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { _, err := f(r.PathValue("id")) - h.done(w, r, "queue", what, err) + h.done(w, r, "queue", what, "#notice", err) } } // A download that finished meanwhile stays in history. func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) { - _, err := h.Queue.Cancel(r.PathValue("id")) - h.done(w, r, "queue", "remove the download", err) + id := r.PathValue("id") + _, err := h.Queue.Cancel(id) + h.done(w, r, "queue", "remove the download", "#remove-"+id+"-notice", err) } func (h *Handler) deleteJob(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) - _, err := h.Queue.Delete(r.PathValue("id"), r.PostFormValue("files") == "1") - h.done(w, r, "history", "remove the download from history", err) + id := r.PathValue("id") + _, err := h.Queue.Delete(id, r.PostFormValue("files") == "1") + h.done(w, r, "history", "remove the download from history", "#remove-"+id+"-notice", err) } // done answers an action on page. htmx gets the page's new state at once and // plain forms go back to the page. Unknown jobs are not errors: the page -// shows they have gone. -func (h *Handler) done(w http.ResponseWriter, r *http.Request, page, what string, err error) { +// shows they have gone. For htmx, a failure's notice goes into the element +// notice selects. +func (h *Handler) done(w http.ResponseWriter, r *http.Request, page, what, notice string, err error) { if err != nil { h.Log.Error("web interface action failed", "action", what, "err", err) - notice := "Couldn’t " + what + ". The log has the details." + msg := "Couldn’t " + what + ". The log has the details." if !htmx(r) { - h.show(w, r, http.StatusInternalServerError, page, notice) + h.show(w, r, http.StatusInternalServerError, page, msg) return } - // Outside the refreshing part, so the next refresh keeps it. - w.Header().Set("HX-Retarget", "#notice") + // Somewhere the next refresh keeps it. + w.Header().Set("HX-Retarget", notice) w.Header().Set("HX-Reswap", "innerHTML") - h.render(w, http.StatusInternalServerError, app, "notice", notice) + h.render(w, http.StatusInternalServerError, app, "notice", msg) return } if htmx(r) { diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 1e0a64f..322dfc2 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -238,6 +238,8 @@ func TestQueuePage(t *testing.T) { "<title>Paused — magnetowid", "Downloads are paused.", `hx-get="/ui/queue"`, + // Actions queue with refreshes, so responses land in order. + `hx-sync:inherited="#app:queue all"`, "Tom & Jerry", `S02E01`, `Paused`, @@ -251,6 +253,10 @@ func TestQueuePage(t *testing.T) { if r.header.Get("Cache-Control") != "no-store" || !strings.Contains(r.header.Get("Content-Security-Policy"), "default-src 'self'") { t.Errorf("queue page headers %v", r.header) } + // htmx would move focus to the first autofocus after every refresh. + if strings.Contains(r.body, "autofocus") || !strings.Contains(r.body, `class="confirm-notice"`) { + t.Errorf("queue page confirmations:\n%s", r.body) + } // Refreshes carry only the changing part and the tab's title. r = get(t, srv, "/ui/queue", c, "HX-Request", "true") @@ -323,6 +329,12 @@ func TestQueueActions(t *testing.T) { if r.status != http.StatusInternalServerError || !strings.Contains(r.body, " Date: Thu, 1 Oct 2026 00:32:03 +0100 Subject: [PATCH 4/4] Tighten comments and docs --- README.md | 7 +++---- docs/usage.md | 31 +++++++++++------------------ internal/downloader/queue.go | 5 ++--- internal/downloader/queue_test.go | 2 +- internal/web/static/style.css | 21 ++++++++++--------- internal/web/templates/app.html | 13 ++++++------ internal/web/templates/history.html | 4 ++-- internal/web/templates/queue.html | 2 +- internal/web/view.go | 14 ++++++------- internal/web/web.go | 17 +++++++--------- internal/web/web_test.go | 18 ++++++++--------- 11 files changed, 60 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index bdf8fc5..be96d41 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,9 @@ for container hostnames, volume mounts and Remote Path Mappings. ## Web interface -Open `http://:8484/` and sign in with the API key to watch and -manage downloads: the current download's progress, what's next, any retries, -and what has finished. Pause, resume or remove downloads, and delete finished -ones' files. See [Web interface](docs/usage.md#web-interface). +Open `http://:8484/` and sign in with the API key to see the +queue and history, and to pause, resume or remove downloads. See +[Web interface](docs/usage.md#web-interface). ## Help and contributing diff --git a/docs/usage.md b/docs/usage.md index f170dac..98c4f71 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -148,9 +148,9 @@ default). Subtitle failures log a warning and keep the video. ### Pausing downloads -Pause or resume the queue or individual jobs in the -[web interface](#web-interface), or with SABnzbd API commands. Sonarr and -Radarr display paused jobs but cannot control pausing: +Pause or resume the queue or single jobs in the [web interface](#web-interface) +or with SABnzbd API commands. Sonarr and Radarr show paused jobs but cannot +pause them: ```sh curl 'http://localhost:8484/api?mode=pause&apikey=YOUR_API_KEY' @@ -166,22 +166,16 @@ interrupted downloads restart from the beginning when resumed. Open `http://:8484/` in a browser and sign in with the API key. -**Queue** shows the running download's progress and time left, the queued -jobs in the order they will run, and paused or retrying jobs with their last -error. It refreshes itself every second. From here you can: - -- pause or resume the whole queue; -- pause or resume a single job; -- remove a job. Removing the running download stops it and deletes what it - has downloaded so far. - -A paused download restarts from the beginning when resumed. +**Queue** shows the running download's progress and time left, queued jobs in +run order, and paused or retrying jobs with their last error. It refreshes +every second. Pause or resume the queue or a job, or remove a job; removing the +running download deletes its partial file. Resumed downloads restart from the +beginning. **History** lists finished and failed downloads, newest first, and refreshes -itself every five seconds. Sonarr and Radarr usually remove downloads they -have imported; the rest stay for 30 days. Removing a download from history can -also delete its folder. Remove only downloads that are already imported: -Sonarr and Radarr cannot import one that has gone from history. +every five seconds. Downloads stay for 30 days unless Sonarr or Radarr removes +them first. Removing one can also delete its folder; Sonarr and Radarr cannot +import a download removed from history. Signing in lasts 30 days. Changing `MAGNETOWID_API_KEY` signs every browser out. Search for titles in Sonarr/Radarr. @@ -268,8 +262,7 @@ under **Settings → Download Clients**: ## Troubleshooting Search for titles in Sonarr/Radarr. The [web interface](#web-interface) shows -magnetowid's download queue and history, with each failed or retrying job's -last error. +the queue and history, with the last error of each failed or retrying job. | Problem | What to check | |---|---| diff --git a/internal/downloader/queue.go b/internal/downloader/queue.go index e454d58..4fe093b 100644 --- a/internal/downloader/queue.go +++ b/internal/downloader/queue.go @@ -312,9 +312,8 @@ func (q *Queue) Delete(id string, deleteFiles bool) (bool, error) { return q.discard(id, deleteFiles, false) } -// Cancel deletes a job that has not finished, so one that finishes as it is -// cancelled stays in history to be imported. It returns false for unknown and -// finished jobs. +// Cancel deletes an unfinished job. It returns false for unknown and finished +// jobs, which stay in history for import. func (q *Queue) Cancel(id string) (bool, error) { return q.discard(id, false, true) } diff --git a/internal/downloader/queue_test.go b/internal/downloader/queue_test.go index 21cbf35..7fa4f61 100644 --- a/internal/downloader/queue_test.go +++ b/internal/downloader/queue_test.go @@ -444,7 +444,7 @@ func TestCancel(t *testing.T) { t.Errorf("cancelling an unknown job = %v, %v", ok, err) } - // A job that finished before the cancel stays for Sonarr/Radarr to import. + // A finished job stays for import. q = startQueue(t, &fakeProvider{}, &fakeEngine{}) done := waitFinished(t, q, add(t, q, "c", "tv", 0, nzb.Ref{Provider: "fake", ID: "c"})) if ok, err := q.Cancel(done.ID); ok || err != nil { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index ddfa239..d5cc183 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -26,7 +26,7 @@ --warn-border: #f3d9a4; --danger: #b42318; --danger-soft: #fdecea; - --danger-solid: #c42b1f; /* under white text */ + --danger-solid: #c42b1f; /* behind white text */ --danger-solid-hover: #a82218; --ok: #0b7568; --ok-soft: #ddf4ef; @@ -374,7 +374,7 @@ code { padding: 32px 20px calc(64px + env(safe-area-inset-bottom)); } -/* Page tabs and the page's own controls */ +/* Tabs and page controls */ .toolbar { display: flex; @@ -758,8 +758,7 @@ code { .added { margin-left: 2px; } -/* Every fact starts with a dot, and the clip hides the one that starts each - line. */ +/* Each fact starts with a dot; the clip hides the dot that starts a line. */ .facts { display: inline-flex; flex-wrap: wrap; @@ -830,7 +829,7 @@ code { color: var(--text-muted); } -/* Asking before removing */ +/* Removal confirmation */ .confirm { width: min(420px, calc(100vw - 32px)); @@ -847,7 +846,7 @@ code { transition: opacity 0.2s ease, transform 0.2s ease, overlay 0.2s allow-discrete, display 0.2s allow-discrete; } -/* Only while open: a display here would show closed popovers. */ +/* display on .confirm would override the display: none of closed popovers. */ .confirm:popover-open { display: grid; gap: 6px; @@ -944,14 +943,14 @@ code { overflow-wrap: anywhere; } -/* The button says whether the files go too. */ +/* The submit label follows the files checkbox. */ .if-deleted { display: none; } .confirm:has(:checked) .if-kept { display: none; } .confirm:has(:checked) .if-deleted { display: inline; } .confirm:has(:checked) .btn-strong { background: var(--danger-solid); color: #fff; } .confirm:has(:checked) .btn-strong:hover { background: var(--danger-solid-hover); } -/* A failed action */ +/* Failure notices */ .notice { position: fixed; @@ -1118,7 +1117,7 @@ code { transition: none; } - /* Hiding a notice is not motion. */ + /* Keep hiding notices: visibility isn't motion. */ .notice .toast { animation: toast-hide 8s step-end forwards !important; } } @@ -1136,7 +1135,7 @@ code { .hero { padding: 20px 18px; } - /* The controls share the eyebrow's line; the chips go below. */ + /* Controls on the eyebrow's line, chips below. */ .hero-tools { display: contents; } .hero .actions { order: 1; margin: -6px -8px -6px auto; } .hero .chips { order: 2; flex-basis: 100%; } @@ -1160,7 +1159,7 @@ code { .row .actions { align-self: start; margin: 4px -4px 0 0; } .pos { display: none; } - /* A sheet at the bottom, within thumb reach. */ + /* Bottom sheet. */ .confirm { width: auto; margin: auto 10px calc(10px + env(safe-area-inset-bottom)); diff --git a/internal/web/templates/app.html b/internal/web/templates/app.html index 7b52adf..9e5ec74 100644 --- a/internal/web/templates/app.html +++ b/internal/web/templates/app.html @@ -1,4 +1,4 @@ -{{/* What the queue and history pages share. */}} +{{/* Shared by the queue and history pages. */}} {{/* Refreshes keep a failed removal's notice while its confirmation is open. */}} {{define "head"}} @@ -44,9 +44,8 @@ -{{/* Actions swap in the page's new state. They queue with the refreshes, so responses land in order and none brings back an - older state. Nothing is cancelled: the bundled htmx frees the queue when a cancelled request ends, while its - replacement still runs. */}} +{{/* Actions queue with refreshes so responses land in order. Nothing is cancelled: the bundled htmx releases the queue + when a cancelled request ends, while its replacement still runs. */}}

{{if eq .Page "history"}}Download history{{else}}Download queue{{end}}

@@ -72,15 +71,15 @@

{{if eq .Page "history"}}Download history{{else}}Dow

{{template "icon-alert"}}{{.}}

{{- end}} -{{/* Asks before removing a job; the page supplies the question and the form. */}} +{{/* Opens the job's removal confirmation, which each page defines. */}} {{define "remove-button" -}} {{- end}} -{{/* A failed removal shows here: a notice below would be hidden by the open confirmation. */}} +{{/* A failed removal's notice: the page's own would be under the open confirmation. */}} {{define "remove-notice"}}
{{end}} -{{/* No autofocus: htmx focuses the first one after every refresh. */}} +{{/* No autofocus: htmx focuses the first autofocus element after every swap. */}} {{define "keep-button" -}} {{- end}} diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 02f0473..7efee13 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -44,12 +44,12 @@

Remove it from history?

{{- end}} -

Finished downloads are kept for {{.KeptDays}} days, or until Sonarr or Radarr removes them.

+

Finished downloads stay for {{.KeptDays}} days unless Sonarr or Radarr removes them first.

{{- else}}
{{template "mark"}}

No finished downloads

-

Finished downloads show up here until Sonarr or Radarr imports them, for up to {{.KeptDays}} days.

+

Finished downloads stay here for {{.KeptDays}} days unless Sonarr or Radarr removes them first.

{{- end}} {{- end}} diff --git a/internal/web/templates/queue.html b/internal/web/templates/queue.html index e6dfd67..b3acc48 100644 --- a/internal/web/templates/queue.html +++ b/internal/web/templates/queue.html @@ -89,7 +89,7 @@

The queue is empty