Compare commits

...

13 Commits

Author SHA1 Message Date
Miguel Palhas 070fd3d7ba Merge blitz/feedback-2: fmt fix and attention queue 2026-08-25 10:58:07 +01:00
Miguel Palhas 0b4ded3fe9 feat(web): icon-only controls and readouts (#230)
Trash replaces the remove labels on title pages and season and
episode rows, a magnifier replaces the deck control, the season
on-disk readout carries a drive glyph before its 0/10, and the
TMDB, TVDB, IMDb and Rotten Tomatoes links carry drawn marks
shipped inline (wordmark badges plus a tomato), never remote
images. Every icon-only control keeps an aria-label naming the
action and the row or title it acts on, and the arm-then-confirm
delete speaks a visible amber "confirm" while armed, so the first
hit never destroys.

Shared CSS lives in one block in style.css headed
"icon-only controls (#230)" — .icon, .control-icon, .icon-mark —
for #231 to consume for the settings rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:58:07 +01:00
Miguel Palhas afc17ca34a Merge #238: queue only targets still waiting for a file
Closes #238
2026-08-25 10:50:56 +01:00
Miguel Palhas 6847d25cf5 feat: queue only targets still waiting for a file
The needs-a-decision queue had no liveness condition on the season lane
and none at all in the API reader, so a season pack that hard-failed
twice, fell back to per-episode grabbing exactly as §6.2 intends, and was
then fully acquired kept notifying for 30 days, and
`GET /api/queues/attention` listed titles the daemon never notified on.

DESIGN.md §5.7 now states the third face of the same rule alongside the
count and the window: a movie or an episode is queued while `wanted` and
not `available`; a season, holding no intent of its own (§4.1), while at
least one of its episodes is. Both readers apply it on all three lanes.

`just ci` passed through the gate.
2026-08-25 10:49:02 +01:00
Miguel Palhas 50056a2bd9 style(api): reformat two error arms
Pre-existing rustfmt drift on the branch base; `just ci` fails on it
before reaching anything else.
2026-08-25 10:48:56 +01:00
Miguel Palhas c962998a2b Merge #229: drop the back button, banner meets the rail
Closes #229
2026-08-25 10:42:05 +01:00
Miguel Palhas 815a072ef0 fix(web): drop back button from movie and series pages (#229)
The rail, browser back, and Esc already cover navigation — the back
button was a third way to do what two other things already do, and it
cost the page its first line.

- Remove #movie-back and #series-back buttons and their event listeners
- Focus lands on the title element (tabindex=-1) instead of the removed
  button on open
- TV releases back button is untouched
- Hero banner now meets the rail with zero top padding on movie/series
- Esc and parent-route behaviour unchanged
- Deep link fallback for TV releases uses #nav-library instead of the
  removed #series-back
2026-08-25 10:40:37 +01:00
Miguel Palhas 690eaeda5c fix(api): let a stranded folder be retried, not refused
Two findings from the integration review of this milestone, both caused
by two sessions editing the same code without seeing each other.

The retry that relocate.rs documents did not converge. The conflict
pre-check ran over every planned rename, including renames whose source
was already gone, and the skip for a missing source came after it. An
undo is best-effort, so a failed move can leave one folder at the
destination with its row still naming the source; every later attempt
then 409'd against the operator's own half-moved library and the only
way out was moving the folder back by hand. The pre-check now skips a
rename whose source is absent, which is what the perform loop already
did. Verified: the new test returns 409 without the change and 200 with.

ApiError::Filesystem rendered as "files not removed: {error}". That was
written for the delete lane; #228 and #236 then returned the same
variant for move failures, so a root path change with one unwritable
folder reported "files not removed" after an operation that removed
nothing. The variant now renders the caller's message and the two
delete lanes carry their own context.
2026-08-25 10:29:38 +01:00
Miguel Palhas d23ae0ebcf Merge #236: move title folders on root path change
Closes #236
2026-08-25 10:21:01 +01:00
Miguel Palhas efb47d64e7 feat(api): move title folders on root path change
Changing a root's path rewrote the row and moved nothing, so every title
under it was mislocated at once. It now reuses the #228 mover: plan every
rename, refuse a destination that already exists, rewrite the media_files
rows in the same transaction as the row change.

The move is all or nothing. A root row carries one path, so a half-moved
library would have to describe both places; instead one folder that
cannot move puts back the ones that already did and leaves the root's
path alone, and the same request is the retry.

just ci ran clean through the gate: 498 tests passed.
2026-08-25 10:20:37 +01:00
Miguel Palhas 442ee3b022 Merge #226: require two recent failures to queue a season
Closes #226
2026-08-25 10:10:50 +01:00
Miguel Palhas ac0e80c044 feat: require two recent failures to queue a season
The season branch of the attention queue listed a season on one failed
grab of any age, so `GET /api/queues/attention` returned Rick and Morty
with every season it has and buried the one that needed attention.

Two changes, both stated in DESIGN.md §5.7:

- The season branch now enforces the same bar the episode branch does:
  two grabs that hard-failed on *different* releases.
- A failed grab counts toward the queue for 30 days
  (`arr_db::ATTENTION_WINDOW`). Nothing clears a `grabs` row, so without
  a window the queue only grows and can never be emptied. #181 gave the
  pack guard a backoff curve for the same reason; this is the queue's
  version of §6.2's "it never gives up entirely, it goes quiet". A
  season the operator dealt with stops failing and drops out; one still
  breaking keeps failing (the pack guard retries at worst weekly) and
  stays.

The window applies to all three hard-fail lanes — movie, episode and
season — because DESIGN.md states one rule for the queue, and to the
daemon's needs-a-decision notifier as well as the API, since both read
the same queue and a season-per-failure notification is the same noise
on a different channel. No schema change: `grabs.grabbed_at` already
carries the timestamp.

Gate: `just ci` green (486 tests).
2026-08-25 10:09:07 +01:00
Miguel Palhas 9fc7272765 Merge #228: move title files on root change
Closes #228
2026-08-25 10:02:00 +01:00
21 changed files with 1731 additions and 212 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"describe": {
"columns": [
{
@@ -94,5 +94,5 @@
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -170,7 +170,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -190,5 +190,5 @@
true
]
},
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
"hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -71,7 +71,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -82,5 +82,5 @@
false
]
},
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
"hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -82,7 +82,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -94,5 +94,5 @@
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
"hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
"hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
}
+34 -1
View File
@@ -378,6 +378,39 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3.
**Two hard failures make a decision, and only for 30 days.** A movie, an
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
against *different* releases hard-failed on it, and both of those failures
happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working.
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
without it the queue only ever grows and the one season that wants attention
sits behind eight that were dealt with months ago. It is the queue's version of
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
dealt with stops producing failures and drops out once the last one ages past
30 days, while a target that is still broken keeps producing them — the pack
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
state is stored, so there is no second thing to keep correct.
**Only a target still waiting for a file is queued.** The count and the window
are one rule seen twice — the queue is meant to be visited and emptied — and
this is its third face: a failure history queues a target only while that
target still has a gap to fill. A movie or an episode is queued while it is
`wanted` and not `available`. A season holds no intent of its own (§4.1), so it
is queued while at least one of its episodes is still wanted and still without
a file. A season pack that hard-failed twice, fell back to per-episode grabbing
exactly as §6.2 says it should, and was then fully acquired leaves at once
rather than waiting out the 30 days — that is the system working, not a
decision. A target that is still broken keeps producing failures and stays.
The same rule applies on all three lanes and in both readers.
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
views of one queue; filtering differently tells the operator two different
stories on two channels.
## 6. Sourcing
### 6.1 Prowlarr, per-indexer Torznab
@@ -488,7 +521,7 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
+411 -49
View File
@@ -199,12 +199,12 @@ impl IntoResponse for ApiError {
tracing::error!(%error, "API database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
}
// The message is the caller's: this variant is returned by the
// delete lane and by the relocate lane, and "files not removed"
// is a lie about a move that failed.
Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("files not removed: {error}"),
)
(StatusCode::INTERNAL_SERVER_ERROR, error.clone())
}
};
(status, Json(ErrorBody { error })).into_response()
@@ -570,7 +570,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
@@ -580,7 +580,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
}
}
Ok(())
@@ -770,6 +770,12 @@ pub async fn grab(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in
// full — two failures on *different* releases, both inside
// `ATTENTION_WINDOW`, against a target still waiting for a file — so this
// endpoint and the daemon's notifier report the same queue. Deliberately not
// a doc comment: utoipa would fold it into the OpenAPI description and drift
// the committed document.
#[utoipa::path(
get, path = "/api/queues/attention", tag = "movies",
responses(
@@ -782,7 +788,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
.fetch_all(pool(&state)?)
.await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -798,34 +804,75 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
/// The TV lanes of the attention queues (§9.5): one entry per series with
/// the episodes and seasons that put it there. The two hard-fail conditions
/// share a lane; a series arriving through both is merged into one entry.
///
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
/// releases, both inside `ATTENTION_WINDOW`, against a target still waiting
/// for a file. One bad torrent is not a decision (#226), a failure the
/// operator already dealt with ages out instead of sitting in the queue
/// forever (#226), and a target since acquired leaves at once (#238). Seasons
/// hold no intent of their own (§4.1), so the season branch reads liveness
/// off its episodes: it is queued while any of them is still wanted and still
/// without a file. The daemon's notifier filters identically.
async fn tv_attention(
state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
let database = pool(state)?;
Ok((
tv_no_pt_source_lane(database).await?,
tv_hard_fail_lane(database).await?,
))
}
let no_pt_rows = sqlx::query!(
/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose
/// every candidate release was rejected for language.
async fn tv_no_pt_source_lane(
database: &sqlx::SqlitePool,
) -> Result<Vec<SeriesAttention>, ApiError> {
let rows = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
)
.fetch_all(database)
.await?;
let mut entries: Vec<SeriesAttention> = Vec::new();
for row in rows {
merge_episode(
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
Ok(entries)
}
/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed
/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a
/// file. The season half reads that last condition off its episodes, which is
/// where intent lives (§4.1). Both halves merge into one entry per series.
async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAttention>, ApiError> {
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
@@ -837,9 +884,12 @@ async fn tv_attention(
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database)
.await?;
@@ -852,30 +902,24 @@ async fn tv_attention(
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1 AND e.state != 'available'
)
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database)
.await?;
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
for row in no_pt_rows {
merge_episode(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
let mut entries: Vec<SeriesAttention> = Vec::new();
for row in episode_hard_fails {
merge_episode(
&mut tv_needs_decision,
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
@@ -887,7 +931,7 @@ async fn tv_attention(
}
for row in season_pack_fails {
merge_season(
&mut tv_needs_decision,
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
@@ -896,8 +940,7 @@ async fn tv_attention(
row.season_number,
);
}
Ok((tv_no_pt_source, tv_needs_decision))
Ok(entries)
}
/// One more qualifying season for its series, creating the series' entry on
@@ -1996,10 +2039,328 @@ mod tests {
);
}
/// A series with one season holding a single wanted, missing episode — the
/// least that satisfies §5.7's liveness condition — for exercising the
/// season lane on its own.
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids tv root");
let series_id: i64 = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?)
RETURNING id",
)
.bind(tmdb_id)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series")
.get(0);
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 1, 'Episode 1', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("episode");
(series_id, season_id)
}
async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 {
sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
)
.bind(guid)
.fetch_one(pool)
.await
.expect("release")
.get(0)
}
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
/// can be exercised without waiting a month.
async fn insert_failed_grab(
pool: &sqlx::SqlitePool,
release_id: i64,
target_kind: &str,
target_id: i64,
infohash: &str,
age_days: i64,
) {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(infohash)
.bind(format!("-{age_days} days"))
.execute(pool)
.await
.expect("failed grab");
}
/// The seasons `GET /api/queues/attention` currently reports for a series.
async fn queued_seasons(base: &str, series_id: i64) -> Vec<i64> {
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.json()
.await
.expect("queues json");
queues["tv_needs_decision"]
.as_array()
.expect("tv lane")
.iter()
.filter(|entry| entry["series_id"] == series_id)
.flat_map(|entry| {
entry["seasons"]
.as_array()
.expect("seasons")
.iter()
.map(|season| season["id"].as_i64().expect("season id"))
})
.collect()
}
/// §5.7: one failed pack is the blacklist working, not a decision. The
/// season lane holds to the same two-distinct-releases bar the episode
/// lane does (#226).
#[tokio::test]
async fn a_season_queues_only_on_two_distinct_release_failures() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
let first = insert_release(pool, "pack-one").await;
insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"one failed pack is not a decision"
);
// A second failure on the *same* release is still one release.
insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"two grabs of one release are not two releases"
);
let second = insert_release(pool, "pack-two").await;
insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await;
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"two distinct releases hard-failed: the operator decides"
);
}
/// §5.7: a failure counts for 30 days. A season the operator has dealt
/// with stops failing and leaves the queue; one still breaking keeps
/// producing failures and stays (#226).
#[tokio::test]
async fn a_season_failure_ages_out_of_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
for (guid, hash, age) in [
("old-one", "hash-old-one", 40),
("old-two", "hash-old-two", 35),
] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "season", season_id, hash, age).await;
}
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"failures older than the window are history, not attention"
);
let fresh = insert_release(pool, "new-one").await;
insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"one recent failure does not revive two stale ones"
);
let fresher = insert_release(pool, "new-two").await;
insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await;
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"still breaking: back in the queue"
);
}
/// §5.7: the queue only holds targets still waiting for a file. A season
/// whose pack failed twice, fell back to per-episode grabbing (§6.2) and
/// was then fully acquired is the system working, so it drops out at once
/// instead of sitting there for 30 days (#238).
#[tokio::test]
async fn a_fully_acquired_season_leaves_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 2, 'Episode 2', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("second episode");
for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await;
}
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"two packs failed and episodes are still missing"
);
// Per-episode grabbing got one of the two. Still a gap, still queued.
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
.bind(season_id)
.execute(pool)
.await
.expect("first episode imported");
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"one episode still wanted and missing: still broken, still queued"
);
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
.bind(season_id)
.execute(pool)
.await
.expect("season imported");
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"every episode acquired: the fallback worked, no decision to make"
);
}
/// §5.7: the same liveness condition on the movie and episode lanes, so
/// `GET /api/queues/attention` reports exactly what the daemon notifies
/// on (#238).
#[tokio::test]
async fn an_acquired_movie_or_episode_leaves_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids root");
let movie = add_movie(&base, 82728, root_id).await;
let movie_id = movie["id"].as_i64().expect("id");
for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await;
}
let (series_id, season_id) = seed_bare_season(pool, 99).await;
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(pool)
.await
.expect("episode id");
for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await;
}
let queues = attention_queues(&base).await;
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
assert_eq!(
queued_episodes(&queues, series_id),
vec![episode_id],
"still wanted and missing: queued"
);
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
.bind(movie_id)
.execute(pool)
.await
.expect("movie imported");
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
.bind(episode_id)
.execute(pool)
.await
.expect("episode imported");
let queues = attention_queues(&base).await;
assert_eq!(
queues["needs_decision"].as_array().map(Vec::len),
Some(0),
"imported from a third release: no decision to make"
);
assert!(
queued_episodes(&queues, series_id).is_empty(),
"imported from a third release: no decision to make"
);
// Withdrawing intent empties the lane just the same.
sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?")
.bind(movie_id)
.execute(pool)
.await
.expect("movie unwanted");
assert_eq!(
attention_queues(&base).await["needs_decision"]
.as_array()
.map(Vec::len),
Some(0),
"nothing is waiting for a file"
);
}
/// The whole attention payload.
async fn attention_queues(base: &str) -> serde_json::Value {
reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.json()
.await
.expect("queues json")
}
/// The episodes a series contributes to the hard-fail TV lane.
fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec<i64> {
queues["tv_needs_decision"]
.as_array()
.expect("tv lane")
.iter()
.filter(|entry| entry["series_id"] == series_id)
.flat_map(|entry| {
entry["episodes"]
.as_array()
.expect("episodes")
.iter()
.map(|episode| episode["id"].as_i64().expect("episode id"))
})
.collect()
}
/// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a
/// season pack that hard-failed; and an episode two different releases
/// hard-failed on.
/// season two different packs hard-failed on; and an episode two different
/// releases hard-failed on.
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
@@ -2051,6 +2412,7 @@ mod tests {
.expect("episode id");
for (kind, guid, suffix) in [
("season", "pack", "pack"),
("season", "pack-two", "pack2"),
("episode", "first", "a"),
("episode", "second", "b"),
] {
+143 -32
View File
@@ -1,7 +1,8 @@
//! Moving a title between roots (issue #228). Changing a title's `root_id`
//! renames its §7.4 folder into the new root and rewrites the `media_files`
//! rows to match, so the layout keeps describing the disk and the new root's
//! policy (§5.1) applies to a library the files are actually in.
//! Moving library files when the layout under them changes: a title changing
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
//! the layout keeps describing the disk and the root's policy (§5.1) applies
//! to a library the files are actually in.
//!
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
//! is a directory rename, never a copy. Hardlinked files keep their inodes
@@ -10,6 +11,16 @@
//! Ordering mirrors `remove_library_files`: the disk is touched before the
//! row changes, so a failed rename leaves the title where its files actually
//! are and the operator can retry.
//!
//! A root path change is the same move repeated over every title under the
//! root, and it is all or nothing. If the seventh of ten folders fails to
//! move, the six already renamed are moved back and the root row is left
//! alone: a root row carries one path, so a half-moved library would have to
//! describe both, and neither the operator nor the next import could tell
//! which titles were where. Refusing leaves one answer — everything is still
//! at the old path — and the retry is the same request again. A retry after
//! an undo that itself failed still converges, because a source that is no
//! longer on disk is skipped while its row is still rewritten.
use std::path::PathBuf;
@@ -40,6 +51,15 @@ pub(crate) struct Relocation {
rewrites: Vec<(i64, String)>,
}
/// Whether the destination root is a directory that must already be there.
/// A title moves into another configured root, which exists; a root moving to
/// a new path is moving somewhere that need not exist yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Destination {
Existing,
Create,
}
/// Rename the title's folders into the new root. Called before the row is
/// written, and only when the root actually changes.
///
@@ -62,11 +82,45 @@ pub(crate) async fn relocate_title(
let old_root = root_path(state, old_root_id).await?;
let new_root = root_path(state, new_root_id).await?;
let files = title_files(state, kind, title_id).await?;
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
}
/// Rename every title folder under a root into the root's new path, for a
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
///
/// A root with no titles under it — or whose titles have nothing on disk —
/// changes path with no filesystem work at all. The new path is created when
/// there is something to move into it, since a root is normally pointed at a
/// directory that does not exist yet.
///
/// # Errors
///
/// The same two as [`relocate_title`], with the whole root's move treated as
/// one unit: one folder that cannot move takes the entire change down and
/// moves back whatever had already moved.
pub(crate) async fn relocate_root(
state: &AppState,
root_id: i64,
old_path: &str,
new_path: &str,
) -> Result<Relocation, ApiError> {
let files = root_files(state, root_id).await?;
relocate_files(&files, old_path, new_path, Destination::Create).await
}
/// The one mover both callers share: plan every rename, refuse every
/// destination that already exists, then perform them, undoing what was
/// performed if one fails.
async fn relocate_files(
files: &[(i64, String)],
old_root: &str,
new_root: &str,
destination: Destination,
) -> Result<Relocation, ApiError> {
let mut renames: Vec<PlannedRename> = Vec::new();
let mut rewrites: Vec<(i64, String)> = Vec::new();
for (file_id, path) in &files {
let Some(source) = title_target(&old_root, path) else {
for (file_id, path) in files {
let Some(source) = title_target(old_root, path) else {
// Outside its own root: not ours to move, and the row keeps
// pointing at where the file really is.
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
@@ -75,7 +129,7 @@ pub(crate) async fn relocate_title(
let Some(name) = source.file_name() else {
continue;
};
let destination = std::path::Path::new(&new_root).join(name);
let destination = std::path::Path::new(new_root).join(name);
if !renames.iter().any(|rename| rename.source == source) {
renames.push(PlannedRename {
source,
@@ -83,9 +137,9 @@ pub(crate) async fn relocate_title(
});
}
let relative = std::path::Path::new(path)
.strip_prefix(std::path::Path::new(&old_root))
.strip_prefix(std::path::Path::new(old_root))
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
let rewritten = std::path::Path::new(&new_root).join(relative);
let rewritten = std::path::Path::new(new_root).join(relative);
let Some(rewritten) = rewritten.to_str() else {
return Err(ApiError::Filesystem(format!(
"non-UTF-8 path under {new_root}"
@@ -95,12 +149,22 @@ pub(crate) async fn relocate_title(
}
// Every destination is checked before anything is renamed, so a conflict
// never leaves a half-moved title behind.
// never leaves a half-moved library behind. A rename whose source is
// already gone is skipped here rather than refused: the perform loop below
// skips it too, and refusing it would make the documented retry
// impossible. After an undo that itself failed, the folder sits at the
// destination while the row still names the source, and every later
// attempt would 409 on a conflict with the operator's own half-moved
// library.
for rename in &renames {
match tokio::fs::symlink_metadata(&rename.source).await {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
_ => {}
}
match tokio::fs::symlink_metadata(&rename.destination).await {
Ok(_) => {
return Err(ApiError::Conflict(format!(
"the destination root already has '{}'",
"the destination already has '{}'",
rename.destination.display()
)))
}
@@ -109,6 +173,14 @@ pub(crate) async fn relocate_title(
}
}
if destination == Destination::Create && !renames.is_empty() {
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
return Err(ApiError::Filesystem(format!(
"could not create '{new_root}': {error}"
)));
}
}
let mut performed: Vec<PlannedRename> = Vec::new();
for rename in renames {
// A recorded file with nothing on disk: the rows still follow the
@@ -123,36 +195,18 @@ pub(crate) async fn relocate_title(
);
continue;
}
Err(error) => {
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
Err(error) => return Err(failed(&rename, &error, performed).await),
}
match tokio::fs::rename(&rename.source, &rename.destination).await {
Ok(()) => {
tracing::info!(
source = %rename.source.display(),
destination = %rename.destination.display(),
"moved title folder between roots"
"moved a title folder"
);
performed.push(rename);
}
Err(error) => {
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
Err(error) => return Err(failed(&rename, &error, performed).await),
}
}
@@ -162,6 +216,26 @@ pub(crate) async fn relocate_title(
})
}
/// One rename failed: move back everything that had already moved and name
/// the folder that stopped the move, so the operator knows which title to
/// look at before retrying.
async fn failed(
rename: &PlannedRename,
error: &std::io::Error,
performed: Vec<PlannedRename>,
) -> ApiError {
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
ApiError::Filesystem(format!(
"could not move '{}': {error}",
rename.source.display()
))
}
impl Relocation {
/// Whether anything on disk actually moved — the trigger for the same
/// single Jellyfin refresh import performs (§7.5).
@@ -224,6 +298,43 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
.await?)
}
/// Every file the service recorded under a root: the rows of every movie in
/// it, and the rows of every episode of every series in it. Ordered so the
/// renames happen in a stable order, which is what makes a failure part-way
/// through reproducible.
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
let mut files: Vec<(i64, String)> = sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
WHERE m.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect();
files.extend(
sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE s.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path)),
);
Ok(files)
}
/// Every file the service recorded for the title: a movie's own rows, or the
/// rows of every episode below a series.
async fn title_files(
+503 -23
View File
@@ -172,26 +172,74 @@ pub async fn update(
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let current = load_root(&state, id).await?;
let path = input.path.trim().to_owned();
let result = sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(pool(&state)?)
.await
.map_err(root_conflict)?;
if result.rows_affected() == 0 {
return Err(ApiError::RootNotFound);
// A path change moves every §7.4 title folder under this root with the
// row (issue #236), the same way changing a title's root moves one
// (#228). Disk first, row second: a failed rename leaves the root row
// alone, so the operator sees the library where its files actually are
// and can retry. A path already taken is refused before any of it, since
// the write would fail afterwards anyway.
let relocation = if path == current.path {
None
} else {
path_is_free(&state, id, &path).await?;
Some(crate::relocate::relocate_root(&state, id, &current.path, &path).await?)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(&mut *transaction)
.await?;
if let Some(relocation) = &relocation {
relocation.rewrite_rows(&mut transaction).await?;
}
Ok(())
}
.await;
let committed = match written {
Ok(()) => transaction.commit().await.map_err(root_conflict),
Err(error) => Err(root_conflict(error)),
};
if let Err(error) = committed {
if let Some(relocation) = &relocation {
relocation.undo().await;
}
return Err(error);
}
if relocation
.as_ref()
.is_some_and(crate::relocate::Relocation::moved_files)
{
crate::relocate::refresh_jellyfin(&state).await;
}
Ok(Json(load_root(&state, id).await?))
}
/// The unique index on `path` would catch this after the move; catching it
/// first keeps a doomed write from touching the disk at all.
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
let taken: Option<i64> =
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
.fetch_optional(pool(state)?)
.await?;
if taken.is_some() {
return Err(ApiError::Conflict(
"a root with this path already exists".into(),
));
}
Ok(())
}
/// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError {
@@ -258,7 +306,7 @@ mod tests {
use crate::{router, AppState, Upstreams};
use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) {
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
@@ -274,14 +322,14 @@ mod tests {
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"))
(dir, state, format!("http://{address}"))
}
#[tokio::test]
async fn roots_carry_their_policy_name() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -325,7 +373,7 @@ mod tests {
#[tokio::test]
async fn a_root_round_trips_through_create_and_update() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
@@ -399,7 +447,7 @@ mod tests {
#[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [
@@ -428,7 +476,7 @@ mod tests {
#[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let mut path = root_input(1);
path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids");
@@ -454,7 +502,7 @@ mod tests {
#[tokio::test]
async fn a_root_with_titles_refuses_to_die() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -495,4 +543,436 @@ mod tests {
.expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
}
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": title, "year": 2024,
"original_language": "en", "root_id": root_id,
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
created["id"].as_i64().expect("movie id")
}
/// One movie's §7.4 folder on disk with a feature in it, and the
/// `media_files` row that points at the feature.
async fn library_folder(
state: &AppState,
movie_id: i64,
root: &std::path::Path,
folder: &str,
) -> std::path::PathBuf {
let folder = root.join(folder);
tokio::fs::create_dir_all(&folder)
.await
.expect("create title folder");
let feature = folder.join("feature.mkv");
tokio::fs::write(&feature, b"feature").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
)
.bind(movie_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
folder
}
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
serde_json::json!({
"kind": root["kind"],
"audience": root["audience"],
"path": path,
"policy_id": root["policy_id"],
})
}
async fn file_paths(state: &AppState) -> Vec<String> {
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
.fetch_all(state.database().expect("database").pool())
.await
.expect("media files")
}
async fn stored_path(base: &str, id: i64) -> String {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
root["path"].as_str().expect("path").to_owned()
}
/// Issue #236: changing a root's path renames every §7.4 title folder
/// under it into the new path and rewrites the `media_files` rows in the
/// same write. Titles under other roots are not this root's business.
#[tokio::test]
async fn changing_a_root_path_moves_every_title_under_it() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let other = tempfile::tempdir().expect("other root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, other.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
// The new path need not exist yet — pointing a root somewhere fresh
// is the ordinary case.
let new = old.path().parent().expect("parent").join("relocated-main");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
for folder in &folders {
assert!(!folder.exists(), "{} left the old path", folder.display());
}
for title in ["Dune", "Arrival", "Sicario"] {
assert!(
new.join(title).join("feature.mkv").exists(),
"{title} arrived under the new path"
);
}
assert!(
untouched.join("feature.mkv").exists(),
"a title under another root is untouched"
);
let paths = file_paths(&state).await;
for path in paths.iter().take(3) {
assert!(
std::path::Path::new(path).starts_with(&new),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(path).exists(),
"the rewritten path describes the disk: {path}"
);
}
assert!(
std::path::Path::new(&paths[3]).starts_with(other.path()),
"the other root's row is untouched: {}",
paths[3]
);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
tokio::fs::remove_dir_all(&new).await.expect("clean up");
}
/// Issue #236: a root with nothing under it changes path with no
/// filesystem work at all — the new path is not even created.
#[tokio::test]
async fn an_empty_root_changes_path_with_no_filesystem_work() {
let (_dir, state, base) = application().await;
let home = tempfile::tempdir().expect("home");
let new = home.path().join("nothing-here");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
assert!(!new.exists(), "nothing was created on disk");
assert!(file_paths(&state).await.is_empty());
}
/// Issue #236: a destination already holding a folder of that name is a
/// conflict, not an overwrite, and it is caught before anything moves.
#[tokio::test]
async fn a_squatted_destination_refuses_the_whole_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
tokio::fs::create_dir_all(new.path().join("Arrival"))
.await
.expect("squatter");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(dune.exists(), "not even the first folder moved");
assert!(arrival.exists());
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
}
/// Integration review of the Feedback pass 2 blitz: the retry the module
/// documents has to actually converge. An undo is best-effort, so a move
/// can fail and leave one folder at the destination with its row still
/// naming the source. Every later attempt used to 409 against the
/// operator's own half-moved library, and the only way out was moving the
/// folder back by hand.
#[tokio::test]
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
// What a failed undo leaves behind: Arrival is already at the new
// path, its row still points at the old one, and the root row was
// never changed.
tokio::fs::create_dir_all(new.path())
.await
.expect("the new root");
tokio::fs::rename(&arrival, new.path().join("Arrival"))
.await
.expect("strand the folder");
assert!(!arrival.exists());
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(
response.status(),
StatusCode::OK,
"the stranded folder is the state we wanted, not a conflict"
);
assert!(!dune.exists(), "the folder still on disk moved");
assert!(new.path().join("Dune").exists());
assert!(new.path().join("Arrival").exists(), "left where it was");
let paths = file_paths(&state).await;
let root = new.path().to_str().expect("utf-8");
assert!(
paths.iter().all(|path| path.starts_with(root)),
"every row follows the root, the stranded one included: {paths:?}"
);
assert_eq!(stored_path(&base, 1).await, root);
}
/// Issue #236: the move is all or nothing. A folder that cannot be
/// renamed part-way through takes the whole change down: what had already
/// moved is moved back, the root row keeps the old path, and the rows
/// still describe the disk, so the operator can fix the folder and send
/// the same request again.
#[cfg(unix)]
#[tokio::test]
async fn one_folder_that_cannot_move_puts_the_others_back() {
use std::os::unix::fs::PermissionsExt;
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-main");
point_root_at(&state, 1, old.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
// Moving a directory to another parent rewrites its `..`, which needs
// write permission on the directory itself: the second title cannot
// move, the first already has.
let stuck = folders[1].clone();
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the second title folder");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"]
.as_str()
.expect("text")
.contains(stuck.to_str().expect("utf-8")),
"the error names the folder that stopped the move: {body}"
);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the second title folder");
for folder in &folders {
assert!(
folder.join("feature.mkv").exists(),
"{} is back where the row says it is",
folder.display()
);
}
assert!(
new.exists(),
"the move reached the disk: the new path was created for it"
);
assert!(
!new.join("Dune").exists(),
"the folder that had already moved was moved back"
);
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
// The same request again, with the folder fixed, is the retry path.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(response.status(), StatusCode::OK);
for title in ["Dune", "Arrival", "Sicario"] {
assert!(new.join(title).join("feature.mkv").exists());
}
}
/// Issue #236: a TV root carries series folders, whose files hang off
/// episodes rather than off the title row. They move with the root too.
#[tokio::test]
async fn a_tv_root_moves_its_series_folders() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-tv");
point_root_at(&state, 3, old.path()).await;
let pool = state.database().expect("database").pool();
sqlx::query(
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
)
.execute(pool)
.await
.expect("series");
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
.execute(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
)
.execute(pool)
.await
.expect("episode");
let season = old
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create season folder");
let episode = season.join("Bluey - S01E02.mkv");
tokio::fs::write(&episode, b"episode").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
)
.bind(episode.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/3"))
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert!(
new.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey - S01E02.mkv")
.exists(),
"the whole series folder moved, season layout intact"
);
let paths = file_paths(&state).await;
assert!(
std::path::Path::new(&paths[0]).starts_with(&new)
&& std::path::Path::new(&paths[0]).exists(),
"the episode row follows the file: {}",
paths[0]
);
}
/// Issue #236: a path another root already holds is refused before the
/// disk is touched at all.
#[tokio::test]
async fn a_path_another_root_holds_is_refused_before_any_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let taken = tempfile::tempdir().expect("taken root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, taken.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "nothing moved");
assert!(!taken.path().join("Dune").exists());
}
}
+2 -2
View File
@@ -734,7 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
@@ -744,7 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
}
}
Ok(())
+215 -12
View File
@@ -2,6 +2,15 @@
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
//! waiting for a file. One bad torrent is not a decision, a failure already
//! dealt with ages out (#226), and a target that has since been acquired
//! leaves at once (#238). The season lane reads that last condition off its
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
//! filters identically, or the two channels tell the operator different
//! stories.
//!
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
@@ -34,8 +43,8 @@ struct TvEntry {
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
/// Seasons two different pack releases hard-failed on (§5.7), sending the
/// season back to per-episode grabbing.
failed_season_packs: Vec<i64>,
}
@@ -59,8 +68,8 @@ impl TvEntry {
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
"season hard-failed twice on different packs",
"seasons hard-failed twice on different packs",
));
}
parts.join("; ")
@@ -134,8 +143,10 @@ impl AttentionAction {
AND (SELECT count(DISTINCT g.release_id)
FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
AND g.state = 'failed') >= 2
"#
AND g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -209,9 +220,14 @@ fn tv_entry(
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// different releases hard-failed post-probe, and seasons two different packs
/// hard-failed on. One entry per series, so the notification can be one per
/// series however long the broken season is.
///
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
/// only while `wanted` and not `available`, and a season only while at least
/// one of its episodes is. A season pack that failed twice and then fell back
/// to per-episode grabbing (§6.2) drops out as those episodes land.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1 AND e.state != 'available'
)
GROUP BY s.id, s.title, s.year, se.id
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -382,6 +408,54 @@ mod tests {
series_id
}
/// A wanted, missing episode: the least that keeps its season live for
/// §5.7's liveness condition.
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
)
.bind(season_id)
.bind(number)
.bind(format!("Episode {number}"))
.fetch_one(database.pool())
.await
.unwrap()
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// stamped `age_days` in the past, so §5.7's window can be exercised
/// without waiting a month.
async fn insert_aged_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
age_days: i64,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
RETURNING id",
)
.bind(release_guid)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.bind(format!("-{age_days} days"))
.execute(database.pool())
.await
.unwrap();
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
@@ -548,9 +622,10 @@ mod tests {
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
// The packs' failures sent this season back to per-episode grabbing;
// they queue the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
@@ -631,4 +706,132 @@ mod tests {
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
/// §5.7: the season lane holds to the same two-distinct-releases bar the
/// episode lane does, so one bad pack does not notify (#226).
#[tokio::test]
async fn one_failed_season_pack_does_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"one failed pack is the blacklist working, not a decision"
);
insert_failed_grab(&database, "season", season_id, "pack-two").await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two distinct packs hard-failed: the operator decides"
);
}
/// §5.7: the queue only holds targets still waiting for a file. A season
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
/// once every episode has landed the system worked, so the season leaves
/// the queue at once rather than notifying for 30 days (#238).
#[tokio::test]
async fn a_fully_acquired_season_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_wanted_episode(&database, season_id, 2).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two packs failed and the season still has episodes missing"
);
// Per-episode grabbing got the first one. Still a gap, still queued.
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"already notified, and still queued"
);
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"every episode acquired: nothing left to decide"
);
// Proof it actually left rather than merely staying quiet: a season
// still queued would not notify again on re-entry.
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"broken again: re-enters the queue and notifies"
);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
/// queue instead of sitting in it forever (#226).
#[tokio::test]
async fn season_failures_older_than_the_window_do_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"failures older than the window are history, not attention"
);
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"still breaking: back in the queue"
);
}
}
+9
View File
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier.
///
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
/// and the one season that wants attention sits behind the ones that do not.
/// Callers pair it with the `grabbed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days";
/// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
+20 -12
View File
@@ -218,10 +218,7 @@
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
@@ -266,12 +263,19 @@
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="remove-panel" hidden></div>
@@ -470,10 +474,7 @@
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
@@ -506,12 +507,19 @@
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
+208 -59
View File
@@ -103,7 +103,7 @@ import {
type TvTarget,
waiveAndGrabTv,
} from "./series";
import { armedDelete, settingsMain } from "./settings";
import { settingsMain } from "./settings";
import "./style.css";
const POLL_MS = 15_000;
@@ -393,14 +393,14 @@ function main() {
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
@@ -1263,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- icon-only controls (#230) ----------------------------------------- */
const SVG_NS = "http://www.w3.org/2000/svg";
const ICON_PATHS = {
trash: [
"M2.8 4.3h10.4",
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
"M6.6 7v4.2",
"M9.4 7v4.2",
],
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
disk: [
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
"M10.9 11.6h.01",
],
} as const;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
for (const d of ICON_PATHS[name]) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "icon icon-mark");
svg.setAttribute("aria-hidden", "true");
if (name === "rt") {
svg.setAttribute("viewBox", "0 0 20 20");
const paths = [
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
"M10 6.6c1.4.5 3 .2 4.1-.8",
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
];
for (const d of paths) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
svg.setAttribute("viewBox", "0 0 44 20");
const box = document.createElementNS(SVG_NS, "rect");
box.setAttribute("x", "1");
box.setAttribute("y", "1.5");
box.setAttribute("width", "42");
box.setAttribute("height", "17");
box.setAttribute("rx", "4");
const letters = document.createElementNS(SVG_NS, "text");
letters.setAttribute("x", "22");
letters.setAttribute("y", "13.2");
letters.setAttribute("text-anchor", "middle");
letters.setAttribute("font-size", "9");
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
svg.append(box, letters);
return svg;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
* visible word "confirm" while armed, so an accidental hit never destroys.
*/
function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet control-icon";
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = "confirm";
button.setAttribute("aria-label", `confirm — ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
@@ -1281,20 +1393,21 @@ function starIcon(): SVGSVGElement {
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.className = "control control-quiet control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
@@ -1414,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
remove.title = `remove ${movie.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
@@ -1606,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
const linkTitle = current?.title ?? "this movie";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbMovieLink(detail.tmdb_id),
),
);
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
actionsEl.append(
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
);
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${current.title}`,
rottenTomatoesSearch(current.title, current.year),
),
);
}
}
@@ -1906,7 +2034,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -1929,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
@@ -3125,7 +3252,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
@@ -3230,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
remove.title = `remove ${current.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -3332,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
const linkTitle = series?.title ?? "this series";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbSeriesLink(detail.tmdb_id),
),
);
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
actionsEl.append(
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
);
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${series.title}`,
rottenTomatoesSearch(series.title, series.year),
),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
const span = countsChip(
`${counts.available}/${counts.wanted}`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
span.prepend(icon("disk"));
return span;
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -3418,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel =
season.number === 0
? "open the specials release deck"
: `open the season ${PAD_TWO(season.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3452,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
const clear = armedDeleteIcon(
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
() => {
const currentId = seriesId;
if (currentId === null) {
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
},
);
line.append(clear);
}
@@ -3539,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
});
clear.setAttribute(
"aria-label",
const clear = armedDeleteIcon(
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
() => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
},
);
actions.append(clear);
} else if (!aired) {
@@ -3584,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3793,7 +3943,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -3814,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
+62
View File
@@ -470,6 +470,62 @@ body {
outline-offset: 2px;
}
/* ---- icon-only controls (#230) ---------------------------------------- */
/* One block, shared: title pages, decks and readouts here, the settings
rows via #231. The glyph carries the action, the aria-label carries the
meaning, and the geometry stays on the --control-h rail with the chips. */
.icon {
display: block;
flex: none;
height: 0.875rem;
width: auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.control-icon {
min-width: var(--control-h);
padding: 0 var(--space-2);
}
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
monochrome on currentColor — never a remote image */
.icon-mark {
height: 1.125rem;
}
.icon-mark rect {
stroke-width: 1.6;
}
/* the mark's letters live in viewBox units, so the size rides the SVG
scale as an attribute; only voice and weight come from the tokens */
.icon-mark text {
fill: currentColor;
stroke: none;
font-family: var(--font-readout);
font-weight: 700;
letter-spacing: 0.06em;
}
/* a readout chip's glyph sits beside its figure, sized to the readout */
.chip .icon {
height: 0.75rem;
stroke-width: 1.7;
}
/* rows widen every .control's padding; an icon control stays near-square */
.season-line .control-icon,
.ep-actions .control-icon,
.movie-controls .control-icon {
padding: 0 var(--space-2);
}
/* ---- unified search (§9.2) ------------------------------------------- */
.rail-search {
@@ -528,6 +584,12 @@ body {
flex: 1;
}
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status {
margin: 0 0 var(--space-4);
color: var(--ink-muted);