diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index e54011a..1e1b68e 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -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. diff --git a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json b/.sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json similarity index 63% rename from .sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json rename to .sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json index 361c234..b39a803 100644 --- a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json +++ b/.sqlx/query-06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", "describe": { "columns": [ { @@ -94,5 +94,5 @@ false ] }, - "hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708" + "hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d" } diff --git a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json b/.sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json similarity index 73% rename from .sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json rename to .sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json index a1a303a..f7e3de3 100644 --- a/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json +++ b/.sqlx/query-0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d.json @@ -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" } diff --git a/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json b/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json new file mode 100644 index 0000000..8d96b35 --- /dev/null +++ b/.sqlx/query-100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484.json @@ -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" +} diff --git a/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json new file mode 100644 index 0000000..adecf74 --- /dev/null +++ b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json @@ -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" +} diff --git a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json similarity index 80% rename from .sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json rename to .sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json index 7559915..d34c95a 100644 --- a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json +++ b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json @@ -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" } diff --git a/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json b/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json new file mode 100644 index 0000000..9b13e30 --- /dev/null +++ b/.sqlx/query-20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6.json @@ -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" +} diff --git a/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json b/.sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json similarity index 50% rename from .sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json rename to .sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json index dd8746a..2169d1b 100644 --- a/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json +++ b/.sqlx/query-2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b" + "hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41" } diff --git a/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json b/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json new file mode 100644 index 0000000..26ddc1e --- /dev/null +++ b/.sqlx/query-2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368.json @@ -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" +} diff --git a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json b/.sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json similarity index 84% rename from .sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json rename to .sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json index ae097a4..0309f43 100644 --- a/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json +++ b/.sqlx/query-35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46.json @@ -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" } diff --git a/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json b/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json new file mode 100644 index 0000000..d962efc --- /dev/null +++ b/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json @@ -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" +} diff --git a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json similarity index 52% rename from .sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json rename to .sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json index b3fa0d0..413eb80 100644 --- a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json +++ b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json @@ -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" } diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json similarity index 55% rename from .sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json rename to .sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json index d9045ff..0c19f57 100644 --- a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json +++ b/.sqlx/query-3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec.json @@ -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" } diff --git a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json b/.sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json similarity index 91% rename from .sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json rename to .sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json index d9e9698..fc3dc47 100644 --- a/.sqlx/query-9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2.json +++ b/.sqlx/query-3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0.json @@ -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" } diff --git a/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json new file mode 100644 index 0000000..ec49475 --- /dev/null +++ b/.sqlx/query-3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe.json @@ -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" +} diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json similarity index 90% rename from .sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json rename to .sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json index a440463..54d5718 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json @@ -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" } diff --git a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json b/.sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json similarity index 89% rename from .sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json rename to .sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json index bc48f31..0d77e5f 100644 --- a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json +++ b/.sqlx/query-4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db.json @@ -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" } diff --git a/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json b/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json new file mode 100644 index 0000000..5262844 --- /dev/null +++ b/.sqlx/query-6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953.json @@ -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" +} diff --git a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json b/.sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json similarity index 81% rename from .sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json rename to .sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json index bcd58e4..14e71f6 100644 --- a/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json +++ b/.sqlx/query-6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8.json @@ -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" } diff --git a/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json b/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json new file mode 100644 index 0000000..280d7c3 --- /dev/null +++ b/.sqlx/query-73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa.json @@ -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" +} diff --git a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json similarity index 86% rename from .sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json rename to .sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json index 6deb1c5..0082174 100644 --- a/.sqlx/query-aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f.json +++ b/.sqlx/query-7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f.json @@ -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" } diff --git a/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json new file mode 100644 index 0000000..41f7f09 --- /dev/null +++ b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json @@ -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" +} diff --git a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json similarity index 80% rename from .sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json rename to .sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json index 70f41db..bb05272 100644 --- a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json +++ b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json @@ -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" } diff --git a/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json new file mode 100644 index 0000000..91528f3 --- /dev/null +++ b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json @@ -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" +} diff --git a/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json b/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json new file mode 100644 index 0000000..ade8330 --- /dev/null +++ b/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json @@ -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" +} diff --git a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json similarity index 86% rename from .sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json rename to .sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json index 8b93c58..90e1265 100644 --- a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json +++ b/.sqlx/query-8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759.json @@ -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" } diff --git a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json similarity index 58% rename from .sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json rename to .sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json index a849773..152b0c0 100644 --- a/.sqlx/query-071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97.json +++ b/.sqlx/query-939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c.json @@ -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" } diff --git a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json similarity index 86% rename from .sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json rename to .sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json index 6e0ebcd..395b36b 100644 --- a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json +++ b/.sqlx/query-9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389.json @@ -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" } diff --git a/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json b/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json new file mode 100644 index 0000000..5bdcdb9 --- /dev/null +++ b/.sqlx/query-a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562.json @@ -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" +} diff --git a/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json new file mode 100644 index 0000000..1a98b0c --- /dev/null +++ b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62" +} diff --git a/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json new file mode 100644 index 0000000..2f69b71 --- /dev/null +++ b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json @@ -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" +} diff --git a/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json b/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json new file mode 100644 index 0000000..39977ba --- /dev/null +++ b/.sqlx/query-ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302.json @@ -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" +} diff --git a/.sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json b/.sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json new file mode 100644 index 0000000..e31f5fb --- /dev/null +++ b/.sqlx/query-bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1.json @@ -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" +} diff --git a/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json b/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json deleted file mode 100644 index e3636bf..0000000 --- a/.sqlx/query-c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE grabs SET state = 'failed' WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294" -} diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json similarity index 55% rename from .sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json rename to .sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json index 207bd32..ed923e9 100644 --- a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json +++ b/.sqlx/query-c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5.json @@ -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" } diff --git a/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json b/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json new file mode 100644 index 0000000..9afc9d2 --- /dev/null +++ b/.sqlx/query-cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96.json @@ -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" +} diff --git a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json b/.sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json similarity index 78% rename from .sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json rename to .sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json index feed8c1..25b72e8 100644 --- a/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json +++ b/.sqlx/query-dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777.json @@ -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" } diff --git a/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json new file mode 100644 index 0000000..4dd5fd6 --- /dev/null +++ b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json @@ -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" +} diff --git a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json similarity index 88% rename from .sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json rename to .sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json index 509e530..910129e 100644 --- a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json +++ b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json @@ -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" } diff --git a/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json new file mode 100644 index 0000000..d65ea71 --- /dev/null +++ b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json @@ -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" +} diff --git a/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json b/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json new file mode 100644 index 0000000..2cdd72a --- /dev/null +++ b/.sqlx/query-fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1.json @@ -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" +} diff --git a/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json new file mode 100644 index 0000000..55302ed --- /dev/null +++ b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json @@ -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" +} diff --git a/DESIGN.md b/DESIGN.md index 754866e..58e868f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index e1dd341..e6d315a 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -12,6 +12,8 @@ mod metadata; mod movies; mod owners; mod policies; +mod reclassify; +mod relocate; mod roots; mod search; mod series; diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 76c5b32..5b860e2 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -76,7 +76,42 @@ pub struct Release { pub parsed: serde_json::Value, pub score: Option, pub verdict: Option, + // The rule behind the verdict: the one that killed a `rejected` row, or + // the one a `waived` row relaxed (#211). Null on an `eligible` row, and + // on a `waived` row stored before migration 0032, which could not record + // it. A plain comment, not a doc comment: doc comments here become + // OpenAPI descriptions and would put the generated client in web/ out of + // date, which #227 and #232 own. pub rejected_rule: Option, + // #227: what the blacklist recorded this release as failing on, when + // `rejected_rule` is `blacklisted`. Null on every other row, and on a + // blacklisted row whose blacklist entry has since gone. A size rejection + // is a policy opinion the operator can relax; a corrupt or mismatched + // release is not, and a bare `blacklisted` reads the same for both. + // Plain comment for the same reason as the field above. + pub blacklist_reason: Option, +} + +/// Fill in [`Release::blacklist_reason`] for every deck row the blacklist +/// holds (#227, §6.3). +/// +/// The blacklist is keyed on the *normalised* name, which SQL cannot compute, +/// so the match happens here over the whole table — a handful of rows, the +/// same reasoning as [`arr_db::blacklist::Blacklist`] itself. +pub(crate) async fn attach_blacklist_reasons( + pool: &sqlx::SqlitePool, + releases: &mut [Release], +) -> Result<(), ApiError> { + if releases.is_empty() { + return Ok(()); + } + let blacklist = arr_db::blacklist::Blacklist::load(pool).await?; + for release in releases.iter_mut() { + release.blacklist_reason = blacklist + .reason_for_candidate(&release.name, &release.download_url) + .map(str::to_owned); + } + Ok(()) } /// A library file and what it cost to accept it (`DESIGN.md` §5.7). @@ -242,13 +277,12 @@ impl IntoResponse for ApiError { None, ) } + // The message is the caller's: this variant is returned by the + // delete lane and by the relocate lane, and "files not removed" + // is a lie about a move that failed. Self::Filesystem(error) => { tracing::error!(%error, "API filesystem error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("files not removed: {error}"), - None, - ) + (StatusCode::INTERNAL_SERVER_ERROR, error.clone(), None) } }; (status, Json(ErrorBody { error, code })).into_response() @@ -284,10 +318,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()) { @@ -450,8 +484,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 title were reached under the old + // overrides; §9.3's deck and the grab gate both read them (`reclassify`). + let overrides_changed = overrides != current.overrides; let overrides = serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; let title = title.trim(); @@ -471,9 +508,57 @@ pub async fn update( } let wanted = input.wanted.unwrap_or(current.wanted); let blocked = input.blocked.unwrap_or(current.blocked); - sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) - .execute(pool(&state)?) - .await?; + // 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::Movie, + id, + current.root_id, + root_id, + ) + .await?, + ) + }; + let mut transaction = pool(&state)?.begin().await?; + let written: Result<(), sqlx::Error> = async { + sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id) + .execute(&mut *transaction) + .await?; + if let Some(relocation) = &relocation { + relocation.rewrite_rows(&mut transaction).await?; + } + Ok(()) + } + .await; + let committed = match written { + Ok(()) => transaction.commit().await.map_err(ApiError::from), + Err(error) => Err(ApiError::from(error)), + }; + if let Err(error) = committed { + if let Some(relocation) = &relocation { + relocation.undo().await; + } + return Err(error); + } + if relocation + .as_ref() + .is_some_and(crate::relocate::Relocation::moved_files) + { + crate::relocate::refresh_jellyfin(&state).await; + } + // 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::movie(&state, id).await?; + } Ok(Json(load_movie(&state, id).await?)) } @@ -566,7 +651,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> Ok(metadata) => metadata, // Already gone is the state we wanted. Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => return Err(ApiError::Filesystem(error.to_string())), + Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))), }; let removed = if metadata.is_dir() { tokio::fs::remove_dir_all(&target).await @@ -576,7 +661,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> match removed { Ok(()) => tracing::info!(target = %target.display(), "removed library files"), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(ApiError::Filesystem(error.to_string())), + Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))), } } Ok(()) @@ -637,7 +722,7 @@ pub async fn releases( Path(id): Path, ) -> Result>, ApiError> { load_movie(&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 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"#, 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 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"#, id) .fetch_all(pool(&state)?) .await?; let policy = state @@ -648,7 +733,8 @@ pub async fn releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)? .policy; - rescore(&mut releases, &policy, None)?; + rescore(&mut releases, &policy, None, 0)?; + attach_blacklist_reasons(pool(&state)?, &mut releases).await?; Ok(Json(releases)) } @@ -662,6 +748,10 @@ pub async fn releases( /// many it covers. `None` is the movie decks: one release is one film, /// whatever episode-shaped noise its name parses to. /// +/// `runtime_minutes` scales the bands by the series' minutes per episode +/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same +/// zero a series with no known runtime gets. +/// /// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// policy weights in the thousands), so the `i64` -> `f64` cast into the /// column's storage type is exact. @@ -670,6 +760,7 @@ pub(crate) fn rescore( releases: &mut [Release], policy: &Policy, season_lengths: Option<&BTreeMap>, + runtime_minutes: u32, ) -> Result<(), ApiError> { let mut totals = Vec::with_capacity(releases.len()); for release in releases.iter() { @@ -680,7 +771,17 @@ pub(crate) fn rescore( let episodes = season_lengths.map_or(1, |lengths| { claimed_episode_count(parsed.episode.as_ref(), lengths) }); - totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); + totals.push( + score( + policy, + Candidate::PreGrab(&parsed), + size, + seeders, + episodes, + runtime_minutes, + ) + .total, + ); } let mut indices: Vec = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -751,6 +852,12 @@ pub async fn grab( Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } +// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in +// full — two failures on *different* releases, both inside +// `ATTENTION_WINDOW`, against a target still waiting for a file — so this +// endpoint and the daemon's notifier report the same queue. Deliberately not +// a doc comment: utoipa would fold it into the OpenAPI description and drift +// the committed document. #[utoipa::path( get, path = "/api/queues/attention", tag = "movies", responses( @@ -763,7 +870,7 @@ pub async fn attention(State(state): State) -> Result 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#) .fetch_all(pool(&state)?) .await?; - let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#) + let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW) .fetch_all(pool(&state)?) .await?; let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; @@ -779,34 +886,75 @@ pub async fn attention(State(state): State) -> Result Result<(Vec, Vec), ApiError> { let database = pool(state)?; + Ok(( + tv_no_pt_source_lane(database).await?, + tv_hard_fail_lane(database).await?, + )) +} - let no_pt_rows = sqlx::query!( +/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose +/// every candidate release was rejected for language. +async fn tv_no_pt_source_lane( + database: &sqlx::SqlitePool, +) -> Result, ApiError> { + let rows = sqlx::query!( r#" - SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, - e.id AS "episode_id!: i64", - se.number AS "season_number!: i64", e.number AS "episode_number!: i64" - FROM episodes e - JOIN seasons se ON se.id = e.season_id - JOIN series s ON s.id = se.series_id - JOIN roots root ON root.id = s.root_id - WHERE root.audience = 'kids' - AND s.blocked = 0 - AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 - AND NOT EXISTS ( - SELECT 1 FROM episode_releases er - JOIN releases r ON r.id = er.release_id - WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') - ) - ORDER BY se.number, e.number - "# + SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", + s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + JOIN roots root ON root.id = s.root_id + WHERE root.audience = 'kids' + AND s.blocked = 0 + AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 + AND NOT EXISTS ( + SELECT 1 FROM episode_releases er + JOIN releases r ON r.id = er.release_id + WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') + ) + ORDER BY se.number, e.number + "# ) .fetch_all(database) .await?; + + let mut entries: Vec = Vec::new(); + for row in rows { + merge_episode( + &mut entries, + row.series_id, + row.tmdb_id, + &row.title, + row.year, + row.episode_id, + row.season_number, + row.episode_number, + ); + } + Ok(entries) +} + +/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed +/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a +/// file. The season half reads that last condition off its episodes, which is +/// where intent lives (§4.1). Both halves merge into one entry per series. +async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result, ApiError> { let episode_hard_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", @@ -818,9 +966,12 @@ async fn tv_attention( JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?) + AND e.wanted = 1 AND e.state != 'available' GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 - "# + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; @@ -833,30 +984,24 @@ async fn tv_attention( JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND g.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.tmdb_id, s.title, s.year, se.id, se.number - "# + HAVING count(DISTINCT g.release_id) >= 2 + "#, + arr_db::ATTENTION_WINDOW ) .fetch_all(database) .await?; - let mut tv_no_pt_source: Vec = Vec::new(); - for row in no_pt_rows { - merge_episode( - &mut tv_no_pt_source, - row.series_id, - row.tmdb_id, - &row.title, - row.year, - row.episode_id, - row.season_number, - row.episode_number, - ); - } - - let mut tv_needs_decision: Vec = Vec::new(); + let mut entries: Vec = Vec::new(); for row in episode_hard_fails { merge_episode( - &mut tv_needs_decision, + &mut entries, row.series_id, row.tmdb_id, &row.title, @@ -868,7 +1013,7 @@ async fn tv_attention( } for row in season_pack_fails { merge_season( - &mut tv_needs_decision, + &mut entries, row.series_id, row.tmdb_id, &row.title, @@ -877,8 +1022,7 @@ async fn tv_attention( row.season_number, ); } - - Ok((tv_no_pt_source, tv_needs_decision)) + Ok(entries) } /// One more qualifying season for its series, creating the series' entry on @@ -1179,6 +1323,201 @@ mod tests { assert_eq!(files[0]["probed"]["resolution"], "1080p"); } + /// #210 on the movie deck: relaxing §5.5's floor for one title moves its + /// size rejections into the waived bucket and no further — a waiver is + /// never picked automatically (§9.3), only offered. + #[tokio::test] + async fn allow_below_floor_moves_a_size_rejection_to_waived_not_eligible() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = created["id"].as_i64().expect("id"); + + // 2 GiB at 1080p, under the movie policy's 3 GiB floor. + let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 2_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule) + VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'size') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"overrides": {"allow_below_floor": true}})) + .send() + .await + .expect("override"); + assert_eq!(response.status(), StatusCode::OK); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["verdict"], "waived"); + // #211: and it says which rule the waiver relaxed, rather than + // sitting in the deck as a bare `waived` beside rejections that each + // name their own. + assert_eq!(releases[0]["rejected_rule"], "size"); + } + + /// #241: moving a title to a root with a different policy re-derives its + /// stored verdicts (§5.1) — an English-audio release a `main` root found + /// eligible is only a waiver under the `kids` root it moved to. + #[tokio::test] + async fn moving_a_movie_to_another_root_rederives_its_verdicts() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = created["id"].as_i64().expect("id"); + + let name = "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 6_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, 'g', ?, ?, 40, '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 movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::OK); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!( + releases[0]["verdict"], "waived", + "kids requires pt-PT, English is a soft fail: {releases:?}" + ); + } + + /// A blacklisted release (§6.3) is not a policy opinion, so no override + /// re-opens it. + #[tokio::test] + async fn an_override_never_revives_a_blacklisted_release() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let movie = add_movie(&base, 693_134, 1).await; + let movie_id = movie["id"].as_i64().expect("id"); + + let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let size = 8_i64 * (1 << 30); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule) + VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'blacklisted') RETURNING id", + ) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)") + .bind(movie_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + reqwest::Client::new() + .patch(format!("{base}/api/movies/{movie_id}")) + .json(&serde_json::json!({"overrides": {"allow_below_floor": true, "only_4k": false}})) + .send() + .await + .expect("override"); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases[0]["verdict"], "rejected"); + assert_eq!(releases[0]["rejected_rule"], "blacklisted"); + // #227: a row the blacklist has no entry for keeps rendering — the + // rule is all the record holds, and no reason is invented for it. + assert_eq!(releases[0]["blacklist_reason"], serde_json::Value::Null); + + // With the entry, the row says what it was blacklisted for: a + // corrupt file is not the same decision as a size rejection. + arr_db::blacklist::add(pool, None, name, "no original-language audio") + .await + .expect("blacklist"); + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!( + releases[0]["blacklist_reason"], + "no original-language audio" + ); + } + #[tokio::test] async fn crud_preserves_intent_and_overrides() { let (_dir, _state, base) = application().await; @@ -1332,6 +1671,259 @@ mod tests { ); } + /// Point one of the seeded roots at a real directory for the duration of + /// a test. + async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path.to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + } + + /// Issue #228: changing a movie's root renames its §7.4 folder into the + /// new root — sidecars ride along inside it — and the `media_files` rows + /// follow in the same write. + #[tokio::test] + async fn changing_root_moves_the_title_folder_and_its_rows() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + point_root_at(&state, 2, destination.path()).await; + + // The wrong kind of root is still rejected, before anything moves. + let rejected = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 3})) + .send() + .await + .expect("move to a tv root"); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], 2); + + assert!(!folder.exists(), "the folder left the old root"); + let moved = destination + .path() + .join("Dune Part Two (2024) [tmdbid-693134]"); + assert!( + moved + .join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv") + .exists(), + "the feature arrived in the new root" + ); + assert!( + moved.join("dune.pt.srt").exists(), + "sidecars travel inside the folder" + ); + + let path: String = sqlx::query_scalar( + "SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("media file row"); + assert!( + std::path::Path::new(&path).starts_with(destination.path()), + "the row follows the file: {path}" + ); + assert!( + std::path::Path::new(&path).exists(), + "the rewritten path describes the disk" + ); + } + + /// Reported in production: moving a title into a root whose directory has + /// never been written to failed with + /// `could not move '...The Batman...': No such file or directory`, naming + /// a folder that was sitting exactly where the operator left it. A root is + /// a database row and nothing makes its directory exist; `rename` reports + /// a missing destination parent as the same `NotFound` as a missing + /// source. Every earlier test pointed the destination at a `tempdir`, + /// which is why the assumption was never exercised. + #[tokio::test] + async fn a_root_whose_directory_does_not_exist_yet_still_receives_the_title() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let parent = tempfile::tempdir().expect("media tree"); + let folder = library_on_disk(&state, id, source.path()).await; + + // Configured, but never written to: the row exists, the folder does not. + let destination = parent.path().join("movies").join("kids"); + assert!(!destination.exists()); + point_root_at(&state, 2, &destination).await; + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!( + response.status(), + StatusCode::OK, + "a root that has no folder yet is not a reason to refuse the move" + ); + + assert!(!folder.exists(), "the folder left the old root"); + assert!( + destination + .join("Dune Part Two (2024) [tmdbid-693134]") + .join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv") + .exists(), + "the feature arrived in a root that had to be created for it" + ); + + let path: String = sqlx::query_scalar( + "SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("media file row"); + assert!( + std::path::Path::new(&path).starts_with(&destination), + "the row follows the file: {path}" + ); + } + + /// Issue #228: a title with nothing on disk changes root with no + /// filesystem work at all — the seeded root paths do not even exist. + #[tokio::test] + async fn a_movie_with_no_files_changes_root_cleanly() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["root_id"], 2); + + let root_id: i64 = sqlx::query_scalar("SELECT root_id FROM movies WHERE id = ?") + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 2); + } + + /// Issue #228: a destination already holding a folder of that name is a + /// conflict, not an overwrite — and the row stays where the files are. + #[tokio::test] + async fn a_destination_collision_is_refused_and_the_row_unchanged() { + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + point_root_at(&state, 2, destination.path()).await; + let squatter = destination + .path() + .join("Dune Part Two (2024) [tmdbid-693134]"); + tokio::fs::create_dir_all(&squatter) + .await + .expect("pre-existing folder"); + tokio::fs::write(squatter.join("theirs.mkv"), b"not ours") + .await + .expect("write squatter file"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + assert!(folder.exists(), "the folder stayed in the old root"); + assert!( + squatter.join("theirs.mkv").exists(), + "the occupant was not overwritten" + ); + let (root_id, path): (i64, String) = sqlx::query_as( + "SELECT m.root_id, f.path FROM movies m + JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id + WHERE m.id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 1, "the row is unchanged"); + assert!(std::path::Path::new(&path).starts_with(source.path())); + } + + /// Issue #228: if the rename fails, the row must not change — the + /// operator sees the title where its files actually are and can retry. + #[cfg(unix)] + #[tokio::test] + async fn a_failed_rename_leaves_the_row_alone() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let movie = add_movie(&base, 693_134, 1).await; + let id = movie["id"].as_i64().expect("id"); + let source = tempfile::tempdir().expect("source root"); + let destination = tempfile::tempdir().expect("destination root"); + let folder = library_on_disk(&state, id, source.path()).await; + point_root_at(&state, 2, destination.path()).await; + tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); + + let response = reqwest::Client::new() + .patch(format!("{base}/api/movies/{id}")) + .json(&serde_json::json!({"root_id": 2})) + .send() + .await + .expect("move root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body: serde_json::Value = response.json().await.expect("error body"); + let error = body["error"].as_str().expect("error text"); + assert!(error.contains(folder.to_str().expect("utf-8 source"))); + assert!(error.contains(destination.path().to_str().expect("utf-8 destination"))); + + tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); + assert!(folder.exists(), "the folder never left the old root"); + let (root_id, path): (i64, String) = sqlx::query_as( + "SELECT m.root_id, f.path FROM movies m + JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id + WHERE m.id = ?", + ) + .bind(id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie row"); + assert_eq!(root_id, 1, "the row is unchanged"); + assert!(std::path::Path::new(&path).starts_with(source.path())); + } + #[test] fn a_title_target_is_the_folder_directly_under_the_root() { let root = "/mnt/media/movies/main"; @@ -1489,6 +2081,7 @@ mod tests { size_bytes, seeders, 1, + 0, ) .total; #[allow(clippy::cast_precision_loss)] @@ -1551,7 +2144,7 @@ mod tests { .expect("release") .last_insert_rowid(); release_ids.push(release_id); - sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')") + sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) VALUES (?, 'movie', ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))") .bind(release_id) .bind(movie_id) .bind(format!("hash-{suffix}")) @@ -1677,10 +2270,332 @@ mod tests { ); } + /// A series with one season holding a single wanted, missing episode — the + /// least that satisfies §5.7's liveness condition — for exercising the + /// season lane on its own. + async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) { + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids tv root"); + let series_id: i64 = sqlx::query( + "INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?) + RETURNING id", + ) + .bind(tmdb_id) + .bind(root_id) + .fetch_one(pool) + .await + .expect("series") + .get(0); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 1, 'Episode 1', 1, 'missing')", + ) + .bind(season_id) + .execute(pool) + .await + .expect("episode"); + (series_id, season_id) + } + + async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 { + sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id", + ) + .bind(guid) + .fetch_one(pool) + .await + .expect("release") + .get(0) + } + + /// A hard-failed grab, its failure stamped `age_days` in the past so the + /// §5.7 window — which runs from the failure — can be exercised without + /// waiting a month. + async fn insert_failed_grab( + pool: &sqlx::SqlitePool, + release_id: i64, + target_kind: &str, + target_id: i64, + infohash: &str, + age_days: i64, + ) { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, 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(infohash) + .bind(format!("-{age_days} days")) + .bind(format!("-{age_days} days")) + .execute(pool) + .await + .expect("failed grab"); + } + + /// The seasons `GET /api/queues/attention` currently reports for a series. + async fn queued_seasons(base: &str, series_id: i64) -> Vec { + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json"); + queues["tv_needs_decision"] + .as_array() + .expect("tv lane") + .iter() + .filter(|entry| entry["series_id"] == series_id) + .flat_map(|entry| { + entry["seasons"] + .as_array() + .expect("seasons") + .iter() + .map(|season| season["id"].as_i64().expect("season id")) + }) + .collect() + } + + /// §5.7: one failed pack is the blacklist working, not a decision. The + /// season lane holds to the same two-distinct-releases bar the episode + /// lane does (#226). + #[tokio::test] + async fn a_season_queues_only_on_two_distinct_release_failures() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + let first = insert_release(pool, "pack-one").await; + insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one failed pack is not a decision" + ); + + // A second failure on the *same* release is still one release. + insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "two grabs of one release are not two releases" + ); + + let second = insert_release(pool, "pack-two").await; + insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "two distinct releases hard-failed: the operator decides" + ); + } + + /// §5.7: a failure counts for 30 days. A season the operator has dealt + /// with stops failing and leaves the queue; one still breaking keeps + /// producing failures and stays (#226). + #[tokio::test] + async fn a_season_failure_ages_out_of_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + + for (guid, hash, age) in [ + ("old-one", "hash-old-one", 40), + ("old-two", "hash-old-two", 35), + ] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "season", season_id, hash, age).await; + } + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "failures older than the window are history, not attention" + ); + + let fresh = insert_release(pool, "new-one").await; + insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await; + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "one recent failure does not revive two stale ones" + ); + + let fresher = insert_release(pool, "new-two").await; + insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await; + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "still breaking: back in the queue" + ); + } + + /// §5.7: the queue only holds targets still waiting for a file. A season + /// whose pack failed twice, fell back to per-episode grabbing (§6.2) and + /// was then fully acquired is the system working, so it drops out at once + /// instead of sitting there for 30 days (#238). + #[tokio::test] + async fn a_fully_acquired_season_leaves_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let (series_id, season_id) = seed_bare_season(pool, 1).await; + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 2, 'Episode 2', 1, 'missing')", + ) + .bind(season_id) + .execute(pool) + .await + .expect("second episode"); + + for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await; + } + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "two packs failed and episodes are still missing" + ); + + // Per-episode grabbing got one of the two. Still a gap, still queued. + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1") + .bind(season_id) + .execute(pool) + .await + .expect("first episode imported"); + assert_eq!( + queued_seasons(&base, series_id).await, + vec![season_id], + "one episode still wanted and missing: still broken, still queued" + ); + + sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?") + .bind(season_id) + .execute(pool) + .await + .expect("season imported"); + assert!( + queued_seasons(&base, series_id).await.is_empty(), + "every episode acquired: the fallback worked, no decision to make" + ); + } + + /// §5.7: the same liveness condition on the movie and episode lanes, so + /// `GET /api/queues/attention` reports exactly what the daemon notifies + /// on (#238). + #[tokio::test] + async fn an_acquired_movie_or_episode_leaves_the_attention_queue() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids root"); + let movie = add_movie(&base, 82728, root_id).await; + let movie_id = movie["id"].as_i64().expect("id"); + for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await; + } + + let (series_id, season_id) = seed_bare_season(pool, 99).await; + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1") + .bind(season_id) + .fetch_one(pool) + .await + .expect("episode id"); + for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] { + let release_id = insert_release(pool, guid).await; + insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await; + } + + let queues = attention_queues(&base).await; + assert_eq!(queues["needs_decision"][0]["id"], movie_id); + assert_eq!( + queued_episodes(&queues, series_id), + vec![episode_id], + "still wanted and missing: queued" + ); + + sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?") + .bind(movie_id) + .execute(pool) + .await + .expect("movie imported"); + sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") + .bind(episode_id) + .execute(pool) + .await + .expect("episode imported"); + + let queues = attention_queues(&base).await; + assert_eq!( + queues["needs_decision"].as_array().map(Vec::len), + Some(0), + "imported from a third release: no decision to make" + ); + assert!( + queued_episodes(&queues, series_id).is_empty(), + "imported from a third release: no decision to make" + ); + + // Withdrawing intent empties the lane just the same. + sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?") + .bind(movie_id) + .execute(pool) + .await + .expect("movie unwanted"); + assert_eq!( + attention_queues(&base).await["needs_decision"] + .as_array() + .map(Vec::len), + Some(0), + "nothing is waiting for a file" + ); + } + + /// The whole attention payload. + async fn attention_queues(base: &str) -> serde_json::Value { + reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json") + } + + /// The episodes a series contributes to the hard-fail TV lane. + fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec { + queues["tv_needs_decision"] + .as_array() + .expect("tv lane") + .iter() + .filter(|entry| entry["series_id"] == series_id) + .flat_map(|entry| { + entry["episodes"] + .as_array() + .expect("episodes") + .iter() + .map(|episode| episode["id"].as_i64().expect("episode id")) + }) + .collect() + } + /// One series hitting all three §9.5 TV entry conditions: two wanted, /// searched episodes whose every candidate was rejected for language; a - /// season pack that hard-failed; and an episode two different releases - /// hard-failed on. + /// season two different packs hard-failed on; and an episode two different + /// releases hard-failed on. async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) { let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") @@ -1732,6 +2647,7 @@ mod tests { .expect("episode id"); for (kind, guid, suffix) in [ ("season", "pack", "pack"), + ("season", "pack-two", "pack2"), ("episode", "first", "a"), ("episode", "second", "b"), ] { @@ -1745,8 +2661,8 @@ mod tests { .expect("release") .get(0); 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, failed_at) + VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", ) .bind(release_id) .bind(kind) diff --git a/crates/arr-api/src/policies.rs b/crates/arr-api/src/policies.rs index a7a07d0..6ebb77c 100644 --- a/crates/arr-api/src/policies.rs +++ b/crates/arr-api/src/policies.rs @@ -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( 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, 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 = 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 = 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 = (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 = 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" + ); + } } diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs new file mode 100644 index 0000000..b946f44 --- /dev/null +++ b/crates/arr-api/src/reclassify.rs @@ -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, + rejected_rule: Option, +} + +/// 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>, + 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, 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, 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))) +} diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs new file mode 100644 index 0000000..6ae9af7 --- /dev/null +++ b/crates/arr-api/src/relocate.rs @@ -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, + 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, +} + +/// 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 { + 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 { + 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 { + let mut renames: Vec = 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 = 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 = 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 { + 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, + created_dirs: Vec, +) -> 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 { + 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, 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, 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(), + }) +} diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index d193843..df9ce47 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -67,6 +67,20 @@ impl RootInput { } } +/// Strips a trailing separator so `/mnt/media` and `/mnt/media/` name the +/// same root — otherwise a `PUT` that only adds or drops a trailing slash +/// takes the relocation branch and every planned destination conflicts with +/// its own source. Leaves a bare `/` alone. +fn normalize_path(path: &str) -> String { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + "/".to_owned() + } else { + stripped.to_owned() + } +} + async fn load_root(state: &AppState, id: i64) -> Result { sqlx::query_as!( Root, @@ -137,7 +151,8 @@ pub async fn create( let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; - let path = input.path.trim().to_owned(); + let path = normalize_path(&input.path); + path_is_free(&state, None, &path).await?; let result = sqlx::query!( "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", input.kind, @@ -172,26 +187,100 @@ pub async fn update( let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; - let path = input.path.trim().to_owned(); - let result = sqlx::query!( - r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') - WHERE id = ?"#, - input.kind, - input.audience, - path, - input.policy_id, - id, - ) - .execute(pool(&state)?) - .await - .map_err(root_conflict)?; - if result.rows_affected() == 0 { - return Err(ApiError::RootNotFound); + let current = load_root(&state, id).await?; + let path = normalize_path(&input.path); + // #244: rows written before #243 can hold a trailing separator, so the + // stored value is normalised too. Comparing a normalised payload against + // a raw stored value means the row can never compare equal: every edit, + // policy changes included, takes the relocation branch, and there each + // planned destination is its own source. The normalised value is what + // `relocate_root` gets as well, so no planned path carries a doubled + // separator. + let current_path = normalize_path(¤t.path); + // A path change moves every §7.4 title folder under this root with the + // row (issue #236), the same way changing a title's root moves one + // (#228). Disk first, row second: a failed rename leaves the root row + // alone, so the operator sees the library where its files actually are + // and can retry. A path already taken is refused before any of it, since + // the write would fail afterwards anyway. + let relocation = if path == current_path { + None + } else { + path_is_free(&state, Some(id), &path).await?; + Some(crate::relocate::relocate_root(&state, id, ¤t_path, &path).await?) + }; + let mut transaction = pool(&state)?.begin().await?; + let written: Result<(), sqlx::Error> = async { + sqlx::query!( + r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?"#, + input.kind, + input.audience, + path, + input.policy_id, + id, + ) + .execute(&mut *transaction) + .await?; + if let Some(relocation) = &relocation { + relocation.rewrite_rows(&mut transaction).await?; + } + Ok(()) + } + .await; + let committed = match written { + Ok(()) => transaction.commit().await.map_err(root_conflict), + Err(error) => Err(root_conflict(error)), + }; + if let Err(error) = committed { + if let Some(relocation) = &relocation { + relocation.undo().await; + } + return Err(error); + } + if relocation + .as_ref() + .is_some_and(crate::relocate::Relocation::moved_files) + { + crate::relocate::refresh_jellyfin(&state).await; + } + // Verdicts already stored under this root were reached under the old + // policy; §9.3's deck and the grab gate both read them (`reclassify`). + if input.policy_id != current.policy_id { + crate::reclassify::root(&state, id).await?; } Ok(Json(load_root(&state, id).await?)) } +/// The unique index on `path` would catch this after the move; catching it +/// first keeps a doomed write from touching the disk at all. `except` is the +/// row being updated, or `None` when creating. +/// +/// The stored side is normalised in SQL, mirroring [`normalize_path`], so +/// `/mnt/media/x` and `/mnt/media/x/` cannot be two roots for one directory +/// (#244). The unique index cannot see that — it compares the raw strings — +/// and migration 0031 leaves any pair that already collides alone rather +/// than failing to apply, so such a row can still be on disk. +async fn path_is_free(state: &AppState, except: Option, path: &str) -> Result<(), ApiError> { + let taken: Option = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM roots + WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ? + AND (? IS NULL OR id <> ?)"#, + path, + except, + except + ) + .fetch_optional(pool(state)?) + .await?; + if taken.is_some() { + return Err(ApiError::Conflict( + "a root with this path already exists".into(), + )); + } + Ok(()) +} + /// A duplicate path or a duplicate (kind, audience) pair is a settings /// mistake the operator can fix, not a server fault. fn root_conflict(error: sqlx::Error) -> ApiError { @@ -258,7 +347,7 @@ mod tests { use crate::{router, AppState, Upstreams}; use axum::http::StatusCode; - async fn application() -> (tempfile::TempDir, String) { + async fn application() -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await @@ -274,14 +363,14 @@ mod tests { .await .expect("bind"); let address = listener.local_addr().expect("address"); - let app = router(state); + let app = router(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); - (dir, format!("http://{address}")) + (dir, state, format!("http://{address}")) } #[tokio::test] async fn roots_carry_their_policy_name() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") @@ -323,9 +412,195 @@ mod tests { .collect() } + async fn policy_id_named(base: &str, name: &str) -> i64 { + let policies: Vec = 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") + } + + async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> 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, 'eligible') RETURNING id", + ) + .bind(name) + .bind(name) + .bind(size) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("release") + } + + /// A movie under the movie main root, its release stamped eligible. + async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 { + let movie: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": 1, "overrides": {} + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = movie["id"].as_i64().expect("movie id"); + let release_id = stamped_eligible_release( + state, + "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP", + 6_i64 * (1 << 30), + ) + .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 + } + + /// A series under the given TV root, with a season 9 pack stamped + /// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band. + async fn series_with_eligible_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 = (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"); + let release_id = stamped_eligible_release( + state, + "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP", + 15_i64 * (1 << 30), + ) + .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_of(url: String) -> serde_json::Value { + let releases: Vec = reqwest::get(url) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + releases[0]["verdict"].clone() + } + + /// #241: `PUT /api/roots/{id}` writing a different `policy_id` + /// re-derives the stored verdicts of every title under the root (§5.1) + /// — and of nothing outside it. + #[tokio::test] + async fn repointing_a_root_at_another_policy_rederives_its_library() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let client = reqwest::Client::new(); + let movie_id = movie_with_eligible_release(&state, &base).await; + let tv_main: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("tv root"); + let series_id = series_with_eligible_pack(&state, &base, tv_main).await; + + // Point the movie main root at the kids policy; the path stays, so + // nothing on disk is touched. + let kids_policy = policy_id_named(&base, "Movies — kids").await; + let response = client + .put(format!("{base}/api/roots/1")) + .json(&serde_json::json!({ + "kind": "movie", "audience": "main", + "path": "/mnt/media/movies/main", "policy_id": kids_policy + })) + .send() + .await + .expect("repoint movie root"); + assert_eq!(response.status(), StatusCode::OK); + + assert_eq!( + verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await, + "waived", + "kids requires pt-PT, English is a soft fail" + ); + // The TV root was not repointed, so its library is untouched. + assert_eq!( + verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await, + "eligible" + ); + + // Now the TV root; the series' pack re-derives too. + let tv_kids_policy = policy_id_named(&base, "TV — kids").await; + let response = client + .put(format!("{base}/api/roots/{tv_main}")) + .json(&serde_json::json!({ + "kind": "tv", "audience": "main", + "path": "/mnt/media/tv/main", "policy_id": tv_kids_policy + })) + .send() + .await + .expect("repoint tv root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert_eq!( + verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await, + "waived", + "kids requires pt-PT, English is a soft fail" + ); + } + #[tokio::test] async fn a_root_round_trips_through_create_and_update() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let policy_ids = first_policy_ids(&base).await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await @@ -399,7 +674,7 @@ mod tests { #[tokio::test] async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let policy_ids = first_policy_ids(&base).await; for (mut payload, field) in [ @@ -428,7 +703,7 @@ mod tests { #[tokio::test] async fn a_duplicate_path_or_pair_conflicts() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let mut path = root_input(1); path["audience"] = serde_json::json!("kids"); path["path"] = serde_json::json!("/mnt/media/movies/kids"); @@ -454,7 +729,7 @@ mod tests { #[tokio::test] async fn a_root_with_titles_refuses_to_die() { - let (_dir, base) = application().await; + let (_dir, _state, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") @@ -495,4 +770,732 @@ mod tests { .expect("delete empty root"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } + + /// A path written straight into the row, no normalisation — how a root + /// created before #243 could end up holding a trailing separator. + async fn point_root_at_raw(state: &AppState, root_id: i64, path: &str) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("store the raw path"); + } + + async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(path.to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + } + + async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 { + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": tmdb_id, "title": title, "year": 2024, + "original_language": "en", "root_id": root_id, + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + created["id"].as_i64().expect("movie id") + } + + /// One movie's §7.4 folder on disk with a feature in it, and the + /// `media_files` row that points at the feature. + async fn library_folder( + state: &AppState, + movie_id: i64, + root: &std::path::Path, + folder: &str, + ) -> std::path::PathBuf { + let folder = root.join(folder); + tokio::fs::create_dir_all(&folder) + .await + .expect("create title folder"); + let feature = folder.join("feature.mkv"); + tokio::fs::write(&feature, b"feature").await.expect("write"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)", + ) + .bind(movie_id) + .bind(feature.to_str().expect("utf-8 path")) + .execute(state.database().expect("database").pool()) + .await + .expect("media file"); + folder + } + + async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value { + let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}")) + .await + .expect("root") + .json() + .await + .expect("root json"); + serde_json::json!({ + "kind": root["kind"], + "audience": root["audience"], + "path": path, + "policy_id": root["policy_id"], + }) + } + + async fn file_paths(state: &AppState) -> Vec { + sqlx::query_scalar("SELECT path FROM media_files ORDER BY id") + .fetch_all(state.database().expect("database").pool()) + .await + .expect("media files") + } + + async fn stored_path(base: &str, id: i64) -> String { + let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}")) + .await + .expect("root") + .json() + .await + .expect("root json"); + root["path"].as_str().expect("path").to_owned() + } + + /// Issue #236: changing a root's path renames every §7.4 title folder + /// under it into the new path and rewrites the `media_files` rows in the + /// same write. Titles under other roots are not this root's business. + #[tokio::test] + async fn changing_a_root_path_moves_every_title_under_it() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let other = tempfile::tempdir().expect("other root"); + point_root_at(&state, 1, old.path()).await; + point_root_at(&state, 2, other.path()).await; + + let mut folders = Vec::new(); + for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] { + let id = add_movie(&base, tmdb_id, title, 1).await; + folders.push(library_folder(&state, id, old.path(), title).await); + } + let elsewhere = add_movie(&base, 200, "Prisoners", 2).await; + let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await; + + // The new path need not exist yet — pointing a root somewhere fresh + // is the ordinary case. + let new = old.path().parent().expect("parent").join("relocated-main"); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + + for folder in &folders { + assert!(!folder.exists(), "{} left the old path", folder.display()); + } + for title in ["Dune", "Arrival", "Sicario"] { + assert!( + new.join(title).join("feature.mkv").exists(), + "{title} arrived under the new path" + ); + } + assert!( + untouched.join("feature.mkv").exists(), + "a title under another root is untouched" + ); + + let paths = file_paths(&state).await; + for path in paths.iter().take(3) { + assert!( + std::path::Path::new(path).starts_with(&new), + "the row follows the file: {path}" + ); + assert!( + std::path::Path::new(path).exists(), + "the rewritten path describes the disk: {path}" + ); + } + assert!( + std::path::Path::new(&paths[3]).starts_with(other.path()), + "the other root's row is untouched: {}", + paths[3] + ); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + + tokio::fs::remove_dir_all(&new).await.expect("clean up"); + } + + /// Issue #236: a root with nothing under it changes path with no + /// filesystem work at all — the new path is not even created. + #[tokio::test] + async fn an_empty_root_changes_path_with_no_filesystem_work() { + let (_dir, state, base) = application().await; + let home = tempfile::tempdir().expect("home"); + let new = home.path().join("nothing-here"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + assert!(!new.exists(), "nothing was created on disk"); + assert!(file_paths(&state).await.is_empty()); + } + + /// Issue #236: a destination already holding a folder of that name is a + /// conflict, not an overwrite, and it is caught before anything moves. + #[tokio::test] + async fn a_squatted_destination_refuses_the_whole_move() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root"); + point_root_at(&state, 1, old.path()).await; + + let first = add_movie(&base, 100, "Dune", 1).await; + let second = add_movie(&base, 101, "Arrival", 1).await; + let dune = library_folder(&state, first, old.path(), "Dune").await; + let arrival = library_folder(&state, second, old.path(), "Arrival").await; + tokio::fs::create_dir_all(new.path().join("Arrival")) + .await + .expect("squatter"); + + let before = file_paths(&state).await; + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + assert!(dune.exists(), "not even the first folder moved"); + assert!(arrival.exists()); + assert_eq!(file_paths(&state).await, before, "the rows are untouched"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the root still points where the files are" + ); + } + + /// Integration review of the Feedback pass 2 blitz: the retry the module + /// documents has to actually converge. An undo is best-effort, so a move + /// can fail and leave one folder at the destination with its row still + /// naming the source. Every later attempt used to 409 against the + /// operator's own half-moved library, and the only way out was moving the + /// folder back by hand. + #[tokio::test] + async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root"); + point_root_at(&state, 1, old.path()).await; + + let first = add_movie(&base, 100, "Dune", 1).await; + let second = add_movie(&base, 101, "Arrival", 1).await; + let dune = library_folder(&state, first, old.path(), "Dune").await; + let arrival = library_folder(&state, second, old.path(), "Arrival").await; + + // What a failed undo leaves behind: Arrival is already at the new + // path, its row still points at the old one, and the root row was + // never changed. + tokio::fs::create_dir_all(new.path()) + .await + .expect("the new root"); + tokio::fs::rename(&arrival, new.path().join("Arrival")) + .await + .expect("strand the folder"); + assert!(!arrival.exists()); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("retry the move"); + assert_eq!( + response.status(), + StatusCode::OK, + "the stranded folder is the state we wanted, not a conflict" + ); + + assert!(!dune.exists(), "the folder still on disk moved"); + assert!(new.path().join("Dune").exists()); + assert!(new.path().join("Arrival").exists(), "left where it was"); + let paths = file_paths(&state).await; + let root = new.path().to_str().expect("utf-8"); + assert!( + paths.iter().all(|path| path.starts_with(root)), + "every row follows the root, the stranded one included: {paths:?}" + ); + assert_eq!(stored_path(&base, 1).await, root); + } + + /// Issue #236: the move is all or nothing. A folder that cannot be + /// renamed part-way through takes the whole change down: what had already + /// moved is moved back, the root row keeps the old path, and the rows + /// still describe the disk, so the operator can fix the folder and send + /// the same request again. + #[cfg(unix)] + #[tokio::test] + async fn one_folder_that_cannot_move_puts_the_others_back() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-main"); + point_root_at(&state, 1, old.path()).await; + + let mut folders = Vec::new(); + for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] { + let id = add_movie(&base, tmdb_id, title, 1).await; + folders.push(library_folder(&state, id, old.path(), title).await); + } + // Moving a directory to another parent rewrites its `..`, which needs + // write permission on the directory itself: the second title cannot + // move, the first already has. + let stuck = folders[1].clone(); + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the second title folder"); + + let before = file_paths(&state).await; + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body: serde_json::Value = response.json().await.expect("error body"); + assert!( + body["error"] + .as_str() + .expect("text") + .contains(stuck.to_str().expect("utf-8")), + "the error names the folder that stopped the move: {body}" + ); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the second title folder"); + for folder in &folders { + assert!( + folder.join("feature.mkv").exists(), + "{} is back where the row says it is", + folder.display() + ); + } + assert!( + !new.exists(), + "the directory created for the failed move was removed, since it never held anything" + ); + assert_eq!(file_paths(&state).await, before, "the rows are untouched"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the root still points where the files are" + ); + + // The same request again, with the folder fixed, is the retry path. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("retry the move"); + assert_eq!(response.status(), StatusCode::OK); + for title in ["Dune", "Arrival", "Sicario"] { + assert!(new.join(title).join("feature.mkv").exists()); + } + } + + /// Issue #243: a directory a failed move did not create is not this + /// request's to remove, even though it sits at the same new root path. + #[cfg(unix)] + #[tokio::test] + async fn a_new_root_that_already_existed_survives_a_failed_move() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let new = tempfile::tempdir().expect("new root, already there"); + point_root_at(&state, 1, old.path()).await; + + let id = add_movie(&base, 100, "Dune", 1).await; + let stuck = library_folder(&state, id, old.path(), "Dune").await; + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); + assert!( + new.path().exists(), + "a directory this request did not create is left alone" + ); + } + + /// Issue #243: a path differing only by a trailing separator is the same + /// root, not a self-conflict — `PUT` with it is a no-op. + #[tokio::test] + async fn a_trailing_separator_is_not_a_path_change() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + point_root_at(&state, 1, old.path()).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let mut trailing = old.path().to_str().expect("utf-8").to_owned(); + trailing.push('/'); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, &trailing).await) + .send() + .await + .expect("update root with a trailing separator"); + assert_eq!(response.status(), StatusCode::OK); + + assert!(folder.exists(), "nothing on disk moved"); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8"), + "the stored path keeps no trailing separator" + ); + } + + /// Issue #243: `create` normalises the same way, so a trailing separator + /// cannot dodge the duplicate-path check. + #[tokio::test] + async fn create_normalises_a_trailing_separator_before_checking_for_conflicts() { + let (_dir, _state, base) = application().await; + let policy_ids = first_policy_ids(&base).await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("roots json"); + let existing_path = roots[0]["path"].as_str().expect("path").to_owned(); + + // Free the (movie, kids) pair so the conflict below is the path + // check alone, not the (kind, audience) uniqueness check. + let kids_root = roots[1]["id"].as_i64().expect("movie kids root id"); + let freed = reqwest::Client::new() + .delete(format!("{base}/api/roots/{kids_root}")) + .send() + .await + .expect("free the seeded movie kids root"); + assert_eq!(freed.status(), StatusCode::NO_CONTENT); + + let mut payload = root_input(policy_ids[0]); + payload["audience"] = serde_json::json!("kids"); + payload["path"] = serde_json::json!(format!("{existing_path}/")); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create root with a trailing separator"); + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "a trailing separator does not dodge the duplicate-path check" + ); + } + + /// Issue #236: a TV root carries series folders, whose files hang off + /// episodes rather than off the title row. They move with the root too. + #[tokio::test] + async fn a_tv_root_moves_its_series_folders() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-tv"); + point_root_at(&state, 3, old.path()).await; + + let pool = state.database().expect("database").pool(); + sqlx::query( + "INSERT INTO series (id, tmdb_id, title, year, original_language, root_id) + VALUES (1, 82728, 'Bluey', 2018, 'en', 3)", + ) + .execute(pool) + .await + .expect("series"); + sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)") + .execute(pool) + .await + .expect("season"); + sqlx::query( + "INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')", + ) + .execute(pool) + .await + .expect("episode"); + let season = old + .path() + .join("Bluey (2018) [tmdbid-82728]") + .join("Season 01"); + tokio::fs::create_dir_all(&season) + .await + .expect("create season folder"); + let episode = season.join("Bluey - S01E02.mkv"); + tokio::fs::write(&episode, b"episode").await.expect("write"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)", + ) + .bind(episode.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("media file"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/3")) + .json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::OK); + + assert!( + new.join("Bluey (2018) [tmdbid-82728]") + .join("Season 01") + .join("Bluey - S01E02.mkv") + .exists(), + "the whole series folder moved, season layout intact" + ); + let paths = file_paths(&state).await; + assert!( + std::path::Path::new(&paths[0]).starts_with(&new) + && std::path::Path::new(&paths[0]).exists(), + "the episode row follows the file: {}", + paths[0] + ); + } + + /// Issue #244: a root *stored* with a trailing separator — creatable + /// through the API at any point before #243 — could not be edited at + /// all. The payload was normalised and the stored value was not, so no + /// payload compared equal: every edit took the relocation branch, where + /// every planned destination is its own source and the pre-check 409s. + #[tokio::test] + async fn a_root_stored_with_a_trailing_separator_can_still_be_edited() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let unslashed = old.path().to_str().expect("utf-8").to_owned(); + point_root_at_raw(&state, 1, &format!("{unslashed}/")).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + // What the settings view sends back: the path exactly as stored, + // separator included, with only the policy changed. + let kids_policy = policy_id_named(&base, "Movies — kids").await; + let mut payload = root_payload(&base, 1, &format!("{unslashed}/")).await; + payload["policy_id"] = kids_policy.into(); + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&payload) + .send() + .await + .expect("edit the root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert!(folder.join("feature.mkv").exists(), "nothing on disk moved"); + assert_eq!( + stored_path(&base, 1).await, + unslashed, + "the row is left normalised, so the next edit compares equal too" + ); + + // And the same payload without the separator is not a relocation + // either. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, &unslashed).await) + .send() + .await + .expect("edit the root again"); + assert_eq!(response.status(), StatusCode::OK); + assert!(folder.join("feature.mkv").exists(), "still nothing moved"); + } + + /// Issue #244: a real path change from a root stored with a trailing + /// separator plans from the normalised value, so neither a destination + /// nor a rewritten row carries a doubled separator. + #[tokio::test] + async fn relocating_a_slash_stored_root_plans_no_doubled_separator() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + let new = home.path().join("relocated-main"); + let unslashed = old.path().to_str().expect("utf-8").to_owned(); + point_root_at_raw(&state, 1, &format!("{unslashed}/")).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!( + response.status(), + StatusCode::OK, + "{:?}", + response.text().await + ); + + assert!(!folder.exists(), "the folder left the old path"); + assert!(new.join("Dune").join("feature.mkv").exists()); + let paths = file_paths(&state).await; + assert!( + !paths[0].contains("//"), + "no doubled separator in the rewritten row: {}", + paths[0] + ); + assert!( + std::path::Path::new(&paths[0]).exists(), + "the rewritten path describes the disk: {}", + paths[0] + ); + assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8")); + } + + /// Issue #244: `create_dir_all` can materialise more than one level for + /// a root path pointed somewhere fresh. A failed move removes every + /// level it created, not only the leaf — and still nothing that was + /// already on disk. + #[cfg(unix)] + #[tokio::test] + async fn a_failed_move_removes_every_level_it_created() { + use std::os::unix::fs::PermissionsExt; + + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let home = tempfile::tempdir().expect("home of the new path"); + // Three levels below a directory that is already there. + let top = home.path().join("media-v2"); + let new = top.join("tv").join("kids"); + point_root_at(&state, 1, old.path()).await; + + let id = add_movie(&base, 100, "Dune", 1).await; + let stuck = library_folder(&state, id, old.path(), "Dune").await; + // Moving a directory to another parent rewrites its `..`, which + // needs write permission on the directory itself. + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)) + .await + .expect("freeze the title folder"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + + tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)) + .await + .expect("thaw the title folder"); + assert!( + !top.exists(), + "every level the failed move created is gone, not only the leaf" + ); + assert!( + home.path().exists(), + "the level that was already there is left alone" + ); + assert!( + stuck.join("feature.mkv").exists(), + "the folder is still where the row says it is" + ); + assert_eq!( + stored_path(&base, 1).await, + old.path().to_str().expect("utf-8") + ); + } + + /// Issue #244: two roots naming one directory, differing only by a + /// trailing separator, are not two roots. The unique index compares raw + /// strings and cannot see it, so the check normalises both sides. + #[tokio::test] + async fn a_slash_stored_path_is_not_free_for_another_root() { + let (_dir, state, base) = application().await; + point_root_at_raw(&state, 2, "/mnt/media/movies/archive/").await; + + // An update onto the stripped form of a path another root holds. + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, "/mnt/media/movies/archive").await) + .send() + .await + .expect("update onto the other root's path"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + // And a create, which the unique index would have let through. + let policy_ids = first_policy_ids(&base).await; + let mut payload = root_input(policy_ids[0]); + payload["kind"] = serde_json::json!("tv"); + payload["audience"] = serde_json::json!("main"); + payload["path"] = serde_json::json!("/mnt/media/movies/archive"); + let deleted = reqwest::Client::new() + .delete(format!("{base}/api/roots/3")) + .send() + .await + .expect("free the (tv, main) pair"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create onto the other root's path"); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + /// Issue #236: a path another root already holds is refused before the + /// disk is touched at all. + #[tokio::test] + async fn a_path_another_root_holds_is_refused_before_any_move() { + let (_dir, state, base) = application().await; + let old = tempfile::tempdir().expect("old root"); + let taken = tempfile::tempdir().expect("taken root"); + point_root_at(&state, 1, old.path()).await; + point_root_at(&state, 2, taken.path()).await; + let id = add_movie(&base, 100, "Dune", 1).await; + let folder = library_folder(&state, id, old.path(), "Dune").await; + + let response = reqwest::Client::new() + .put(format!("{base}/api/roots/1")) + .json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await) + .send() + .await + .expect("move the root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!(folder.exists(), "nothing moved"); + assert!(!taken.path().join("Dune").exists()); + } } diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 0c178b4..b12f1d4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -156,6 +156,10 @@ pub struct ClassifiedRelease { pub score_terms: ScoreTerms, pub verdict: String, pub rule: Option, + /// 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, } #[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>, + runtime_minutes: u32, ) -> Result { 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) { +pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option) { 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"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 607feb8..1c05b23 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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, pub episodes: Vec, } @@ -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); - 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)?) - .await?; + // 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(&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, 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, 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, ) -> Result>, 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 { + 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>, 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, + /// When the grab entered `failed`, RFC3339. `None` on a row that failed + /// before migration 0030 gave the column a value. + pub failed_at: Option, +} + +/// 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, 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, + /// #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, } #[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" + r#"SELECT count(*) AS "failures!: i64", + 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 { + 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 = (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 = + 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 = (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 = + 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 = + 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 = (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 = + 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!( diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 2d3e948..6d237e3 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -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)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 1b592a4..7306ebb 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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, 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!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index 3cfa5b4..d569358 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -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 { - 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); diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 9012f30..ec67e69 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -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, /// Episodes two different releases hard-failed post-probe (§5.7). hard_failed_episodes: Vec, - /// 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, } @@ -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, TvEntry)>, sqlx::Error> { let mut tv = HashMap::new(); @@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result, 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, 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" + ); + } } diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index d462a77..4a404ca 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -1192,6 +1192,7 @@ pub(crate) async fn store_release( original_language: &Language, blacklist: &Blacklist, ) -> Result, 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 { + 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), 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) { 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] diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index 7b5a54f..cc7eb21 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -308,6 +308,7 @@ impl ImportAction { Candidate::PostDownload(&feature.media), Some(feature.size), 1, + 0, ); let waiver: Option = 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, original_language: Option, + /// §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, release_name: String, } @@ -974,6 +990,7 @@ async fn pending_tv_imports(database: &Db) -> Result, 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, 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, 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, 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") - .fetch_one(h.database.pool()) - .await - .unwrap(); + let (grab_state, failed_at): (String, Option) = + 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") - .fetch_one(h.database.pool()) - .await - .unwrap(); + let (grab_state, failed_at): (String, Option) = + 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()) diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 1116b96..2b353c7 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .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" + r#"SELECT count(*) AS "failures!: i64", + 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. diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 46d7471..391c47e 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -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, backdrop_path: Option, vote_average: Option, + runtime_minutes: Option, } /// 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 = + 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 = + 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. diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index e3fc68b..706facf 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -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 { let row = sqlx::query!( - r#"SELECT count(*) AS "failures!: i64", - max(grabbed_at) AS "last_failed_at?: String" + r#"SELECT count(*) AS "failures!: i64", + 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 = 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] diff --git a/crates/arr-db/migrations/0026_series_runtime.sql b/crates/arr-db/migrations/0026_series_runtime.sql new file mode 100644 index 0000000..a121900 --- /dev/null +++ b/crates/arr-db/migrations/0026_series_runtime.sql @@ -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); diff --git a/crates/arr-db/migrations/0030_grab_failed_at.sql b/crates/arr-db/migrations/0030_grab_failed_at.sql new file mode 100644 index 0000000..ec926bf --- /dev/null +++ b/crates/arr-db/migrations/0030_grab_failed_at.sql @@ -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'; diff --git a/crates/arr-db/migrations/0031_normalise_root_paths.sql b/crates/arr-db/migrations/0031_normalise_root_paths.sql new file mode 100644 index 0000000..c67840f --- /dev/null +++ b/crates/arr-db/migrations/0031_normalise_root_paths.sql @@ -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 + ); diff --git a/crates/arr-db/migrations/0032_waived_rule.sql b/crates/arr-db/migrations/0032_waived_rule.sql new file mode 100644 index 0000000..3a18906 --- /dev/null +++ b/crates/arr-db/migrations/0032_waived_rule.sql @@ -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; diff --git a/crates/arr-db/src/blacklist.rs b/crates/arr-db/src/blacklist.rs index 989ab04..bc5843f 100644 --- a/crates/arr-db/src/blacklist.rs +++ b/crates/arr-db/src/blacklist.rs @@ -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, - infohashes: HashSet, + names: HashMap, + infohashes: HashMap, } impl Blacklist { @@ -39,17 +45,30 @@ impl Blacklist { /// /// If the query fails. pub async fn load(pool: &SqlitePool) -> Result { + // 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; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index c530100..99ca66f 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -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 = + 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 = + 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; diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 354d183..5701a71 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -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, 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) ); diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 05d8125..e3a6f71 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -156,6 +156,10 @@ pub struct Series { pub vote_average: Option, /// 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, pub seasons: Vec, } @@ -368,6 +372,8 @@ pub(crate) struct RawSeries { #[serde(default)] vote_count: u32, #[serde(default)] + episode_run_time: Vec, + #[serde(default)] seasons: Vec, #[serde(default)] external_ids: Option, @@ -406,6 +412,10 @@ impl From 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() diff --git a/web/index.html b/web/index.html index bb477d2..0dbf164 100644 --- a/web/index.html +++ b/web/index.html @@ -231,10 +231,7 @@ -->
-
- - -
+
@@ -268,7 +265,7 @@ id="movie-blocked" aria-pressed="false" > - blocked + no targeted search
+
@@ -323,7 +330,7 @@ @@ -496,10 +503,7 @@ -->
-
- - -
+
@@ -521,23 +525,36 @@
-

library

+
@@ -556,6 +573,19 @@ + +
diff --git a/web/src/icons.ts b/web/src/icons.ts new file mode 100644 index 0000000..45786e2 --- /dev/null +++ b/web/src/icons.ts @@ -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; +} diff --git a/web/src/library.ts b/web/src/library.ts index ad6531f..517ba94 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -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`; } } diff --git a/web/src/main.ts b/web/src/main.ts index 8ae3995..e971e44 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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("#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("#series-back"), + origin: must("#nav-library"), returnTo: must("#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("#movie"); const deckEl = must("#deck"); - const back = must("#movie-back"); const statusEl = must("#movie-status"); const hero = must("#movie-hero"); const poster = must("#movie-poster"); @@ -2143,6 +2205,10 @@ function movieMain(views: HideableView[]): MovieView { const actionsEl = must("#movie-actions"); const wanted = must("#movie-wanted"); const blocked = must("#movie-blocked"); + const blockedNote = must("#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("#movie-root"); const sweep = must("#movie-sweep"); const remove = must("#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; 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("#tv-releases-sub"); const sweep = must("#tv-releases-sweep"); const statusEl = must("#tv-releases-status"); + const failureEl = must("#tv-releases-failure"); + const failureRelease = must("#tv-failure-release"); + const failureWhat = must("#tv-failure-what"); + const failureNext = must("#tv-failure-next"); const dom = buildBucketDom(must("#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("#series"); const deckEl = must("#deck"); - const back = must("#series-back"); const hero = must("#series-hero"); const poster = must("#series-poster"); const titleEl = must("#series-title"); @@ -3993,6 +4188,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const actionsEl = must("#series-actions"); const statusEl = must("#series-status"); const seasonsList = must("#rows-seasons"); + const rootSelect = must("#series-root"); const remove = must("#series-remove"); const removeWrap = must("#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,26 +4589,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { // #174: files go, episodes stop being wanted, the season stays listed. // Only offered with files on disk — intent alone is the tracked toggle. if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) { - const clear = armedDelete("remove files", () => { - const currentId = seriesId; - if (currentId === null) { - return; - } - void removeSeasonFiles(currentId, season.number).then((outcome) => { - if (outcome.kind === "error") { - clear.disabled = false; - setStatus(`remove failed — ${outcome.detail}`, "fault"); + const clear = armedDeleteIcon( + `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, + () => { + const currentId = seriesId; + if (currentId === null) { return; } - // the control itself disappears with the files; the tracked - // toggle is the season's control that survives the repaint - focusKey = `track-${season.number}`; - void load(); - }); - }); - clear.setAttribute( - "aria-label", - `remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`, + void removeSeasonFiles(currentId, season.number).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // the control itself disappears with the files; the tracked + // toggle is the season's control that survives the repaint + focusKey = `track-${season.number}`; + void load(); + }); + }, ); line.append(clear); } @@ -4396,21 +4675,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { if (aired && onDisk) { // #174: the file goes and the episode stops being wanted; the row // stays listed. Same arm-then-confirm as a settings row. - const clear = armedDelete("remove file", () => { - void removeEpisodeFiles(episode.id).then((outcome) => { - if (outcome.kind === "error") { - clear.disabled = false; - setStatus(`remove failed — ${outcome.detail}`, "fault"); - return; - } - // once missing, the row's want control is what remains to focus - focusKey = `want-${episode.id}`; - void load(); - }); - }); - clear.setAttribute( - "aria-label", + const clear = armedDeleteIcon( `remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`, + () => { + void removeEpisodeFiles(episode.id).then((outcome) => { + if (outcome.kind === "error") { + clear.disabled = false; + setStatus(`remove failed — ${outcome.detail}`, "fault"); + return; + } + // once missing, the row's want control is what remains to focus + focusKey = `want-${episode.id}`; + void load(); + }); + }, ); actions.append(clear); } else if (!aired) { @@ -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( diff --git a/web/src/releases.ts b/web/src/releases.ts index be4f5a1..edfdf87 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -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 | null { switch (rule) { @@ -264,11 +292,18 @@ export function waiverOverride(rule: string | null): Record | 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 { try { const body = (await response.json()) as { error?: string }; diff --git a/web/src/series.ts b/web/src/series.ts index 0c5d63c..701fa36 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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 { } } +/** Move a series to another TV root through its partial update endpoint. */ +export async function updateSeries( + seriesId: number, + patch: { root_id: number }, +): Promise { + 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 { try { diff --git a/web/src/settings.ts b/web/src/settings.ts index 2bfd61a..ec29b08 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -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; } diff --git a/web/src/style.css b/web/src/style.css index 6c35ad6..17abb03 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 {