diff --git a/README.md b/README.md index 01bae8d..be96d41 100644 --- a/README.md +++ b/README.md @@ -87,9 +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 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 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 3e48bd5..98c4f71 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 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' @@ -163,13 +164,21 @@ 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, 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 +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 and manage downloads in Sonarr/Radarr. +out. Search for titles in Sonarr/Radarr. ## Correcting matches @@ -252,8 +261,8 @@ 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 +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 16f3406..4fe093b 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,19 @@ 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 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) +} + +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 +517,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..7fa4f61 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 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 { + 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() diff --git a/internal/web/static/style.css b/internal/web/static/style.css index ae2e18b..d5cc183 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; /* behind 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)); } +/* Tabs and page 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,28 @@ code { .added { margin-left: 2px; } +/* Each fact starts with a dot; the clip hides the dot that starts a 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 +829,168 @@ code { color: var(--text-muted); } +/* Removal confirmation */ + +.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; +} + +/* display on .confirm would override the display: none of 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 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); } + +/* Failure notices */ + +.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); +} + +.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 { @@ -755,6 +1093,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 +1116,9 @@ code { animation: none; transition: none; } + + /* Keep hiding notices: visibility isn't motion. */ + .notice .toast { animation: toast-hide 8s step-end forwards !important; } } /* Phones */ @@ -785,21 +1134,42 @@ code { .container { gap: 20px; padding: 20px 16px calc(48px + env(safe-area-inset-bottom)); } .hero { padding: 20px 18px; } + + /* 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%; } .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; } + /* Bottom sheet. */ + .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..9e5ec74 --- /dev/null +++ b/internal/web/templates/app.html @@ -0,0 +1,91 @@ +{{/* Shared by the queue and history pages. */}} + +{{/* Refreshes keep a failed removal's notice while its confirmation is open. */}} +{{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 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}}

+ + {{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}} + +{{/* Opens the job's removal confirmation, which each page defines. */}} +{{define "remove-button" -}} + +{{- end}} + +{{/* 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 autofocus element after every swap. */}} +{{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..7efee13 --- /dev/null +++ b/internal/web/templates/history.html @@ -0,0 +1,55 @@ +{{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 stay for {{.KeptDays}} days unless Sonarr or Radarr removes them first.

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

No finished downloads

+

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 bb8555e..b3acc48 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..4b2f1a5 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -11,13 +11,80 @@ import ( "github.com/combor/magnetowid/internal/downloader" ) +// chrome is the queue summary every page shows. +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 the URL the page polls. +func (c chrome) Refresh() string { + if c.Page == "history" { + return "/ui/history" + } + return "/ui/queue" +} + +// Every is the page's poll interval. +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, or final size; 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 is the title and episode, for controls and the tab 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 { + // Show a download stopping for a pause as queued. + 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 failed job's Started is 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..bc7d9c8 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: sign-in, and live queue and +// history pages with download controls. // // 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 page with an optional notice. htmx requests get only the +// refreshing part. +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, "#notice", 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, "#notice", err) + } +} + +func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) { + 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) + 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 requests get its new state, plain +// forms a redirect to it. A failed htmx action's notice goes into the element +// notice selects, which refreshes don't replace. +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) + msg := "Couldn’t " + what + ". The log has the details." + if !htmx(r) { + h.show(w, r, http.StatusInternalServerError, page, msg) + return + } + w.Header().Set("HX-Retarget", notice) + w.Header().Set("HX-Reswap", "innerHTML") + h.render(w, http.StatusInternalServerError, app, "notice", msg) + 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..b3ee447 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 permanently. +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 { @@ -200,6 +238,8 @@ func TestQueuePage(t *testing.T) { "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`, @@ -213,17 +253,145 @@ 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 focuses the first autofocus element after every swap. + 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") 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 +408,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 +493,18 @@ func TestQueueView(t *testing.T) { t.Errorf("state %q, title %q, version %q", v.State(), v.Title(), v.Version) } + // A download stopping for a pause is shown as queued. + 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 +512,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 +526,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 +537,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 +554,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 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 +611,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"},