Merge main into blitz/subtitles

Feedback pass 2 and the size-band work landed on main while this branch
was finishing. Brings them in ahead of the merge back.

# Conflicts:
#	crates/arr-api/src/movies.rs
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
#	web/src/main.ts
This commit is contained in:
Miguel Palhas
2026-08-25 17:53:18 +01:00
76 changed files with 7184 additions and 416 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
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
own control per row; on a waived row it is "waive + grab" and writes the
per-title override (rule→override mapping in releases.ts — bookkeeping,
never policy; verdicts always come from the API). Esc steps back one
own control per row; where the rule that failed has an override it reads
"waive + grab" and writes the per-title override (rule→override mapping in
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.
- 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.
@@ -94,5 +94,5 @@
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
"hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?",
"describe": {
"columns": [
{
"name": "required_audio!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484"
}
@@ -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",
"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": {
"columns": [
{
@@ -103,8 +103,19 @@
}
},
{
"name": "release_name!: String",
"name": "runtime_minutes",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
@@ -127,8 +138,9 @@
false,
true,
true,
true,
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"
}
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM movies WHERE root_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
}
@@ -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",
"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": {
"columns": [],
"parameters": {
"Right": 13
"Right": 14
},
"nullable": []
},
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
"hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -170,7 +170,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -190,5 +190,5 @@
true
]
},
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
"hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
"describe": {
"columns": [
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "target_id"
}
}
},
{
"name": "name!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "infohash!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "infohash"
}
}
},
{
"name": "failed_at",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "failed_at"
}
}
},
{
"name": "grabbed_at!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "grabbed_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe"
}
@@ -1,6 +1,6 @@
{
"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": {
"columns": [
{
@@ -145,6 +145,17 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
@@ -163,8 +174,9 @@
true,
true,
true,
true,
true
]
},
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -82,7 +82,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -94,5 +94,5 @@
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
"hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
"hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f"
"hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f"
}
@@ -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",
"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": {
"columns": [
{
@@ -114,8 +114,19 @@
}
},
{
"name": "release_name!: String",
"name": "runtime_minutes",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
@@ -139,8 +150,9 @@
false,
true,
true,
true,
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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
"hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist",
"query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id",
"describe": {
"columns": [
{
@@ -24,6 +24,17 @@
"name": "infohash"
}
}
},
{
"name": "reason!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "blacklist",
"name": "reason"
}
}
}
],
"parameters": {
@@ -31,8 +42,9 @@
},
"nullable": [
false,
true
true,
false
]
},
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97"
"hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
"hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM roots\n WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?\n AND (? IS NULL OR id <> ?)",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs SET state = 'failed' WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM series WHERE root_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -71,7 +71,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -82,5 +82,5 @@
false
]
},
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
"hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777"
}
@@ -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",
"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": {
"columns": [
{
@@ -145,6 +145,17 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
@@ -163,8 +174,9 @@
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,12 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1"
}
@@ -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"
}
+104 -1
View File
@@ -199,6 +199,26 @@ fake profile per root and ignoring whatever it sends.
Per-title `overrides` relax the root policy for one title. Same mechanism in
both directions — `only_4k` tightens, `allow_english_audio` loosens.
**Stored verdicts follow the effective policy.** A release's verdict is
stamped by the search that found it, and both §9.3's deck and the daemon's
manual-grab gate read that stored column. So the four operator actions that
change a title's effective policy — editing its overrides, moving it to a
root with a different policy, pointing a root at a different policy, and
editing the contents of a policy some root points at — re-derive the stored
verdicts of everything they touch, inline in the same request. The operator
is never left reading a verdict computed under a policy that no longer
applies.
The fourth is the widest: a repoint moves one library, a policy edit moves
every library sharing the policy. Inline still holds there. Re-evaluation is
pure and in-memory, a row whose verdict does not move is not rewritten, and
the ceiling is the whole database rather than something that grows with the
number of roots — roots partition titles, and a title has exactly one root.
Measured over 2000 titles and 10 000 stored releases split across two roots
sharing one policy: 0.36 s when the edit moves no verdict, 2.7 s in the
pathological case where it moves all 10 000. A rename changes no rule and
re-derives nothing.
### 5.2 Language
Requires a concept the release name does not carry: the **original language of
@@ -307,6 +327,26 @@ measured by its full size. That is the behaviour today, and it makes a pack look
oversized rather than undersized — it fails toward rejecting a good pack rather
than grabbing a bad one, and the next search after a refresh has the real count.
**A band describes a rate, not a fixed size per episode.** The shipped values
are read against a **reference runtime of 45 minutes**: a 1080p target of 2 GiB
means 2 GiB per 45 minutes of episode. Before the per-episode figure is compared
against them, a band's floor and target are both scaled by
`runtime / 45`, where `runtime` is the series' minutes-per-episode from TMDB
metadata. For a typical drama around 45 minutes the shipped numbers keep exactly
their current meaning; a 22-minute show is judged against roughly half the floor
and half the target instead of being rejected for weighing half of what an hour
of video weighs. The shipped band values themselves do not change — the
reference runtime is chosen so they do not have to.
**A missing or zero runtime is the reference runtime.** When metadata carries no
per-episode runtime, or carries zero, the scale factor is 1 and the band applies
unscaled — exactly today's behaviour. The rule does not guess a duration, for
the same reason the unknown episode count does not guess a number.
**Movies are not scaled.** This applies to episodes only. A movie's bands are
already tuned against feature length, so its floor and target keep their current
meaning regardless of the movie's own runtime.
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
does not matter. Telesync, CAM and screener are **hard filters**, not low
@@ -358,6 +398,46 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3.
**Two hard failures make a decision, and only for 30 days.** A movie, an
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
against *different* releases hard-failed on it, and both of those failures
happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working.
The window runs from the failure, not the grab. The two are usually minutes
apart, but a torrent can sit stalling on a dead swarm for five weeks before
`ffprobe` finally condemns it — and that failure is fresh evidence the target
is broken now, not history. Measured from the grab it would be born outside
the window and a genuinely broken target could never surface. So `grabs`
records `failed_at` alongside `grabbed_at`, and the window reads it.
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 already express one rule — bounded attention — and this is another
face of it: 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 not `available`. A
season pack that hard-failed twice, fell back to per-episode grabbing
exactly as §6.2 says it should, and was then fully acquired leaves at once
rather than waiting out the 30 days — that is the system working, not a
decision. A target that is still broken keeps producing failures and stays.
The same rule applies on all three lanes and in both readers.
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
views of one queue; filtering differently tells the operator two different
stories on two channels.
## 6. Sourcing
### 6.1 Prowlarr, per-indexer Torznab
@@ -400,6 +480,22 @@ its own, the escape hatch is the season release deck, not a lane exception.
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
title's metadata changes. It never gives up entirely, it goes quiet.
**The ladder runs from the failure, not the grab.** A failed season-pack grab
quiets the pack lane on that same curve, and the rung is measured from the
moment the grab entered `failed``grabs.failed_at`, the column §5.7's
attention window reads — not from when it was sent. The two are usually
minutes apart, but a torrent can stall on a dead swarm for five weeks before
`ffprobe` condemns it at import. Measured from the grab, the whole ladder has
already elapsed by the time the failure lands, so the lane retries the source
that just failed at once, which is the one thing the backoff exists to
prevent. The ladder's job is to stay off a source that has recently failed,
and "recently" can only mean recently failed.
One anchor covers both features. §5.7's window and this ladder ask the same
question of the same event and read the same column; a target still broken
keeps producing fresh failures, and each one both re-arms this backoff and
holds the target in the attention queue.
**Do not search before the release exists.** TMDB carries release dates; a
movie with no digital release date gets zero targeted searches. This is the
single largest source of wasted queries in Radarr and it is free to avoid.
@@ -468,6 +564,13 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots
are assumed to share one filesystem, so the move is a rename, never a copy.
Changing a root's path is the same move over every title under it, and it
is all or nothing: one folder that cannot move puts back the ones that
already did and leaves the root's path alone, so the stored path always
describes the disk.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of.
@@ -576,7 +679,7 @@ notifying on everything and being muted within a week.
- **Imported** → to the title's owners. The only good-news notification.
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
or hard-failed twice on different releases.
or the needs-a-decision queue (§5.7).
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
unreachable, disk full.
+2
View File
@@ -12,6 +12,8 @@ mod metadata;
mod movies;
mod owners;
mod policies;
mod reclassify;
mod relocate;
mod roots;
mod search;
mod series;
File diff suppressed because it is too large Load Diff
+332 -9
View File
@@ -173,6 +173,33 @@ struct PolicyColumns {
score_weights: String,
}
/// Everything in a policy row that a verdict depends on — the name is the
/// one column that does not.
#[derive(PartialEq, Eq)]
struct PolicyRules {
required_audio: String,
dub_blacklist: String,
hdr_rules: String,
size_bands: String,
resolution_pref: String,
source_weights: String,
score_weights: String,
}
impl PolicyColumns {
fn rules(&self) -> PolicyRules {
PolicyRules {
required_audio: self.required_audio.clone(),
dub_blacklist: self.dub_blacklist.clone(),
hdr_rules: self.hdr_rules.clone(),
size_bands: self.size_bands.clone(),
resolution_pref: self.resolution_pref.clone(),
source_weights: self.source_weights.clone(),
score_weights: self.score_weights.clone(),
}
}
}
fn column<T: serde::de::DeserializeOwned>(
column: &'static str,
value: &str,
@@ -376,6 +403,8 @@ pub async fn update(
input.validate().map_err(ApiError::Invalid)?;
let name = input.name.trim().to_owned();
let columns = input.into_columns(name)?;
let after = columns.rules();
let before = stored_rules(&state, id).await?;
let result = sqlx::query!(
r#"UPDATE policies SET
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
@@ -405,9 +434,44 @@ pub async fn update(
if result.rows_affected() == 0 {
return Err(ApiError::PolicyNotFound);
}
// Every verdict stored under every root pointing here was reached under
// the rules this write just replaced; §9.3's deck and the grab gate both
// read them (`reclassify`). A rename leaves the rules alone, so it walks
// nothing.
if before.is_none_or(|before| before != after) {
crate::reclassify::policy(&state, id).await?;
}
Ok(Json(load_policy(&state, id).await?))
}
/// The rule columns of a policy as they stand, or `None` when there is no
/// such row. Compared against what the write is about to store, so an edit
/// that only moves the name does not re-derive a library.
async fn stored_rules(state: &AppState, id: i64) -> Result<Option<PolicyRules>, ApiError> {
let row = sqlx::query!(
r#"SELECT required_audio AS "required_audio!: String",
dub_blacklist AS "dub_blacklist!: String",
hdr_rules AS "hdr_rules!: String",
size_bands AS "size_bands!: String",
resolution_pref AS "resolution_pref!: String",
source_weights AS "source_weights!: String",
score_weights AS "score_weights!: String"
FROM policies WHERE id = ?"#,
id
)
.fetch_optional(pool(state)?)
.await?;
Ok(row.map(|row| PolicyRules {
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,
}))
}
#[utoipa::path(
delete, path = "/api/policies/{policy_id}", tag = "policies",
params(("policy_id" = i64, Path, description = "Policy row id")),
@@ -450,7 +514,7 @@ mod tests {
use crate::{router, Upstreams};
use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) {
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
@@ -466,9 +530,9 @@ mod tests {
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"))
(dir, state, format!("http://{address}"))
}
fn valid_input(name: &str) -> serde_json::Value {
@@ -501,7 +565,7 @@ mod tests {
#[tokio::test]
async fn crud_round_trips_a_policy() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let created: serde_json::Value = create(&base, valid_input("test policy"))
.await
@@ -557,7 +621,7 @@ mod tests {
#[tokio::test]
async fn a_referenced_policy_refuses_to_die() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -582,7 +646,7 @@ mod tests {
#[tokio::test]
async fn an_unknown_resolution_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let mut payload = valid_input("bad bands");
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
@@ -596,7 +660,7 @@ mod tests {
#[tokio::test]
async fn every_field_validates_by_name() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
let mut payload = valid_input("validation probe");
patch(&mut payload);
@@ -645,7 +709,7 @@ mod tests {
#[tokio::test]
async fn malformed_json_is_422_not_400_or_500() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let client = reqwest::Client::new();
let response = client
.post(format!("{base}/api/policies"))
@@ -667,8 +731,267 @@ mod tests {
#[tokio::test]
async fn a_duplicate_name_conflicts() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let response = create(&base, valid_input("Movies — main")).await;
assert_eq!(response.status(), StatusCode::CONFLICT);
}
async fn policy_id_named(base: &str, name: &str) -> i64 {
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
.await
.expect("policies")
.json()
.await
.expect("policies json");
policies
.iter()
.find(|policy| policy["name"] == name)
.and_then(|policy| policy["id"].as_i64())
.expect("policy id")
}
/// A movie under `root_id`, with one English 1080p release stamped
/// `verdict` as a search would have stamped it.
async fn movie_with_release(
state: &AppState,
base: &str,
root_id: i64,
tmdb_id: i64,
verdict: &str,
) -> i64 {
let movie: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024,
"original_language": "en", "root_id": root_id, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = movie["id"].as_i64().expect("movie id");
let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP");
let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await;
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("movie association");
movie_id
}
/// One release row, verdict stamped by hand. `waived` carries no rule,
/// which the `releases` CHECK allows — only `rejected` needs one.
async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 {
let parsed = arr_parse::parse(name);
sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id",
)
.bind(name)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.bind(verdict)
.fetch_one(state.database().expect("database").pool())
.await
.expect("release")
}
/// A series under `root_id` with a ten-episode season 9 pack stamped
/// `waived`. Under its own policy the pack is eligible, so the stamp
/// only survives if nothing re-derived it.
async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
let client = reqwest::Client::new();
let series: serde_json::Value = client
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
"original_language": "en", "root_id": root_id,
"auto_track": false
}))
.send()
.await
.expect("create series")
.json()
.await
.expect("series json");
let series_id = series["id"].as_i64().expect("series 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: serde_json::Value = client
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
.send()
.await
.expect("create season")
.json()
.await
.expect("season json");
let season_id = season["id"].as_i64().expect("season id");
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
let release_id = stamped_release(
state,
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
15_i64 * (1 << 30),
"waived",
)
.await;
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("season association");
series_id
}
async fn verdict_at(url: String) -> serde_json::Value {
let releases: Vec<serde_json::Value> = reqwest::get(url)
.await
.expect("releases")
.json()
.await
.expect("releases json");
releases[0]["verdict"].clone()
}
async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value {
verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await
}
async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 {
sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?")
.bind(kind)
.bind(audience)
.fetch_one(state.database().expect("database").pool())
.await
.expect("root id")
}
/// The same policy document with a different required-audio rule, so an
/// English release that was eligible becomes a waiver.
fn requires_portuguese(name: &str) -> serde_json::Value {
let mut payload = valid_input(name);
payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] });
payload
}
/// #246: editing a policy re-derives the stored verdicts of every title
/// under every root pointing at it (§5.1) — both libraries when two
/// roots share it, and nothing under a root that does not.
#[tokio::test]
async fn editing_a_policy_rederives_every_root_that_points_at_it() {
let (_dir, state, base) = application().await;
let client = reqwest::Client::new();
let shared = policy_id_named(&base, "Movies — main").await;
let main_root = root_id_of(&state, "movie", "main").await;
let kids_root = root_id_of(&state, "movie", "kids").await;
// Two roots on one policy: the edit has two libraries to reach
// rather than one. The path stays, so nothing on disk moves.
let response = client
.put(format!("{base}/api/roots/{kids_root}"))
.json(&serde_json::json!({
"kind": "movie", "audience": "kids",
"path": "/mnt/media/movies/kids", "policy_id": shared,
}))
.send()
.await
.expect("repoint kids root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await;
let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await;
// On the TV main root, a different policy, so the edit must not
// reach it. Stamped against its own policy's answer, so a walk that
// did reach it would show.
let tv_root = root_id_of(&state, "tv", "main").await;
let series_id = series_with_waived_pack(&state, &base, tv_root).await;
let response = client
.put(format!("{base}/api/policies/{shared}"))
.json(&requires_portuguese("Movies — main"))
.send()
.await
.expect("edit policy");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
movie_verdict(&base, here).await,
"waived",
"the first root's library re-derives"
);
assert_eq!(
movie_verdict(&base, there).await,
"waived",
"and so does the second root's, sharing the policy"
);
assert_eq!(
verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"waived",
"a root on another policy keeps the verdict it was stamped with"
);
}
/// Renaming a policy changes no rule, so it re-derives nothing — the
/// stamped verdict survives even though the rules would not produce it.
#[tokio::test]
async fn renaming_a_policy_leaves_verdicts_alone() {
let (_dir, state, base) = application().await;
let client = reqwest::Client::new();
let kids = policy_id_named(&base, "Movies — kids").await;
let kids_root = root_id_of(&state, "movie", "kids").await;
// Write the rules through the API once, so the rename that follows
// stores byte-identical rule columns and the only change is the name.
let response = client
.put(format!("{base}/api/policies/{kids}"))
.json(&valid_input("Movies — kids"))
.send()
.await
.expect("normalise policy");
assert_eq!(response.status(), StatusCode::OK);
// Deliberately the wrong answer: these rules make an English release
// a waiver, so a re-derivation would move this row.
let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await;
let response = client
.put(format!("{base}/api/policies/{kids}"))
.json(&valid_input("Movies — children"))
.send()
.await
.expect("rename policy");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
movie_verdict(&base, movie).await,
"eligible",
"a rename touches no rule, so it walks nothing"
);
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Stored verdicts, re-derived when a title's effective policy changes:
//! an overrides edit (§9.3), a move to a root with a different policy, a
//! root pointed at a different policy, or an edit to the contents of a
//! policy some root points at (§5.1).
//!
//! 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. A root move and a
//! policy repoint invalidate the column the same way, just wider: nothing
//! else re-reads it, so the write that changed the effective policy is the
//! only place the correction can happen. A policy edit invalidates it wider
//! still: every root pointing at that policy, not just one.
//!
//! 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
}
/// Re-evaluate every release of every title under one root, for a
/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1).
///
/// Title by title through [`movie`] and [`series`], so the skip rules — no
/// stored original language, the blacklist (§6.3) — stay in one place. Rows
/// whose verdict the new policy does not move are left unwritten, so the
/// usual case (most of a library re-evaluates to the same answer) costs
/// reads, not writes.
pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
let movie_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#,
root_id
)
.fetch_all(pool(state)?)
.await?;
for movie_id in movie_ids {
movie(state, movie_id).await?;
}
let series_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#,
root_id
)
.fetch_all(pool(state)?)
.await?;
for series_id in series_ids {
series(state, series_id).await?;
}
Ok(())
}
/// Re-evaluate every release of every title under every root that points at
/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1).
///
/// Root by root through [`root`], which is title by title through [`movie`]
/// and [`series`]: one walker, one set of skip rules, one place that decides
/// a row does not need rewriting.
///
/// This is the widest of the four re-derivations — a root repoint moves one
/// library, a policy edit moves every library sharing the policy — but the
/// ceiling is the whole database rather than something that grows with it,
/// since roots partition titles and a title has exactly one root.
pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> {
let root_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#,
policy_id
)
.fetch_all(pool(state)?)
.await?;
for root_id in root_ids {
root(state, root_id).await?;
}
Ok(())
}
#[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,
);
// A waiver records the rule it relaxed, the same as a rejection
// (#211). Migration 0032 relaxed `releases` to
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
// can, and the daemon writes waivers the same way.
let (verdict, rule) = verdict_columns(&evaluation.verdict);
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)))
}
+418
View File
@@ -0,0 +1,418 @@
//! 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. This module only moves things: the
//! stored verdicts a root change invalidates (§5.1) are re-derived by the
//! calling handler through `reclassify`, after the row commits.
//!
//! 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)>,
/// Every directory level this request materialised for the new root,
/// deepest first. `create_dir_all` can make more than one — moving a
/// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that
/// exists creates both `tv` and `kids` — and [`Self::undo`] removes all
/// of them, so a failed move leaves nothing behind (#244). Levels that
/// were already on disk are never in this list and are never touched. A
/// move that commits never calls `undo` and the directories stay.
created_dirs: Vec<PathBuf>,
}
/// 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).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).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,
) -> 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())),
}
}
// Tracked only for the levels this call is the one to create, so a
// failed move can remove them again without ever touching a directory
// that already existed on disk. Recorded before `create_dir_all`, since
// afterwards there is no way to tell which levels it made.
let mut created_dirs: Vec<PathBuf> = Vec::new();
// A configured root need not exist on disk. Without creating it here,
// `rename` reports its missing parent as the same `NotFound` as a missing
// source and sends the operator looking at the wrong end of the move.
if !renames.is_empty() {
created_dirs = missing_levels(std::path::Path::new(new_root)).await;
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, created_dirs).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, created_dirs).await),
}
}
Ok(Relocation {
performed,
rewrites,
created_dirs,
})
}
/// The levels of `path` that are not on disk, deepest first — exactly what a
/// following `create_dir_all` will materialise. The walk stops at the first
/// ancestor that exists, so nothing already there is ever listed.
async fn missing_levels(path: &std::path::Path) -> Vec<PathBuf> {
let mut missing = Vec::new();
for ancestor in path.ancestors() {
// `ancestors` ends in an empty path for a relative input; there is
// no level above that to create.
if ancestor.as_os_str().is_empty() || tokio::fs::symlink_metadata(ancestor).await.is_ok() {
break;
}
missing.push(ancestor.to_path_buf());
}
missing
}
/// One rename failed: move back everything that had already moved, remove
/// every directory level this request created, 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>,
created_dirs: Vec<PathBuf>,
) -> ApiError {
Relocation {
performed,
rewrites: Vec::new(),
created_dirs,
}
.undo()
.await;
// Name both ends. `rename` returns NotFound for a missing destination
// parent as readily as for a missing source, and naming only the source
// sent an operator looking for a file that was sitting where they left it.
ApiError::Filesystem(format!(
"could not move '{}' to '{}': {error}",
rename.source.display(),
rename.destination.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"
);
}
}
// `remove_dir` rather than `remove_dir_all`: it only succeeds on an
// empty directory, so anything unexpected left inside it — this
// request's own undo failing, say — is a reason to leave it alone.
// Deepest first, since a parent cannot go while its child is there;
// the first level that will not go stops the walk, because every
// level above it now has content and refusing is the right answer.
for directory in &self.created_dirs {
if let Err(error) = tokio::fs::remove_dir(directory).await {
tracing::warn!(
path = %directory.display(),
%error,
"could not remove a directory created for a move that did not complete"
);
break;
}
}
}
}
/// §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(),
})
}
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
pub score_terms: ScoreTerms,
pub verdict: String,
pub rule: Option<String>,
/// What the blacklist recorded this release as failing on (#227, §6.3),
/// when `rule` is `blacklisted`. `None` on every other row. A policy
/// rejection and a bad release both read as `blacklisted` without it.
pub blacklist_reason: Option<String>,
}
#[utoipa::path(
@@ -490,6 +494,7 @@ async fn movie_releases(
&original_language,
&blacklist,
None,
0,
)?);
}
}
@@ -525,6 +530,7 @@ async fn episode_releases(
.await?
.ok_or(ApiError::EpisodeNotFound)?;
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
.episode_policy(episode_id)
.await
@@ -568,6 +574,7 @@ async fn episode_releases(
&original_language,
&blacklist,
Some(&season_lengths),
runtime_minutes,
)?);
}
}
@@ -709,6 +716,7 @@ fn classify(
original_language: &Language,
blacklist: &Blacklist,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
@@ -724,11 +732,17 @@ fn classify(
Candidate::PreGrab(&parsed),
release.size,
episodes,
runtime_minutes,
);
// #227: the reason the blacklist holds is what tells a policy rejection
// the operator can relax from a release that should never be retried.
let blacklist_reason = blacklist
.reason_for_candidate(&release.name, &release.download_url)
.map(str::to_owned);
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned()))
} else {
verdict(&evaluation.verdict)
verdict_columns(&evaluation.verdict)
};
let score = score(
policy,
@@ -736,6 +750,7 @@ fn classify(
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episodes,
runtime_minutes,
);
// 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
@@ -775,10 +790,11 @@ fn classify(
score_terms: terms,
verdict: verdict.to_owned(),
rule,
blacklist_reason,
})
}
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
match verdict {
Verdict::Eligible => ("eligible", None),
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
@@ -1465,6 +1481,7 @@ mod tests {
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release")
};
@@ -1558,7 +1575,7 @@ mod tests {
imdb_id: None,
};
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(
release,
@@ -1567,6 +1584,7 @@ mod tests {
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release");
+641 -16
View File
@@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
use crate::movies::{
attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release,
};
use crate::owners::Owner;
use crate::search::tmdb_client;
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
@@ -110,6 +112,10 @@ pub struct Season {
/// which is why the row still exists. A conflict for the operator to
/// resolve; nothing was deleted from disk.
pub vanished: bool,
/// #227. The last season pack that downloaded in full and was condemned
/// at import, while the season is still waiting for a file. `None` when
/// no pack was abandoned, or when the gap has since been filled.
pub import_failure: Option<ImportFailure>,
pub episodes: Vec<Episode>,
}
@@ -184,10 +190,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
};
if object
.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(
"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()) {
@@ -468,11 +474,15 @@ pub async fn create(
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 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!(
"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.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)?)
.await?;
@@ -561,8 +571,11 @@ pub async fn update(
if title.trim().is_empty() {
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)?;
// 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 =
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = title.trim();
@@ -575,9 +588,57 @@ pub async fn update(
let auto_track = input.auto_track.unwrap_or(current.auto_track);
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
let blocked = input.blocked.unwrap_or(current.blocked);
// A root change moves the §7.4 title folder with the row (issue #228).
// Disk first, row second: a failed rename leaves the row alone, so the
// 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(pool(&state)?)
.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;
}
// A root change swaps the effective policy the same way an overrides
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
// when the root changed.
if overrides_changed || relocation.is_some() {
crate::reclassify::series(&state, id).await?;
}
Ok(Json(load_series(&state, id).await?))
}
@@ -684,7 +745,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
@@ -694,7 +755,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
}
}
Ok(())
@@ -1030,6 +1091,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
.fetch_all(pool(state)?)
.await?;
let mut failures = season_import_failures(state, series_id).await?;
Ok(seasons
.into_iter()
.map(|season| Season {
@@ -1038,6 +1101,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
number: season.number,
tracked: season.tracked,
vanished: season.vanished,
import_failure: failures.remove(&season.id),
episodes: episodes
.iter()
.filter(|episode| episode.season_id == season.id)
@@ -1407,7 +1471,7 @@ pub async fn episode_releases(
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> {
let episode = load_episode(&state, id).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
.fetch_all(pool(&state)?)
.await?;
let policy = state
@@ -1419,7 +1483,9 @@ pub async fn episode_releases(
.ok_or(ApiError::EpisodeNotFound)?
.policy;
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)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases))
}
@@ -1458,6 +1524,19 @@ pub async fn grab_episode(
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
/// revealed episodes counts zero, which `claimed_episode_count` treats as
/// 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(
state: &AppState,
series_id: i64,
@@ -1548,7 +1627,7 @@ pub async fn season_releases(
) -> Result<Json<Vec<Release>>, ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
.fetch_all(pool(&state)?)
.await?;
let policy = state
@@ -1560,10 +1639,94 @@ pub async fn season_releases(
.ok_or(ApiError::SeasonNotFound)?
.policy;
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)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases))
}
/// A grab for this season that downloaded in full and was then condemned at
/// import (#227, §5.7).
///
/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to
/// the reaper — the release is blacklisted and every episode it was covering
/// reopens as a gap. Nothing on screen connected the two, so the season read
/// `0/10` as though no grab had ever been tried. Every fact is already
/// recorded; this is the row that carries them out.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ImportFailure {
/// The blacklisted release name, as the indexer spelled it.
pub release: String,
/// What the blacklist recorded it as failing on — a policy rule name the
/// operator can relax, or a sentence about the release itself. `None`
/// when no blacklist row answers to either key, which is possible after a
/// blacklist row is cleared by hand; the failure is still true.
pub reason: Option<String>,
/// When the grab entered `failed`, RFC3339. `None` on a row that failed
/// before migration 0030 gave the column a value.
pub failed_at: Option<String>,
}
/// The most recent abandoned pack per season of one series (#227).
///
/// Only seasons still waiting for a file are answered: once the gap is
/// filled, the failure is history and the season has nothing to explain. The
/// blacklist reason is matched in memory because its name key is normalised,
/// which SQL cannot compute.
async fn season_import_failures(
state: &AppState,
series_id: i64,
) -> Result<HashMap<i64, ImportFailure>, ApiError> {
let pool = pool(state)?;
let rows = sqlx::query!(
r#"SELECT g.target_id AS "season_id!: i64",
r.name AS "name!: String",
g.infohash AS "infohash!: String",
g.failed_at,
g.grabbed_at AS "grabbed_at!: String"
FROM grabs g
JOIN releases r ON r.id = g.release_id
JOIN seasons s ON s.id = g.target_id
WHERE g.target_kind = 'season'
AND g.state = 'failed'
AND s.series_id = ?
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = s.id AND e.wanted
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
)
)
ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#,
series_id
)
.fetch_all(pool)
.await?;
if rows.is_empty() {
return Ok(HashMap::new());
}
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
// Ascending order, so the last row written for a season wins.
Ok(rows
.into_iter()
.map(|row| {
let reason = blacklist
.reason_for_infohash(&row.infohash)
.or_else(|| blacklist.reason_for_name(&row.name))
.map(str::to_owned);
(
row.season_id,
ImportFailure {
release: row.name,
reason,
failed_at: row.failed_at,
},
)
})
.collect())
}
/// Which lane a season's missing episodes take (#182, §6.2).
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
@@ -1605,6 +1768,11 @@ pub struct SeasonPackState {
/// When a pack sweep for this season last completed. `None` means no
/// pack search has ever run, so an empty deck is pending, not settled.
pub last_pack_search_at: Option<String>,
/// #227. The last pack that downloaded in full and was then condemned at
/// import, while the season is still waiting for a file. A deck that
/// cannot say this leaves the season reading as though nothing was ever
/// tried, with the torrent still sitting at 100% in Transmission.
pub import_failure: Option<ImportFailure>,
}
#[utoipa::path(
@@ -1648,9 +1816,12 @@ pub async fn season_pack_state(
.await?;
// The same failed-pack tally the grab lane backs off on (#181), read
// here so the deck can name the window instead of guessing at one.
// `last_failed_at` really is the failure time (#245): `reopens_at` and
// `pack_retry_at` below hand it to the deck, so an alias holding a grab
// time would put a grab under a name §5.7 gave to something else.
let failed = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -1696,6 +1867,9 @@ pub async fn season_pack_state(
pack_failures: failed.failures,
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
last_pack_search_at,
import_failure: season_import_failures(&state, series_id)
.await?
.remove(&season_id),
}))
}
@@ -1915,6 +2089,321 @@ mod tests {
response.json().await.expect("season json")
}
async fn seasons_json(base: &str, series_id: i64) -> Vec<serde_json::Value> {
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
.await
.expect("seasons")
.json()
.await
.expect("seasons json")
}
/// #227, the operator's own report: a pack downloaded in full, §5.7
/// condemned it at import, every episode went back to `missing`, and the
/// season read `0/10` with nothing anywhere saying a grab had been tried.
/// The season row, its deck and the deck's blacklisted release now each
/// carry the failure and the reason it failed on.
#[tokio::test]
async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() {
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, 8, serde_json::json!(episodes)).await;
let season_id = season["id"].as_i64().expect("season id");
assert!(
season["import_failure"].is_null(),
"nothing has been grabbed yet"
);
// §4.1: tracking the season is what makes its episodes wanted, and a
// season with no intent has no gap to explain.
let tracked = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}/seasons/8"))
.json(&serde_json::json!({"tracked": true}))
.send()
.await
.expect("track the season");
assert_eq!(tracked.status(), StatusCode::OK);
let pool = state.database().expect("database").pool();
let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP";
let parsed = arr_parse::parse(name);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id",
)
.bind(name)
.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");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')",
)
.bind(release_id)
.bind(season_id)
.execute(pool)
.await
.expect("failed pack grab");
// The failure with no blacklist row yet: still a failure, and the
// reason is simply not known. Rows written before the blacklist
// carried one read this way and must keep rendering.
let seasons = seasons_json(&base, series_id).await;
let row = &seasons[0]["import_failure"];
assert_eq!(row["release"], name);
assert_eq!(row["reason"], serde_json::Value::Null);
assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z");
arr_db::blacklist::add(pool, Some("ABC123"), name, "size")
.await
.expect("blacklist");
let seasons = seasons_json(&base, series_id).await;
assert_eq!(seasons[0]["import_failure"]["reason"], "size");
// The deck the season row leads to says the same thing.
let pack_state: serde_json::Value = reqwest::get(format!(
"{base}/api/series/{series_id}/seasons/8/pack-state"
))
.await
.expect("pack state")
.json()
.await
.expect("pack state json");
assert_eq!(pack_state["import_failure"]["release"], name);
assert_eq!(pack_state["import_failure"]["reason"], "size");
// §6.3: the release itself is rejected in the deck, and now names
// what it was blacklisted for — a size rejection the operator can
// relax, not a corrupt file they should leave alone.
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["blacklist_reason"], "size");
// Once the gap is filled the failure is history: the season has
// nothing left to explain and stops saying it.
for episode in seasons[0]["episodes"].as_array().expect("episodes") {
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)")
.bind(episode["id"].as_i64().expect("episode id"))
.bind(format!("/library/e{}.mkv", episode["number"]))
.execute(pool)
.await
.expect("file on disk");
}
let seasons = seasons_json(&base, series_id).await;
assert!(seasons[0]["import_failure"].is_null());
}
/// 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"
);
// #211: the waiver keeps the rule it relaxed, so the deck names it
// the way it names a rejection (§9.3) instead of showing a bare
// `waived`.
assert_eq!(releases[0]["rejected_rule"], "size");
// 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");
}
/// #241: moving a series to a root with a different policy re-derives
/// the stored verdicts of its whole deck (§5.1), season packs included.
#[tokio::test]
async fn moving_a_series_to_another_root_rederives_its_verdicts() {
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 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 15 GiB: 1.5 GiB each, inside the 1080p band.
let pool = state.database().expect("database").pool();
let name = "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 15_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'eligible') 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!({"root_id": kids_root}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::OK);
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",
"kids requires pt-PT, English is a soft fail: {releases:?}"
);
}
#[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
/// 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
@@ -2640,8 +3129,11 @@ mod tests {
.await
.expect("release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, 'hash', 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
)
.bind(release_id)
.bind(season_id)
@@ -2657,6 +3149,26 @@ mod tests {
"the deck offers a date, not just a closed door"
);
// §6.2/#245: push the grab five weeks back and leave the failure
// where it is. The deck reads the failure, so the window it names is
// unmoved — anchored on the grab it would have expired long ago and
// the deck would claim the lane was open.
sqlx::query(
"UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days')
WHERE target_kind = 'season' AND target_id = ?",
)
.bind(season_id)
.execute(pool)
.await
.expect("age the grab");
let stalled = state_of(url.clone()).await;
assert_eq!(stalled["lane"], "per_episode");
assert_eq!(stalled["reason"], "pack_backoff");
assert_eq!(
stalled["pack_retry_at"], quiet["pack_retry_at"],
"the window is anchored on the failure, so aging the grab moves nothing"
);
// §14 outranks it: a pack would re-import what is on disk, so
// clearing the failure would not earn a pack anyway.
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
@@ -3704,6 +4216,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]
fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!(
+8
View File
@@ -242,6 +242,14 @@ pub struct Policy {
pub struct TitleOverrides {
pub only_4k: 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)]
+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
/// movie or an unknown count; zero is treated as one.
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.
@@ -112,6 +117,7 @@ pub fn evaluate(
candidate: Candidate<'_>,
size_bytes: Option<u64>,
episode_count: u32,
runtime_minutes: u32,
) -> Evaluation {
let context = EvaluationContext {
policy,
@@ -120,6 +126,7 @@ pub fn evaluate(
candidate,
size_bytes,
episode_count,
runtime_minutes,
};
let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule,
@@ -224,6 +231,12 @@ impl PolicyRule for SourceRule {
/// "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,
/// 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)]
pub struct SizeRule;
@@ -233,9 +246,17 @@ impl PolicyRule for SizeRule {
else {
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),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
}
@@ -522,7 +543,7 @@ mod tests {
}
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(
@@ -537,6 +558,7 @@ mod tests {
candidate,
None,
1,
0,
)
.verdict
}
@@ -552,6 +574,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
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]
fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy();
@@ -735,6 +931,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -792,6 +989,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
@@ -812,6 +1010,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -845,6 +1044,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let soft = FixedRule {
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
@@ -874,6 +1074,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let first = FixedRule {
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
@@ -1010,7 +1211,8 @@ mod tests {
&en(),
Candidate::PreGrab(&claims),
None,
1
1,
0,
)
.verdict,
Verdict::Eligible
@@ -1024,7 +1226,8 @@ mod tests {
&en(),
Candidate::PostDownload(&media),
None,
1
1,
0,
)
.verdict,
Verdict::Waived(Rule::RequiredAudio)
@@ -1102,6 +1305,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
@@ -1114,6 +1318,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
}
@@ -1128,6 +1333,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
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;
/// 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
/// code, for the same reason the size bands are.
#[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,
/// 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.
///
/// 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]
pub fn score(
policy: &Policy,
@@ -101,13 +113,16 @@ pub fn score(
size_bytes: u64,
seeders: u32,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
let weights = &policy.score_weights;
let claimed = candidate.resolution();
let per_episode = per_episode_size(size_bytes, episode_count);
let size = claimed
.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
.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).
#[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(
policy,
Candidate::PreGrab(&release.parsed),
release.size,
release.seeders,
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
/// 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
/// opinion, not a rejection.
#[must_use]
@@ -154,11 +178,11 @@ pub fn is_below_floor(
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> {
policy
.size_bands
.get(&resolution)
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
policy.size_bands.get(&resolution).map(|band| {
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
})
}
/// 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))
}
/// 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
/// grows with every gigabyte above it.
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
@@ -329,6 +373,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -350,6 +395,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -358,6 +404,10 @@ mod tests {
}
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 overrides = TitleOverrides::default();
let language = Language::Other("en".to_owned());
@@ -369,6 +419,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes),
episode_count,
runtime_minutes,
})
}
@@ -400,6 +451,7 @@ mod tests {
Candidate::PreGrab(&claims),
Some(gib(60)),
1,
0,
);
// 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!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None
);
}
@@ -481,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..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.total, score.source + score.seeders);
@@ -499,8 +551,8 @@ mod tests {
},
);
let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 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, 0);
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
assert_eq!(under.size, 0);
@@ -548,7 +600,7 @@ mod tests {
resolution: Some(resolution),
..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);
@@ -565,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..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!(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() {
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
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.total, episode.total);
@@ -628,7 +680,7 @@ mod tests {
// 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.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true)
);
assert_eq!(
@@ -638,22 +690,141 @@ mod tests {
// The same total over three episodes is 10 GiB each and passes.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
Some(false)
);
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]
fn an_unknown_episode_count_falls_back_to_one_episode() {
let single = scored(ClaimedSource::WebDl, gib(22), 10);
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!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
);
}
@@ -733,18 +904,18 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..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 uhd = scored(ClaimedResolution::P2160, uhd_pack);
// Neither pack trips the floor per episode, so the ranking decides.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false)
);
assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false)
);
assert!(uhd.total > hd.total);
+251 -16
View File
@@ -2,6 +2,15 @@
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
//! waiting for a file. One bad torrent is not a decision, a failure already
//! dealt with ages out (#226), and a target that has since been acquired
//! leaves at once (#238). The season lane reads that last condition off its
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
//! filters identically, or the two channels tell the operator different
//! stories.
//!
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
@@ -34,8 +43,8 @@ struct TvEntry {
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
/// Seasons two different pack releases hard-failed on (§5.7), sending the
/// season back to per-episode grabbing.
failed_season_packs: Vec<i64>,
}
@@ -59,8 +68,8 @@ impl TvEntry {
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
"season hard-failed twice on different packs",
"seasons hard-failed twice on different packs",
));
}
parts.join("; ")
@@ -134,8 +143,10 @@ impl AttentionAction {
AND (SELECT count(DISTINCT g.release_id)
FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
AND g.state = 'failed') >= 2
"#
AND g.state = 'failed'
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -209,9 +220,14 @@ fn tv_entry(
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// different releases hard-failed post-probe, and seasons two different packs
/// hard-failed on. One entry per series, so the notification can be one per
/// series however long the broken season is.
///
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
/// only while `wanted` and not `available`, and a season only while at least
/// one of its episodes is. A season pack that failed twice and then fell back
/// to per-episode grabbing (§6.2) drops out as those episodes land.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1 AND e.state != 'available'
)
GROUP BY s.id, s.title, s.year, se.id
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -382,13 +408,32 @@ mod tests {
series_id
}
/// A wanted, missing episode: the least that keeps its season live for
/// §5.7's liveness condition.
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
)
.bind(season_id)
.bind(number)
.bind(format!("Episode {number}"))
.fetch_one(database.pool())
.await
.unwrap()
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
/// grabbed `grab_age_days` ago and failed `fail_age_days` ago, so §5.7's
/// window — which runs from the failure — can be exercised without
/// waiting a month.
async fn insert_dated_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
grab_age_days: i64,
fail_age_days: i64,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
@@ -400,18 +445,53 @@ mod tests {
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
VALUES (?, ?, ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.bind(format!("-{grab_age_days} days"))
.bind(format!("-{fail_age_days} days"))
.execute(database.pool())
.await
.unwrap();
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// failed `age_days` in the past.
async fn insert_aged_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
age_days: i64,
) {
insert_dated_failed_grab(
database,
target_kind,
target_id,
release_guid,
age_days,
age_days,
)
.await;
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
) {
insert_dated_failed_grab(database, target_kind, target_id, release_guid, 0, 0).await;
}
async fn action(server: &MockServer) -> AttentionAction {
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
@@ -548,9 +628,10 @@ mod tests {
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
// The packs' failures sent this season back to per-episode grabbing;
// they queue the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
@@ -631,4 +712,158 @@ mod tests {
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
/// §5.7: the season lane holds to the same two-distinct-releases bar the
/// episode lane does, so one bad pack does not notify (#226).
#[tokio::test]
async fn one_failed_season_pack_does_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"one failed pack is the blacklist working, not a decision"
);
insert_failed_grab(&database, "season", season_id, "pack-two").await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two distinct packs hard-failed: the operator decides"
);
}
/// §5.7: the queue only holds targets still waiting for a file. A season
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
/// once every episode has landed the system worked, so the season leaves
/// the queue at once rather than notifying for 30 days (#238).
#[tokio::test]
async fn a_fully_acquired_season_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_wanted_episode(&database, season_id, 2).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two packs failed and the season still has episodes missing"
);
// Per-episode grabbing got the first one. Still a gap, still queued.
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"already notified, and still queued"
);
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"every episode acquired: nothing left to decide"
);
// Proof it actually left rather than merely staying quiet: a season
// still queued would not notify again on re-entry.
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"broken again: re-enters the queue and notifies"
);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
/// queue instead of sitting in it forever (#226).
#[tokio::test]
async fn season_failures_older_than_the_window_do_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"failures older than the window are history, not attention"
);
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"still breaking: back in the queue"
);
}
/// §5.7/#239: the window runs from the failure, not the grab. A torrent
/// that stalls on a slow swarm for five weeks and then hard-fails at
/// import is fresh evidence the target is broken, however old the grab.
#[tokio::test]
async fn a_grab_stalled_past_the_window_before_failing_still_counts() {
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;
// Both grabbed 40 days ago — outside the window — but failed today.
insert_dated_failed_grab(&database, "season", season_id, "stalled-one", 40, 0).await;
insert_dated_failed_grab(&database, "season", season_id, "stalled-two", 40, 0).await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"grab age is irrelevant: two fresh failures queue the target"
);
}
}
+55 -1
View File
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language,
blacklist: &Blacklist,
) -> 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(
database,
release,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language,
blacklist,
1,
0,
)
.await?;
sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => BTreeMap::new(),
};
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(
database,
release,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language,
blacklist,
episode_count,
runtime_minutes,
)
.await?;
for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.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(
database: &Db,
release: &SearchRelease,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
original_language: &Language,
blacklist: &Blacklist,
episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed),
release.size,
episode_count,
runtime_minutes,
);
let scored = score(
policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episode_count,
runtime_minutes,
);
// 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
@@ -1503,10 +1534,17 @@ fn search_query(movie: &PendingMovie) -> String {
)
}
/// The `verdict` and `rejected_rule` columns for a verdict.
///
/// A waiver names the rule it relaxed (#211). §5.7 calls a soft fail
/// "watchable but not what was asked", and which rule was relaxed is the
/// whole content of that sentence, so §9.3's deck can name it the way it
/// names a rejection. Rows written before 0032 hold `NULL` there and stay
/// readable.
fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
match verdict {
Verdict::Eligible => ("eligible", None),
Verdict::Waived(_) => ("waived", None),
Verdict::Waived(rule) => ("waived", Some(rule.name())),
Verdict::Rejected(rule) => ("rejected", Some(rule.name())),
}
}
@@ -1998,6 +2036,22 @@ mod tests {
);
}
/// #211: a waiver names the rule it relaxed, the same as a rejection, so
/// §9.3's deck shows what was given up instead of a bare `waived`.
/// Migration 0032 relaxed the constraint that forbade it.
#[test]
fn a_waiver_records_the_rule_it_relaxed() {
assert_eq!(
verdict_columns(&Verdict::Waived(arr_core::Rule::Size)),
("waived", Some("size".to_owned()))
);
assert_eq!(verdict_columns(&Verdict::Eligible), ("eligible", None));
assert_eq!(
verdict_columns(&Verdict::Rejected(arr_core::Rule::RequiredAudio)),
("rejected", Some("required_audio".to_owned()))
);
}
/// Every candidate is cached with its verdict, which is what the manual
/// search view and the attention queues read (§9.3).
#[tokio::test]
+34 -4
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// 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();
for assignment in assignments {
if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -615,7 +621,10 @@ impl ImportAction {
)
.await?;
sqlx::query!(
"UPDATE grabs SET state = 'failed' WHERE id = ?",
"UPDATE grabs
SET state = 'failed',
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
@@ -753,7 +762,10 @@ impl ImportAction {
)
.await?;
sqlx::query!(
"UPDATE grabs SET state = 'failed' WHERE id = ?",
"UPDATE grabs
SET state = 'failed',
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
@@ -938,6 +950,10 @@ struct PendingTvImport {
series_title: String,
series_year: Option<i64>,
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,
}
@@ -974,6 +990,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN episodes e ON e.id = g.target_id
@@ -997,6 +1014,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1011,6 +1029,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN seasons se ON se.id = g.target_id
@@ -1033,6 +1052,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1557,11 +1577,16 @@ mod tests {
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
assert_eq!(reason, "dolby_vision_profile");
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "failed");
assert!(
failed_at.is_some(),
"§5.7's window runs from the failure, so the failure is stamped"
);
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
.fetch_one(h.database.pool())
.await
@@ -2195,11 +2220,16 @@ mod tests {
)],
"only the release is blacklisted, never the season"
);
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "failed");
assert!(
failed_at.is_some(),
"§5.7's window runs from the failure, so the failure is stamped"
);
let states: Vec<(String, bool)> =
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
.fetch_all(h.database.pool())
+57 -1
View File
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
)
.fetch_all(database.pool())
.await?;
// §6.2's ladder runs from the failure, not the grab (#245), with
// `grabbed_at` as the fallback for rows older than #239's column.
let failed_packs = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -1164,6 +1166,60 @@ mod tests {
);
}
/// §6.2, issue #245: the RSS lane reads the same ladder, anchored on the
/// failure. A pack sent five weeks ago and condemned at import ten
/// minutes ago holds the lane shut, where anchoring on the grab would
/// have handed it the very release class that just failed.
#[tokio::test]
async fn a_pack_that_stalled_for_weeks_stays_backed_off_on_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
'https://tracker/oldpack.torrent', '{}', 'eligible')
RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, 'dead', 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes'))",
)
.bind(release_id)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sent: Vec<(String, i64, String)> = tv_grabs(&database)
.await
.into_iter()
.filter(|(_, _, state)| state == "sent")
.collect();
assert!(
!sent.iter().any(|(kind, _, _)| kind == "season"),
"the failure is ten minutes old, so the pack lane is shut: {sent:?}"
);
assert_eq!(
sent,
vec![
("episode".to_owned(), episodes[0], "sent".to_owned()),
("episode".to_owned(), episodes[1], "sent".to_owned()),
("episode".to_owned(), episodes[2], "sent".to_owned()),
]
);
}
/// §6.2 with #117's guard: an episode already on disk keeps the season
/// per-episode here too — the pack is skipped and the open gaps take
/// their singles.
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
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
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,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
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 = ?"#,
series_id
)
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
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);
if ended != stale.upstream_ended {
sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
runtime_minutes: Option<i64>,
}
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
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
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.
+50 -6
View File
@@ -1139,11 +1139,15 @@ async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabErr
/// Whether failed season-pack grabs still hold this season off the pack
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
/// (each failed grab is one attempt), it never disables it. Anchored on the
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent
/// can stall for weeks before `ffprobe` condemns it, and measured from the
/// grab the whole ladder would already have elapsed when the failure lands.
/// `grabbed_at` is the fallback for rows written before #239 added the
/// column.
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let row = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
@@ -1496,8 +1500,17 @@ mod tests {
/// release on the blacklist and the season falls back to per-episode —
/// the pack is not tried again and the episodes are not written off.
/// Seed what the import tick leaves behind after a pack fails: one
/// `failed` season grab per (infohash, age) pair.
/// `failed` season grab per (infohash, age) pair, grabbed and failed at
/// the same age, which is the usual case — the two are minutes apart.
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
let pairs: Vec<(&str, &str)> = ages.iter().map(|age| (*age, *age)).collect();
stalled_failed_packs(database, season_id, &pairs).await;
}
/// The same seed, but with the grab and the failure at different ages —
/// the #245 case, where a torrent stalls for weeks before `ffprobe`
/// condemns it at import.
async fn stalled_failed_packs(database: &Db, season_id: i64, ages: &[(&str, &str)]) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
@@ -1507,16 +1520,19 @@ mod tests {
.fetch_one(database.pool())
.await
.unwrap();
for (index, age) in ages.iter().enumerate() {
for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
grabbed_at, failed_at)
VALUES (?, 'season', ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(season_id)
.bind(format!("dead{index}"))
.bind(age)
.bind(grabbed_age)
.bind(failed_age)
.execute(database.pool())
.await
.unwrap();
@@ -1575,6 +1591,34 @@ mod tests {
);
}
/// §6.2, issue #245: the ladder runs from the failure, not the grab. A
/// pack sent five weeks ago and condemned by `ffprobe` ten minutes ago
/// is one minute into a 1h window, not five weeks past it — the lane
/// stays quiet and the episodes carry the season instead.
#[tokio::test]
async fn a_pack_that_stalled_for_weeks_backs_off_from_the_failure() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sources: Vec<String> = fake
.torrents()
.into_iter()
.map(|torrent| torrent.source)
.collect();
assert!(
sources
.iter()
.all(|source| !source.ends_with("pack.torrent")),
"the grab is five weeks old but the failure is ten minutes old: {sources:?}"
);
assert_eq!(sources.len(), 3, "{sources:?}");
}
/// Repeated failures ride the capped curve: five failed packs mean a 7d
/// window — still closed at 6d, open at 8d. Quiet, never off.
#[tokio::test]
@@ -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);
@@ -0,0 +1,8 @@
-- #239: §5.7's 30-day attention window runs from the failure, but only
-- grabbed_at existed, so a torrent that stalled past the window and then
-- hard-failed at import was born outside it. failed_at records the moment
-- the grab entered 'failed'. Existing failed rows get grabbed_at as the
-- best available approximation.
ALTER TABLE grabs ADD COLUMN failed_at TEXT;
UPDATE grabs SET failed_at = grabbed_at WHERE state = 'failed';
@@ -0,0 +1,34 @@
-- Issue #244. A root's path could be stored with a trailing separator until
-- #243 normalised the incoming value. `roots::update` normalises the payload
-- and compares it against the stored value, so such a row never compares
-- equal: every edit, a policy change included, takes the relocation branch,
-- and there every planned destination is its own source. Normalising the
-- payload alone fixed the half that cannot bite; this is the other half.
--
-- `rtrim` strips every trailing separator at once, so '/mnt/x//' normalises
-- in one pass. A bare '/' rtrims to the empty string and is put back, which
-- is what `normalize_path` in arr-api does.
--
-- Guarded, because `roots.path` is UNIQUE and a migration that cannot apply
-- stops the daemon booting -- worse than the bug it fixes. A row is
-- normalised only when no other row shares its normalised path: neither a
-- row already holding the stripped value, nor another trailing-separator row
-- that would strip to the same thing. Every row in such a group is left
-- exactly as it is. That leaves two roots naming one directory, which is a
-- settings mistake for the operator to resolve by hand, not a reason to
-- refuse to boot.
--
-- This cannot introduce a collision either. An updated row's new value is a
-- normalised path no other row normalises to, and a row left alone whose raw
-- path equalled that value would have had the same normalised path, which is
-- the case the guard excludes.
UPDATE roots
SET path = CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
WHERE path <> CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
AND NOT EXISTS (
SELECT 1
FROM roots AS other
WHERE other.id <> roots.id
AND CASE WHEN rtrim(other.path, '/') = '' THEN '/' ELSE rtrim(other.path, '/') END
= CASE WHEN rtrim(roots.path, '/') = '' THEN '/' ELSE rtrim(roots.path, '/') END
);
@@ -0,0 +1,68 @@
-- no-transaction
-- #211: a waived release could not record which rule it relaxed. The old
-- constraint made the rule name an exact synonym for rejection:
--
-- CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))
--
-- so §9.3's deck showed a bare `waived` beside rejected rows that each named
-- their own rule, and §5.7's "watchable but not what was asked" lost the half
-- that says what was not asked for. The relaxed form still demands a rule on
-- a rejection and stops demanding its absence elsewhere.
--
-- SQLite cannot alter a CHECK, so the table is rebuilt (see 0014). Unlike the
-- rebuilds there, `releases` is a parent: `grabs`, `movie_releases`,
-- `episode_releases` and `season_releases` all point at it, three of them
-- ON DELETE CASCADE. Dropping the old table with foreign keys enforced would
-- delete those children (or, for `grabs`, refuse outright), so this follows
-- SQLite's own procedure — foreign keys off, the rebuild in one transaction,
-- foreign keys back on. `PRAGMA foreign_keys` is a no-op inside a
-- transaction, which is why the file opens `-- no-transaction` and manages
-- its own; the migration is still all-or-nothing.
--
-- Rows are copied verbatim. Every existing `waived` row keeps its NULL and
-- goes on reading as it does today; only rows written after this migration
-- carry a waived rule.
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE releases_new (
id INTEGER PRIMARY KEY,
-- Prowlarr's indexer id. Not a foreign key: indexers live in Prowlarr.
indexer_id INTEGER NOT NULL,
guid TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
seeders INTEGER,
publish_date TEXT,
download_url TEXT NOT NULL,
-- §5.6. What the release name claims, before anything is downloaded.
parsed TEXT NOT NULL CHECK (json_valid(parsed)),
score REAL,
-- §9.3. Three buckets. `rejected_rule` names the rule that killed it so
-- an over-strict filter is visible without reading release names, and
-- §5.7's waiver names the rule it relaxed for the same reason.
verdict TEXT CHECK (verdict IN ('eligible', 'waived', 'rejected')),
rejected_rule TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (indexer_id, guid),
CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)
) STRICT;
INSERT INTO releases_new (
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
parsed, score, verdict, rejected_rule, created_at
)
SELECT
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
parsed, score, verdict, rejected_rule, created_at
FROM releases;
DROP TABLE releases;
ALTER TABLE releases_new RENAME TO releases;
CREATE INDEX releases_verdict ON releases (verdict, score);
COMMIT;
PRAGMA foreign_keys = ON;
+106 -9
View File
@@ -12,7 +12,7 @@
//! infohash, and
//! - **infohash**, because the same torrent is re-listed under a new name.
use std::collections::HashSet;
use std::collections::HashMap;
use sqlx::SqlitePool;
@@ -21,15 +21,21 @@ use sqlx::SqlitePool;
/// rejected row names the rule that killed it" reads the same everywhere.
pub const RULE: &str = "blacklisted";
/// Every blacklist key, loaded once per tick or request.
/// Every blacklist key and the reason it was written under, loaded once per
/// tick or request.
///
/// Household scale: a handful of rows. Loading it whole and matching in
/// memory keeps the check identical for a release that has a database row and
/// one that has only just arrived from an indexer.
///
/// The reason travels with the key because §9.3's deck has to say what a row
/// was blacklisted for (#227): a release the policy rejected on size is one
/// the operator can relax and try again, and a corrupt or mismatched one is
/// not. A bare `blacklisted` makes those two read the same.
#[derive(Debug, Clone, Default)]
pub struct Blacklist {
names: HashSet<String>,
infohashes: HashSet<String>,
names: HashMap<String, String>,
infohashes: HashMap<String, String>,
}
impl Blacklist {
@@ -39,17 +45,30 @@ impl Blacklist {
///
/// If the query fails.
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
// Oldest first, so a key that hard-failed twice under different
// reasons keeps the first one — the same rule [`add`] applies when it
// refuses to write the second row.
let rows = sqlx::query!(
r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"#
r#"SELECT normalised_name AS "normalised_name!: String",
infohash,
reason AS "reason!: String"
FROM blacklist
ORDER BY id"#
)
.fetch_all(pool)
.await?;
let mut blacklist = Self::default();
for row in rows {
blacklist.names.insert(row.normalised_name);
blacklist
.names
.entry(row.normalised_name)
.or_insert_with(|| row.reason.clone());
if let Some(infohash) = row.infohash {
blacklist.infohashes.insert(infohash.to_ascii_lowercase());
blacklist
.infohashes
.entry(infohash.to_ascii_lowercase())
.or_insert(row.reason);
}
}
Ok(blacklist)
@@ -58,14 +77,44 @@ impl Blacklist {
/// Whether this release name has been blacklisted, under any spelling.
#[must_use]
pub fn blocks_name(&self, release_name: &str) -> bool {
self.names.contains(&arr_parse::normalise(release_name))
self.names.contains_key(&arr_parse::normalise(release_name))
}
/// Whether this infohash has been blacklisted. Case-insensitive:
/// Transmission and Torznab disagree on the hex casing.
#[must_use]
pub fn blocks_infohash(&self, infohash: &str) -> bool {
self.infohashes.contains(&infohash.to_ascii_lowercase())
self.infohashes.contains_key(&infohash.to_ascii_lowercase())
}
/// What this release name was blacklisted for, or `None` if it was not.
#[must_use]
pub fn reason_for_name(&self, release_name: &str) -> Option<&str> {
self.names
.get(&arr_parse::normalise(release_name))
.map(String::as_str)
}
/// What this infohash was blacklisted for, or `None` if it was not.
#[must_use]
pub fn reason_for_infohash(&self, infohash: &str) -> Option<&str> {
self.infohashes
.get(&infohash.to_ascii_lowercase())
.map(String::as_str)
}
/// What a candidate was blacklisted for, under either key (#227).
///
/// The name is asked first: it is the key every candidate has, and a
/// `.torrent` URL hides its infohash until the download client fetches
/// it, exactly as [`Blacklist::blocks_candidate`] describes.
#[must_use]
pub fn reason_for_candidate(&self, release_name: &str, download_url: &str) -> Option<&str> {
self.reason_for_name(release_name).or_else(|| {
magnet_infohash(download_url)
.and_then(|hash| self.infohashes.get(&hash))
.map(String::as_str)
})
}
/// Whether a candidate is blacklisted before anything is sent to the
@@ -175,6 +224,54 @@ mod tests {
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
}
#[tokio::test]
async fn a_key_carries_the_reason_it_was_blacklisted_for() {
let (database, _dir) = database().await;
add(
database.pool(),
Some(HASH),
"Rick.And.Morty.S08.1080p",
"size",
)
.await
.unwrap();
add(
database.pool(),
None,
"Some.Other.Pack.S01",
"no file matches a wanted episode",
)
.await
.unwrap();
let blacklist = Blacklist::load(database.pool()).await.unwrap();
// #227: the deck has to tell a policy rejection from a bad release,
// and the reason is the only thing that says which.
assert_eq!(
blacklist.reason_for_name("Rick And Morty S08 1080p"),
Some("size")
);
assert_eq!(
blacklist.reason_for_infohash(&HASH.to_ascii_uppercase()),
Some("size")
);
assert_eq!(
blacklist.reason_for_name("Some.Other.Pack.S01"),
Some("no file matches a wanted episode")
);
assert_eq!(blacklist.reason_for_name("Never.Failed.S01"), None);
let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=Renamed.Pack");
assert_eq!(
blacklist.reason_for_candidate("Renamed.Pack", &magnet),
Some("size")
);
assert_eq!(
blacklist.reason_for_candidate("Renamed.Pack", "https://tracker/x.torrent"),
None
);
}
#[tokio::test]
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
let (database, _dir) = database().await;
+269
View File
@@ -24,6 +24,17 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier. Measured from `failed_at` — the moment
/// the grab hard-failed, not the moment it was grabbed — so a torrent that
/// stalls past the window and then fails still counts (#239).
///
/// 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 `failed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days";
/// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
@@ -343,6 +354,93 @@ mod tests {
assert_eq!(renamed_from_imported, "available");
}
/// The four seeded roots, up to but not including migration 0031, with
/// the given legacy paths written straight into the rows.
async fn roots_before_normalisation(paths: &[(i64, &str)]) -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().expect("tempdir");
let db = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
MIGRATOR
.run_to(30, db.pool())
.await
.expect("migrations before #244");
for (id, path) in paths {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path)
.bind(id)
.execute(db.pool())
.await
.expect("legacy path, valid under the unique index");
}
(dir, db)
}
async fn root_path(db: &Db, id: i64) -> String {
sqlx::query_scalar("SELECT path FROM roots WHERE id = ?")
.bind(id)
.fetch_one(db.pool())
.await
.expect("root path")
}
/// #244: a root's path could be stored with a trailing separator until
/// #243 normalised the incoming value, and `roots::update` compares a
/// normalised payload against the stored value — so such a row could
/// never be edited again. Migration 0031 strips the separator, and skips
/// a row whose stripped form another row already holds rather than
/// tripping the unique index and refusing to apply.
#[tokio::test]
async fn root_paths_are_normalised_but_never_onto_a_path_in_use() {
let (_dir, db) = roots_before_normalisation(&[
(1, "/mnt/media/movies/main/"),
(2, "/mnt/collide"),
(3, "/mnt/collide/"),
(4, "/mnt/media/tv/kids///"),
])
.await;
db.migrate()
.await
.expect("0031 applies with a collision present");
assert_eq!(root_path(&db, 1).await, "/mnt/media/movies/main");
assert_eq!(
root_path(&db, 4).await,
"/mnt/media/tv/kids",
"every trailing separator goes in one pass"
);
assert_eq!(root_path(&db, 2).await, "/mnt/collide");
assert_eq!(
root_path(&db, 3).await,
"/mnt/collide/",
"left exactly as it is: normalising it would collide with root 2"
);
}
/// #244: the harder half of the same guard. Two rows that strip to the
/// same path, *neither* of which already holds the stripped value, still
/// have to be left alone — normalising them would collide with each
/// other, and a migration that cannot apply stops the daemon booting.
#[tokio::test]
async fn two_rows_that_would_collide_with_each_other_stop_nothing() {
let (_dir, db) =
roots_before_normalisation(&[(1, "/mnt/one/"), (2, "/mnt/dup/"), (3, "/mnt/dup//")])
.await;
db.migrate()
.await
.expect("0031 applies with a mutually colliding pair present");
assert_eq!(root_path(&db, 2).await, "/mnt/dup/");
assert_eq!(root_path(&db, 3).await, "/mnt/dup//");
assert_eq!(
root_path(&db, 1).await,
"/mnt/one",
"the rest of the table is still normalised"
);
}
/// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a
/// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old
/// tables does not cascade-delete `movie_releases`/`episode_releases`.
@@ -426,6 +524,177 @@ mod tests {
);
}
/// #211: 0032 rebuilds `releases`, which is a parent — `grabs`,
/// `movie_releases`, `episode_releases` and `season_releases` all point
/// at it, three of them ON DELETE CASCADE. The rebuild runs with foreign
/// keys off so dropping the old table neither cascades those children
/// away nor is refused by `grabs`.
#[tokio::test]
async fn the_releases_rebuild_preserves_children_and_rows() {
let (_dir, db) = a_deck_with_every_child_row().await;
db.migrate().await.expect("remaining migrations");
for (label, query, expected) in [
("releases", "SELECT count(*) FROM releases", 3),
("movie_releases", "SELECT count(*) FROM movie_releases", 1),
(
"episode_releases",
"SELECT count(*) FROM episode_releases",
1,
),
("season_releases", "SELECT count(*) FROM season_releases", 1),
("grabs", "SELECT count(*) FROM grabs", 1),
] {
let rows: i64 = sqlx::query_scalar(query)
.fetch_one(db.pool())
.await
.expect("count");
assert_eq!(rows, expected, "{label} survives the releases rebuild");
}
// An existing waived row keeps its NULL and reads as it did before.
let waived: Option<String> =
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'waived-guid'")
.fetch_one(db.pool())
.await
.expect("waived row");
assert_eq!(waived, None);
let rejected: Option<String> =
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'rejected-guid'")
.fetch_one(db.pool())
.await
.expect("rejected row");
assert_eq!(rejected.as_deref(), Some("size"));
// Foreign keys are back on for the connection the migration used.
let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(db.pool())
.await
.expect("foreign_keys");
assert_eq!(foreign_keys, 1);
}
/// A database migrated to just before 0032, holding one release per
/// verdict and one row in every table that points at `releases`.
async fn a_deck_with_every_child_row() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().expect("tempdir");
let db = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
MIGRATOR
.run_to(31, db.pool())
.await
.expect("migrations before the releases rebuild");
let movie_id = sqlx::query(
"INSERT INTO movies (tmdb_id, title, root_id)
SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' LIMIT 1",
)
.execute(db.pool())
.await
.expect("movie")
.last_insert_rowid();
let series_id = sqlx::query(
"INSERT INTO series (tmdb_id, title, root_id)
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
)
.execute(db.pool())
.await
.expect("series")
.last_insert_rowid();
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
.bind(series_id)
.execute(db.pool())
.await
.expect("season")
.last_insert_rowid();
let episode_id =
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, 'x')")
.bind(season_id)
.execute(db.pool())
.await
.expect("episode")
.last_insert_rowid();
// One row per verdict, including the waived row this issue is about,
// which under the old constraint could only hold NULL.
for (guid, verdict, rule) in [
("eligible-guid", "eligible", None),
("waived-guid", "waived", None),
("rejected-guid", "rejected", Some("size")),
] {
sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
VALUES (1, ?, ?, 1024, 'http://x', '{}', ?, ?)",
)
.bind(guid)
.bind(guid)
.bind(verdict)
.bind(rule)
.execute(db.pool())
.await
.expect("release");
}
let release_id: i64 =
sqlx::query_scalar("SELECT id FROM releases WHERE guid = 'eligible-guid'")
.fetch_one(db.pool())
.await
.expect("release id");
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(db.pool())
.await
.expect("movie link");
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
.bind(episode_id)
.bind(release_id)
.execute(db.pool())
.await
.expect("episode link");
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(db.pool())
.await
.expect("season link");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash)
VALUES (?, 'movie', ?, 'infohash-1')",
)
.bind(release_id)
.bind(movie_id)
.execute(db.pool())
.await
.expect("grab");
(dir, db)
}
/// The relaxed constraint (#211) admits a rule on a waiver and still
/// refuses a rejection without one.
#[tokio::test]
async fn a_waiver_may_name_the_rule_it_relaxed() {
let (_dir, db) = fresh().await;
sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
VALUES (1, 'waived', 'Some.Release', 1024, 'http://x', '{}', 'waived', 'size')",
)
.execute(db.pool())
.await
.expect("a waiver names its rule");
sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, 'rejected', 'Some.Release', 1024, 'http://x', '{}', 'rejected')",
)
.execute(db.pool())
.await
.expect_err("a rejection still has to name its rule");
}
#[tokio::test]
async fn seeds_two_tv_roots_with_distinct_policies() {
let (_dir, db) = fresh().await;
+75 -1
View File
@@ -193,6 +193,7 @@ impl Db {
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_kind: row.root_kind,
@@ -268,6 +269,75 @@ impl Db {
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_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_kind: row.root_kind,
@@ -337,6 +407,7 @@ impl Db {
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_kind: row.root_kind,
@@ -428,6 +499,8 @@ struct OverridesJson {
only_4k: bool,
#[serde(default)]
allow_english_audio: bool,
#[serde(default)]
allow_below_floor: bool,
}
fn gib(value: u64) -> u64 {
@@ -623,7 +696,8 @@ mod tests {
&loaded.policy,
Resolution::R1080p,
one_and_a_half_gib,
1
1,
0
),
Some(false)
);
+10
View File
@@ -156,6 +156,10 @@ pub struct Series {
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
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>,
}
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
#[serde(default)]
vote_count: u32,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
episode_runtime: raw
.episode_run_time
.into_iter()
.find(|&minutes| minutes > 0),
seasons: raw
.seasons
.into_iter()
+46 -16
View File
@@ -231,10 +231,7 @@
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
@@ -268,7 +265,7 @@
id="movie-blocked"
aria-pressed="false"
>
blocked
no targeted search
</button>
<label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span>
@@ -279,14 +276,24 @@
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<p class="control-note readout dim" id="movie-blocked-note" hidden>
targeted search is off for this title. RSS still matches it, so a release can still arrive.
</p>
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
@@ -323,7 +330,7 @@
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-waived">waived</h3>
<h3 class="deck-label" id="label-waived">below policy</h3>
<button
type="button"
class="bucket-toggle readout"
@@ -466,7 +473,7 @@
</button>
</header>
<p class="queue-note readout dim">
a policy edit rescores search decks immediately — open any title's releases to check it.
a policy edit rescores every title's releases immediately — open any title to check it.
</p>
<ul class="deck-rows" id="rows-policies"></ul>
</section>
@@ -496,10 +503,7 @@
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
@@ -521,23 +525,36 @@
</div>
</section>
<!-- removal is the one library control a series carries (issue 175):
the same arming panel a movie uses — evidence first, then the
decision — wired to DELETE /api/series/{id} -->
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<label class="movie-root-field">
<span class="field-label readout dim" id="series-root-label">root</span>
<select
class="form-input movie-root"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
@@ -556,6 +573,19 @@
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<!-- issue 227: a pack that downloaded in full and was condemned at
import (§5.7). It outlives the status line because it is a fact
about the season, not about the request in flight. -->
<section class="deck-notice" id="tv-releases-failure" hidden aria-labelledby="tv-failure-label">
<h3 class="deck-label" id="tv-failure-label">pack abandoned at import</h3>
<p class="notice-release readout" id="tv-failure-release"></p>
<p class="notice-line" id="tv-failure-what"></p>
<p class="notice-line" id="tv-failure-next"></p>
<p class="notice-line dim">
nothing was imported, and the torrent was left where it is: nothing is
deleted early to satisfy the library (§7.3).
</p>
</section>
<div id="tv-buckets"></div>
</main>
+80
View File
@@ -0,0 +1,80 @@
// The shared icon-only control block (#230), consumed by the title pages,
// decks and readouts in main.ts and the settings rows (#231). One glyph set,
// one arm-then-confirm idiom — never a second implementation per view.
export 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",
],
pencil: [
"M11.2 2.4a1.5 1.5 0 0 1 2.1 0l.3.3a1.5 1.5 0 0 1 0 2.1L5.9 12.5l-3.2.8.8-3.2z",
"M10.1 3.5l2.4 2.4",
],
} as const;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
export 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;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
* visible word "confirm" while armed, so an accidental hit never destroys.
*/
export 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;
}
+6 -1
View File
@@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean {
* `allow_english_audio` reads as "english, no dub", not as a clean match.
* The import pipeline records the relaxed rule; both the bare rule name and
* an object carrying one are accepted.
*
* Every arm names what was relaxed, never what the record calls it — the
* operator can act on "english, no dub" and cannot act on "waived".
*/
export function waiverLabel(waiver: unknown): string | null {
const rule = waiverRule(waiver);
@@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null {
return "english, no dub";
case "resolution":
return "below wanted resolution";
case "size":
return "below the size floor";
default:
return `waived · ${rule.replaceAll("_", " ")}`;
return `${rule.replaceAll("_", " ")} relaxed`;
}
}
+342 -61
View File
@@ -5,6 +5,7 @@ import {
probeHealth,
type SubtitleHealth,
} from "./health";
import { armedDeleteIcon, icon, SVG_NS } from "./icons";
import {
fetchLibrary,
type LibrarySeries,
@@ -44,6 +45,9 @@ import {
} from "./queues";
import {
type ActionOutcome,
blacklistAdvice,
blacklistClass,
blacklistReasonLabel,
bucketOf,
type FilesOutcome,
formatAudio,
@@ -56,11 +60,14 @@ import {
formatSource,
formatSweepAge,
grabRelease,
type ImportFailure,
libraryFolder,
type MovieRelease,
movieFiles,
movieReleases,
movieSearchState,
overridable,
type PackStateOutcome,
probedAttributeTags,
queueSearch,
removeMovie,
@@ -70,7 +77,6 @@ import {
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
@@ -112,9 +118,10 @@ import {
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
updateSeries,
waiveAndGrabTv,
} from "./series";
import { armedDelete, settingsMain } from "./settings";
import { settingsMain } from "./settings";
import "./style.css";
import {
deleteSubtitle,
@@ -482,14 +489,14 @@ function main() {
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
@@ -808,6 +815,18 @@ function searchMain(
return { showIdle, restore };
}
/**
* What the operator reads for the stored `blocked` flag. §6.3 gives it one
* effect — it stops the targeted-search lane and leaves RSS matching on —
* and "blocked" reads as "block this title outright", which is the wrong
* promise. The flag, the column and the API keep their name; only the words
* on screen say what it does. [`BLOCKED_EFFECT`] carries the RSS half.
*/
const NO_TARGETED_SEARCH = "no targeted search";
/** The half a three-word label cannot hold, for titles and accessible names. */
const BLOCKED_EFFECT = "arr stops searching for this title; RSS still matches it";
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
const span = document.createElement("span");
span.className = "chip readout";
@@ -1750,7 +1769,7 @@ function libraryRow(
chips.append(chip("not wanted"));
}
if (movie.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
@@ -1783,7 +1802,7 @@ function librarySeriesRow(
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(series.vote_average);
if (rating !== null) {
@@ -2083,6 +2102,48 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- icon-only controls (#230) ----------------------------------------- */
// icon() and armedDeleteIcon() live in icons.ts, shared with the settings
// rows (#231); the §9.6 painters below stay here as their only consumer.
/** §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;
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
@@ -2116,20 +2177,21 @@ function closeIcon(): SVGSVGElement {
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.className = "control control-quiet control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
@@ -2143,6 +2205,10 @@ function movieMain(views: HideableView[]): MovieView {
const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked");
const blockedNote = must<HTMLElement>("#movie-blocked-note");
// the accessible name carries what three words cannot, at rest and on
blocked.setAttribute("aria-label", `${NO_TARGETED_SEARCH}${BLOCKED_EFFECT}`);
blocked.title = BLOCKED_EFFECT;
const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
@@ -2189,7 +2255,7 @@ function movieMain(views: HideableView[]): MovieView {
if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
@@ -2251,6 +2317,8 @@ function movieMain(views: HideableView[]): MovieView {
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
remove.title = `remove ${movie.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
@@ -2265,7 +2333,7 @@ function movieMain(views: HideableView[]): MovieView {
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
chipsEl.append(chip(NO_TARGETED_SEARCH));
}
}
@@ -2286,6 +2354,10 @@ function movieMain(views: HideableView[]): MovieView {
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
// §6.3 is the whole point of the flag and a bare toggle hides it: the
// note appears when the flag is on, which is when the RSS half is the
// thing that surprises. At rest the label and the title carry it.
blockedNote.hidden = !movie.blocked;
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
@@ -2340,7 +2412,7 @@ function movieMain(views: HideableView[]): MovieView {
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
setStatus(`targeted search change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
@@ -2443,13 +2515,26 @@ function movieMain(views: HideableView[]): MovieView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
const linkTitle = current?.title ?? "this movie";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbMovieLink(detail.tmdb_id),
),
);
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
actionsEl.append(
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
);
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${current.title}`,
rottenTomatoesSearch(current.title, current.year),
),
);
}
}
@@ -2777,7 +2862,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -2800,8 +2886,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
@@ -2962,7 +3046,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
interface ReleaseActions {
reload: () => Promise<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: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
@@ -3026,10 +3111,23 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} 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.
//
// Only a rejected row needs forcing. A row below policy is offered
// already, one click, whatever its rule — and since #211 gave it a rule
// at all, counting every overridable row here claimed the whole
// collapsed deck had to be forced.
const forceable = releases.filter(
(release) => bucketOf(release) === "rejected" && overridable(release),
).length;
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
none.textContent =
forceable > 0
? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed`
: "none — every candidate is below policy or rejected";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
@@ -3116,7 +3214,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom {
const dom: BucketsDom = {
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
waived: collapsed("waived"),
waived: collapsed("below policy"),
rejected: collapsed("rejected"),
};
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
@@ -3165,10 +3263,36 @@ function releaseRow(
}),
);
}
// #227: a blacklisted release was grabbed, downloaded and condemned at
// import. Which of the two things happened decides what the operator does
// next, and `blacklisted` alone reads the same for both.
const blacklisted =
bucket === "rejected" && release.rejected_rule === "blacklisted"
? blacklistClass(release.blacklist_reason)
: null;
if (bucket !== "eligible") {
// §9.3: every row that is not eligible names the rule behind it, waived
// and rejected alike — three waivers for three different reasons read
// identically otherwise, and reading release names to tell them apart is
// the Radarr defect this view exists to fix. #211 gave a waived row the
// rule it relaxed; a row written before it still has none, and says the
// plainer thing alone.
const verdict =
bucket === "waived"
? release.rejected_rule
? `below policy · ${ruleLabel(release.rejected_rule)}`
: "below policy"
: blacklisted
? `blacklisted · ${blacklistReasonLabel(release.blacklist_reason)}`
: release.rejected_rule
? `rejected · ${ruleLabel(release.rejected_rule)}`
: "rejected";
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
chip(verdict, (span) => {
span.dataset.verdict = bucket;
if (blacklisted) {
span.dataset.blacklist = blacklisted;
}
}),
);
}
@@ -3177,6 +3301,13 @@ function releaseRow(
name.className = "rel-name readout";
name.textContent = release.name;
line.append(name);
if (blacklisted) {
const why = document.createElement("span");
why.className = "rel-why readout";
why.dataset.blacklist = blacklisted;
why.textContent = blacklistAdvice(release.blacklist_reason);
line.append(why);
}
item.append(line);
const note = document.createElement("span");
@@ -3184,12 +3315,22 @@ function releaseRow(
note.setAttribute("role", "status");
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");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
// "force grab" says what the click does; the accessible name says what
// it forces past, since the rule is the reason the row is not eligible.
grab.textContent = writesOverride ? "force grab" : "grab";
if (writesOverride) {
const forced = `force grab — relaxes ${ruleLabel(release.rejected_rule)} for this title and grabs anyway`;
grab.setAttribute("aria-label", forced);
grab.title = forced;
}
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
@@ -3462,7 +3603,7 @@ function seriesRow(
);
}
if (series.blocked) {
chips.append(chip("blocked"));
chips.append(chip(NO_TARGETED_SEARCH));
}
chips.append(
chip(series.status, (span) => {
@@ -3574,7 +3715,7 @@ function seriesCard(
);
}
if (series.blocked) {
chips.push(chip("blocked"));
chips.push(chip(NO_TARGETED_SEARCH));
}
chips.push(
chip(series.status, (span) => {
@@ -3611,7 +3752,7 @@ function movieCard(
chips.push(chip("not wanted"));
}
if (movie.blocked) {
chips.push(chip("blocked"));
chips.push(chip(NO_TARGETED_SEARCH));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
@@ -3650,6 +3791,10 @@ function tvReleasesMain(): TvReleasesView {
const sub = must<HTMLElement>("#tv-releases-sub");
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
const statusEl = must<HTMLElement>("#tv-releases-status");
const failureEl = must<HTMLElement>("#tv-releases-failure");
const failureRelease = must<HTMLElement>("#tv-failure-release");
const failureWhat = must<HTMLElement>("#tv-failure-what");
const failureNext = must<HTMLElement>("#tv-failure-next");
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
let request: TvDeckRequest | null = null;
@@ -3692,15 +3837,49 @@ function tvReleasesMain(): TvReleasesView {
statusEl.dataset.action = "";
}
/**
* #227: the season's own history, above the candidates. A pack that
* downloaded in full and was condemned at import (§5.7) blacklists the
* release and puts every episode back to `missing`, which leaves the season
* reading `0/10` as though nothing had ever been tried. It sits outside the
* status line because it is a fact about the season, not about the request
* in flight, and it has to survive a sweep that repaints the status.
*/
function paintFailure(failure: ImportFailure | null) {
if (failure === null) {
failureEl.hidden = true;
return;
}
failureEl.dataset.blacklist = blacklistClass(failure.reason);
failureEl.hidden = false;
failureRelease.textContent =
failure.failed_at === null
? failure.release
: `${failure.release} · ${formatSweepAge(failure.failed_at)}`;
// A rule name reads as a preposition — "condemned on size"; a reason
// written as a sentence has to be quoted, not conjugated.
const on =
blacklistClass(failure.reason) === "policy"
? ` on ${blacklistReasonLabel(failure.reason)}`
: "";
const said = blacklistClass(failure.reason) === "release" ? `${failure.reason}. ` : "";
failureWhat.textContent = `downloaded in full, then condemned at import${on}${said}every episode went back to missing and the release is blacklisted.`;
failureNext.textContent = `${blacklistAdvice(failure.reason)}.`;
}
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
grab: (release, bucket) => {
const current = request;
if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
return Promise.resolve({
kind: "error",
detail: "releases closed",
overrideWritten: false,
});
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
@@ -3731,9 +3910,17 @@ function tvReleasesMain(): TvReleasesView {
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (!paintBuckets(dom, outcome.releases, actions)) {
const painted = paintBuckets(dom, outcome.releases, actions);
// One read of the season's state, shared by the notice and the empty
// verdict below — they answer two questions from the same row.
const pack = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
paintFailure(pack?.kind === "state" ? pack.state.import_failure : null);
if (!painted) {
clearBuckets(dom);
await emptyVerdict(current, ticket, sweepIfEmpty);
await emptyVerdict(current, ticket, sweepIfEmpty, pack);
return;
}
setStatus(null);
@@ -3747,8 +3934,13 @@ function tvReleasesMain(): TvReleasesView {
* answers the third; `last_pack_search_at` separates the first two, the
* same way a movie's `last_searched_at` does (#177).
*/
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
const outcome = await current.target.packState?.();
async function emptyVerdict(
current: TvDeckRequest,
ticket: number,
sweepIfEmpty: boolean,
known?: PackStateOutcome,
) {
const outcome = known ?? (await current.target.packState?.());
if (ticket !== sequence || request !== current) {
return;
}
@@ -3840,6 +4032,9 @@ function tvReleasesMain(): TvReleasesView {
if (ticket !== sequence || request !== current) {
return;
}
if (state?.kind === "state") {
paintFailure(state.state.import_failure);
}
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
sweep.disabled = false;
await emptyVerdict(current, ticket, false);
@@ -3912,6 +4107,7 @@ function tvReleasesMain(): TvReleasesView {
next.returnTo.hidden = true;
view.hidden = false;
clearBuckets(dom);
paintFailure(null);
sweep.disabled = false;
back.focus();
void load(true);
@@ -3980,7 +4176,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
@@ -3993,6 +4188,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const actionsEl = must<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const rootSelect = must<HTMLSelectElement>("#series-root");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#series-remove-panel");
@@ -4087,6 +4283,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
remove.title = `remove ${current.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -4104,10 +4302,53 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}),
);
if (current.blocked) {
chipsEl.append(chip("blocked"));
chipsEl.append(chip(NO_TARGETED_SEARCH));
}
}
function paintRootControl() {
const current = series;
if (!current) {
return;
}
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "tv")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
if (rootSelect.value !== String(current.root_id)) {
const option = document.createElement("option");
option.value = String(current.root_id);
option.textContent = `root ${current.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(current.root_id);
}
rootSelect.addEventListener("change", () => {
const current = series;
if (!current) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === current.root_id) {
return;
}
rootSelect.disabled = true;
void updateSeries(current.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(current.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
focusKey = "series-root";
void load();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
@@ -4189,26 +4430,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
const linkTitle = series?.title ?? "this series";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbSeriesLink(detail.tmdb_id),
),
);
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
actionsEl.append(
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
);
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${series.title}`,
rottenTomatoesSearch(series.title, series.year),
),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
const span = countsChip(
`${counts.available}/${counts.wanted}`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
span.prepend(icon("disk"));
return span;
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -4275,8 +4531,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel =
season.number === 0
? "search releases for specials"
: `search releases for season ${PAD_TWO(season.number)}`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -4296,6 +4558,24 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
});
line.append(disclose, name, track, seasonCountsChip(season));
// #227: the season the operator's report is about read `0/10` with
// nothing saying a pack had been grabbed, downloaded in full and thrown
// out at import. The counts chip beside this one is exactly the number
// that looked like nothing was ever tried.
const failure = season.import_failure;
if (failure) {
const kind = blacklistClass(failure.reason);
const when = failure.failed_at === null ? "" : ` ${formatSweepAge(failure.failed_at)}`;
const told = `a pack for this season downloaded in full and failed at import${when}${failure.release} · ${blacklistAdvice(failure.reason)}`;
line.append(
chip(`import failed · ${blacklistReasonLabel(failure.reason)}`, (span) => {
span.dataset.flag = "import-failed";
span.dataset.blacklist = kind;
span.title = told;
span.setAttribute("aria-label", told);
}),
);
}
// The season-level twin of the episode flag above: gone upstream while
// files under it remained.
if (season.vanished) {
@@ -4309,7 +4589,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const clear = armedDeleteIcon(
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
() => {
const currentId = seriesId;
if (currentId === null) {
return;
@@ -4325,10 +4607,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
},
);
line.append(clear);
}
@@ -4396,7 +4675,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
const clear = armedDeleteIcon(
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
() => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
@@ -4407,10 +4688,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
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`,
},
);
actions.append(clear);
} else if (!aired) {
@@ -4441,8 +4719,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel = `search releases for ${episodeTag(seasonNumber, episode.number)}`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -4657,6 +4938,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
: [],
);
paintHeader();
paintRootControl();
clearRichDetail();
renderSeasons();
syncRefreshWatch(ticket);
@@ -4686,7 +4968,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -4707,8 +4990,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
+77
View File
@@ -26,6 +26,12 @@ export interface MovieRelease {
score: number | null;
verdict: string | null;
rejected_rule: string | null;
/**
* What the blacklist recorded this release as failing on (#227, §6.3).
* Null on every row the blacklist does not hold, and on a blacklisted row
* whose entry has since gone — `blacklisted` is then all the record has.
*/
blacklist_reason: string | null;
}
export type ReleasesOutcome =
@@ -113,6 +119,23 @@ export interface SeasonPackState {
pack_failures: number;
pack_retry_at: string | null;
last_pack_search_at: string | null;
import_failure: ImportFailure | null;
}
/**
* A pack that downloaded in full and was condemned at import (#227, §5.7).
*
* The torrent stays where it is — §7.3 hands that lifecycle to the reaper —
* the release is blacklisted and every episode it covered reopens as a gap.
* Nothing on screen joined those facts, so the season read `0/10` as though
* no grab had ever been tried and the operator found out by opening
* Transmission.
*/
export interface ImportFailure {
release: string;
/** A policy rule name, or a sentence about the release. See `blacklistClass`. */
reason: string | null;
failed_at: string | null;
}
export type PackStateOutcome =
@@ -257,6 +280,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).
* Mapping a rule name to its override is bookkeeping, not policy — the
* 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 {
switch (rule) {
@@ -264,11 +292,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
case "size":
return { allow_below_floor: true };
default:
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 =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
@@ -455,6 +490,48 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
/**
* What a blacklisting was: the policy turning a file down, or the release
* itself failing (#227).
*
* The blacklist reason is either a policy rule name — the same vocabulary
* `rejected_rule` uses — or a sentence about the release, written where the
* import gave up before any rule was consulted. The two demand opposite
* decisions: a size rejection is the operator's own floor and they can relax
* it, a corrupt or mismatched pack is not theirs to argue with. `unknown` is
* a row the blacklist no longer answers for; nothing is claimed about it.
*/
export type BlacklistClass = "policy" | "release" | "unknown";
export function blacklistClass(reason: string | null): BlacklistClass {
if (reason === null) {
return "unknown";
}
return reason in RULE_LABEL ? "policy" : "release";
}
/** The reason as a chip word: a rule's short label, or the sentence itself. */
export function blacklistReasonLabel(reason: string | null): string {
return reason === null ? "reason not recorded" : (RULE_LABEL[reason] ?? reason);
}
/**
* What the operator does about it, which is the whole difference between the
* two classes — and the sentence #227 exists to put on screen.
*/
export function blacklistAdvice(reason: string | null): string {
switch (blacklistClass(reason)) {
case "policy":
return waiverOverride(reason) === null
? "policy rejected the file — that rule has no per-title relaxation"
: `policy rejected the file — relax ${ruleLabel(reason)} for this title and the next candidate can pass`;
case "release":
return "the release itself failed at import — a retry downloads the same files";
default:
return "blacklisted before the reason was recorded";
}
}
export async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
+27
View File
@@ -5,6 +5,7 @@
import type { MetadataTrailer } from "./movie";
import type {
ActionOutcome,
ImportFailure,
MovieRelease,
PackStateOutcome,
ReleasesOutcome,
@@ -49,6 +50,12 @@ export interface ApiSeason {
tracked: boolean;
/** Gone upstream while a file under it remained — a conflict, not a state. */
vanished: boolean;
/**
* #227: the last pack that downloaded in full and was condemned at import,
* while the season is still waiting for a file. Null once the gap is
* filled — a season with nothing missing has nothing to explain.
*/
import_failure: ImportFailure | null;
episodes: ApiEpisode[];
}
@@ -65,6 +72,26 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
}
}
/** Move a series to another TV root through its partial update endpoint. */
export async function updateSeries(
seriesId: number,
patch: { root_id: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
try {
+21 -55
View File
@@ -2,6 +2,7 @@
// plus the settings view over them — same reasoning as search.ts: the
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { armedDeleteIcon, icon } from "./icons";
import { navigate } from "./router";
import type { Root } from "./search";
@@ -222,40 +223,14 @@ function listValue(input: HTMLInputElement): string[] {
.filter((part) => part !== "");
}
/**
* First click arms the destructive action, second confirms. The armed state
* clears on blur or after a few seconds, so an accidental double click
* never deletes. Shared with the series detail rows — one confirmation
* idiom for row-level destruction, not one per page.
*/
export function armedDelete(label: string, execute: () => void): HTMLButtonElement {
const button = el("button", "control control-quiet readout", label);
/** Icon-only edit toggle for one row; the label names the row it opens. */
function editIcon(label: string): HTMLButtonElement {
const button = el("button", "control control-quiet control-icon");
button.type = "button";
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.textContent = label;
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = `confirm ${label}`;
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
button.setAttribute("aria-label", label);
button.title = label;
button.setAttribute("aria-expanded", "false");
button.append(icon("pencil"));
return button;
}
@@ -350,31 +325,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
/* -- roots -------------------------------------------------------------- */
/** A delete/edit action pair sharing the row's feedback line. */
function rowActions(
/** The row's line: identity, chips right, the icon action pair, feedback. */
function rowLine(
item: HTMLLIElement,
title: string,
chips: HTMLSpanElement,
del: HTMLButtonElement,
edit: HTMLButtonElement,
): HTMLParagraphElement {
const actions = el("div", "settings-row-actions");
actions.append(edit, del);
item.append(actions);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
item.append(note);
item.append(rowTitle(title), chips, actions, note);
return note;
}
function rootRow(root: Root): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
row.append(rowTitle(root.path), chips);
item.append(row);
const del = armedDelete("delete", () => {
const del = armedDeleteIcon(`delete root ${root.path}`, () => {
void deleteRow("roots", root.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
@@ -386,9 +359,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
const edit = editIcon(`edit root ${root.path}`);
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
@@ -400,7 +371,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
const note = rowLine(item, root.path, chips, del, edit);
return item;
}
@@ -482,17 +453,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}
function policyRow(policy: PolicyDoc): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(policy.resolution_pref.join(" ") || "no resolutions"));
chips.append(chip(requiredAudioLabel(policy)));
const bandCount = Object.keys(policy.size_bands).length;
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
row.append(rowTitle(policy.name), chips);
item.append(row);
const del = armedDelete("delete", () => {
const del = armedDeleteIcon(`delete policy ${policy.name}`, () => {
void deleteRow("policies", policy.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
@@ -504,9 +472,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
const edit = editIcon(`edit policy ${policy.name}`);
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
@@ -518,7 +484,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
const note = rowLine(item, policy.name, chips, del, edit);
return item;
}
+200 -2
View File
@@ -481,6 +481,62 @@ body {
outline-offset: 2px;
}
/* ---- icon-only controls (#230) ---------------------------------------- */
/* One block, shared: title pages, decks and readouts here, the settings
rows via #231. The glyph carries the action, the aria-label carries the
meaning, and the geometry stays on the --control-h rail with the chips. */
.icon {
display: block;
flex: none;
height: 0.875rem;
width: auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.control-icon {
min-width: var(--control-h);
padding: 0 var(--space-2);
}
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
monochrome on currentColor — never a remote image */
.icon-mark {
height: 1.125rem;
}
.icon-mark rect {
stroke-width: 1.6;
}
/* the mark's letters live in viewBox units, so the size rides the SVG
scale as an attribute; only voice and weight come from the tokens */
.icon-mark text {
fill: currentColor;
stroke: none;
font-family: var(--font-readout);
font-weight: 700;
letter-spacing: 0.06em;
}
/* a readout chip's glyph sits beside its figure, sized to the readout */
.chip .icon {
height: 0.75rem;
stroke-width: 1.7;
}
/* rows widen every .control's padding; an icon control stays near-square */
.season-line .control-icon,
.ep-actions .control-icon,
.movie-controls .control-icon {
padding: 0 var(--space-2);
}
/* ---- unified search (§9.2) ------------------------------------------- */
.rail-search {
@@ -539,6 +595,12 @@ body {
flex: 1;
}
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status {
margin: 0 0 var(--space-4);
color: var(--ink-muted);
@@ -566,6 +628,48 @@ body {
vertical-align: baseline;
}
/* #227: a pack abandoned at import, above the candidates. A quiet panel, not
an alert: the failure is history the season owes an explanation for, and
the operator opened this deck to grab something, not to be shouted at. The
tone follows the same reading as the row chips — amber for their own policy
floor, red for a release that failed on its own. */
.deck-notice {
margin: 0 0 var(--space-6);
padding: var(--space-3) var(--space-4) var(--space-4);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.deck-notice[data-blacklist="policy"] {
background: oklch(from var(--signal-warn) l c h / 7%);
border-color: oklch(from var(--signal-warn) l c h / 40%);
}
.deck-notice[data-blacklist="release"] {
background: oklch(from var(--signal-fault) l c h / 7%);
border-color: oklch(from var(--signal-fault) l c h / 40%);
}
/* the release name is evidence, in the readout face like every other one */
.notice-release {
margin: 0 0 var(--space-2);
overflow-wrap: anywhere;
font-size: var(--text-xs);
color: var(--ink);
}
.notice-line {
margin: 0;
max-width: 68ch;
font-size: var(--text-sm);
color: var(--ink-muted);
}
.notice-line + .notice-line {
margin-top: var(--space-2);
}
.deck-group {
margin: 0 0 var(--space-8);
}
@@ -593,6 +697,18 @@ body {
color: var(--ink-faint);
}
.deck-notice .deck-label {
margin-bottom: var(--space-2);
}
.deck-notice[data-blacklist="policy"] .deck-label {
color: var(--signal-warn);
}
.deck-notice[data-blacklist="release"] .deck-label {
color: var(--signal-fault);
}
.deck-rows {
margin: 0;
padding: 0;
@@ -668,6 +784,35 @@ body {
color: var(--verdict-rejected);
}
/* #227: a blacklisted release was grabbed, downloaded in full and thrown out
at import, and the two ways that happens want opposite decisions. The
policy turning a file down is the operator's own floor — amber, the hue
this app already gives a gap they can act on. The release itself failing is
the one case in the deck that is genuinely broken, so it takes fault red;
the "never fault red" rule above is about a rejection, and this is not one.
A row whose blacklist entry is gone claims nothing and stays slate. */
.chip[data-blacklist="policy"] {
color: var(--signal-warn);
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
.chip[data-blacklist="release"] {
color: var(--signal-fault);
border-color: oklch(from var(--signal-fault) l c h / 55%);
}
/* a reason written as a sentence is longer than any rule name, and the chips
around it are fixed-width columns that must not be pushed off the line
(§9.3: no horizontal scroll at any viewport). This one chip wraps instead. */
.chip[data-blacklist],
.chip[data-flag="import-failed"] {
min-width: 0;
max-width: 100%;
padding-top: var(--space-1);
padding-bottom: var(--space-1);
overflow-wrap: anywhere;
}
/* media state ramp (§4.2): green on disk, violet downloading, amber wanted
and still missing. Unwanted-missing and parked are nothing-happening and
stay neutral; `parked` exists so a vanished grab never reads as a gap. */
@@ -1148,6 +1293,20 @@ body {
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
/* #227: what the blacklisting means for the next move, on its own line under
the chips. Only a blacklisted row carries it, so the dense list stays dense
everywhere else. */
.rel-why {
flex-basis: 100%;
min-width: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.rel-why[data-blacklist="release"] {
color: oklch(from var(--signal-fault) 0.78 0.1 h);
}
.rel-note {
font-size: var(--text-xs);
color: var(--ink-muted);
@@ -1468,6 +1627,13 @@ body {
font-size: var(--text-xs);
}
/* #227: the season the operator's own report was about read `0/10` beside
this chip's absence. It sits next to the counts because that number is what
looked like nothing had ever been tried. */
.chip[data-flag="import-failed"] {
cursor: help;
}
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
.chip[data-flag="vanished"] {
color: var(--signal-warn);
@@ -1866,6 +2032,17 @@ body {
padding: 0 var(--space-3);
}
/* The one control whose effect is narrower than its name suggests (§6.3)
says so under the row while it is on: three words on the button, the
half they cannot hold on the line below. Aligned with the row's own
padding so it reads as a note on the controls, not a new section. */
.control-note {
margin: 0;
padding: 0 var(--space-1) var(--space-2);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright);
@@ -2097,11 +2274,32 @@ body {
/* ---- settings (issue #116) -------------------------------------------- */
/* rows follow the queues pattern: line, then an action pair */
/* one machined line per row (#231): identity, chips at the right edge,
then the icon action pair — the episode-row idiom, not a two-line group */
.settings-row-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-1) 0 var(--space-2);
margin-left: var(--space-3);
}
.settings-row .row-title {
overflow-wrap: anywhere;
}
/* feedback and the edit panel drop below the line, full width */
.settings-row .add-note,
.settings-row .edit-panel {
flex-basis: 100%;
}
/* narrow, the row wraps like an episode row: identity first, chips
full-width (the shared .row-chips rule), the icon pair keeps the right
edge. Lives here, after the base rule, so the margin actually flips. */
@media (max-width: 46rem) {
.settings-row-actions {
margin-left: auto;
}
}
.form-grid {