Compare commits

..

22 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
Miguel Palhas bce3d3823d feat(api): move title files on root change
Changing a movie's or series' root previously rewrote root_id and left
the files behind, so the §7.4 layout stopped describing the disk and
the root's policy applied to a library the files were not in. Series
had no root control at all.

All roots share one ZFS dataset, so the move is a rename of the title
folder into the new root, never a copy — hardlinks and the seeding
torrent survive it (§7.3). Disk first, row second: a destination that
already holds the folder is a 409, a failed rename leaves the row
unchanged, and a title with nothing on disk moves with no filesystem
work. media_files rows are rewritten in the same transaction as the
root_id, and a successful move triggers the §7.5 Jellyfin refresh.

Issue #228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:55:18 +01:00
Miguel Palhas c64c572781 refactor(arr): give arr-api its own jellyfin client
arr-daemon depends on arr-api, so a handler in arr-api can never
reach the daemon's private JellyfinClient. Move it into arr-api and
attach an instance to AppState, so a manual subtitle write can ask
for the same refresh import already does (#195).
2026-08-25 09:47:53 +01:00
Miguel Palhas 8c3e1c4a92 Merge milestone 'Size bands and waivers'
ci / web (push) Successful in 45s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Successful in 1m38s
A size band is now a rate: floor and target scale by the series'
minutes per episode against a 45-minute reference, so a short-form
show is no longer judged against an hour of video. Shipped band
values are unchanged — the reference runtime is chosen so they keep
their meaning. A missing runtime scales by one, and movies are never
scaled.

A size rejection can also be waived. The override relaxes the floor
for one title into a waiver rather than lifting it, so the release
stays out of automatic grabbing and imports on the record (§5.7).

The runtime migration is numbered 0026 to leave 0024 and 0025 to the
concurrent subtitles milestone; two files claiming one version do not
conflict in git and would have reached main unnoticed.

Closes #208, #209, #210
2026-08-24 22:51:55 +01:00
Miguel Palhas bb2708f7ee chore(db): renumber the runtime migration to 0026
The concurrent subtitles milestone carries 0024_subtitles.sql and
0025_subtitle_settings.sql. Two migrations claiming version 24 do not
conflict in git — the filenames differ — so both would land on main and
sqlx would see a duplicate version. Renumbering here is the half that
does not depend on the other milestone acting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:47:18 +01:00
Miguel Palhas 5c5b1e234c Merge #209: scale size bands by episode runtime
Closes #209
2026-08-24 22:47:11 +01:00
Miguel Palhas 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +01:00
Miguel Palhas 024786f356 Merge #210: let a size rejection be waived
Closes #210
2026-08-24 22:16:01 +01:00
Miguel Palhas f0d45996a0 Merge #208: size bands scale with episode runtime
Closes #208
2026-08-24 22:16:01 +01:00
Miguel Palhas 9fee07f080 feat: let a size rejection be waived
A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.

`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.

Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.

Closes #210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 22:15:14 +01:00
57 changed files with 4187 additions and 267 deletions
+9 -3
View File
@@ -29,9 +29,15 @@ reads the library as derived status, not flags.
counts behind cyan show/hide toggles. Release name is a secondary counts behind cyan show/hide toggles. Release name is a secondary
truncated line, full string plus indexer/date/bytes on row expand. Every truncated line, full string plus indexer/date/bytes on row expand. Every
waived/rejected row carries a verdict chip naming the rule. Grab is its waived/rejected row carries a verdict chip naming the rule. Grab is its
own control per row; on a waived row it is "waive + grab" and writes the own control per row; where the rule that failed has an override it reads
per-title override (rule→override mapping in releases.ts — bookkeeping, "waive + grab" and writes the per-title override (rule→override mapping in
never policy; verdicts always come from the API). Esc steps back one releases.ts — bookkeeping, never policy; verdicts always come from the
API). A rejected row offers it too — §9.3's one click exists precisely for
the rule the operator disagrees with, and a size rejection (#210) is only
ever rejected. What the click produces stays a waiver: the row re-reads as
a dashed WAIVED, never eligible. When ELIGIBLE is empty and something is
waivable, its "none" line carries the count, so the way out is not folded
inside a collapsed bucket. Esc steps back one
layer: deck → search results → board. layer: deck → search results → board.
- Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head, - Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head,
never a row affordance — a dense list is the wrong place for a delete. never a row affordance — a dense list is the wrong place for a delete.
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -94,5 +94,5 @@
false false
] ]
}, },
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708" "hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
} }
@@ -0,0 +1,182 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM series s\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE s.id = ?\n ",
"describe": {
"columns": [
{
"name": "overrides!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "overrides"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "root_kind!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "root_audience!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "root_path!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 13,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ", "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -103,8 +103,19 @@
} }
}, },
{ {
"name": "release_name!: String", "name": "runtime_minutes",
"ordinal": 9, "ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 10,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
@@ -127,8 +138,9 @@
false, false,
true, true,
true, true,
true,
false false
] ]
}, },
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc" "hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b" "hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
} }
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_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": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
}
@@ -1,12 +1,12 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Right": 13 "Right": 14
}, },
"nullable": [] "nullable": []
}, },
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331" "hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series WHERE id = ?", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series WHERE id = ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -145,6 +145,17 @@
"name": "vote_average" "name": "vote_average"
} }
} }
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -163,8 +174,9 @@
true, true,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10" "hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -170,7 +170,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -190,5 +190,5 @@
true true
] ]
}, },
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2" "hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5"
} }
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
}
@@ -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"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n WHERE r.id IN (\n SELECT er.release_id\n FROM episode_releases er\n JOIN episodes e ON e.id = er.episode_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n UNION\n SELECT sr.release_id\n FROM season_releases sr\n JOIN seasons se ON se.id = sr.season_id\n WHERE se.series_id = ?\n )\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ", "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -114,8 +114,19 @@
} }
}, },
{ {
"name": "release_name!: String", "name": "runtime_minutes",
"ordinal": 10, "ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 11,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
@@ -139,8 +150,9 @@
false, false,
true, true,
true, true,
true,
false false
] ]
}, },
"hash": "7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4" "hash": "81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b"
} }
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT original_language FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "original_language",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4"
}
@@ -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 WHERE se.series_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": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT runtime_minutes FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -71,7 +71,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -82,5 +82,5 @@
false false
] ]
}, },
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51" "hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -82,7 +82,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -94,5 +94,5 @@
false 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", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -38,7 +38,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -46,5 +46,5 @@
true true
] ]
}, },
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d" "hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -49,7 +49,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -58,5 +58,5 @@
false false
] ]
}, },
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e" "hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1"
} }
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n JOIN movie_releases mr ON mr.release_id = r.id\n WHERE mr.movie_id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -49,7 +49,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 0 "Right": 1
}, },
"nullable": [ "nullable": [
false, false,
@@ -58,5 +58,5 @@
false false
] ]
}, },
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6" "hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -145,6 +145,17 @@
"name": "vote_average" "name": "vote_average"
} }
} }
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -163,8 +174,9 @@
true, true,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644" "hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16"
}
@@ -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"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
}
Generated
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"serde_json", "serde_json",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"utoipa", "utoipa",
+35
View File
@@ -378,6 +378,39 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3. 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. Sourcing
### 6.1 Prowlarr, per-indexer Torznab ### 6.1 Prowlarr, per-indexer Torznab
@@ -488,6 +521,8 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and - **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal. 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 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 During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of. folder it has no record of.
+1
View File
@@ -18,6 +18,7 @@ reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
utoipa = { workspace = true } utoipa = { workspace = true }
@@ -1,8 +1,9 @@
//! Jellyfin library refresh after import. See DESIGN.md §7.5. //! Jellyfin library refresh after import or a subtitle write. See DESIGN.md
//! §7.5 and §15.
//! //!
//! Jellyfin's own filesystem watcher misses the hardlinked file, so import //! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so
//! asks it to rescan directly. A refresh failure must not fail the import — //! callers ask it to rescan directly. A refresh failure must not fail the
//! the caller logs and continues (§7.5). //! caller — it logs and continues (§7.5).
use std::time::Duration; use std::time::Duration;
@@ -27,6 +28,9 @@ pub struct JellyfinClient {
} }
impl JellyfinClient { impl JellyfinClient {
/// # Errors
///
/// If the underlying HTTP client cannot be built.
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> { pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?; let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
Ok(Self { Ok(Self {
@@ -38,6 +42,10 @@ impl JellyfinClient {
/// Trigger a full library scan. Jellyfin exposes no per-library refresh /// Trigger a full library scan. Jellyfin exposes no per-library refresh
/// without knowing that library's ID, which this app never learns. /// without knowing that library's ID, which this app never learns.
///
/// # Errors
///
/// If the request fails, or Jellyfin answers with a non-success status.
pub async fn refresh(&self) -> Result<(), Error> { pub async fn refresh(&self) -> Result<(), Error> {
let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/')); let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/'));
let mut request = self.client.post(url); let mut request = self.client.post(url);
+3
View File
@@ -7,10 +7,13 @@
//! to compile, and the gate in DESIGN.md §12 fails with it. //! to compile, and the gate in DESIGN.md §12 fails with it.
mod health; mod health;
pub mod jellyfin;
mod metadata; mod metadata;
mod movies; mod movies;
mod owners; mod owners;
mod policies; mod policies;
mod reclassify;
mod relocate;
mod roots; mod roots;
mod search; mod search;
mod series; mod series;
+782 -57
View File
@@ -199,12 +199,12 @@ impl IntoResponse for ApiError {
tracing::error!(%error, "API database error"); tracing::error!(%error, "API database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into()) (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) => { Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error"); tracing::error!(%error, "API filesystem error");
( (StatusCode::INTERNAL_SERVER_ERROR, error.clone())
StatusCode::INTERNAL_SERVER_ERROR,
format!("files not removed: {error}"),
)
} }
}; };
(status, Json(ErrorBody { error })).into_response() (status, Json(ErrorBody { error })).into_response()
@@ -240,10 +240,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
}; };
if object if object
.keys() .keys()
.any(|key| key != "only_4k" && key != "allow_english_audio") .any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
{ {
return Err(ApiError::Invalid( return Err(ApiError::Invalid(
"overrides supports only only_4k and allow_english_audio".into(), "overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
)); ));
} }
if object.values().any(|value| !value.is_boolean()) { if object.values().any(|value| !value.is_boolean()) {
@@ -406,8 +406,11 @@ pub async fn update(
if title.trim().is_empty() { if title.trim().is_empty() {
return Err(ApiError::Invalid("title cannot be empty".into())); return Err(ApiError::Invalid("title cannot be empty".into()));
} }
let overrides = input.overrides.unwrap_or(current.overrides); let overrides = input.overrides.unwrap_or(current.overrides.clone());
validate_overrides(&overrides)?; validate_overrides(&overrides)?;
// Verdicts already stored for this title were reached under the old
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
let overrides_changed = overrides != current.overrides;
let overrides = let overrides =
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = title.trim(); let title = title.trim();
@@ -427,9 +430,54 @@ pub async fn update(
} }
let wanted = input.wanted.unwrap_or(current.wanted); let wanted = input.wanted.unwrap_or(current.wanted);
let blocked = input.blocked.unwrap_or(current.blocked); let blocked = input.blocked.unwrap_or(current.blocked);
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) // A root change moves the §7.4 title folder with the row (issue #228).
.execute(pool(&state)?) // Disk first, row second: a failed rename leaves the row alone, so the
.await?; // operator sees the title where its files actually are and can retry —
// the same ordering `remove_library_files` documents.
let relocation = if root_id == current.root_id {
None
} else {
Some(
crate::relocate::relocate_title(
&state,
crate::relocate::TitleKind::Movie,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, 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(ApiError::from),
Err(error) => Err(ApiError::from(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;
}
if overrides_changed {
crate::reclassify::movie(&state, id).await?;
}
Ok(Json(load_movie(&state, id).await?)) Ok(Json(load_movie(&state, id).await?))
} }
@@ -522,7 +570,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
Ok(metadata) => metadata, Ok(metadata) => metadata,
// Already gone is the state we wanted. // Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, 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() { let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await tokio::fs::remove_dir_all(&target).await
@@ -532,7 +580,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
match removed { match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"), Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 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(()) Ok(())
@@ -604,7 +652,7 @@ pub async fn releases(
.map_err(|error| ApiError::Database(error.to_string()))? .map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::NotFound)? .ok_or(ApiError::NotFound)?
.policy; .policy;
rescore(&mut releases, &policy, None)?; rescore(&mut releases, &policy, None, 0)?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -618,6 +666,10 @@ pub async fn releases(
/// many it covers. `None` is the movie decks: one release is one film, /// many it covers. `None` is the movie decks: one release is one film,
/// whatever episode-shaped noise its name parses to. /// whatever episode-shaped noise its name parses to.
/// ///
/// `runtime_minutes` scales the bands by the series' minutes per episode
/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same
/// zero a series with no known runtime gets.
///
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of
/// policy weights in the thousands), so the `i64` -> `f64` cast into the /// policy weights in the thousands), so the `i64` -> `f64` cast into the
/// column's storage type is exact. /// column's storage type is exact.
@@ -626,6 +678,7 @@ pub(crate) fn rescore(
releases: &mut [Release], releases: &mut [Release],
policy: &Policy, policy: &Policy,
season_lengths: Option<&BTreeMap<u32, u32>>, season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let mut totals = Vec::with_capacity(releases.len()); let mut totals = Vec::with_capacity(releases.len());
for release in releases.iter() { for release in releases.iter() {
@@ -636,7 +689,17 @@ pub(crate) fn rescore(
let episodes = season_lengths.map_or(1, |lengths| { let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths) claimed_episode_count(parsed.episode.as_ref(), lengths)
}); });
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); totals.push(
score(
policy,
Candidate::PreGrab(&parsed),
size,
seeders,
episodes,
runtime_minutes,
)
.total,
);
} }
let mut indices: Vec<usize> = (0..releases.len()).collect(); let mut indices: Vec<usize> = (0..releases.len()).collect();
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
@@ -707,6 +770,12 @@ pub async fn grab(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) 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( #[utoipa::path(
get, path = "/api/queues/attention", tag = "movies", get, path = "/api/queues/attention", tag = "movies",
responses( responses(
@@ -719,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"#) 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)?) .fetch_all(pool(&state)?)
.await?; .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)?) .fetch_all(pool(&state)?)
.await?; .await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -735,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 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 /// 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. /// 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( async fn tv_attention(
state: &AppState, state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> { ) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
let database = pool(state)?; 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#" r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64", e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64" se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e FROM episodes e
JOIN seasons se ON se.id = e.season_id JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids' WHERE root.audience = 'kids'
AND s.blocked = 0 AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM episode_releases er SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
) )
ORDER BY se.number, e.number ORDER BY se.number, e.number
"# "#
) )
.fetch_all(database) .fetch_all(database)
.await?; .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!( let episode_hard_fails = sqlx::query!(
r#" r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
@@ -774,9 +884,12 @@ async fn tv_attention(
JOIN seasons se ON se.id = e.season_id JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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 GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2 HAVING count(DISTINCT g.release_id) >= 2
"# "#,
arr_db::ATTENTION_WINDOW
) )
.fetch_all(database) .fetch_all(database)
.await?; .await?;
@@ -789,30 +902,24 @@ async fn tv_attention(
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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 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) .fetch_all(database)
.await?; .await?;
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new(); let mut entries: 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();
for row in episode_hard_fails { for row in episode_hard_fails {
merge_episode( merge_episode(
&mut tv_needs_decision, &mut entries,
row.series_id, row.series_id,
row.tmdb_id, row.tmdb_id,
&row.title, &row.title,
@@ -824,7 +931,7 @@ async fn tv_attention(
} }
for row in season_pack_fails { for row in season_pack_fails {
merge_season( merge_season(
&mut tv_needs_decision, &mut entries,
row.series_id, row.series_id,
row.tmdb_id, row.tmdb_id,
&row.title, &row.title,
@@ -833,8 +940,7 @@ async fn tv_attention(
row.season_number, row.season_number,
); );
} }
Ok(entries)
Ok((tv_no_pt_source, tv_needs_decision))
} }
/// One more qualifying season for its series, creating the series' entry on /// One more qualifying season for its series, creating the series' entry on
@@ -1135,6 +1241,114 @@ mod tests {
assert_eq!(files[0]["probed"]["resolution"], "1080p"); assert_eq!(files[0]["probed"]["resolution"], "1080p");
} }
/// #210 on the movie deck: relaxing §5.5's floor for one title moves its
/// size rejections into the waived bucket and no further — a waiver is
/// never picked automatically (§9.3), only offered.
#[tokio::test]
async fn allow_below_floor_moves_a_size_rejection_to_waived_not_eligible() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = created["id"].as_i64().expect("id");
// 2 GiB at 1080p, under the movie policy's 3 GiB floor.
let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 2_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'size') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
.send()
.await
.expect("override");
assert_eq!(response.status(), StatusCode::OK);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "waived");
}
/// A blacklisted release (§6.3) is not a policy opinion, so no override
/// re-opens it.
#[tokio::test]
async fn an_override_never_revives_a_blacklisted_release() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let movie = add_movie(&base, 693_134, 1).await;
let movie_id = movie["id"].as_i64().expect("id");
let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 8_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'blacklisted') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true, "only_4k": false}}))
.send()
.await
.expect("override");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
}
#[tokio::test] #[tokio::test]
async fn crud_preserves_intent_and_overrides() { async fn crud_preserves_intent_and_overrides() {
let (_dir, _state, base) = application().await; let (_dir, _state, base) = application().await;
@@ -1288,6 +1502,197 @@ mod tests {
); );
} }
/// Point one of the seeded roots at a real directory for the duration of
/// a test.
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");
}
/// Issue #228: changing a movie's root renames its §7.4 folder into the
/// new root — sidecars ride along inside it — and the `media_files` rows
/// follow in the same write.
#[tokio::test]
async fn changing_root_moves_the_title_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 3}))
.send()
.await
.expect("move to a tv root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
assert!(
moved
.join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv")
.exists(),
"the feature arrived in the new root"
);
assert!(
moved.join("dune.pt.srt").exists(),
"sidecars travel inside the folder"
);
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("media file row");
assert!(
std::path::Path::new(&path).starts_with(destination.path()),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"the rewritten path describes the disk"
);
}
/// Issue #228: a title with nothing on disk changes root with no
/// filesystem work at all — the seeded root paths do not even exist.
#[tokio::test]
async fn a_movie_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
let root_id: i64 = sqlx::query_scalar("SELECT root_id FROM movies WHERE id = ?")
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 2);
}
/// Issue #228: a destination already holding a folder of that name is a
/// conflict, not an overwrite — and the row stays where the files are.
#[tokio::test]
async fn a_destination_collision_is_refused_and_the_row_unchanged() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
let squatter = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
tokio::fs::create_dir_all(&squatter)
.await
.expect("pre-existing folder");
tokio::fs::write(squatter.join("theirs.mkv"), b"not ours")
.await
.expect("write squatter file");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "the folder stayed in the old root");
assert!(
squatter.join("theirs.mkv").exists(),
"the occupant was not overwritten"
);
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
/// Issue #228: if the rename fails, the row must not change — the
/// operator sees the title where its files actually are and can retry.
#[tokio::test]
async fn a_failed_rename_leaves_the_row_alone() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
// A destination whose parent does not exist makes the rename itself
// fail while the collision pre-check still passes.
point_root_at(
&state,
2,
&destination.path().join("missing").join("library"),
)
.await;
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(folder.exists(), "the folder never left the old root");
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
#[test] #[test]
fn a_title_target_is_the_folder_directly_under_the_root() { fn a_title_target_is_the_folder_directly_under_the_root() {
let root = "/mnt/media/movies/main"; let root = "/mnt/media/movies/main";
@@ -1445,6 +1850,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
.total; .total;
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
@@ -1633,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, /// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a /// searched episodes whose every candidate was rejected for language; a
/// season pack that hard-failed; and an episode two different releases /// season two different packs hard-failed on; and an episode two different
/// hard-failed on. /// releases hard-failed on.
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) { async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
let root_id: i64 = let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
@@ -1688,6 +2412,7 @@ mod tests {
.expect("episode id"); .expect("episode id");
for (kind, guid, suffix) in [ for (kind, guid, suffix) in [
("season", "pack", "pack"), ("season", "pack", "pack"),
("season", "pack-two", "pack2"),
("episode", "first", "a"), ("episode", "first", "a"),
("episode", "second", "b"), ("episode", "second", "b"),
] { ] {
+216
View File
@@ -0,0 +1,216 @@
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
//!
//! A release's verdict is stamped once, by the search that found it. Both the
//! deck and the daemon's manual-grab gate read that stored column, so an
//! override written from the deck's one-click waive would change nothing
//! until the next sweep — the row the operator just acted on would keep
//! reading `rejected` and the grab would be refused.
//!
//! So the rules run again here, over the releases already attached to the
//! title. This is the same correction the daemon makes when a grab turns out
//! to be blacklisted: fix the row now rather than leave §9.3 offering, or
//! refusing, something that is no longer true.
//!
//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is
//! left exactly as it is.
use std::collections::BTreeMap;
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::claimed_episode_count;
use arr_core::{Language, ParsedRelease, Policy, TitleOverrides};
use arr_db::blacklist;
use crate::movies::{pool, ApiError};
use crate::search::verdict_columns;
use crate::state::AppState;
/// One stored release, as much of it as re-evaluation needs.
struct Stored {
id: i64,
size: i64,
parsed: serde_json::Value,
verdict: Option<String>,
rejected_rule: Option<String>,
}
/// Re-evaluate every release attached to one movie.
///
/// A movie with no stored original language is left alone, the same rule the
/// daemon's search applies: without it the required-audio rule has nothing to
/// compare, and guessing would move verdicts on worse evidence than the ones
/// already there.
pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> {
let Some(language) = movie_language(state, movie_id).await? else {
return Ok(());
};
let Some(loaded) = state
.database()
.ok_or(ApiError::Unavailable)?
.movie_policy(movie_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
else {
return Ok(());
};
let releases = sqlx::query_as!(
Stored,
r#"
SELECT r.id AS "id!: i64",
r.size AS "size!: i64",
r.parsed AS "parsed!: serde_json::Value",
r.verdict,
r.rejected_rule
FROM releases r
JOIN movie_releases mr ON mr.release_id = r.id
WHERE mr.movie_id = ?
"#,
movie_id
)
.fetch_all(pool(state)?)
.await?;
apply(
state,
&releases,
&loaded.policy,
&loaded.overrides,
&language,
None,
0,
)
.await
}
/// Re-evaluate every release attached to one series, episode decks and season
/// decks alike — overrides sit on the series (§5.1), so one change moves both.
pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> {
let Some(language) = series_language(state, series_id).await? else {
return Ok(());
};
let Some(loaded) = state
.database()
.ok_or(ApiError::Unavailable)?
.series_policy(series_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
else {
return Ok(());
};
let releases = sqlx::query_as!(
Stored,
r#"
SELECT r.id AS "id!: i64",
r.size AS "size!: i64",
r.parsed AS "parsed!: serde_json::Value",
r.verdict,
r.rejected_rule
FROM releases r
WHERE r.id IN (
SELECT er.release_id
FROM episode_releases er
JOIN episodes e ON e.id = er.episode_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?
UNION
SELECT sr.release_id
FROM season_releases sr
JOIN seasons se ON se.id = sr.season_id
WHERE se.series_id = ?
)
"#,
series_id,
series_id
)
.fetch_all(pool(state)?)
.await?;
// A size band describes one episode (§5.5), so a pack's verdict needs the
// same divisor the deck scores it with.
let lengths = crate::series::season_lengths(state, series_id).await?;
let runtime = crate::series::series_runtime(state, series_id).await?;
apply(
state,
&releases,
&loaded.policy,
&loaded.overrides,
&language,
Some(&lengths),
runtime,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn apply(
state: &AppState,
releases: &[Stored],
policy: &Policy,
overrides: &TitleOverrides,
original_language: &Language,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> {
for release in releases {
if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
continue;
}
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
.map_err(|error| ApiError::Database(error.to_string()))?;
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
// A stored zero is a release the indexer gave no size for, and a
// release with no size has nothing to say about its band — the same
// reading the deck's rescore takes.
let size = u64::try_from(release.size).ok().filter(|size| *size > 0);
let evaluation = evaluate(
policy,
overrides,
original_language,
Candidate::PreGrab(&parsed),
size,
episodes,
runtime_minutes,
);
// `releases` allows a rule name only on a rejected row
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
// which is also how the daemon writes a waiver.
let (verdict, rule) = verdict_columns(&evaluation.verdict);
let rule = if verdict == "rejected" { rule } else { None };
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
continue;
}
sqlx::query!(
"UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
verdict,
rule,
release.id
)
.execute(pool(state)?)
.await?;
}
Ok(())
}
async fn movie_language(state: &AppState, movie_id: i64) -> Result<Option<Language>, ApiError> {
let stored = sqlx::query_scalar!(
"SELECT original_language FROM movies WHERE id = ?",
movie_id
)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(stored.map(|value| arr_db::policy::language(&value)))
}
async fn series_language(state: &AppState, series_id: i64) -> Result<Option<Language>, ApiError> {
let stored = sqlx::query_scalar!(
"SELECT original_language FROM series WHERE id = ?",
series_id
)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(stored.map(|value| arr_db::policy::language(&value)))
}
+370
View File
@@ -0,0 +1,370 @@
//! 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
//! and the torrent keeps seeding against them (§7.3).
//!
//! 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;
use crate::movies::{pool, title_target, ApiError};
use crate::state::AppState;
/// Which table owns the moving title's files.
#[derive(Debug, Clone, Copy)]
pub(crate) enum TitleKind {
Movie,
Series,
}
/// One rename from the old root into the new one: a §7.4 title folder, or a
/// loose file sitting straight in the root.
#[derive(Debug)]
struct PlannedRename {
source: PathBuf,
destination: PathBuf,
}
/// The renames already performed on disk and the row rewrites they imply.
/// The database half is the caller's transaction; [`Self::undo`] is for when
/// that transaction fails after the disk already changed.
#[derive(Debug)]
pub(crate) struct Relocation {
performed: Vec<PlannedRename>,
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.
///
/// A title with nothing on disk — no `media_files` rows, or rows whose
/// targets are already gone — changes root with no filesystem work at all.
///
/// # Errors
///
/// [`ApiError::Conflict`] when the destination already holds an entry of the
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
/// a rename fails; whatever had already been renamed is moved back first, so
/// the row the caller then leaves unchanged still describes the disk.
pub(crate) async fn relocate_title(
state: &AppState,
kind: TitleKind,
title_id: i64,
old_root_id: i64,
new_root_id: i64,
) -> Result<Relocation, ApiError> {
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 {
// 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");
continue;
};
let Some(name) = source.file_name() else {
continue;
};
let destination = std::path::Path::new(new_root).join(name);
if !renames.iter().any(|rename| rename.source == source) {
renames.push(PlannedRename {
source,
destination,
});
}
let relative = std::path::Path::new(path)
.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 Some(rewritten) = rewritten.to_str() else {
return Err(ApiError::Filesystem(format!(
"non-UTF-8 path under {new_root}"
)));
};
rewrites.push((*file_id, rewritten.to_owned()));
}
// Every destination is checked before anything is renamed, so a conflict
// 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 already has '{}'",
rename.destination.display()
)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
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
// title, the same way a delete treats already-gone as done. Checked
// on the source, so a missing *destination* parent stays an error.
match tokio::fs::symlink_metadata(&rename.source).await {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
source = %rename.source.display(),
"nothing on disk to move for this title"
);
continue;
}
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 a title folder"
);
performed.push(rename);
}
Err(error) => return Err(failed(&rename, &error, performed).await),
}
}
Ok(Relocation {
performed,
rewrites,
})
}
/// 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).
pub(crate) fn moved_files(&self) -> bool {
!self.performed.is_empty()
}
/// Point the `media_files` rows at the new root, inside the caller's
/// transaction so they land together with the `root_id` change or not at
/// all.
pub(crate) async fn rewrite_rows(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(), sqlx::Error> {
for (file_id, path) in &self.rewrites {
sqlx::query!(
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
/// Best-effort reversal of the renames, for when the disk moved but the
/// database write failed. A reversal that itself fails is logged: at that
/// point the operator's retry is the recovery path.
pub(crate) async fn undo(&self) {
for rename in self.performed.iter().rev() {
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
tracing::error!(
source = %rename.source.display(),
destination = %rename.destination.display(),
%error,
"could not move the title folder back after a failed root change"
);
}
}
}
}
/// §7.5 after a move: the same single best-effort refresh import performs.
/// Failure logs and never fails the write that already committed.
pub(crate) async fn refresh_jellyfin(state: &AppState) {
if let Some(jellyfin) = state.jellyfin() {
if let Err(error) = jellyfin.refresh().await {
tracing::warn!(%error, "jellyfin refresh after a root change failed");
}
}
}
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
Ok(sqlx::query_scalar!(
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
root_id
)
.fetch_one(pool(state)?)
.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(
state: &AppState,
kind: TitleKind,
title_id: i64,
) -> Result<Vec<(i64, String)>, ApiError> {
Ok(match kind {
TitleKind::Movie => sqlx::query!(
r#"SELECT id AS "id!: i64", path AS "path!: String"
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
TitleKind::Series => 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
WHERE se.series_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
})
}
+503 -23
View File
@@ -172,26 +172,74 @@ pub async fn update(
let input = parsed(body)?; let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?; input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?; input.policy_exists(&state).await?;
let current = load_root(&state, id).await?;
let path = input.path.trim().to_owned(); let path = input.path.trim().to_owned();
let result = sqlx::query!( // A path change moves every §7.4 title folder under this root with the
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, // row (issue #236), the same way changing a title's root moves one
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') // (#228). Disk first, row second: a failed rename leaves the root row
WHERE id = ?"#, // alone, so the operator sees the library where its files actually are
input.kind, // and can retry. A path already taken is refused before any of it, since
input.audience, // the write would fail afterwards anyway.
path, let relocation = if path == current.path {
input.policy_id, None
id, } else {
) path_is_free(&state, id, &path).await?;
.execute(pool(&state)?) Some(crate::relocate::relocate_root(&state, id, &current.path, &path).await?)
.await };
.map_err(root_conflict)?; let mut transaction = pool(&state)?.begin().await?;
if result.rows_affected() == 0 { let written: Result<(), sqlx::Error> = async {
return Err(ApiError::RootNotFound); 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?)) 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 /// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault. /// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError { fn root_conflict(error: sqlx::Error) -> ApiError {
@@ -258,7 +306,7 @@ mod tests {
use crate::{router, AppState, Upstreams}; use crate::{router, AppState, Upstreams};
use axum::http::StatusCode; use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) { async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db")) let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await .await
@@ -274,14 +322,14 @@ mod tests {
.await .await
.expect("bind"); .expect("bind");
let address = listener.local_addr().expect("address"); 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") }); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}")) (dir, state, format!("http://{address}"))
} }
#[tokio::test] #[tokio::test]
async fn roots_carry_their_policy_name() { 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")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
.expect("roots") .expect("roots")
@@ -325,7 +373,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_root_round_trips_through_create_and_update() { 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 policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
@@ -399,7 +447,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { 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; let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [ for (mut payload, field) in [
@@ -428,7 +476,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() { 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); let mut path = root_input(1);
path["audience"] = serde_json::json!("kids"); path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids"); path["path"] = serde_json::json!("/mnt/media/movies/kids");
@@ -454,7 +502,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_root_with_titles_refuses_to_die() { 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")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
.expect("roots") .expect("roots")
@@ -495,4 +543,436 @@ mod tests {
.expect("delete empty root"); .expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT); 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());
}
} }
+11 -3
View File
@@ -490,6 +490,7 @@ async fn movie_releases(
&original_language, &original_language,
&blacklist, &blacklist,
None, None,
0,
)?); )?);
} }
} }
@@ -525,6 +526,7 @@ async fn episode_releases(
.await? .await?
.ok_or(ApiError::EpisodeNotFound)?; .ok_or(ApiError::EpisodeNotFound)?;
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
let loaded = database let loaded = database
.episode_policy(episode_id) .episode_policy(episode_id)
.await .await
@@ -568,6 +570,7 @@ async fn episode_releases(
&original_language, &original_language,
&blacklist, &blacklist,
Some(&season_lengths), Some(&season_lengths),
runtime_minutes,
)?); )?);
} }
} }
@@ -709,6 +712,7 @@ fn classify(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
season_lengths: Option<&BTreeMap<u32, u32>>, season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> { ) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
@@ -724,11 +728,12 @@ fn classify(
Candidate::PreGrab(&parsed), Candidate::PreGrab(&parsed),
release.size, release.size,
episodes, episodes,
runtime_minutes,
); );
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned())) ("rejected", Some(blacklist::RULE.to_owned()))
} else { } else {
verdict(&evaluation.verdict) verdict_columns(&evaluation.verdict)
}; };
let score = score( let score = score(
policy, policy,
@@ -736,6 +741,7 @@ fn classify(
release.size.unwrap_or_default(), release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(), release.seeders.unwrap_or_default(),
episodes, episodes,
runtime_minutes,
); );
// A release with no size has nothing to say about its size band, so that // A release with no size has nothing to say about its size band, so that
// term is dropped rather than scored as if it were at the floor. Every // term is dropped rather than scored as if it were at the floor. Every
@@ -778,7 +784,7 @@ fn classify(
}) })
} }
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) { pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
match verdict { match verdict {
Verdict::Eligible => ("eligible", None), Verdict::Eligible => ("eligible", None),
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))), Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
@@ -1465,6 +1471,7 @@ mod tests {
&Language::Other("en".into()), &Language::Other("en".into()),
&Blacklist::default(), &Blacklist::default(),
None, None,
0,
) )
.expect("classified release") .expect("classified release")
}; };
@@ -1558,7 +1565,7 @@ mod tests {
imdb_id: None, imdb_id: None,
}; };
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
let classified = classify( let classified = classify(
release, release,
@@ -1567,6 +1574,7 @@ mod tests {
&Language::Other("en".into()), &Language::Other("en".into()),
&Blacklist::default(), &Blacklist::default(),
None, None,
0,
) )
.expect("classified release"); .expect("classified release");
+315 -12
View File
@@ -184,10 +184,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
}; };
if object if object
.keys() .keys()
.any(|key| key != "only_4k" && key != "allow_english_audio") .any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
{ {
return Err(ApiError::Invalid( return Err(ApiError::Invalid(
"overrides supports only only_4k and allow_english_audio".into(), "overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
)); ));
} }
if object.values().any(|value| !value.is_boolean()) { if object.values().any(|value| !value.is_boolean()) {
@@ -468,11 +468,15 @@ pub async fn create(
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone()); let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone()); let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average); let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
let runtime_minutes = tmdb_series
.as_ref()
.and_then(|s| s.episode_runtime)
.map(i64::from);
let result = sqlx::query!( let result = sqlx::query!(
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides, input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average, poster_path, backdrop_path, vote_average, runtime_minutes,
) )
.execute(pool(&state)?) .execute(pool(&state)?)
.await?; .await?;
@@ -561,8 +565,11 @@ pub async fn update(
if title.trim().is_empty() { if title.trim().is_empty() {
return Err(ApiError::Invalid("title cannot be empty".into())); return Err(ApiError::Invalid("title cannot be empty".into()));
} }
let overrides = input.overrides.unwrap_or(current.overrides); let overrides = input.overrides.unwrap_or(current.overrides.clone());
validate_overrides(&overrides)?; validate_overrides(&overrides)?;
// Verdicts already stored for this series were reached under the old
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
let overrides_changed = overrides != current.overrides;
let overrides = let overrides =
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = title.trim(); let title = title.trim();
@@ -575,9 +582,54 @@ pub async fn update(
let auto_track = input.auto_track.unwrap_or(current.auto_track); let auto_track = input.auto_track.unwrap_or(current.auto_track);
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended); let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
let blocked = input.blocked.unwrap_or(current.blocked); let blocked = input.blocked.unwrap_or(current.blocked);
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) // A root change moves the §7.4 title folder with the row (issue #228).
.execute(pool(&state)?) // Disk first, row second: a failed rename leaves the row alone, so the
.await?; // operator sees the title where its files actually are and can retry —
// the same ordering `remove_library_files` documents.
let relocation = if root_id == current.root_id {
None
} else {
Some(
crate::relocate::relocate_title(
&state,
crate::relocate::TitleKind::Series,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, 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(ApiError::from),
Err(error) => Err(ApiError::from(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;
}
if overrides_changed {
crate::reclassify::series(&state, id).await?;
}
Ok(Json(load_series(&state, id).await?)) Ok(Json(load_series(&state, id).await?))
} }
@@ -682,7 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
Ok(metadata) => metadata, Ok(metadata) => metadata,
// Already gone is the state we wanted. // Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, 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() { let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await tokio::fs::remove_dir_all(&target).await
@@ -692,7 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
match removed { match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"), Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 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(()) Ok(())
@@ -1373,7 +1425,8 @@ pub async fn episode_releases(
.ok_or(ApiError::EpisodeNotFound)? .ok_or(ApiError::EpisodeNotFound)?
.policy; .policy;
let lengths = season_lengths(&state, episode.series_id).await?; let lengths = season_lengths(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?; let runtime = series_runtime(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -1412,6 +1465,19 @@ pub async fn grab_episode(
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no /// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
/// revealed episodes counts zero, which `claimed_episode_count` treats as /// revealed episodes counts zero, which `claimed_episode_count` treats as
/// unknown. /// unknown.
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
/// behind runtime-scaled size bands. Zero when unknown, which applies the
/// bands unscaled.
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
pub(crate) async fn season_lengths( pub(crate) async fn season_lengths(
state: &AppState, state: &AppState,
series_id: i64, series_id: i64,
@@ -1514,7 +1580,8 @@ pub async fn season_releases(
.ok_or(ApiError::SeasonNotFound)? .ok_or(ApiError::SeasonNotFound)?
.policy; .policy;
let lengths = season_lengths(&state, series_id).await?; let lengths = season_lengths(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?; let runtime = series_runtime(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -1869,6 +1936,129 @@ mod tests {
response.json().await.expect("season json") response.json().await.expect("season json")
} }
/// The production case behind #210: every pack of a season is under
/// §5.5's per-episode floor, so the deck holds three candidates and
/// nothing is grabbable. Writing `allow_below_floor` turns the
/// rejections into waivers — offered, never automatic.
#[tokio::test]
async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let episodes: Vec<serde_json::Value> = (1..=10)
.map(|number| {
serde_json::json!({
"number": number, "title": format!("Episode {number}"),
"air_date": "2025-01-01"
})
})
.collect();
let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await;
let season_id = season["id"].as_i64().expect("season id");
// Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor.
let pool = state.database().expect("database").pool();
let name = "Bluey S09 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 8_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
.send()
.await
.expect("override");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}"))
.await
.expect("series")
.json()
.await
.expect("series json");
assert_eq!(updated["overrides"]["allow_below_floor"], true);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(
releases[0]["verdict"], "waived",
"a waived grab stays a waiver; nothing here makes it eligible"
);
// The row's rule name goes with the rejection; what survives is the
// dashed `waived` verdict the deck reads (§9.3).
assert!(releases[0]["rejected_rule"].is_null());
// The grab the deck's one click sends is now accepted.
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::ACCEPTED);
// And clearing the override puts the floor back.
reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {}}))
.send()
.await
.expect("clear override");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "size");
}
#[tokio::test]
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let response = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {"allow_anything": true}}))
.send()
.await
.expect("override");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
/// §9.6: the three stored artwork fields come off the series detail /// §9.6: the three stored artwork fields come off the series detail
/// response at add time — the same call that used to fetch only the /// response at add time — the same call that used to fetch only the
/// TVDB id — so a series added today has a poster before tomorrow's /// TVDB id — so a series added today has a poster before tomorrow's
@@ -3506,6 +3696,119 @@ mod tests {
); );
} }
/// Issue #228: changing a series' root renames its §7.4 title folder —
/// season subfolders inside it — into the new root, and every episode's
/// `media_files` row follows.
#[tokio::test]
async fn changing_root_moves_the_series_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]),
)
.await;
let season_id = season["id"].as_i64().expect("season id");
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("episode id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
tokio::fs::create_dir_all(folder.join("Season 01"))
.await
.expect("create season folder");
tokio::fs::write(&episode_file, b"episode")
.await
.expect("write episode");
let pool = state.database().expect("database").pool();
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root)
.execute(pool)
.await
.expect("point the root at the tempdir");
}
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
)
.bind(episode_id)
.bind(episode_file.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": 1}))
.send()
.await
.expect("move to a movie root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey (2018) - S01E01.mkv");
assert!(moved.exists(), "the episode arrived, season folder intact");
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
)
.bind(episode_id)
.fetch_one(pool)
.await
.expect("media file row");
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
}
/// Issue #228: a series with nothing on disk changes root with no
/// filesystem work at all.
#[tokio::test]
async fn a_series_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
}
#[test] #[test]
fn air_dates_parse_as_dates_and_as_timestamps() { fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!( assert_eq!(
+16
View File
@@ -7,6 +7,8 @@ use std::time::Duration;
use arr_db::Db; use arr_db::Db;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::jellyfin::JellyfinClient;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key /// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
/// is configurable, so this is a constant that tests point elsewhere. /// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3"; pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
@@ -76,6 +78,7 @@ pub struct AppState {
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>, pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>, metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>, pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
jellyfin: Option<JellyfinClient>,
} }
/// Work explicitly requested through the movie API. /// Work explicitly requested through the movie API.
@@ -146,6 +149,7 @@ impl AppState {
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)), pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands, metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)), pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
jellyfin: None,
}) })
} }
@@ -156,6 +160,18 @@ impl AppState {
self self
} }
/// Attach the Jellyfin client, so a manual subtitle grab or translation
/// can trigger the same library refresh import does (§7.5, §15).
#[must_use]
pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self {
self.jellyfin = Some(jellyfin);
self
}
pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> {
self.jellyfin.as_ref()
}
/// Wait for the next manual movie action in the daemon's reconcile loop. /// Wait for the next manual movie action in the daemon's reconcile loop.
/// ///
/// # Errors /// # Errors
+8
View File
@@ -237,6 +237,14 @@ pub struct Policy {
pub struct TitleOverrides { pub struct TitleOverrides {
pub only_4k: bool, pub only_4k: bool,
pub allow_english_audio: bool, pub allow_english_audio: bool,
/// Take a release below its size band's floor on this title (§5.5).
///
/// No band is right for every title, and the floor is a hard reject, so
/// a title the band is wrong about has nothing grabbable at all. This
/// relaxes the floor to a soft fail rather than removing it: the release
/// is waived, never eligible, so it stays a deliberate manual grab and
/// imports on the record as a §5.7 waiver.
pub allow_below_floor: bool,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
+211 -5
View File
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
/// describes one episode, so the size rule divides by this. One for a /// describes one episode, so the size rule divides by this. One for a
/// movie or an unknown count; zero is treated as one. /// movie or an unknown count; zero is treated as one.
pub episode_count: u32, pub episode_count: u32,
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
/// rate against 45 minutes, so the size rule scales its floor by
/// `runtime / 45`. Zero is a missing runtime and applies the band
/// unscaled; movies are never scaled and pass zero.
pub runtime_minutes: u32,
} }
/// A rule's identity when no violation exists to carry concrete evidence. /// A rule's identity when no violation exists to carry concrete evidence.
@@ -112,6 +117,7 @@ pub fn evaluate(
candidate: Candidate<'_>, candidate: Candidate<'_>,
size_bytes: Option<u64>, size_bytes: Option<u64>,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Evaluation { ) -> Evaluation {
let context = EvaluationContext { let context = EvaluationContext {
policy, policy,
@@ -120,6 +126,7 @@ pub fn evaluate(
candidate, candidate,
size_bytes, size_bytes,
episode_count, episode_count,
runtime_minutes,
}; };
let rules: [&dyn PolicyRule; 6] = [ let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule, &ResolutionRule,
@@ -224,6 +231,12 @@ impl PolicyRule for SourceRule {
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far /// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
/// a candidate sits from the band's *target* is [`crate::score`]'s question, /// a candidate sits from the band's *target* is [`crate::score`]'s question,
/// not this rule's. /// not this rule's.
///
/// `allow_below_floor` softens the floor for one title rather than lifting
/// it: a below-floor release becomes a waiver, so it is never picked
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
/// [`ResolutionRule`], where an override moves a failure between hard and
/// soft and never makes the rule stop applying.
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct SizeRule; pub struct SizeRule;
@@ -233,9 +246,17 @@ impl PolicyRule for SizeRule {
else { else {
return RuleEvaluation::Unknown(RuleKind::Size); return RuleEvaluation::Unknown(RuleKind::Size);
}; };
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) match crate::score::is_below_floor(
{ context.policy,
resolution,
size,
context.episode_count,
context.runtime_minutes,
) {
None => RuleEvaluation::Unknown(RuleKind::Size), None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size), Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size), Some(false) => RuleEvaluation::Pass(RuleKind::Size),
} }
@@ -522,7 +543,7 @@ mod tests {
} }
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate(policy, overrides, &en(), candidate, None, 1).verdict evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
} }
fn verdict_for( fn verdict_for(
@@ -537,6 +558,7 @@ mod tests {
candidate, candidate,
None, None,
1, 1,
0,
) )
.verdict .verdict
} }
@@ -552,6 +574,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -655,6 +678,179 @@ mod tests {
); );
} }
/// A policy with one 1080p band, so the floor has something to say.
fn banded_policy() -> Policy {
Policy {
size_bands: BTreeMap::from([(
Resolution::R1080p,
crate::SizeBand {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
},
)]),
resolution_preference: vec![Resolution::R1080p],
..policy()
}
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
let evaluation = evaluate(
&banded_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
);
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
}
#[test]
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
let policy = banded_policy();
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Pre-grab the deck offers it, and only as a waiver: `waived` is
// never picked automatically (§9.3), so the operator still decides.
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
// And the import records the waiver rather than hard-failing (§5.7).
let media = probed(Resolution::R1080p, Some(Source::WebDl));
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(
&banded_policy(),
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(4 << 30),
1,
0,
)
.verdict,
Verdict::Eligible
);
}
/// §5.5: the floor takes the per-episode figure, so the override that
/// relaxes it has to travel the same divisor.
#[test]
fn allow_below_floor_waives_a_pack_measured_per_episode() {
let policy = banded_policy();
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Ten episodes at 1 GiB each: over the floor in total, under it per
// episode, which is the figure the floor compares.
let size = Some(10 << 30);
assert_eq!(
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Rejected(Rule::Size)
);
assert_eq!(
evaluate(
&policy,
&TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
},
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
/// floor, and `allow_below_floor` still only softens what remains below
/// it — it never bypasses the scaled comparison.
#[test]
fn allow_below_floor_waives_against_the_scaled_floor() {
let policy = banded_policy();
let waive = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
// the override has nothing to waive; 0.5 GiB is below even the
// scaled floor and stays a waiver rather than eligible.
let at = |size_bytes, overrides| {
evaluate(
&policy,
overrides,
&en(),
Candidate::PreGrab(&claims),
Some(size_bytes),
1,
22,
)
.verdict
};
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
assert_eq!(
at(1 << 29, &TitleOverrides::default()),
Verdict::Rejected(Rule::Size)
);
}
#[test] #[test]
fn every_unsafe_source_hard_fails_in_both_phases() { fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy(); let policy = policy();
@@ -735,6 +931,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -792,6 +989,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
let expected = if rejected { let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile)) Verdict::Rejected(Rule::DolbyVisionProfile(profile))
@@ -812,6 +1010,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -845,6 +1044,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: None, size_bytes: None,
episode_count: 1, episode_count: 1,
runtime_minutes: 0,
}; };
let soft = FixedRule { let soft = FixedRule {
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
@@ -874,6 +1074,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: None, size_bytes: None,
episode_count: 1, episode_count: 1,
runtime_minutes: 0,
}; };
let first = FixedRule { let first = FixedRule {
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
@@ -1010,7 +1211,8 @@ mod tests {
&en(), &en(),
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1 1,
0,
) )
.verdict, .verdict,
Verdict::Eligible Verdict::Eligible
@@ -1024,7 +1226,8 @@ mod tests {
&en(), &en(),
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1 1,
0,
) )
.verdict, .verdict,
Verdict::Waived(Rule::RequiredAudio) Verdict::Waived(Rule::RequiredAudio)
@@ -1099,6 +1302,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
@@ -1111,6 +1315,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
} }
@@ -1125,6 +1330,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!( assert_eq!(
+193 -22
View File
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
const BYTES_PER_GIB: i64 = 1 << 30; const BYTES_PER_GIB: i64 = 1 << 30;
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
/// rate against a 45-minute episode, and both floor and target scale by
/// `runtime / 45` before a per-episode size is compared to them.
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
/// How much each scoring term is worth. Policy data, not constants in the /// How much each scoring term is worth. Policy data, not constants in the
/// code, for the same reason the size bands are. /// code, for the same reason the size bands are.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -94,6 +99,13 @@ pub struct Score {
/// band is compared against. The caller supplies it — `arr-core` has no IO, /// band is compared against. The caller supplies it — `arr-core` has no IO,
/// and a `Season` claim names a season, not a length. A movie, and any release /// and a `Season` claim names a season, not a length. A movie, and any release
/// whose count is unknown, is one episode; zero is treated as one. /// whose count is unknown, is one episode; zero is treated as one.
///
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
/// same way — scales its floor and target before the comparison. Zero is a
/// missing runtime and applies the band unscaled, exactly the pre-scaling
/// behaviour. Movies are never scaled: their bands are already tuned against
/// feature length, so a movie caller passes zero.
#[must_use] #[must_use]
pub fn score( pub fn score(
policy: &Policy, policy: &Policy,
@@ -101,13 +113,16 @@ pub fn score(
size_bytes: u64, size_bytes: u64,
seeders: u32, seeders: u32,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Score { ) -> Score {
let weights = &policy.score_weights; let weights = &policy.score_weights;
let claimed = candidate.resolution(); let claimed = candidate.resolution();
let per_episode = per_episode_size(size_bytes, episode_count); let per_episode = per_episode_size(size_bytes, episode_count);
let size = claimed let size = claimed
.and_then(|resolution| policy.size_bands.get(&resolution)) .and_then(|resolution| policy.size_bands.get(&resolution))
.map_or(0, |band| size_points(band, weights, per_episode)); .map_or(0, |band| {
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
});
let source = candidate let source = candidate
.source() .source()
.map_or(0, |source| source_points(policy, source)); .map_or(0, |source| source_points(policy, source));
@@ -130,13 +145,19 @@ pub fn score(
/// ///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6). /// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
#[must_use] #[must_use]
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { pub fn score_release(
policy: &Policy,
release: &Release,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
score( score(
policy, policy,
Candidate::PreGrab(&release.parsed), Candidate::PreGrab(&release.parsed),
release.size, release.size,
release.seeders, release.seeders,
episode_count, episode_count,
runtime_minutes,
) )
} }
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor /// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
/// would let every pack through untested. Zero `episode_count` is one episode. /// would let every pack through untested. Zero `episode_count` is one episode.
/// ///
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
///
/// `None` when the policy carries no band for that resolution: no band is no /// `None` when the policy carries no band for that resolution: no band is no
/// opinion, not a rejection. /// opinion, not a rejection.
#[must_use] #[must_use]
@@ -154,11 +178,11 @@ pub fn is_below_floor(
resolution: Resolution, resolution: Resolution,
size_bytes: u64, size_bytes: u64,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> { ) -> Option<bool> {
policy policy.size_bands.get(&resolution).map(|band| {
.size_bands per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
.get(&resolution) })
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
} }
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor /// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
@@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
size_bytes / u64::from(episode_count.max(1)) size_bytes / u64::from(episode_count.max(1))
} }
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
/// the reference runtime — the band applies unscaled.
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
return *band;
}
SizeBand {
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
}
}
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
let scaled =
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// The size term: a ramp from the floor up to the target, then a penalty that /// The size term: a ramp from the floor up to the target, then a penalty that
/// grows with every gigabyte above it. /// grows with every gigabyte above it.
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 { fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
@@ -329,6 +373,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
} }
@@ -350,6 +395,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
} }
@@ -358,6 +404,10 @@ mod tests {
} }
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
size_rule_at(size_bytes, episode_count, 0)
}
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
let policy = policy(); let policy = policy();
let overrides = TitleOverrides::default(); let overrides = TitleOverrides::default();
let language = Language::Other("en".to_owned()); let language = Language::Other("en".to_owned());
@@ -369,6 +419,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes), size_bytes: Some(size_bytes),
episode_count, episode_count,
runtime_minutes,
}) })
} }
@@ -400,6 +451,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
Some(gib(60)), Some(gib(60)),
1, 1,
0,
); );
// A bad score, but a score: nothing filters it out, so a selection // A bad score, but a score: nothing filters it out, so a selection
@@ -466,11 +518,11 @@ mod tests {
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true) Some(true)
); );
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1), is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None None
); );
} }
@@ -481,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
assert_eq!(score.size, 0); assert_eq!(score.size, 0);
assert_eq!(score.total, score.source + score.seeders); assert_eq!(score.total, score.source + score.seeders);
@@ -499,8 +551,8 @@ mod tests {
}, },
); );
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
assert_eq!(under.size, 0); assert_eq!(under.size, 0);
@@ -548,7 +600,7 @@ mod tests {
resolution: Some(resolution), resolution: Some(resolution),
..NameClaims::default() ..NameClaims::default()
}; };
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
}; };
assert_eq!(at(ClaimedResolution::P2160), 2 * step); assert_eq!(at(ClaimedResolution::P2160), 2 * step);
@@ -565,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
assert_eq!(unranked.resolution, 0); assert_eq!(unranked.resolution, 0);
assert_eq!(unclaimed.resolution, 0); assert_eq!(unclaimed.resolution, 0);
@@ -617,7 +669,7 @@ mod tests {
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() { fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
let episode = scored(ClaimedSource::WebDl, gib(22), 10); let episode = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10); let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
assert_eq!(pack.size, episode.size); assert_eq!(pack.size, episode.size);
assert_eq!(pack.total, episode.total); assert_eq!(pack.total, episode.total);
@@ -628,7 +680,7 @@ mod tests {
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor // 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
// — a pack of mud-quality encodes fails as plainly as one of them. // — a pack of mud-quality encodes fails as plainly as one of them.
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10), is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true) Some(true)
); );
assert_eq!( assert_eq!(
@@ -638,22 +690,141 @@ mod tests {
// The same total over three episodes is 10 GiB each and passes. // The same total over three episodes is 10 GiB each and passes.
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
Some(false) Some(false)
); );
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
} }
/// The corrected acceptance criterion from issue #209: a series whose
/// runtime is known and short is judged against a proportionally scaled
/// floor and target, at both 22 and 45 minutes.
#[test]
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
// 5 GiB fails the unscaled floor and clears the 22-minute one.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
Some(false)
);
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
Some(true)
);
// 45 minutes is the reference runtime: identical to no scaling.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
);
// The size rule takes the same scaled floor.
assert_eq!(
size_rule_at(gib(5), 1, 0),
RuleEvaluation::HardFail(Rule::Size)
);
assert_eq!(
size_rule_at(gib(5), 1, 45),
RuleEvaluation::HardFail(Rule::Size)
);
assert!(matches!(
size_rule_at(gib(5), 1, 22),
RuleEvaluation::Pass(_)
));
assert_eq!(
size_rule_at(gib(3), 1, 22),
RuleEvaluation::HardFail(Rule::Size)
);
}
#[test]
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
let claims = claims(ClaimedSource::WebDl);
let scored_at_runtime = |size, runtime| {
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
};
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
// minutes, and both sit at the top of the size term.
let at_target = i64::from(ScoreWeights::default().size_at_target);
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
// A below-target bitrate lands on the same point of the ramp at any
// runtime, give or take integer rounding.
let half_way_45 = scored_at_runtime(gib(15), 45);
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
assert!((half_way_45 - half_way_22).abs() <= 1);
}
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
/// scaling must not let them through.
#[test]
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
let mut policy = policy();
policy.size_bands.insert(
Resolution::R1080p,
SizeBand {
floor_bytes: gib(1),
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
},
);
let episodes = 10;
for per_episode_gib in [19, 24, 32] {
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
Some(true)
);
}
// Half a GiB per episode clears the scaled floor: the floor still
// discriminates rather than rejecting every 22-minute release.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
Some(false)
);
}
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
/// reproduces the pre-scaling score exactly, pinned to literals the same
/// way #180 pinned movie scoring.
#[test]
fn a_missing_runtime_reproduces_the_unscaled_score() {
let claims = claims(ClaimedSource::WebDl);
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
assert_eq!(
missing,
Score {
total: 918,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
}
);
assert_eq!(
missing,
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
);
}
#[test] #[test]
fn an_unknown_episode_count_falls_back_to_one_episode() { fn an_unknown_episode_count_falls_back_to_one_episode() {
let single = scored(ClaimedSource::WebDl, gib(22), 10); let single = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0); let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0);
assert_eq!(zero, single); assert_eq!(zero, single);
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
); );
} }
@@ -733,18 +904,18 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
}; };
let hd = scored(ClaimedResolution::P1080, hd_pack); let hd = scored(ClaimedResolution::P1080, hd_pack);
let uhd = scored(ClaimedResolution::P2160, uhd_pack); let uhd = scored(ClaimedResolution::P2160, uhd_pack);
// Neither pack trips the floor per episode, so the ranking decides. // Neither pack trips the floor per episode, so the ranking decides.
assert_eq!( assert_eq!(
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes), is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false) Some(false)
); );
assert_eq!( assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false) Some(false)
); );
assert!(uhd.total > hd.total); assert!(uhd.total > hd.total);
+215 -12
View File
@@ -2,6 +2,15 @@
//! the no-PT-source queue, or hard-failed twice on different releases (the //! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3). //! 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 //! 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 //! 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 //! again. A series notifies as its series, never per episode — a broken
@@ -34,8 +43,8 @@ struct TvEntry {
no_pt_source: Vec<i64>, no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7). /// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>, hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to /// Seasons two different pack releases hard-failed on (§5.7), sending the
/// per-episode grabbing. /// season back to per-episode grabbing.
failed_season_packs: Vec<i64>, failed_season_packs: Vec<i64>,
} }
@@ -59,8 +68,8 @@ impl TvEntry {
if !self.failed_season_packs.is_empty() { if !self.failed_season_packs.is_empty() {
parts.push(plural( parts.push(plural(
self.failed_season_packs.len(), self.failed_season_packs.len(),
"season pack hard-failed", "season hard-failed twice on different packs",
"season packs hard-failed", "seasons hard-failed twice on different packs",
)); ));
} }
parts.join("; ") parts.join("; ")
@@ -134,8 +143,10 @@ impl AttentionAction {
AND (SELECT count(DISTINCT g.release_id) AND (SELECT count(DISTINCT g.release_id)
FROM grabs g FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id 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()) .fetch_all(database.pool())
.await?; .await?;
@@ -209,9 +220,14 @@ fn tv_entry(
/// TV roll-up (§9.5): every queued series with what put it there — wanted /// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two /// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab /// different releases hard-failed post-probe, and seasons two different packs
/// hard-failed. One entry per series, so the notification can be one per /// hard-failed on. One entry per series, so the notification can be one per
/// series however long the broken season is. /// 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> { async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new(); 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 seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available' AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2 HAVING count(DISTINCT g.release_id) >= 2
"# "#,
arr_db::ATTENTION_WINDOW
) )
.fetch_all(database.pool()) .fetch_all(database.pool())
.await?; .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 seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed' 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 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()) .fetch_all(database.pool())
.await?; .await?;
@@ -382,6 +408,54 @@ mod tests {
series_id 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`, /// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe. /// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab( async fn insert_failed_grab(
@@ -548,9 +622,10 @@ mod tests {
.unwrap(); .unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await; insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await; insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing; // The packs' failures sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message. // 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").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await; let server = MockServer::start().await;
let action = action(&server).await; let action = action(&server).await;
@@ -631,4 +706,132 @@ mod tests {
assert_eq!(second.len(), 0, "leaves the queue once imported"); assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1); 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"
);
}
} }
+31
View File
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> { ) -> Result<Option<Eligible>, GrabError> {
// A movie is one episode's worth and is never runtime-scaled (§5.5).
let (release_id, eligible) = classify_and_store( let (release_id, eligible) = classify_and_store(
database, database,
release, release,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language, original_language,
blacklist, blacklist,
1, 1,
0,
) )
.await?; .await?;
sqlx::query!( sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => BTreeMap::new(), _ => BTreeMap::new(),
}; };
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
// §5.5: the size bands scale by the series' minutes per episode.
let runtime_minutes = match episode_ids.first() {
Some(&episode_id) => series_runtime_of(database, episode_id).await?,
None => 0,
};
let (release_id, eligible) = classify_and_store( let (release_id, eligible) = classify_and_store(
database, database,
release, release,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language, original_language,
blacklist, blacklist,
episode_count, episode_count,
runtime_minutes,
) )
.await?; .await?;
for episode_id in episode_ids { for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.collect()) .collect())
} }
/// The minutes-per-episode of the series one covered episode belongs to
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
/// series has no known runtime, which applies the bands unscaled.
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
let minutes = sqlx::query_scalar!(
r#"SELECT s.runtime_minutes FROM series s
WHERE s.id = (SELECT s2.series_id FROM episodes e
JOIN seasons s2 ON s2.id = e.season_id
WHERE e.id = ?)"#,
episode_id
)
.fetch_optional(database.pool())
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
#[allow(clippy::too_many_arguments)]
async fn classify_and_store( async fn classify_and_store(
database: &Db, database: &Db,
release: &SearchRelease, release: &SearchRelease,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> { ) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate( let evaluation = evaluate(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed), Candidate::PreGrab(&parsed),
release.size, release.size,
episode_count, episode_count,
runtime_minutes,
); );
let scored = score( let scored = score(
policy, policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
release.size.unwrap_or_default(), release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(), release.seeders.unwrap_or_default(),
episode_count, episode_count,
runtime_minutes,
); );
// A release that did not say its size is not a tiny one: scoring it // A release that did not say its size is not a tiny one: scoring it
// against the band's floor would bury it. Same treatment as the manual // against the band's floor would bury it. Same treatment as the manual
+15 -1
View File
@@ -25,9 +25,9 @@ use arr_db::Db;
use arr_dl::TransmissionClient; use arr_dl::TransmissionClient;
use arr_probe::Prober; use arr_probe::Prober;
use crate::jellyfin::JellyfinClient;
use crate::notify::Notifier; use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome}; use crate::reconcile::{Action, ActionFuture, Outcome};
use arr_api::jellyfin::JellyfinClient;
/// A failure during one import tick. /// A failure during one import tick.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media), Candidate::PostDownload(&feature.media),
Some(feature.size), Some(feature.size),
1, 1,
0,
); );
let waiver: Option<Rule> = match evaluation.verdict { let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => { Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be // §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns // imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes. // the whole release (§5.7), not the episodes.
let runtime_minutes = pending
.runtime_minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0);
let mut imports = Vec::new(); let mut imports = Vec::new();
for assignment in assignments { for assignment in assignments {
if assignment.episode.has_file { if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media), Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size), Some(assignment.file.size),
1, 1,
runtime_minutes,
); );
let waiver = match evaluation.verdict { let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => { Verdict::Rejected(rule) => {
@@ -938,6 +944,10 @@ struct PendingTvImport {
series_title: String, series_title: String,
series_year: Option<i64>, series_year: Option<i64>,
original_language: Option<String>, original_language: Option<String>,
/// §5.5: the series' minutes per episode, scaling the size bands the
/// same way the pre-grab verdict scaled them. `None` applies them
/// unscaled.
runtime_minutes: Option<i64>,
release_name: String, release_name: String,
} }
@@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String", s.title AS "series_title!: String",
s.year AS "series_year", s.year AS "series_year",
s.original_language, s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String" r.name AS "release_name!: String"
FROM grabs g FROM grabs g
JOIN episodes e ON e.id = g.target_id JOIN episodes e ON e.id = g.target_id
@@ -997,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title, series_title: row.series_title,
series_year: row.series_year, series_year: row.series_year,
original_language: row.original_language, original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name, release_name: row.release_name,
})); }));
@@ -1011,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String", s.title AS "series_title!: String",
s.year AS "series_year", s.year AS "series_year",
s.original_language, s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String" r.name AS "release_name!: String"
FROM grabs g FROM grabs g
JOIN seasons se ON se.id = g.target_id JOIN seasons se ON se.id = g.target_id
@@ -1033,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title, series_title: row.series_title,
series_year: row.series_year, series_year: row.series_year,
original_language: row.original_language, original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name, release_name: row.release_name,
})); }));
+16 -7
View File
@@ -6,7 +6,6 @@ mod config;
mod grab; mod grab;
mod import; mod import;
mod indexers; mod indexers;
mod jellyfin;
mod manual; mod manual;
mod metadata; mod metadata;
mod notify; mod notify;
@@ -94,7 +93,7 @@ enum Error {
#[error("transmission client: {0}")] #[error("transmission client: {0}")]
Transmission(#[from] arr_dl::Error), Transmission(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")] #[error("jellyfin client: {0}")]
Jellyfin(#[from] jellyfin::Error), Jellyfin(#[from] arr_api::jellyfin::Error),
#[error("ntfy client: {0}")] #[error("ntfy client: {0}")]
Notify(#[from] notify::NotifyError), Notify(#[from] notify::NotifyError),
#[error("bind {addr}: {source}")] #[error("bind {addr}: {source}")]
@@ -124,6 +123,7 @@ async fn run() -> Result<(), Error> {
None None
}; };
let notifier = Notifier::new(config.ntfy_url.clone())?; let notifier = Notifier::new(config.ntfy_url.clone())?;
let api_jellyfin = jellyfin_client(&config)?;
let (reconcile, manual_grab, manual_tv) = let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?; reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own // Issue #176: the on-demand half of the metadata lane needs its own
@@ -144,7 +144,9 @@ async fn run() -> Result<(), Error> {
if let Some(tmdb_url) = config.tmdb_url { if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(tmdb_url); upstreams = upstreams.with_tmdb_url(tmdb_url);
} }
let state = AppState::new(upstreams)?.with_database(database.clone()); let state = AppState::new(upstreams)?
.with_database(database.clone())
.with_jellyfin(api_jellyfin);
let app = arr_api::router(state.clone()) let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat)) .merge(arr_compat::router(compat))
@@ -294,10 +296,7 @@ fn reconcile_loop(
} else { } else {
tracing::warn!("TMDB is not configured: series metadata refresh is disabled"); tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
} }
let jellyfin = jellyfin::JellyfinClient::new( let jellyfin = jellyfin_client(config)?;
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?;
// Grab before import, so a download that completes on this tick is // Grab before import, so a download that completes on this tick is
// imported on this tick. // imported on this tick.
reconcile = reconcile.register( reconcile = reconcile.register(
@@ -443,3 +442,13 @@ async fn shutdown() {
tracing::info!("shutting down"); tracing::info!("shutting down");
} }
/// The Jellyfin client, built fresh for each of its two independent callers:
/// import's own reconcile action, and the subtitle API's manual grab and
/// translate handlers (§7.5, §15).
fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient, Error> {
Ok(arr_api::jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?)
}
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language, title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average poster_path, backdrop_path, vote_average, runtime_minutes
FROM series FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
) )
@@ -118,7 +118,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language, title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average poster_path, backdrop_path, vote_average, runtime_minutes
FROM series WHERE id = ?"#, FROM series WHERE id = ?"#,
series_id series_id
) )
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
changed = true; changed = true;
} }
} }
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
// frequently empty; a known value is never overwritten by a missing
// one, so a series keeps its runtime across TMDB's blank spells.
let runtime = metadata.episode_runtime.map(i64::from);
if runtime.is_some() && runtime != stale.runtime_minutes {
sqlx::query!(
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
runtime,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
let ended = is_upstream_ended(&metadata.status); let ended = is_upstream_ended(&metadata.status);
if ended != stale.upstream_ended { if ended != stale.upstream_ended {
sqlx::query!( sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>, poster_path: Option<String>,
backdrop_path: Option<String>, backdrop_path: Option<String>,
vote_average: Option<f64>, vote_average: Option<f64>,
runtime_minutes: Option<i64>,
} }
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, /// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would /// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
assert_eq!(vote, Some(8.417)); assert_eq!(vote, Some(8.417));
} }
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
/// by, and a later refresh with TMDB's frequently-empty
/// `episode_run_time` never blanks a known value.
#[tokio::test]
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
let (_dir, database) = seeded_series(false).await;
let server = MockServer::start().await;
let body = |episode_run_time: serde_json::Value| {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"episode_run_time": episode_run_time,
"seasons": []
})
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
server.reset().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
.mount(&server)
.await;
expire_refresh(&database).await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1 /// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked, /// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted. /// nothing arrives wanted.
@@ -0,0 +1,6 @@
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
-- reference runtime, scaled by the series' minutes per episode. NULL is a
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
-- the bands apply unscaled.
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
+9
View File
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file. /// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); 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. /// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
+75 -1
View File
@@ -193,6 +193,7 @@ impl Db {
overrides: TitleOverrides { overrides: TitleOverrides {
only_4k: overrides.only_4k, only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio, allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
}, },
root_id: row.root_id, root_id: row.root_id,
root_kind: row.root_kind, root_kind: row.root_kind,
@@ -268,6 +269,75 @@ impl Db {
overrides: TitleOverrides { overrides: TitleOverrides {
only_4k: overrides.only_4k, only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio, allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
root_audience: row.root_audience,
root_path: row.root_path,
}))
}
/// The policy attached to one series' root, with the series' own
/// overrides (§5.1).
///
/// `None` when the series does not exist.
///
/// # Errors
///
/// If the query fails, or a policy column does not hold the JSON its
/// migration promises.
pub async fn series_policy(&self, series_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
let row = sqlx::query!(
r#"
SELECT s.overrides AS "overrides!: String",
r.id AS "root_id!: i64",
r.kind AS "root_kind!: String",
r.audience AS "root_audience!: String",
r.path AS "root_path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String",
p.required_audio AS "required_audio!: String",
p.dub_blacklist AS "dub_blacklist!: String",
p.hdr_rules AS "hdr_rules!: String",
p.size_bands AS "size_bands!: String",
p.resolution_pref AS "resolution_pref!: String",
p.source_weights AS "source_weights!: String",
p.score_weights AS "score_weights!: String"
FROM series s
JOIN roots r ON r.id = s.root_id
JOIN policies p ON p.id = r.policy_id
WHERE s.id = ?
"#,
series_id
)
.fetch_optional(self.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let overrides: OverridesJson = json("overrides", &row.overrides)?;
let policy = PolicyColumns {
id: row.policy_id,
name: row.policy_name,
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}
.to_policy()?;
Ok(Some(TitlePolicy {
policy,
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
}, },
root_id: row.root_id, root_id: row.root_id,
root_kind: row.root_kind, root_kind: row.root_kind,
@@ -337,6 +407,7 @@ impl Db {
overrides: TitleOverrides { overrides: TitleOverrides {
only_4k: overrides.only_4k, only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio, allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
}, },
root_id: row.root_id, root_id: row.root_id,
root_kind: row.root_kind, root_kind: row.root_kind,
@@ -428,6 +499,8 @@ struct OverridesJson {
only_4k: bool, only_4k: bool,
#[serde(default)] #[serde(default)]
allow_english_audio: bool, allow_english_audio: bool,
#[serde(default)]
allow_below_floor: bool,
} }
fn gib(value: u64) -> u64 { fn gib(value: u64) -> u64 {
@@ -623,7 +696,8 @@ mod tests {
&loaded.policy, &loaded.policy,
Resolution::R1080p, Resolution::R1080p,
one_and_a_half_gib, one_and_a_half_gib,
1 1,
0
), ),
Some(false) Some(false)
); );
+10
View File
@@ -156,6 +156,10 @@ pub struct Series {
pub vote_average: Option<f64>, pub vote_average: Option<f64>,
/// How many votes the rating rests on. /// How many votes the rating rests on.
pub vote_count: u32, pub vote_count: u32,
/// Minutes per episode, the first non-zero entry of TMDB's
/// `episode_run_time`. Frequently empty for returning series — §5.5
/// treats a missing runtime as the reference runtime.
pub episode_runtime: Option<u32>,
pub seasons: Vec<SeasonSummary>, pub seasons: Vec<SeasonSummary>,
} }
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
#[serde(default)] #[serde(default)]
vote_count: u32, vote_count: u32,
#[serde(default)] #[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>, seasons: Vec<RawSeasonSummary>,
#[serde(default)] #[serde(default)]
external_ids: Option<RawExternalIds>, external_ids: Option<RawExternalIds>,
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
backdrop_path: non_empty(raw.backdrop_path), backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average), vote_average: rating(raw.vote_average),
vote_count: raw.vote_count, vote_count: raw.vote_count,
episode_runtime: raw
.episode_run_time
.into_iter()
.find(|&minutes| minutes > 0),
seasons: raw seasons: raw
.seasons .seasons
.into_iter() .into_iter()
+20 -12
View File
@@ -218,10 +218,7 @@
--> -->
<main class="deck" id="movie" hidden aria-label="movie detail"> <main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head"> <p class="deck-status readout" id="movie-status" role="status" hidden></p>
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata"> <section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body"> <div class="movie-body">
@@ -266,12 +263,19 @@
<button type="button" class="control" id="movie-sweep">search indexers</button> <button type="button" class="control" id="movie-sweep">search indexers</button>
<button <button
type="button" type="button"
class="control control-quiet" class="control control-quiet control-icon"
id="movie-remove" id="movie-remove"
aria-expanded="false" aria-expanded="false"
aria-controls="remove-panel" 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> </button>
</div> </div>
<div class="remove-panel" id="remove-panel" hidden></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"> <main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head"> <p class="deck-status readout" id="series-status" role="status" hidden></p>
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<section class="module movie-hero" id="series-hero" aria-label="title metadata"> <section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body"> <div class="movie-body">
@@ -506,12 +507,19 @@
<span class="movie-controls-space"></span> <span class="movie-controls-space"></span>
<button <button
type="button" type="button"
class="control control-quiet" class="control control-quiet control-icon"
id="series-remove" id="series-remove"
aria-expanded="false" aria-expanded="false"
aria-controls="series-remove-panel" 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> </button>
</div> </div>
<div class="remove-panel" id="series-remove-panel" hidden></div> <div class="remove-panel" id="series-remove-panel" hidden></div>
+233 -68
View File
@@ -50,6 +50,7 @@ import {
movieFiles, movieFiles,
movieReleases, movieReleases,
movieSearchState, movieSearchState,
overridable,
probedAttributeTags, probedAttributeTags,
queueSearch, queueSearch,
removeMovie, removeMovie,
@@ -59,7 +60,6 @@ import {
totalSize, totalSize,
type WaiveOutcome, type WaiveOutcome,
waiveAndGrab, waiveAndGrab,
waiverOverride,
} from "./releases"; } from "./releases";
import { currentRoute, navigate, type Route } from "./router"; import { currentRoute, navigate, type Route } from "./router";
import { import {
@@ -103,7 +103,7 @@ import {
type TvTarget, type TvTarget,
waiveAndGrabTv, waiveAndGrabTv,
} from "./series"; } from "./series";
import { armedDelete, settingsMain } from "./settings"; import { settingsMain } from "./settings";
import "./style.css"; import "./style.css";
const POLL_MS = 15_000; const POLL_MS = 15_000;
@@ -393,14 +393,14 @@ function main() {
must<HTMLElement>("#library"), must<HTMLElement>("#library"),
{ kind: "library" }, { kind: "library" },
); );
// no origin click to restore focus to on a deep link — the series // no origin click to restore focus to on a deep link — the library
// view's back control is the closest stand-in // rail button is the closest stand-in
tvDeck.open({ tvDeck.open({
title, title,
sub, sub,
seriesId, seriesId,
target, target,
origin: must<HTMLButtonElement>("#series-back"), origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"), returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId }, 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 ------------------------- */ /* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */ /** 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. */ /** 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"); const link = document.createElement("a");
link.className = "control control-quiet"; link.className = "control control-quiet control-icon";
link.href = href; link.href = href;
link.target = "_blank"; link.target = "_blank";
link.rel = "noreferrer"; link.rel = "noreferrer";
link.textContent = label; link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link; return link;
} }
function movieMain(views: HideableView[]): MovieView { function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie"); const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck"); const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status"); const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero"); const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster"); const poster = must<HTMLImageElement>("#movie-poster");
@@ -1352,7 +1465,7 @@ function movieMain(views: HideableView[]): MovieView {
if (!movie) { if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false }; return { kind: "error", detail: "page closed", overrideWritten: false };
} }
if (bucket === "waived") { if (bucket !== "eligible") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule); return waiveAndGrab(movie.id, release.id, release.rejected_rule);
} }
const outcome = await grabRelease(movie.id, release.id); const outcome = await grabRelease(movie.id, release.id);
@@ -1414,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
} }
titleEl.textContent = movie.title; titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year); 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(); chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id); const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${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"; trailer.textContent = "trailer";
actionsEl.append(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) { 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) { if (current !== null) {
actionsEl.append( 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; filesSection.hidden = true;
diskRows.replaceChildren(); diskRows.replaceChildren();
sweep.disabled = false; sweep.disabled = false;
back.focus(); titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load(); await load();
} }
@@ -1929,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus(); target?.focus();
} }
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer — // capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window // the library and search decks also listen for Escape on this window
window.addEventListener( window.addEventListener(
@@ -2091,7 +2218,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
interface ReleaseActions { interface ReleaseActions {
reload: () => Promise<void>; reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void; notify: (text: string, tone?: "fault") => void;
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */ /** One click on grab — a plain grab, or waive-then-grab where the rule
* that failed has an override (§9.3). */
grab: ( grab: (
release: MovieRelease, release: MovieRelease,
bucket: "eligible" | "waived" | "rejected", bucket: "eligible" | "waived" | "rejected",
@@ -2155,10 +2283,16 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions)); dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
} }
} else { } else {
// §9.3: over-strict filters must be visible, not silently absent // §9.3: over-strict filters must be visible, not silently absent — and
// where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket.
const waivable = releases.filter(overridable).length;
const none = document.createElement("li"); const none = document.createElement("li");
none.className = "rel rel-none readout dim"; none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy"; none.textContent =
waivable > 0
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
: "none — every candidate was waived or rejected by policy";
dom.eligible.rows.append(none); dom.eligible.rows.append(none);
} }
for (const name of ["waived", "rejected"] as const) { for (const name of ["waived", "rejected"] as const) {
@@ -2295,8 +2429,14 @@ function releaseRow(
); );
} }
if (bucket !== "eligible") { if (bucket !== "eligible") {
// A rejected row always names its rule; a waived one cannot — the
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
// `waived` plainly rather than calling a classified row unclassified.
const verdict = release.rejected_rule
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
: bucket;
line.append( line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => { chip(verdict, (span) => {
span.dataset.verdict = bucket; span.dataset.verdict = bucket;
}), }),
); );
@@ -2313,11 +2453,14 @@ function releaseRow(
note.setAttribute("role", "status"); note.setAttribute("role", "status");
note.hidden = true; note.hidden = true;
if (bucket !== "rejected") { // A rejected row is normally inert, but §9.3's one click is exactly for
// the rule the operator disagrees with: where an override exists, the row
// offers it and the grab that follows stays a waiver.
const writesOverride = bucket !== "eligible" && overridable(release);
if (bucket !== "rejected" || writesOverride) {
const grab = document.createElement("button"); const grab = document.createElement("button");
grab.type = "button"; grab.type = "button";
grab.className = "control rel-grab"; grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab"; grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => { grab.addEventListener("click", () => {
grab.disabled = true; grab.disabled = true;
@@ -2829,7 +2972,7 @@ function tvReleasesMain(): TvReleasesView {
if (!current) { if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false }); return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
} }
if (bucket === "waived") { if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
} }
return current.target return current.target
@@ -3109,7 +3252,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series"); const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck"); const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero"); const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster"); const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title"); const titleEl = must<HTMLElement>("#series-title");
@@ -3214,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
} }
titleEl.textContent = current.title; titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year); 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(); chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id); const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`)); chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -3316,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer"; trailer.textContent = "trailer";
actionsEl.append(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) // a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) { 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) { if (series !== null) {
actionsEl.append( 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 { function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season); const counts = seasonCounts(season);
return countsChip( const span = countsChip(
`${counts.available}/${counts.wanted} on disk`, `${counts.available}/${counts.wanted}`,
counts.available, counts.available,
counts.wanted, counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`, `${counts.available} of ${counts.wanted} wanted episodes on disk`,
); );
span.prepend(icon("disk"));
return span;
} }
function seasonRow(season: ApiSeason): HTMLLIElement { function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -3402,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button"); const deckBtn = document.createElement("button");
deckBtn.type = "button"; deckBtn.type = "button";
deckBtn.className = "control"; deckBtn.className = "control control-icon";
deckBtn.textContent = "deck"; 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", () => { deckBtn.addEventListener("click", () => {
const currentId = seriesId; const currentId = seriesId;
const currentTitle = series?.title ?? ""; const currentTitle = series?.title ?? "";
@@ -3436,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed. // #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle. // Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) { if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => { const clear = armedDeleteIcon(
const currentId = seriesId; `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
if (currentId === null) { () => {
return; const currentId = seriesId;
} if (currentId === null) {
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return; return;
} }
// the control itself disappears with the files; the tracked void removeSeasonFiles(currentId, season.number).then((outcome) => {
// toggle is the season's control that survives the repaint if (outcome.kind === "error") {
focusKey = `track-${season.number}`; clear.disabled = false;
void load(); setStatus(`remove failed — ${outcome.detail}`, "fault");
}); return;
}); }
clear.setAttribute( // the control itself disappears with the files; the tracked
"aria-label", // toggle is the season's control that survives the repaint
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, focusKey = `track-${season.number}`;
void load();
});
},
); );
line.append(clear); line.append(clear);
} }
@@ -3523,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) { if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row // #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row. // stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => { const clear = armedDeleteIcon(
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",
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`, `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); actions.append(clear);
} else if (!aired) { } else if (!aired) {
@@ -3568,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button"); const deckBtn = document.createElement("button");
deckBtn.type = "button"; deckBtn.type = "button";
deckBtn.className = "control"; deckBtn.className = "control control-icon";
deckBtn.textContent = "deck"; 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", () => { deckBtn.addEventListener("click", () => {
const currentId = seriesId; const currentId = seriesId;
const currentTitle = series?.title ?? ""; const currentTitle = series?.title ?? "";
@@ -3777,7 +3943,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false; view.hidden = false;
clearRemove(); clearRemove();
clearRichDetail(); clearRichDetail();
back.focus(); titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load(); await load();
} }
@@ -3798,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus(); origin?.focus();
} }
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered // capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer // first, so one Esc steps back one layer
window.addEventListener( window.addEventListener(
+12
View File
@@ -257,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise<A
* The per-title override one click on a waived row writes (§5.2, §9.3). * The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy — the * Mapping a rule name to its override is bookkeeping, not policy — the
* verdict itself always comes from the API. * verdict itself always comes from the API.
*
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
* the release stays out of automatic grabbing and imports on the record as
* a §5.7 waiver. No band is right for every title, which is why the
* override exists at all.
*/ */
export function waiverOverride(rule: string | null): Record<string, unknown> | null { export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) { switch (rule) {
@@ -264,11 +269,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
return { allow_english_audio: true }; return { allow_english_audio: true };
case "resolution": case "resolution":
return { only_4k: false }; return { only_4k: false };
case "size":
return { allow_below_floor: true };
default: default:
return null; return null;
} }
} }
/** Whether one click on this row has an override to write (§9.3). */
export function overridable(release: { rejected_rule: string | null }): boolean {
return waiverOverride(release.rejected_rule) !== null;
}
export type WaiveOutcome = export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean } | { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean }; | { kind: "error"; detail: string; overrideWritten: boolean };
+62
View File
@@ -470,6 +470,62 @@ body {
outline-offset: 2px; 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) ------------------------------------------- */ /* ---- unified search (§9.2) ------------------------------------------- */
.rail-search { .rail-search {
@@ -528,6 +584,12 @@ body {
flex: 1; flex: 1;
} }
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status { .deck-status {
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
color: var(--ink-muted); color: var(--ink-muted);