Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 070fd3d7ba | |||
| 0b4ded3fe9 | |||
| afc17ca34a | |||
| 6847d25cf5 | |||
| 50056a2bd9 | |||
| c962998a2b | |||
| 815a072ef0 | |||
| 690eaeda5c | |||
| d23ae0ebcf | |||
| efb47d64e7 | |||
| 442ee3b022 | |||
| ac0e80c044 | |||
| 9fc7272765 | |||
| bce3d3823d | |||
| c64c572781 | |||
| 8c3e1c4a92 | |||
| bb2708f7ee | |||
| 5c5b1e234c | |||
| 917aa4fa76 | |||
| 024786f356 | |||
| f0d45996a0 | |||
| 9fee07f080 | |||
| bd52941a6d | |||
| 405358bf24 | |||
| 93c064701c | |||
| d474909ab5 | |||
| 1e03873209 | |||
| 2286cba862 | |||
| 577eef3f31 | |||
| a5fd543561 | |||
| dc6c25f582 | |||
| 0c2ba6ff15 | |||
| a3897df0ab | |||
| 83a0b247c9 | |||
| 74cf82489b | |||
| b2147fe7b9 | |||
| dc145ae2c1 | |||
| 3261741415 | |||
| 998717b578 | |||
| ddf1c5fd93 | |||
| 88a353dc68 | |||
| f954db9830 | |||
| d8797e1996 |
@@ -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.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "de2153eefd6ec03ceeaf640d599271834cb06c94eda7a9956c5bd7399593c0ae"
|
||||
"hash": "0516e88e07d3708ba4ccc52d7ba635d45e9d1b2c639b71102e241f50982502eb"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d"
|
||||
}
|
||||
+19
-7
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\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 WHERE e.id = ?",
|
||||
"query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\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 WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -16,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -27,7 +38,7 @@
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -38,7 +49,7 @@
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -49,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -63,6 +74,7 @@
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
|
||||
"hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failures!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
},
|
||||
{
|
||||
"name": "last_failed_at?: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 17
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0f0966881a69a9f4009df7b27ebeadce81719e044c8fb65ba404b45b70bf8d8c"
|
||||
}
|
||||
+182
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "episodes",
|
||||
"name": "air_date"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "has_file!: bool",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "last_pack_search_at",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "last_pack_search_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 17
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
|
||||
}
|
||||
+15
-3
@@ -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 WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -145,10 +145,21 @@
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -163,8 +174,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
|
||||
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -170,7 +170,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -190,5 +190,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
||||
"hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failed!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT tmdb_id AS \"tmdb_id!: i64\", metadata_refreshed_at\n FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "movies",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -82,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
||||
"hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -82,7 +82,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -94,5 +94,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
||||
"hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -46,5 +46,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
||||
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
|
||||
"hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "failures!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
},
|
||||
{
|
||||
"name": "last_failed_at?: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
||||
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"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, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tmdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "tvdb_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "title"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "year"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "original_language"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "root_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "auto_track"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "upstream_ended"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "metadata_refreshed_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "poster_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "backdrop_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "backdrop_path"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "roots",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
|
||||
}
|
||||
Generated
+2
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
@@ -172,6 +173,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -77,7 +77,8 @@ Policy
|
||||
required_audio rule set, see §5.2
|
||||
dub_blacklist [pt-BR]
|
||||
hdr_rules see §5.3
|
||||
size_bands per resolution: floor, target, penalty curve
|
||||
size_bands per resolution: floor, target, penalty curve,
|
||||
all per episode (§5.5)
|
||||
resolution_pref [2160p, 1080p]
|
||||
source_weights small tiebreaker
|
||||
|
||||
@@ -284,6 +285,48 @@ remux scores badly but stays eligible, so it is picked when it is the only
|
||||
option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K
|
||||
encode that looks like mud.
|
||||
|
||||
**A band describes one episode.** For a movie the question never arises — one
|
||||
release is one film. For TV a release may carry a single episode, several, or a
|
||||
whole season, and the shipped TV numbers are episode numbers: a 1080p target of
|
||||
2 GiB is what one episode should weigh, not a ten-episode pack. So a release is
|
||||
measured by its size divided by the number of episodes it covers, and that
|
||||
per-episode figure is what both the target penalty and the floor are compared
|
||||
against. A 20 GiB pack of ten episodes is scored as 2 GiB and sits at target,
|
||||
not ten times above it.
|
||||
|
||||
The floor takes the same figure. It is a hard reject rather than a penalty, so
|
||||
comparing a pack's total against an episode-sized floor lets every pack through
|
||||
untested — wrong in the opposite direction from the target penalty. The floor
|
||||
exists to keep a 3 GB 4K encode out, and a pack of ten such encodes has to fail
|
||||
it just as plainly.
|
||||
|
||||
**An unknown episode count is one episode.** A release matched before its
|
||||
season's episodes have been revealed by a metadata refresh has nothing to divide
|
||||
by, and the rule does not guess a number: the divisor is 1 and the release is
|
||||
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
|
||||
@@ -335,6 +378,39 @@ A policy violation found by `ffprobe` is not one thing.
|
||||
|
||||
Neither deletes the torrent. See §7.3.
|
||||
|
||||
**Two hard failures make a decision, and only for 30 days.** A movie, an
|
||||
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
|
||||
against *different* releases hard-failed on it, and both of those failures
|
||||
happened within the last 30 days. One bad torrent is not a decision — a
|
||||
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
||||
grabbed, which is the system working.
|
||||
|
||||
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
||||
without it the queue only ever grows and the one season that wants attention
|
||||
sits behind eight that were dealt with months ago. It is the queue's version of
|
||||
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
|
||||
dealt with stops producing failures and drops out once the last one ages past
|
||||
30 days, while a target that is still broken keeps producing them — the pack
|
||||
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
|
||||
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||
state is stored, so there is no second thing to keep correct.
|
||||
|
||||
**Only a target still waiting for a file is queued.** The count and the window
|
||||
are one rule seen twice — the queue is meant to be visited and emptied — and
|
||||
this is its third face: a failure history queues a target only while that
|
||||
target still has a gap to fill. A movie or an episode is queued while it is
|
||||
`wanted` and not `available`. A season holds no intent of its own (§4.1), so it
|
||||
is queued while at least one of its episodes is still wanted and still without
|
||||
a file. A season pack that hard-failed twice, fell back to per-episode grabbing
|
||||
exactly as §6.2 says it should, and was then fully acquired leaves at once
|
||||
rather than waiting out the 30 days — that is the system working, not a
|
||||
decision. A target that is still broken keeps producing failures and stays.
|
||||
|
||||
The same rule applies on all three lanes and in both readers.
|
||||
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
|
||||
views of one queue; filtering differently tells the operator two different
|
||||
stories on two channels.
|
||||
|
||||
## 6. Sourcing
|
||||
|
||||
### 6.1 Prowlarr, per-indexer Torznab
|
||||
@@ -445,6 +521,8 @@ 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.
|
||||
@@ -578,8 +656,11 @@ thing that can be down.
|
||||
fragments, never URLs; the browser composes the URL and chooses the size. No
|
||||
image proxy and no image cache in the service.
|
||||
|
||||
**Rich detail is not persisted.** It is served through arr-meta's existing
|
||||
24-hour response cache. The single exception is `poster_path`, `backdrop_path`
|
||||
**Rich detail is not persisted.** It is served through arr-meta's 24-hour
|
||||
response cache, which lives on disk in a directory alongside the database
|
||||
(§10), bounded by both age and total size (#158) — not the database itself,
|
||||
and not an image cache; images stay hotlinked as above. The single exception
|
||||
is `poster_path`, `backdrop_path`
|
||||
and `vote_average`, stored on `movies` and `series` and written by the daily
|
||||
metadata refresh (§8), so library views render without a TMDB call.
|
||||
|
||||
@@ -624,7 +705,7 @@ service.
|
||||
Policy lives in the database, not a config file — size targets and DV rules get
|
||||
tuned by hand during testing and a restart-to-reload loop gets old immediately.
|
||||
Only bootstrap settings (bind address, Prowlarr URL, Transmission URL, TMDB key,
|
||||
media root) come from config/env.
|
||||
media root, TMDB response cache directory) come from config/env.
|
||||
|
||||
Backup is `sqlite3 .backup` on a timer.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Jellyfin library refresh after import. See DESIGN.md §7.5.
|
||||
//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md
|
||||
//! §7.5 and §15.
|
||||
//!
|
||||
//! Jellyfin's own filesystem watcher misses the hardlinked file, so import
|
||||
//! asks it to rescan directly. A refresh failure must not fail the import —
|
||||
//! the caller logs and continues (§7.5).
|
||||
//! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so
|
||||
//! callers ask it to rescan directly. A refresh failure must not fail the
|
||||
//! caller — it logs and continues (§7.5).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -27,6 +28,9 @@ pub struct JellyfinClient {
|
||||
}
|
||||
|
||||
impl JellyfinClient {
|
||||
/// # Errors
|
||||
///
|
||||
/// If the underlying HTTP client cannot be built.
|
||||
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
|
||||
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
|
||||
Ok(Self {
|
||||
@@ -38,6 +42,10 @@ impl JellyfinClient {
|
||||
|
||||
/// Trigger a full library scan. Jellyfin exposes no per-library refresh
|
||||
/// without knowing that library's ID, which this app never learns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the request fails, or Jellyfin answers with a non-success status.
|
||||
pub async fn refresh(&self) -> Result<(), Error> {
|
||||
let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/'));
|
||||
let mut request = self.client.post(url);
|
||||
@@ -7,10 +7,13 @@
|
||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||
|
||||
mod health;
|
||||
pub mod jellyfin;
|
||||
mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
mod reclassify;
|
||||
mod relocate;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
@@ -40,7 +43,8 @@ pub use series::{
|
||||
UpdateSeason, UpdateSeries,
|
||||
};
|
||||
pub use state::{
|
||||
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
|
||||
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
||||
DEFAULT_TMDB_URL,
|
||||
};
|
||||
pub use trailer::{Trailer, TrailerKind};
|
||||
|
||||
@@ -88,6 +92,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(metadata::movie_metadata))
|
||||
.routes(routes!(series::list, series::create))
|
||||
.routes(routes!(series::get, series::update, series::delete))
|
||||
.routes(routes!(series::refresh_metadata))
|
||||
.routes(routes!(series::seasons, series::create_season))
|
||||
.routes(routes!(series::update_season))
|
||||
.routes(routes!(series::delete_season_files))
|
||||
@@ -98,6 +103,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::grab_episode))
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::season_pack_state))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(metadata::series_metadata))
|
||||
@@ -332,6 +338,10 @@ mod tests {
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases",
|
||||
"get",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/pack-state",
|
||||
"get",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
||||
"post",
|
||||
|
||||
+867
-63
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
|
||||
//!
|
||||
//! A release's verdict is stamped once, by the search that found it. Both the
|
||||
//! deck and the daemon's manual-grab gate read that stored column, so an
|
||||
//! override written from the deck's one-click waive would change nothing
|
||||
//! until the next sweep — the row the operator just acted on would keep
|
||||
//! reading `rejected` and the grab would be refused.
|
||||
//!
|
||||
//! So the rules run again here, over the releases already attached to the
|
||||
//! title. This is the same correction the daemon makes when a grab turns out
|
||||
//! to be blacklisted: fix the row now rather than leave §9.3 offering, or
|
||||
//! refusing, something that is no longer true.
|
||||
//!
|
||||
//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is
|
||||
//! left exactly as it is.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::claimed_episode_count;
|
||||
use arr_core::{Language, ParsedRelease, Policy, TitleOverrides};
|
||||
use arr_db::blacklist;
|
||||
|
||||
use crate::movies::{pool, ApiError};
|
||||
use crate::search::verdict_columns;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One stored release, as much of it as re-evaluation needs.
|
||||
struct Stored {
|
||||
id: i64,
|
||||
size: i64,
|
||||
parsed: serde_json::Value,
|
||||
verdict: Option<String>,
|
||||
rejected_rule: Option<String>,
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one movie.
|
||||
///
|
||||
/// A movie with no stored original language is left alone, the same rule the
|
||||
/// daemon's search applies: without it the required-audio rule has nothing to
|
||||
/// compare, and guessing would move verdicts on worse evidence than the ones
|
||||
/// already there.
|
||||
pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = movie_language(state, movie_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.movie_policy(movie_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
JOIN movie_releases mr ON mr.release_id = r.id
|
||||
WHERE mr.movie_id = ?
|
||||
"#,
|
||||
movie_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one series, episode decks and season
|
||||
/// decks alike — overrides sit on the series (§5.1), so one change moves both.
|
||||
pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = series_language(state, series_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.series_policy(series_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
WHERE r.id IN (
|
||||
SELECT er.release_id
|
||||
FROM episode_releases er
|
||||
JOIN episodes e ON e.id = er.episode_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?
|
||||
UNION
|
||||
SELECT sr.release_id
|
||||
FROM season_releases sr
|
||||
JOIN seasons se ON se.id = sr.season_id
|
||||
WHERE se.series_id = ?
|
||||
)
|
||||
"#,
|
||||
series_id,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
// A size band describes one episode (§5.5), so a pack's verdict needs the
|
||||
// same divisor the deck scores it with.
|
||||
let lengths = crate::series::season_lengths(state, series_id).await?;
|
||||
let runtime = crate::series::series_runtime(state, series_id).await?;
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
Some(&lengths),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn apply(
|
||||
state: &AppState,
|
||||
releases: &[Stored],
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<(), ApiError> {
|
||||
for release in releases {
|
||||
if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
|
||||
continue;
|
||||
}
|
||||
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
// A stored zero is a release the indexer gave no size for, and a
|
||||
// release with no size has nothing to say about its band — the same
|
||||
// reading the deck's rescore takes.
|
||||
let size = u64::try_from(release.size).ok().filter(|size| *size > 0);
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// `releases` allows a rule name only on a rejected row
|
||||
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
|
||||
// which is also how the daemon writes a waiver.
|
||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||
let rule = if verdict == "rejected" { rule } else { None };
|
||||
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
verdict,
|
||||
rule,
|
||||
release.id
|
||||
)
|
||||
.execute(pool(state)?)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn movie_language(state: &AppState, movie_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM movies WHERE id = ?",
|
||||
movie_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
|
||||
async fn series_language(state: &AppState, series_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM series WHERE id = ?",
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Moving library files when the layout under them changes: a title changing
|
||||
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
|
||||
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
|
||||
//! the layout keeps describing the disk and the root's policy (§5.1) applies
|
||||
//! to a library the files are actually in.
|
||||
//!
|
||||
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
|
||||
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
||||
//! and the torrent keeps seeding against them (§7.3).
|
||||
//!
|
||||
//! Ordering mirrors `remove_library_files`: the disk is touched before the
|
||||
//! row changes, so a failed rename leaves the title where its files actually
|
||||
//! are and the operator can retry.
|
||||
//!
|
||||
//! A root path change is the same move repeated over every title under the
|
||||
//! root, and it is all or nothing. If the seventh of ten folders fails to
|
||||
//! move, the six already renamed are moved back and the root row is left
|
||||
//! alone: a root row carries one path, so a half-moved library would have to
|
||||
//! describe both, and neither the operator nor the next import could tell
|
||||
//! which titles were where. Refusing leaves one answer — everything is still
|
||||
//! at the old path — and the retry is the same request again. A retry after
|
||||
//! an undo that itself failed still converges, because a source that is no
|
||||
//! longer on disk is skipped while its row is still rewritten.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::movies::{pool, title_target, ApiError};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Which table owns the moving title's files.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum TitleKind {
|
||||
Movie,
|
||||
Series,
|
||||
}
|
||||
|
||||
/// One rename from the old root into the new one: a §7.4 title folder, or a
|
||||
/// loose file sitting straight in the root.
|
||||
#[derive(Debug)]
|
||||
struct PlannedRename {
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
/// The renames already performed on disk and the row rewrites they imply.
|
||||
/// The database half is the caller's transaction; [`Self::undo`] is for when
|
||||
/// that transaction fails after the disk already changed.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Relocation {
|
||||
performed: Vec<PlannedRename>,
|
||||
rewrites: Vec<(i64, String)>,
|
||||
}
|
||||
|
||||
/// Whether the destination root is a directory that must already be there.
|
||||
/// A title moves into another configured root, which exists; a root moving to
|
||||
/// a new path is moving somewhere that need not exist yet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Destination {
|
||||
Existing,
|
||||
Create,
|
||||
}
|
||||
|
||||
/// Rename the title's folders into the new root. Called before the row is
|
||||
/// written, and only when the root actually changes.
|
||||
///
|
||||
/// A title with nothing on disk — no `media_files` rows, or rows whose
|
||||
/// targets are already gone — changes root with no filesystem work at all.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`ApiError::Conflict`] when the destination already holds an entry of the
|
||||
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
|
||||
/// a rename fails; whatever had already been renamed is moved back first, so
|
||||
/// the row the caller then leaves unchanged still describes the disk.
|
||||
pub(crate) async fn relocate_title(
|
||||
state: &AppState,
|
||||
kind: TitleKind,
|
||||
title_id: i64,
|
||||
old_root_id: i64,
|
||||
new_root_id: i64,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let old_root = root_path(state, old_root_id).await?;
|
||||
let new_root = root_path(state, new_root_id).await?;
|
||||
let files = title_files(state, kind, title_id).await?;
|
||||
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
|
||||
}
|
||||
|
||||
/// Rename every title folder under a root into the root's new path, for a
|
||||
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
|
||||
///
|
||||
/// A root with no titles under it — or whose titles have nothing on disk —
|
||||
/// changes path with no filesystem work at all. The new path is created when
|
||||
/// there is something to move into it, since a root is normally pointed at a
|
||||
/// directory that does not exist yet.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The same two as [`relocate_title`], with the whole root's move treated as
|
||||
/// one unit: one folder that cannot move takes the entire change down and
|
||||
/// moves back whatever had already moved.
|
||||
pub(crate) async fn relocate_root(
|
||||
state: &AppState,
|
||||
root_id: i64,
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let files = root_files(state, root_id).await?;
|
||||
relocate_files(&files, old_path, new_path, Destination::Create).await
|
||||
}
|
||||
|
||||
/// The one mover both callers share: plan every rename, refuse every
|
||||
/// destination that already exists, then perform them, undoing what was
|
||||
/// performed if one fails.
|
||||
async fn relocate_files(
|
||||
files: &[(i64, String)],
|
||||
old_root: &str,
|
||||
new_root: &str,
|
||||
destination: Destination,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
||||
for (file_id, path) in files {
|
||||
let Some(source) = title_target(old_root, path) else {
|
||||
// Outside its own root: not ours to move, and the row keeps
|
||||
// pointing at where the file really is.
|
||||
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
|
||||
continue;
|
||||
};
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let destination = std::path::Path::new(new_root).join(name);
|
||||
if !renames.iter().any(|rename| rename.source == source) {
|
||||
renames.push(PlannedRename {
|
||||
source,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
let relative = std::path::Path::new(path)
|
||||
.strip_prefix(std::path::Path::new(old_root))
|
||||
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
|
||||
let rewritten = std::path::Path::new(new_root).join(relative);
|
||||
let Some(rewritten) = rewritten.to_str() else {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"non-UTF-8 path under {new_root}"
|
||||
)));
|
||||
};
|
||||
rewrites.push((*file_id, rewritten.to_owned()));
|
||||
}
|
||||
|
||||
// Every destination is checked before anything is renamed, so a conflict
|
||||
// never leaves a half-moved library behind. A rename whose source is
|
||||
// already gone is skipped here rather than refused: the perform loop below
|
||||
// skips it too, and refusing it would make the documented retry
|
||||
// impossible. After an undo that itself failed, the folder sits at the
|
||||
// destination while the row still names the source, and every later
|
||||
// attempt would 409 on a conflict with the operator's own half-moved
|
||||
// library.
|
||||
for rename in &renames {
|
||||
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
_ => {}
|
||||
}
|
||||
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||
Ok(_) => {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"the destination already has '{}'",
|
||||
rename.destination.display()
|
||||
)))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
if destination == Destination::Create && !renames.is_empty() {
|
||||
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"could not create '{new_root}': {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut performed: Vec<PlannedRename> = Vec::new();
|
||||
for rename in renames {
|
||||
// A recorded file with nothing on disk: the rows still follow the
|
||||
// title, the same way a delete treats already-gone as done. Checked
|
||||
// on the source, so a missing *destination* parent stays an error.
|
||||
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::warn!(
|
||||
source = %rename.source.display(),
|
||||
"nothing on disk to move for this title"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
"moved a title folder"
|
||||
);
|
||||
performed.push(rename);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Relocation {
|
||||
performed,
|
||||
rewrites,
|
||||
})
|
||||
}
|
||||
|
||||
/// One rename failed: move back everything that had already moved and name
|
||||
/// the folder that stopped the move, so the operator knows which title to
|
||||
/// look at before retrying.
|
||||
async fn failed(
|
||||
rename: &PlannedRename,
|
||||
error: &std::io::Error,
|
||||
performed: Vec<PlannedRename>,
|
||||
) -> ApiError {
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
ApiError::Filesystem(format!(
|
||||
"could not move '{}': {error}",
|
||||
rename.source.display()
|
||||
))
|
||||
}
|
||||
|
||||
impl Relocation {
|
||||
/// Whether anything on disk actually moved — the trigger for the same
|
||||
/// single Jellyfin refresh import performs (§7.5).
|
||||
pub(crate) fn moved_files(&self) -> bool {
|
||||
!self.performed.is_empty()
|
||||
}
|
||||
|
||||
/// Point the `media_files` rows at the new root, inside the caller's
|
||||
/// transaction so they land together with the `root_id` change or not at
|
||||
/// all.
|
||||
pub(crate) async fn rewrite_rows(
|
||||
&self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
for (file_id, path) in &self.rewrites {
|
||||
sqlx::query!(
|
||||
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
path,
|
||||
file_id
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort reversal of the renames, for when the disk moved but the
|
||||
/// database write failed. A reversal that itself fails is logged: at that
|
||||
/// point the operator's retry is the recovery path.
|
||||
pub(crate) async fn undo(&self) {
|
||||
for rename in self.performed.iter().rev() {
|
||||
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
|
||||
tracing::error!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
%error,
|
||||
"could not move the title folder back after a failed root change"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §7.5 after a move: the same single best-effort refresh import performs.
|
||||
/// Failure logs and never fails the write that already committed.
|
||||
pub(crate) async fn refresh_jellyfin(state: &AppState) {
|
||||
if let Some(jellyfin) = state.jellyfin() {
|
||||
if let Err(error) = jellyfin.refresh().await {
|
||||
tracing::warn!(%error, "jellyfin refresh after a root change failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Every file the service recorded under a root: the rows of every movie in
|
||||
/// it, and the rows of every episode of every series in it. Ordered so the
|
||||
/// renames happen in a stable order, which is what makes a failure part-way
|
||||
/// through reproducible.
|
||||
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
|
||||
let mut files: Vec<(i64, String)> = sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
||||
WHERE m.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect();
|
||||
files.extend(
|
||||
sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE s.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path)),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Every file the service recorded for the title: a movie's own rows, or the
|
||||
/// rows of every episode below a series.
|
||||
async fn title_files(
|
||||
state: &AppState,
|
||||
kind: TitleKind,
|
||||
title_id: i64,
|
||||
) -> Result<Vec<(i64, String)>, ApiError> {
|
||||
Ok(match kind {
|
||||
TitleKind::Movie => sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", path AS "path!: String"
|
||||
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
|
||||
title_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect(),
|
||||
TitleKind::Series => sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?"#,
|
||||
title_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
+503
-23
@@ -172,26 +172,74 @@ pub async fn update(
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
input.policy_exists(&state).await?;
|
||||
let current = load_root(&state, id).await?;
|
||||
let path = input.path.trim().to_owned();
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?"#,
|
||||
input.kind,
|
||||
input.audience,
|
||||
path,
|
||||
input.policy_id,
|
||||
id,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(root_conflict)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::RootNotFound);
|
||||
// A path change moves every §7.4 title folder under this root with the
|
||||
// row (issue #236), the same way changing a title's root moves one
|
||||
// (#228). Disk first, row second: a failed rename leaves the root row
|
||||
// alone, so the operator sees the library where its files actually are
|
||||
// and can retry. A path already taken is refused before any of it, since
|
||||
// the write would fail afterwards anyway.
|
||||
let relocation = if path == current.path {
|
||||
None
|
||||
} else {
|
||||
path_is_free(&state, id, &path).await?;
|
||||
Some(crate::relocate::relocate_root(&state, id, ¤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;
|
||||
}
|
||||
Ok(Json(load_root(&state, id).await?))
|
||||
}
|
||||
|
||||
/// The unique index on `path` would catch this after the move; catching it
|
||||
/// first keeps a doomed write from touching the disk at all.
|
||||
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
|
||||
let taken: Option<i64> =
|
||||
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?;
|
||||
if taken.is_some() {
|
||||
return Err(ApiError::Conflict(
|
||||
"a root with this path already exists".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A duplicate path or a duplicate (kind, audience) pair is a settings
|
||||
/// mistake the operator can fix, not a server fault.
|
||||
fn root_conflict(error: sqlx::Error) -> ApiError {
|
||||
@@ -258,7 +306,7 @@ mod tests {
|
||||
use crate::{router, AppState, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, String) {
|
||||
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
@@ -274,14 +322,14 @@ mod tests {
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let app = router(state);
|
||||
let app = router(state.clone());
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
(dir, state, format!("http://{address}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roots_carry_their_policy_name() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -325,7 +373,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_round_trips_through_create_and_update() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
@@ -399,7 +447,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
|
||||
for (mut payload, field) in [
|
||||
@@ -428,7 +476,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_duplicate_path_or_pair_conflicts() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let mut path = root_input(1);
|
||||
path["audience"] = serde_json::json!("kids");
|
||||
path["path"] = serde_json::json!("/mnt/media/movies/kids");
|
||||
@@ -454,7 +502,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_with_titles_refuses_to_die() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -495,4 +543,436 @@ mod tests {
|
||||
.expect("delete empty root");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(path.to_str().expect("utf-8 root"))
|
||||
.bind(root_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("point the root at the tempdir");
|
||||
}
|
||||
|
||||
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
|
||||
let created: serde_json::Value = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": tmdb_id, "title": title, "year": 2024,
|
||||
"original_language": "en", "root_id": root_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie")
|
||||
.json()
|
||||
.await
|
||||
.expect("movie json");
|
||||
created["id"].as_i64().expect("movie id")
|
||||
}
|
||||
|
||||
/// One movie's §7.4 folder on disk with a feature in it, and the
|
||||
/// `media_files` row that points at the feature.
|
||||
async fn library_folder(
|
||||
state: &AppState,
|
||||
movie_id: i64,
|
||||
root: &std::path::Path,
|
||||
folder: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let folder = root.join(folder);
|
||||
tokio::fs::create_dir_all(&folder)
|
||||
.await
|
||||
.expect("create title folder");
|
||||
let feature = folder.join("feature.mkv");
|
||||
tokio::fs::write(&feature, b"feature").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
|
||||
)
|
||||
.bind(movie_id)
|
||||
.bind(feature.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
folder
|
||||
}
|
||||
|
||||
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
|
||||
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
||||
.await
|
||||
.expect("root")
|
||||
.json()
|
||||
.await
|
||||
.expect("root json");
|
||||
serde_json::json!({
|
||||
"kind": root["kind"],
|
||||
"audience": root["audience"],
|
||||
"path": path,
|
||||
"policy_id": root["policy_id"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn file_paths(state: &AppState) -> Vec<String> {
|
||||
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
|
||||
.fetch_all(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media files")
|
||||
}
|
||||
|
||||
async fn stored_path(base: &str, id: i64) -> String {
|
||||
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
||||
.await
|
||||
.expect("root")
|
||||
.json()
|
||||
.await
|
||||
.expect("root json");
|
||||
root["path"].as_str().expect("path").to_owned()
|
||||
}
|
||||
|
||||
/// Issue #236: changing a root's path renames every §7.4 title folder
|
||||
/// under it into the new path and rewrites the `media_files` rows in the
|
||||
/// same write. Titles under other roots are not this root's business.
|
||||
#[tokio::test]
|
||||
async fn changing_a_root_path_moves_every_title_under_it() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let other = tempfile::tempdir().expect("other root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
point_root_at(&state, 2, other.path()).await;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
||||
let id = add_movie(&base, tmdb_id, title, 1).await;
|
||||
folders.push(library_folder(&state, id, old.path(), title).await);
|
||||
}
|
||||
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
|
||||
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
|
||||
|
||||
// The new path need not exist yet — pointing a root somewhere fresh
|
||||
// is the ordinary case.
|
||||
let new = old.path().parent().expect("parent").join("relocated-main");
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
for folder in &folders {
|
||||
assert!(!folder.exists(), "{} left the old path", folder.display());
|
||||
}
|
||||
for title in ["Dune", "Arrival", "Sicario"] {
|
||||
assert!(
|
||||
new.join(title).join("feature.mkv").exists(),
|
||||
"{title} arrived under the new path"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
untouched.join("feature.mkv").exists(),
|
||||
"a title under another root is untouched"
|
||||
);
|
||||
|
||||
let paths = file_paths(&state).await;
|
||||
for path in paths.iter().take(3) {
|
||||
assert!(
|
||||
std::path::Path::new(path).starts_with(&new),
|
||||
"the row follows the file: {path}"
|
||||
);
|
||||
assert!(
|
||||
std::path::Path::new(path).exists(),
|
||||
"the rewritten path describes the disk: {path}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
std::path::Path::new(&paths[3]).starts_with(other.path()),
|
||||
"the other root's row is untouched: {}",
|
||||
paths[3]
|
||||
);
|
||||
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
||||
|
||||
tokio::fs::remove_dir_all(&new).await.expect("clean up");
|
||||
}
|
||||
|
||||
/// Issue #236: a root with nothing under it changes path with no
|
||||
/// filesystem work at all — the new path is not even created.
|
||||
#[tokio::test]
|
||||
async fn an_empty_root_changes_path_with_no_filesystem_work() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let home = tempfile::tempdir().expect("home");
|
||||
let new = home.path().join("nothing-here");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
||||
assert!(!new.exists(), "nothing was created on disk");
|
||||
assert!(file_paths(&state).await.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #236: a destination already holding a folder of that name is a
|
||||
/// conflict, not an overwrite, and it is caught before anything moves.
|
||||
#[tokio::test]
|
||||
async fn a_squatted_destination_refuses_the_whole_move() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let new = tempfile::tempdir().expect("new root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let first = add_movie(&base, 100, "Dune", 1).await;
|
||||
let second = add_movie(&base, 101, "Arrival", 1).await;
|
||||
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
||||
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
||||
tokio::fs::create_dir_all(new.path().join("Arrival"))
|
||||
.await
|
||||
.expect("squatter");
|
||||
|
||||
let before = file_paths(&state).await;
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
|
||||
assert!(dune.exists(), "not even the first folder moved");
|
||||
assert!(arrival.exists());
|
||||
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
||||
assert_eq!(
|
||||
stored_path(&base, 1).await,
|
||||
old.path().to_str().expect("utf-8"),
|
||||
"the root still points where the files are"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration review of the Feedback pass 2 blitz: the retry the module
|
||||
/// documents has to actually converge. An undo is best-effort, so a move
|
||||
/// can fail and leave one folder at the destination with its row still
|
||||
/// naming the source. Every later attempt used to 409 against the
|
||||
/// operator's own half-moved library, and the only way out was moving the
|
||||
/// folder back by hand.
|
||||
#[tokio::test]
|
||||
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let new = tempfile::tempdir().expect("new root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let first = add_movie(&base, 100, "Dune", 1).await;
|
||||
let second = add_movie(&base, 101, "Arrival", 1).await;
|
||||
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
||||
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
||||
|
||||
// What a failed undo leaves behind: Arrival is already at the new
|
||||
// path, its row still points at the old one, and the root row was
|
||||
// never changed.
|
||||
tokio::fs::create_dir_all(new.path())
|
||||
.await
|
||||
.expect("the new root");
|
||||
tokio::fs::rename(&arrival, new.path().join("Arrival"))
|
||||
.await
|
||||
.expect("strand the folder");
|
||||
assert!(!arrival.exists());
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("retry the move");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"the stranded folder is the state we wanted, not a conflict"
|
||||
);
|
||||
|
||||
assert!(!dune.exists(), "the folder still on disk moved");
|
||||
assert!(new.path().join("Dune").exists());
|
||||
assert!(new.path().join("Arrival").exists(), "left where it was");
|
||||
let paths = file_paths(&state).await;
|
||||
let root = new.path().to_str().expect("utf-8");
|
||||
assert!(
|
||||
paths.iter().all(|path| path.starts_with(root)),
|
||||
"every row follows the root, the stranded one included: {paths:?}"
|
||||
);
|
||||
assert_eq!(stored_path(&base, 1).await, root);
|
||||
}
|
||||
|
||||
/// Issue #236: the move is all or nothing. A folder that cannot be
|
||||
/// renamed part-way through takes the whole change down: what had already
|
||||
/// moved is moved back, the root row keeps the old path, and the rows
|
||||
/// still describe the disk, so the operator can fix the folder and send
|
||||
/// the same request again.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
let new = home.path().join("relocated-main");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
||||
let id = add_movie(&base, tmdb_id, title, 1).await;
|
||||
folders.push(library_folder(&state, id, old.path(), title).await);
|
||||
}
|
||||
// Moving a directory to another parent rewrites its `..`, which needs
|
||||
// write permission on the directory itself: the second title cannot
|
||||
// move, the first already has.
|
||||
let stuck = folders[1].clone();
|
||||
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
||||
.await
|
||||
.expect("freeze the second title folder");
|
||||
|
||||
let before = file_paths(&state).await;
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.expect("text")
|
||||
.contains(stuck.to_str().expect("utf-8")),
|
||||
"the error names the folder that stopped the move: {body}"
|
||||
);
|
||||
|
||||
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
||||
.await
|
||||
.expect("thaw the second title folder");
|
||||
for folder in &folders {
|
||||
assert!(
|
||||
folder.join("feature.mkv").exists(),
|
||||
"{} is back where the row says it is",
|
||||
folder.display()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
new.exists(),
|
||||
"the move reached the disk: the new path was created for it"
|
||||
);
|
||||
assert!(
|
||||
!new.join("Dune").exists(),
|
||||
"the folder that had already moved was moved back"
|
||||
);
|
||||
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
||||
assert_eq!(
|
||||
stored_path(&base, 1).await,
|
||||
old.path().to_str().expect("utf-8"),
|
||||
"the root still points where the files are"
|
||||
);
|
||||
|
||||
// The same request again, with the folder fixed, is the retry path.
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("retry the move");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
for title in ["Dune", "Arrival", "Sicario"] {
|
||||
assert!(new.join(title).join("feature.mkv").exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #236: a TV root carries series folders, whose files hang off
|
||||
/// episodes rather than off the title row. They move with the root too.
|
||||
#[tokio::test]
|
||||
async fn a_tv_root_moves_its_series_folders() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
let new = home.path().join("relocated-tv");
|
||||
point_root_at(&state, 3, old.path()).await;
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
sqlx::query(
|
||||
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
|
||||
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
let season = old
|
||||
.path()
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01");
|
||||
tokio::fs::create_dir_all(&season)
|
||||
.await
|
||||
.expect("create season folder");
|
||||
let episode = season.join("Bluey - S01E02.mkv");
|
||||
tokio::fs::write(&episode, b"episode").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
|
||||
)
|
||||
.bind(episode.to_str().expect("utf-8 path"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/3"))
|
||||
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
assert!(
|
||||
new.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01")
|
||||
.join("Bluey - S01E02.mkv")
|
||||
.exists(),
|
||||
"the whole series folder moved, season layout intact"
|
||||
);
|
||||
let paths = file_paths(&state).await;
|
||||
assert!(
|
||||
std::path::Path::new(&paths[0]).starts_with(&new)
|
||||
&& std::path::Path::new(&paths[0]).exists(),
|
||||
"the episode row follows the file: {}",
|
||||
paths[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #236: a path another root already holds is refused before the
|
||||
/// disk is touched at all.
|
||||
#[tokio::test]
|
||||
async fn a_path_another_root_holds_is_refused_before_any_move() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let taken = tempfile::tempdir().expect("taken root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
point_root_at(&state, 2, taken.path()).await;
|
||||
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert!(folder.exists(), "nothing moved");
|
||||
assert!(!taken.path().join("Dune").exists());
|
||||
}
|
||||
}
|
||||
|
||||
+182
-18
@@ -1,7 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::score;
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
||||
use arr_db::policy::language;
|
||||
use arr_db::{blacklist, Blacklist};
|
||||
@@ -36,9 +37,23 @@ pub struct SearchResponse {
|
||||
/// In-library hits, grouped first (§9.2): movies and series by title.
|
||||
pub library: Vec<LibraryResult>,
|
||||
pub tmdb: Vec<TmdbResult>,
|
||||
/// Why the `tmdb` group is what it is. An unreachable TMDB must not cost
|
||||
/// the operator the library half (issue #168), so the group goes empty
|
||||
/// while this says why: `"ok"` with an empty group means TMDB answered
|
||||
/// and found nothing — a different world from `"unavailable"`.
|
||||
pub tmdb_status: TmdbStatus,
|
||||
pub manual: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TmdbStatus {
|
||||
/// The `tmdb` group carries TMDB's answer — possibly an empty one.
|
||||
Ok,
|
||||
/// TMDB could not be reached or is unconfigured.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// One in-library hit. Tagged so a client can branch on what it found
|
||||
/// without re-deriving it from fields.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -167,6 +182,7 @@ pub async fn search(
|
||||
kind,
|
||||
library: Vec::new(),
|
||||
tmdb: Vec::new(),
|
||||
tmdb_status: TmdbStatus::Ok,
|
||||
manual: Some(input.to_owned()),
|
||||
}));
|
||||
}
|
||||
@@ -211,8 +227,27 @@ pub async fn search(
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let results = search_tmdb(&tmdb, kind, input).await?;
|
||||
// A degraded TMDB must not discard the library results already in hand
|
||||
// (issue #168): the group goes empty and `tmdb_status` says why, so the
|
||||
// operator can tell "TMDB found nothing" from "TMDB is unreachable".
|
||||
let (results, tmdb_status) = match tmdb_client(&state) {
|
||||
Ok(tmdb) => match search_tmdb(&tmdb, kind, input).await {
|
||||
Ok(found) => (found, TmdbStatus::Ok),
|
||||
// TMDB answered and has no such title — an empty group that is
|
||||
// not a fault.
|
||||
Err(ApiError::NotFound) => (Vec::new(), TmdbStatus::Ok),
|
||||
Err(ApiError::Upstream(name)) => {
|
||||
tracing::warn!(upstream = name, "unified search: upstream unavailable");
|
||||
(Vec::new(), TmdbStatus::Unavailable)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
Err(ApiError::Upstream(name)) => {
|
||||
tracing::warn!(upstream = name, "unified search: upstream unavailable");
|
||||
(Vec::new(), TmdbStatus::Unavailable)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if matches!(kind, SearchInputKind::ImdbId) {
|
||||
for result in &results {
|
||||
match result {
|
||||
@@ -240,6 +275,7 @@ pub async fn search(
|
||||
kind,
|
||||
library,
|
||||
tmdb: results,
|
||||
tmdb_status,
|
||||
manual: None,
|
||||
}))
|
||||
}
|
||||
@@ -415,7 +451,7 @@ async fn movie_releases(
|
||||
let indexers = prowlarr
|
||||
.indexers()
|
||||
.await
|
||||
.map_err(|_| ApiError::Unavailable)?;
|
||||
.map_err(|_| ApiError::Upstream("prowlarr"))?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let policy = loaded.policy;
|
||||
let overrides = loaded.overrides;
|
||||
@@ -453,6 +489,8 @@ async fn movie_releases(
|
||||
&overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
None,
|
||||
0,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -476,7 +514,7 @@ async fn episode_releases(
|
||||
) -> Result<Vec<ClassifiedRelease>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let episode = sqlx::query!(
|
||||
r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
|
||||
r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
@@ -487,6 +525,8 @@ async fn episode_releases(
|
||||
.fetch_optional(database.pool())
|
||||
.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
|
||||
@@ -512,7 +552,7 @@ async fn episode_releases(
|
||||
let indexers = prowlarr
|
||||
.indexers()
|
||||
.await
|
||||
.map_err(|_| ApiError::Unavailable)?;
|
||||
.map_err(|_| ApiError::Upstream("prowlarr"))?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
|
||||
let mut classified = Vec::new();
|
||||
@@ -529,6 +569,8 @@ async fn episode_releases(
|
||||
&loaded.overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
Some(&season_lengths),
|
||||
runtime_minutes,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -634,8 +676,9 @@ fn prowlarr_client(state: &AppState) -> Result<ProwlarrClient, ApiError> {
|
||||
let api_key = upstreams
|
||||
.prowlarr_api_key
|
||||
.clone()
|
||||
.ok_or(ApiError::Unavailable)?;
|
||||
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable)
|
||||
.ok_or(ApiError::Upstream("prowlarr"))?;
|
||||
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key)
|
||||
.map_err(|_| ApiError::Upstream("prowlarr"))
|
||||
}
|
||||
|
||||
pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
||||
@@ -643,17 +686,17 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
|
||||
let key = upstreams
|
||||
.tmdb_api_key
|
||||
.clone()
|
||||
.ok_or(ApiError::Unavailable)?;
|
||||
.ok_or(ApiError::Upstream("tmdb"))?;
|
||||
arr_meta::TmdbClient::builder(key)
|
||||
.base_url(&upstreams.tmdb_url)
|
||||
.build()
|
||||
.map_err(|_| ApiError::Unavailable)
|
||||
.map_err(|_| ApiError::Upstream("tmdb"))
|
||||
}
|
||||
|
||||
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
match error {
|
||||
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
|
||||
_ => ApiError::Unavailable,
|
||||
_ => ApiError::Upstream("tmdb"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,25 +711,37 @@ fn classify(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
||||
// divided by how many episodes it covers. `None` is the movie search —
|
||||
// one release is one film.
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
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,
|
||||
Candidate::PreGrab(&parsed),
|
||||
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
|
||||
@@ -729,7 +784,7 @@ fn classify(
|
||||
})
|
||||
}
|
||||
|
||||
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
match verdict {
|
||||
Verdict::Eligible => ("eligible", None),
|
||||
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
|
||||
@@ -781,6 +836,23 @@ mod tests {
|
||||
async fn application(
|
||||
tmdb: &MockServer,
|
||||
prowlarr: &MockServer,
|
||||
) -> (tempfile::TempDir, AppState, String) {
|
||||
application_with(
|
||||
tmdb.uri(),
|
||||
Some("tmdb-key".into()),
|
||||
Some("prowlarr-key".into()),
|
||||
prowlarr.uri(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`application`] but with each upstream knob explicit, so tests
|
||||
/// can take TMDB or the API keys away.
|
||||
async fn application_with(
|
||||
tmdb_url: String,
|
||||
tmdb_key: Option<String>,
|
||||
prowlarr_key: Option<String>,
|
||||
prowlarr_url: String,
|
||||
) -> (tempfile::TempDir, AppState, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
@@ -788,10 +860,10 @@ mod tests {
|
||||
.expect("database");
|
||||
database.migrate().await.expect("migrate");
|
||||
let state = AppState::new(
|
||||
Upstreams::new(prowlarr.uri(), "http://127.0.0.1:1".into())
|
||||
.with_prowlarr_api_key(Some("prowlarr-key".into()))
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("tmdb-key".into())),
|
||||
Upstreams::new(prowlarr_url, "http://127.0.0.1:1".into())
|
||||
.with_prowlarr_api_key(prowlarr_key)
|
||||
.with_tmdb_url(tmdb_url)
|
||||
.with_tmdb_api_key(tmdb_key),
|
||||
)
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
@@ -985,6 +1057,94 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["title"], "1917");
|
||||
}
|
||||
|
||||
/// Issue #168: an unreachable TMDB must not cost the operator the
|
||||
/// library half. The request succeeds, the tmdb group goes empty, and
|
||||
/// `tmdb_status` says the upstream — not a clean "nothing found".
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_tmdb_still_returns_the_library_half() {
|
||||
// Port 1 is closed; TMDB is configured but cannot be reached.
|
||||
let (dir, state, base) = application_with(
|
||||
"http://127.0.0.1:1".into(),
|
||||
Some("tmdb-key".into()),
|
||||
Some("prowlarr-key".into()),
|
||||
"http://127.0.0.1:1".into(),
|
||||
)
|
||||
.await;
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)")
|
||||
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/search?q=Dune"))
|
||||
.await
|
||||
.expect("search");
|
||||
assert_eq!(response.status(), 200);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["library"][0]["kind"], "movie");
|
||||
assert_eq!(body["library"][0]["title"], "Dune");
|
||||
assert!(body["tmdb"].as_array().expect("tmdb").is_empty());
|
||||
assert_eq!(body["tmdb_status"], "unavailable");
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// Issue #168: "TMDB answered and found nothing" must stay
|
||||
/// distinguishable from "TMDB could not be reached" — both leave the
|
||||
/// tmdb group empty, only `tmdb_status` tells them apart.
|
||||
#[tokio::test]
|
||||
async fn an_empty_tmdb_answer_is_not_an_unavailable_tmdb() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
for endpoint in ["/search/movie", "/search/tv"] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(endpoint))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
}
|
||||
let (_dir, _state, base) = application(&tmdb, &prowlarr).await;
|
||||
|
||||
let body: serde_json::Value = reqwest::get(format!("{base}/api/search?q=nonexistent"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(body["library"].as_array().expect("library").len(), 0);
|
||||
assert_eq!(body["tmdb"].as_array().expect("tmdb").len(), 0);
|
||||
assert_eq!(body["tmdb_status"], "ok");
|
||||
}
|
||||
|
||||
/// Issue #168: a Prowlarr fault names Prowlarr, never the database.
|
||||
#[tokio::test]
|
||||
async fn a_prowlarr_fault_names_prowlarr() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id":693_134, "imdb_id":"tt15239678", "title":"Dune Part Two",
|
||||
"original_title":"Dune Part Two", "original_language":"en", "status":"Released"
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
// No Prowlarr API key configured: the client cannot even be built.
|
||||
let (_dir, state, base) = application_with(
|
||||
tmdb.uri(),
|
||||
Some("tmdb-key".into()),
|
||||
None,
|
||||
"http://127.0.0.1:1".into(),
|
||||
)
|
||||
.await;
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (693134, 'Dune Part Two', 2024, 'en', 2)")
|
||||
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/releases?movie_id=1"))
|
||||
.await
|
||||
.expect("releases");
|
||||
assert_eq!(response.status(), 503);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["error"], "prowlarr unavailable");
|
||||
}
|
||||
|
||||
/// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does —
|
||||
/// a hit on TMDB and, when tracked, in the library set too.
|
||||
#[tokio::test]
|
||||
@@ -1310,6 +1470,8 @@ mod tests {
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release")
|
||||
};
|
||||
@@ -1403,7 +1565,7 @@ mod tests {
|
||||
imdb_id: None,
|
||||
};
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
|
||||
|
||||
let classified = classify(
|
||||
release,
|
||||
@@ -1411,6 +1573,8 @@ mod tests {
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
|
||||
+739
-18
@@ -13,13 +13,13 @@
|
||||
//! - **Status is derived, never stored** (§4.2). Every series the API returns
|
||||
//! carries a status computed from its episodes at request time.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
|
||||
use arr_core::{
|
||||
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
|
||||
SeriesStatus, TitleOverrides,
|
||||
derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState,
|
||||
PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides,
|
||||
};
|
||||
use arr_db::policy::language;
|
||||
use axum::extract::{Path, Query, State};
|
||||
@@ -32,7 +32,7 @@ use utoipa::{IntoParams, ToSchema};
|
||||
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
|
||||
use crate::owners::Owner;
|
||||
use crate::search::tmdb_client;
|
||||
use crate::state::{AppState, EpisodeCommand, SeasonCommand};
|
||||
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
||||
|
||||
/// A series with the status derived from its episodes (§4.2).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -57,6 +57,10 @@ pub struct Series {
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// NULL until a metadata refresh has stamped it (#160). Issue #177: this
|
||||
/// is what tells the SPA a series with no seasons yet is still seeding
|
||||
/// its first refresh, rather than a title upstream genuinely lists none.
|
||||
pub metadata_refreshed_at: Option<String>,
|
||||
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
|
||||
pub status: String,
|
||||
/// Episodes currently marked wanted (§4.1 — the only intent).
|
||||
@@ -180,10 +184,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()) {
|
||||
@@ -327,6 +331,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
|
||||
blocked: row.blocked,
|
||||
poster_path: row.poster_path.clone(),
|
||||
vote_average: row.vote_average,
|
||||
metadata_refreshed_at: row.metadata_refreshed_at.clone(),
|
||||
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
|
||||
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
|
||||
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
|
||||
@@ -463,17 +468,30 @@ 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?;
|
||||
let series_id = result.last_insert_rowid();
|
||||
// Issue #176: a new series has no seasons until a refresh reveals them,
|
||||
// and the metadata lane is daily. Ask it to run now. Asynchronous by
|
||||
// design — the add is already committed and must not wait on, or fail
|
||||
// because of, TMDB. If nothing is draining, the daily sweep still picks
|
||||
// the series up: `metadata_refreshed_at` is NULL.
|
||||
if let Err(error) = state.send_metadata_command(MetadataCommand::Series { series_id }) {
|
||||
tracing::warn!(series_id, %error, "metadata refresh not queued for the new series");
|
||||
}
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(load_series(&state, result.last_insert_rowid()).await?),
|
||||
Json(load_series(&state, series_id).await?),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -500,6 +518,31 @@ pub async fn get(
|
||||
Ok(Json(load_series(&state, id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/series/{series_id}/refresh-metadata", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
responses(
|
||||
(status = 202, body = Accepted),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
/// Issue #177: the same on-demand command `create` sends on add, resent by
|
||||
/// hand when a page gave up polling for it. Same terms — asynchronous, best
|
||||
/// effort, and a failure leaves `metadata_refreshed_at` NULL for the daily
|
||||
/// sweep to pick up.
|
||||
pub async fn refresh_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(series_id): Path<i64>,
|
||||
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
|
||||
load_series_row(&state, series_id).await?;
|
||||
state
|
||||
.send_metadata_command(MetadataCommand::Series { series_id })
|
||||
.map_err(|_| ApiError::Unavailable)?;
|
||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries,
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
@@ -522,8 +565,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();
|
||||
@@ -536,9 +582,54 @@ 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;
|
||||
}
|
||||
if overrides_changed {
|
||||
crate::reclassify::series(&state, id).await?;
|
||||
}
|
||||
Ok(Json(load_series(&state, id).await?))
|
||||
}
|
||||
|
||||
@@ -643,7 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
Ok(metadata) => metadata,
|
||||
// Already gone is the state we wanted.
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target).await
|
||||
@@ -653,7 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
match removed {
|
||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1321,7 +1412,7 @@ pub async fn episode_releases(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||
load_episode(&state, id).await?;
|
||||
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)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
@@ -1333,7 +1424,9 @@ pub async fn episode_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::EpisodeNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||
let runtime = series_runtime(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -1368,6 +1461,47 @@ pub async fn grab_episode(
|
||||
|
||||
/// Resolves a season by its number within one series, so the deck is
|
||||
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
|
||||
/// Per-season episode counts for one series: the divisor data behind
|
||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||
/// unknown.
|
||||
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
|
||||
/// behind runtime-scaled size bands. Zero when unknown, which applies the
|
||||
/// bands unscaled.
|
||||
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
|
||||
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn season_lengths(
|
||||
state: &AppState,
|
||||
series_id: i64,
|
||||
) -> Result<BTreeMap<u32, u32>, ApiError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
|
||||
FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id
|
||||
WHERE se.series_id = ?
|
||||
GROUP BY se.number"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
u32::try_from(row.number).unwrap_or_default(),
|
||||
u32::try_from(row.episodes).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
|
||||
@@ -1445,10 +1579,147 @@ pub async fn season_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::SeasonNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, series_id).await?;
|
||||
let runtime = series_runtime(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
/// Which lane a season's missing episodes take (#182, §6.2).
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SeasonLane {
|
||||
SeasonPack,
|
||||
PerEpisode,
|
||||
}
|
||||
|
||||
/// What holds a season off the pack lane, in the order that answers the
|
||||
/// operator's question best — the most fundamental reason first (#182).
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PackLaneReason {
|
||||
NoEpisodes,
|
||||
StillAiring,
|
||||
EpisodesOnDisk,
|
||||
PackBackoff,
|
||||
}
|
||||
|
||||
/// Why the season release deck holds what it holds (#182).
|
||||
///
|
||||
/// An empty deck is three different truths, and until this endpoint existed
|
||||
/// the deck could not tell them apart, so it blamed backoff for all three:
|
||||
/// a pack sweep that has not landed yet, a pack sweep that landed and found
|
||||
/// nothing, and a season on the per-episode lane, for which no pack sweep is
|
||||
/// coming at all. The lane says which; `last_pack_search_at` separates the
|
||||
/// first two, exactly as a movie's `last_searched_at` does (#177).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeasonPackState {
|
||||
pub lane: SeasonLane,
|
||||
/// `None` when the season is on the pack lane.
|
||||
pub reason: Option<PackLaneReason>,
|
||||
/// Failed pack grabs so far. §6.2's backoff step is indexed by this, so
|
||||
/// it is also how long the lane stays quiet (#181).
|
||||
pub pack_failures: i64,
|
||||
/// When the pack lane reopens on its own, RFC3339. `None` unless a
|
||||
/// failure's window is currently open.
|
||||
pub pack_retry_at: Option<String>,
|
||||
/// When a pack sweep for this season last completed. `None` means no
|
||||
/// pack search has ever run, so an empty deck is pending, not settled.
|
||||
pub last_pack_search_at: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/seasons/{season_number}/pack-state", tag = "series",
|
||||
params(
|
||||
("series_id" = i64, Path, description = "Series row id"),
|
||||
("season_number" = i64, Path, description = "Season number, not its row id")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = SeasonPackState),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn season_pack_state(
|
||||
State(state): State<AppState>,
|
||||
Path((series_id, number)): Path<(i64, i64)>,
|
||||
) -> Result<Json<SeasonPackState>, ApiError> {
|
||||
load_series_row(&state, series_id).await?;
|
||||
let season_id = load_season_id(&state, series_id, number).await?;
|
||||
let pool = pool(&state)?;
|
||||
|
||||
let episodes = sqlx::query!(
|
||||
r#"SELECT e.air_date,
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
) AS "has_file!: bool"
|
||||
FROM episodes e
|
||||
WHERE e.season_id = ?"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let last_pack_search_at = sqlx::query_scalar!(
|
||||
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
season_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.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.
|
||||
let failed = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let window = chrono::TimeDelta::from_std(search_backoff(failed.failures)).unwrap_or_default();
|
||||
let reopens_at = if failed.failures > 0 {
|
||||
failed
|
||||
.last_failed_at
|
||||
.as_deref()
|
||||
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
|
||||
.map(|at| at.with_timezone(&Utc) + window)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pack_retry_at = reopens_at.filter(|at| *at > Utc::now());
|
||||
|
||||
let air_dates: Vec<Option<SystemTime>> = episodes
|
||||
.iter()
|
||||
.map(|episode| air_date(episode.air_date.as_deref()))
|
||||
.collect();
|
||||
let reason = season_grab_reason(&SeasonGrabFacts {
|
||||
air_dates: &air_dates,
|
||||
now: SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
||||
pack_backoff_active: pack_retry_at.is_some(),
|
||||
});
|
||||
|
||||
Ok(Json(SeasonPackState {
|
||||
lane: if reason.is_some() {
|
||||
SeasonLane::PerEpisode
|
||||
} else {
|
||||
SeasonLane::SeasonPack
|
||||
},
|
||||
reason: reason.map(|reason| match reason {
|
||||
PerEpisodeReason::NoEpisodes => PackLaneReason::NoEpisodes,
|
||||
PerEpisodeReason::StillAiring => PackLaneReason::StillAiring,
|
||||
PerEpisodeReason::EpisodesOnDisk => PackLaneReason::EpisodesOnDisk,
|
||||
PerEpisodeReason::PackBackoff => PackLaneReason::PackBackoff,
|
||||
}),
|
||||
pack_failures: failed.failures,
|
||||
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
||||
last_pack_search_at,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series",
|
||||
params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
|
||||
@@ -1665,6 +1936,129 @@ mod tests {
|
||||
response.json().await.expect("season json")
|
||||
}
|
||||
|
||||
/// The production case behind #210: every pack of a season is under
|
||||
/// §5.5's per-episode floor, so the deck holds three candidates and
|
||||
/// nothing is grabbable. Writing `allow_below_floor` turns the
|
||||
/// rejections into waivers — offered, never automatic.
|
||||
#[tokio::test]
|
||||
async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||
.map(|number| {
|
||||
serde_json::json!({
|
||||
"number": number, "title": format!("Episode {number}"),
|
||||
"air_date": "2025-01-01"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
|
||||
// Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor.
|
||||
let pool = state.database().expect("database").pool();
|
||||
let name = "Bluey S09 1080p WEB-DL x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 8_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
|
||||
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("association");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}"))
|
||||
.await
|
||||
.expect("series")
|
||||
.json()
|
||||
.await
|
||||
.expect("series json");
|
||||
assert_eq!(updated["overrides"]["allow_below_floor"], true);
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(
|
||||
releases[0]["verdict"], "waived",
|
||||
"a waived grab stays a waiver; nothing here makes it eligible"
|
||||
);
|
||||
// The row's rule name goes with the rejection; what survives is the
|
||||
// dashed `waived` verdict the deck reads (§9.3).
|
||||
assert!(releases[0]["rejected_rule"].is_null());
|
||||
|
||||
// The grab the deck's one click sends is now accepted.
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("grab");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
|
||||
// And clearing the override puts the floor back.
|
||||
reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("clear override");
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases[0]["verdict"], "rejected");
|
||||
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_anything": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// §9.6: the three stored artwork fields come off the series detail
|
||||
/// 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
|
||||
@@ -1740,6 +2134,118 @@ mod tests {
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// Issue #176: the daily metadata lane is what reveals a new series'
|
||||
/// seasons, so adding one queues a refresh instead of leaving the page
|
||||
/// empty for up to a day. TMDB here is a closed port: the add still
|
||||
/// returns 201 and the command is still queued, because the refresh is
|
||||
/// the lane's problem and not the request's.
|
||||
#[tokio::test]
|
||||
async fn adding_a_series_queues_a_refresh_even_with_tmdb_down() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url("http://127.0.0.1:1".into())
|
||||
.with_tmdb_api_key(Some("key".into())),
|
||||
)
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let served = state.clone();
|
||||
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 82_728, "title": "Bluey", "root_id": 3
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create series");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
|
||||
.as_i64()
|
||||
.expect("series id");
|
||||
|
||||
assert_eq!(
|
||||
state.next_metadata_command().await.expect("command"),
|
||||
MetadataCommand::Series { series_id }
|
||||
);
|
||||
let refreshed_at: Option<String> =
|
||||
sqlx::query_scalar("SELECT metadata_refreshed_at FROM series WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("series row");
|
||||
assert!(
|
||||
refreshed_at.is_none(),
|
||||
"an unrefreshed series stays due for the scheduled sweep"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #177: the series response carries `metadata_refreshed_at` so
|
||||
/// the SPA can tell a never-refreshed series from a settled empty one.
|
||||
#[tokio::test]
|
||||
async fn series_response_exposes_metadata_refreshed_at() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
assert!(series["metadata_refreshed_at"].is_null());
|
||||
}
|
||||
|
||||
/// Issue #177: the retry control a gave-up poll offers resends the same
|
||||
/// on-demand command `create` sends, so a second refresh attempt does
|
||||
/// not need the scheduled sweep to come around.
|
||||
#[tokio::test]
|
||||
async fn refreshing_metadata_by_hand_queues_the_same_command() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("series id");
|
||||
assert_eq!(
|
||||
state
|
||||
.next_metadata_command()
|
||||
.await
|
||||
.expect("create's command"),
|
||||
MetadataCommand::Series { series_id }
|
||||
);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series/{series_id}/refresh-metadata"))
|
||||
.send()
|
||||
.await
|
||||
.expect("refresh metadata");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
assert_eq!(
|
||||
state
|
||||
.next_metadata_command()
|
||||
.await
|
||||
.expect("retry's command"),
|
||||
MetadataCommand::Series { series_id }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshing_metadata_for_an_unknown_series_is_a_404() {
|
||||
let (_dir, _state, base) = application().await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series/404/refresh-metadata"))
|
||||
.send()
|
||||
.await
|
||||
.expect("refresh metadata");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_must_sit_on_a_tv_root() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -2215,6 +2721,108 @@ mod tests {
|
||||
assert_eq!(after["status"], "complete");
|
||||
}
|
||||
|
||||
/// #182: an empty season deck has to say which empty it is. The lane
|
||||
/// answers "no pack is coming"; `last_pack_search_at` answers "one ran
|
||||
/// and found nothing" — and until both existed the deck blamed backoff
|
||||
/// for every case, including seasons that were never on the pack lane.
|
||||
#[tokio::test]
|
||||
async fn the_pack_state_names_why_a_season_deck_is_empty() {
|
||||
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 season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Pilot", "air_date": "2001-01-01"},
|
||||
{"number": 2, "title": "Second", "air_date": "2001-01-08"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
|
||||
let state_of = |url: String| async move {
|
||||
let response = reqwest::get(url).await.expect("pack state");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("pack state json")
|
||||
};
|
||||
let url = format!("{base}/api/series/{series_id}/seasons/1/pack-state");
|
||||
|
||||
let fresh = state_of(url.clone()).await;
|
||||
assert_eq!(fresh["lane"], "season_pack");
|
||||
assert_eq!(fresh["reason"], serde_json::Value::Null);
|
||||
assert_eq!(fresh["last_pack_search_at"], serde_json::Value::Null);
|
||||
assert_eq!(fresh["pack_failures"], 0);
|
||||
|
||||
// A pack sweep that ran turns an empty deck from pending to settled.
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET last_pack_search_at = '2026-01-01T00:00:00.000Z' WHERE id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("stamp the sweep");
|
||||
let swept = state_of(url.clone()).await;
|
||||
assert_eq!(swept["lane"], "season_pack");
|
||||
assert_eq!(swept["last_pack_search_at"], "2026-01-01T00:00:00.000Z");
|
||||
|
||||
// §6.2/#181: a failed pack quiets the lane, and the deck says until
|
||||
// when rather than only that it is quiet.
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'pack', 'Bluey S01 1080p WEB-DL', 1000, 'url', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.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'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed pack grab");
|
||||
let quiet = state_of(url.clone()).await;
|
||||
assert_eq!(quiet["lane"], "per_episode");
|
||||
assert_eq!(quiet["reason"], "pack_backoff");
|
||||
assert_eq!(quiet["pack_failures"], 1);
|
||||
assert!(
|
||||
quiet["pack_retry_at"].is_string(),
|
||||
"the deck offers a date, not just a closed door"
|
||||
);
|
||||
|
||||
// §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)")
|
||||
.bind(episode_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("file on disk");
|
||||
let on_disk = state_of(url.clone()).await;
|
||||
assert_eq!(on_disk["reason"], "episodes_on_disk");
|
||||
|
||||
// An unaired episode outranks both.
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET air_date = '2999-01-01' WHERE season_id = ? AND number = 2",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("unair the finale");
|
||||
let airing = state_of(url).await;
|
||||
assert_eq!(airing["reason"], "still_airing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_episode_actions_are_scoped_and_respect_blocked() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -3088,6 +3696,119 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #228: changing a series' root renames its §7.4 title folder —
|
||||
/// season subfolders inside it — into the new root, and every episode's
|
||||
/// `media_files` row follows.
|
||||
#[tokio::test]
|
||||
async fn changing_root_moves_the_series_folder_and_its_rows() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let main_root = tv_root(&state, "main").await;
|
||||
let kids_root = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, main_root, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]),
|
||||
)
|
||||
.await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("episode id");
|
||||
|
||||
let source = tempfile::tempdir().expect("source root");
|
||||
let destination = tempfile::tempdir().expect("destination root");
|
||||
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
|
||||
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
|
||||
tokio::fs::create_dir_all(folder.join("Season 01"))
|
||||
.await
|
||||
.expect("create season folder");
|
||||
tokio::fs::write(&episode_file, b"episode")
|
||||
.await
|
||||
.expect("write episode");
|
||||
let pool = state.database().expect("database").pool();
|
||||
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(path.to_str().expect("utf-8 root"))
|
||||
.bind(root)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("point the root at the tempdir");
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(episode_file.to_str().expect("utf-8 path"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
// The wrong kind of root is still rejected, before anything moves.
|
||||
let rejected = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": 1}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move to a movie root");
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": kids_root}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["root_id"], kids_root);
|
||||
|
||||
assert!(!folder.exists(), "the folder left the old root");
|
||||
let moved = destination
|
||||
.path()
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01")
|
||||
.join("Bluey (2018) - S01E01.mkv");
|
||||
assert!(moved.exists(), "the episode arrived, season folder intact");
|
||||
|
||||
let path: String = sqlx::query_scalar(
|
||||
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("media file row");
|
||||
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
|
||||
}
|
||||
|
||||
/// Issue #228: a series with nothing on disk changes root with no
|
||||
/// filesystem work at all.
|
||||
#[tokio::test]
|
||||
async fn a_series_with_no_files_changes_root_cleanly() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let main_root = tv_root(&state, "main").await;
|
||||
let kids_root = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, main_root, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": kids_root}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["root_id"], kids_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::time::Duration;
|
||||
use arr_db::Db;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
|
||||
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
|
||||
/// is configurable, so this is a constant that tests point elsewhere.
|
||||
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
||||
@@ -74,6 +76,9 @@ pub struct AppState {
|
||||
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
|
||||
season_commands: mpsc::Sender<SeasonCommand>,
|
||||
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||
metadata_commands: mpsc::Sender<MetadataCommand>,
|
||||
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
|
||||
jellyfin: Option<JellyfinClient>,
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the movie API.
|
||||
@@ -104,6 +109,22 @@ pub enum SeasonCommand {
|
||||
Grab { season_id: i64, release_id: i64 },
|
||||
}
|
||||
|
||||
/// A title whose TMDB metadata should be refreshed now rather than on the
|
||||
/// daily lane's next tick (issue #176).
|
||||
///
|
||||
/// Sent when a title is added: a new series has no seasons at all until a
|
||||
/// refresh reveals them, and a new movie has no digital release date, so
|
||||
/// waiting up to a day is the difference between a usable page and an empty
|
||||
/// one. Unlike the other three commands this is not an operator action, so a
|
||||
/// full channel is dropped rather than reported — the scheduled sweep still
|
||||
/// owns the title, because a title that was never refreshed keeps
|
||||
/// `metadata_refreshed_at` NULL.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetadataCommand {
|
||||
Series { series_id: i64 },
|
||||
Movie { movie_id: i64 },
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Build the state, including the shared HTTP client.
|
||||
///
|
||||
@@ -115,6 +136,7 @@ impl AppState {
|
||||
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
||||
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
|
||||
let (season_commands, pending_season_commands) = mpsc::channel(64);
|
||||
let (metadata_commands, pending_metadata_commands) = mpsc::channel(64);
|
||||
Ok(Self {
|
||||
http,
|
||||
upstreams: Arc::new(upstreams),
|
||||
@@ -125,6 +147,9 @@ impl AppState {
|
||||
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
|
||||
season_commands,
|
||||
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
|
||||
metadata_commands,
|
||||
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
|
||||
jellyfin: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +160,18 @@ impl AppState {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the Jellyfin client, so a manual subtitle grab or translation
|
||||
/// can trigger the same library refresh import does (§7.5, §15).
|
||||
#[must_use]
|
||||
pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self {
|
||||
self.jellyfin = Some(jellyfin);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> {
|
||||
self.jellyfin.as_ref()
|
||||
}
|
||||
|
||||
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -162,6 +199,16 @@ impl AppState {
|
||||
self.pending_season_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
/// Wait for the next on-demand metadata refresh in the daemon's metadata
|
||||
/// lane.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If every sender has been dropped.
|
||||
pub async fn next_metadata_command(&self) -> Option<MetadataCommand> {
|
||||
self.pending_metadata_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
pub(crate) fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
@@ -194,4 +241,14 @@ impl AppState {
|
||||
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
|
||||
self.season_commands.try_send(command)
|
||||
}
|
||||
|
||||
/// Ask the metadata lane to refresh one title now. Best effort by
|
||||
/// design: an add must not fail because the channel is full or because
|
||||
/// nothing is draining it, so the caller logs and carries on.
|
||||
pub(crate) fn send_metadata_command(
|
||||
&self,
|
||||
command: MetadataCommand,
|
||||
) -> Result<(), mpsc::error::TrySendError<MetadataCommand>> {
|
||||
self.metadata_commands.try_send(command)
|
||||
}
|
||||
}
|
||||
|
||||
+141
-18
@@ -4,12 +4,13 @@
|
||||
//! while a season is airing, grab per episode. A completed season with no
|
||||
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
|
||||
//! encode. A pack that hard-failed must not cost the whole season, so the
|
||||
//! season falls back to per-episode instead of being blacklisted outright.
|
||||
//! season falls back to per-episode while the failure's §6.2 backoff window
|
||||
//! is open, and retries the pack once it elapses — quiet, never off.
|
||||
//!
|
||||
//! Re-grabbing a pack once an airing season completes is deliberately not
|
||||
//! done (§14): a season with any episode already on disk grabs per episode.
|
||||
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// How a season's missing wanted episodes should be grabbed next.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -31,28 +32,80 @@ pub struct SeasonGrabFacts<'a> {
|
||||
/// re-grabs a pack over episodes on disk, and a pack must not re-import
|
||||
/// what exists).
|
||||
pub any_episode_on_disk: bool,
|
||||
/// Whether a season-pack grab for this season already hard-failed.
|
||||
pub pack_hard_failed: bool,
|
||||
/// Whether a failed season-pack grab still holds the season off the
|
||||
/// pack lane — true only while the §6.2 backoff window is open.
|
||||
pub pack_backoff_active: bool,
|
||||
}
|
||||
|
||||
/// Picks the grab mode for one season.
|
||||
/// Why a season is not on the season-pack lane.
|
||||
///
|
||||
/// The season release deck (§9.3) is empty for a season on the per-episode
|
||||
/// lane and stays empty however long it waits, so it has to name which of
|
||||
/// these it is rather than blaming a sweep that is not coming (#182).
|
||||
/// Ordered by how fundamental the answer is: an unaired episode outranks a
|
||||
/// failed pack, because clearing the failure would still not earn a pack.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PerEpisodeReason {
|
||||
/// No episodes are known for the season, so there is nothing to pack.
|
||||
NoEpisodes,
|
||||
/// An episode has not aired, or carries no air date at all.
|
||||
StillAiring,
|
||||
/// §14: a pack would re-import episodes that are already on disk.
|
||||
EpisodesOnDisk,
|
||||
/// §6.2: a failed pack grab's backoff window is still open.
|
||||
PackBackoff,
|
||||
}
|
||||
|
||||
/// Why one season takes the per-episode lane, or `None` when it takes the
|
||||
/// pack lane.
|
||||
///
|
||||
/// A season is fully released only when every known episode has an air date
|
||||
/// in the past. An episode with no date could still be unaired, and grabbing
|
||||
/// a "complete" pack of a season that is not complete costs a whole torrent
|
||||
/// of the wrong thing — so an undated episode keeps the season per-episode.
|
||||
#[must_use]
|
||||
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
let fully_released = !facts.air_dates.is_empty()
|
||||
&& facts
|
||||
.air_dates
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now));
|
||||
pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option<PerEpisodeReason> {
|
||||
if facts.air_dates.is_empty() {
|
||||
return Some(PerEpisodeReason::NoEpisodes);
|
||||
}
|
||||
if !facts
|
||||
.air_dates
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now))
|
||||
{
|
||||
return Some(PerEpisodeReason::StillAiring);
|
||||
}
|
||||
if facts.any_episode_on_disk {
|
||||
return Some(PerEpisodeReason::EpisodesOnDisk);
|
||||
}
|
||||
if facts.pack_backoff_active {
|
||||
return Some(PerEpisodeReason::PackBackoff);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
|
||||
SeasonGrabMode::SeasonPack
|
||||
} else {
|
||||
SeasonGrabMode::PerEpisode
|
||||
/// Picks the grab mode for one season.
|
||||
#[must_use]
|
||||
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
match season_grab_reason(facts) {
|
||||
None => SeasonGrabMode::SeasonPack,
|
||||
Some(_) => SeasonGrabMode::PerEpisode,
|
||||
}
|
||||
}
|
||||
|
||||
/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed
|
||||
/// by how many attempts have already been spent.
|
||||
///
|
||||
/// The pack lane counts a season's failed pack grabs as its attempts, so the
|
||||
/// deck can say when the lane reopens rather than only that it is shut.
|
||||
#[must_use]
|
||||
pub fn search_backoff(attempts: i64) -> Duration {
|
||||
match attempts {
|
||||
..=1 => Duration::from_hours(1),
|
||||
2 => Duration::from_hours(6),
|
||||
3 => Duration::from_hours(24),
|
||||
4 => Duration::from_hours(72),
|
||||
_ => Duration::from_hours(24 * 7),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +122,7 @@ mod tests {
|
||||
air_dates,
|
||||
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
||||
any_episode_on_disk: false,
|
||||
pack_hard_failed: false,
|
||||
pack_backoff_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +158,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() {
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut facts = facts(&aired);
|
||||
facts.pack_hard_failed = true;
|
||||
facts.pack_backoff_active = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
@@ -120,4 +173,74 @@ mod tests {
|
||||
facts.any_episode_on_disk = true;
|
||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||
}
|
||||
|
||||
/// #182: the deck says which of the four it is, not just "not a pack".
|
||||
#[test]
|
||||
fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() {
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&[])),
|
||||
Some(PerEpisodeReason::NoEpisodes)
|
||||
);
|
||||
|
||||
let airing = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
|
||||
];
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&airing)),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts(&undated)),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
|
||||
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||
let mut on_disk = facts(&aired);
|
||||
on_disk.any_episode_on_disk = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&on_disk),
|
||||
Some(PerEpisodeReason::EpisodesOnDisk)
|
||||
);
|
||||
|
||||
let mut backoff = facts(&aired);
|
||||
backoff.pack_backoff_active = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&backoff),
|
||||
Some(PerEpisodeReason::PackBackoff)
|
||||
);
|
||||
|
||||
assert_eq!(season_grab_reason(&facts(&aired)), None);
|
||||
}
|
||||
|
||||
/// A failed pack is not the headline when the season could not have had
|
||||
/// a pack anyway — clearing it would change nothing.
|
||||
#[test]
|
||||
fn an_unaired_episode_outranks_a_failed_pack() {
|
||||
let airing = [
|
||||
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
|
||||
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
|
||||
];
|
||||
let mut facts = facts(&airing);
|
||||
facts.pack_backoff_active = true;
|
||||
facts.any_episode_on_disk = true;
|
||||
assert_eq!(
|
||||
season_grab_reason(&facts),
|
||||
Some(PerEpisodeReason::StillAiring)
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2's curve, shared by the pack lane so the deck can say when it
|
||||
/// reopens rather than only that it is shut.
|
||||
#[test]
|
||||
fn the_backoff_curve_climbs_and_caps_at_a_week() {
|
||||
assert_eq!(search_backoff(0), Duration::from_hours(1));
|
||||
assert_eq!(search_backoff(1), Duration::from_hours(1));
|
||||
assert_eq!(search_backoff(2), Duration::from_hours(6));
|
||||
assert_eq!(search_backoff(3), DAY);
|
||||
assert_eq!(search_backoff(4), 3 * DAY);
|
||||
assert_eq!(search_backoff(5), 7 * DAY);
|
||||
assert_eq!(search_backoff(50), 7 * DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ pub mod status;
|
||||
pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use grabbing::{
|
||||
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts,
|
||||
SeasonGrabMode,
|
||||
};
|
||||
pub use matching::{
|
||||
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
|
||||
WantedEpisode, WantedMovie,
|
||||
@@ -234,6 +237,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)]
|
||||
|
||||
@@ -64,6 +64,15 @@ pub struct EvaluationContext<'a> {
|
||||
/// when the caller has no size to offer, which abstains rather than
|
||||
/// rejects.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band
|
||||
/// 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.
|
||||
@@ -107,6 +116,8 @@ pub fn evaluate(
|
||||
original_language: &Language,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Evaluation {
|
||||
let context = EvaluationContext {
|
||||
policy,
|
||||
@@ -114,6 +125,8 @@ pub fn evaluate(
|
||||
original_language,
|
||||
candidate,
|
||||
size_bytes,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
};
|
||||
let rules: [&dyn PolicyRule; 6] = [
|
||||
&ResolutionRule,
|
||||
@@ -218,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;
|
||||
|
||||
@@ -227,8 +246,17 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
match crate::score::is_below_floor(context.policy, resolution, size) {
|
||||
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),
|
||||
}
|
||||
@@ -515,7 +543,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None).verdict
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
|
||||
}
|
||||
|
||||
fn verdict_for(
|
||||
@@ -529,6 +557,8 @@ mod tests {
|
||||
original_language,
|
||||
candidate,
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict
|
||||
}
|
||||
@@ -543,6 +573,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -646,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();
|
||||
@@ -725,6 +930,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -781,6 +988,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
let expected = if rejected {
|
||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||
@@ -800,6 +1009,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -832,6 +1043,8 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let soft = FixedRule {
|
||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||
@@ -860,6 +1073,8 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let first = FixedRule {
|
||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||
@@ -995,7 +1210,9 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -1008,7 +1225,9 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::RequiredAudio)
|
||||
@@ -1082,6 +1301,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
|
||||
@@ -1093,6 +1314,8 @@ mod tests {
|
||||
&Language::PortugueseBrazil,
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
}
|
||||
@@ -1106,6 +1329,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
assert_eq!(
|
||||
|
||||
+437
-17
@@ -19,10 +19,19 @@
|
||||
//! remux playback test in `DESIGN.md` §14; the model is the decision, not the
|
||||
//! constants.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_parse::EpisodeClaim;
|
||||
|
||||
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)]
|
||||
@@ -84,13 +93,36 @@ pub struct Score {
|
||||
/// resolution the policy carries no band for — scores zero on the dominant
|
||||
/// term rather than being penalised for what the release name failed to say.
|
||||
/// The resolution-rank term treats an unclaimed resolution the same way.
|
||||
///
|
||||
/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the
|
||||
/// divisor that turns a season pack's total into the per-episode figure the
|
||||
/// 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, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
||||
pub fn score(
|
||||
policy: &Policy,
|
||||
candidate: Candidate<'_>,
|
||||
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, size_bytes));
|
||||
.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));
|
||||
@@ -113,25 +145,98 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
#[must_use]
|
||||
pub fn score_release(policy: &Policy, release: &Release) -> 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,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a size falls below its band's floor, which is a hard filter.
|
||||
///
|
||||
/// The floor takes the same per-episode figure as the target penalty
|
||||
/// (`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]
|
||||
pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option<bool> {
|
||||
policy
|
||||
.size_bands
|
||||
.get(&resolution)
|
||||
.map(|band| size_bytes < band.floor_bytes)
|
||||
pub fn is_below_floor(
|
||||
policy: &Policy,
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Option<bool> {
|
||||
policy.size_bands.get(&resolution).map(|band| {
|
||||
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
|
||||
})
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
/// behind [`score`] and [`is_below_floor`].
|
||||
///
|
||||
/// `season_lengths` maps season number to that season's episode count as the
|
||||
/// caller knows it — the caller supplies the fact the way `apply_auto_track`
|
||||
/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes`
|
||||
/// claim knows its own length; a `Season` or `Seasons` claim is summed from
|
||||
/// the map. Anything unknown — no claim, a daily episode, a season the map
|
||||
/// does not carry — falls back to one episode, which measures the release by
|
||||
/// its full size and fails toward rejecting a good pack rather than grabbing
|
||||
/// a bad one.
|
||||
#[must_use]
|
||||
pub fn claimed_episode_count(
|
||||
claim: Option<&EpisodeClaim>,
|
||||
season_lengths: &BTreeMap<u32, u32>,
|
||||
) -> u32 {
|
||||
let count = match claim {
|
||||
Some(EpisodeClaim::Episodes { episodes, .. }) => {
|
||||
u32::try_from(episodes.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0),
|
||||
Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last)
|
||||
.map(|season| season_lengths.get(&season).copied().unwrap_or(0))
|
||||
.fold(0u32, u32::saturating_add),
|
||||
Some(EpisodeClaim::Daily { .. }) | None => 1,
|
||||
};
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// The per-episode figure every size band is compared against.
|
||||
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
|
||||
@@ -262,7 +367,14 @@ mod tests {
|
||||
|
||||
fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score {
|
||||
let claims = claims(source);
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The same, at a resolution the caller picks.
|
||||
@@ -277,10 +389,25 @@ mod tests {
|
||||
source: Some(source),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
size_rule_for(size_bytes, 1)
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -291,6 +418,8 @@ mod tests {
|
||||
original_language: &language,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: Some(size_bytes),
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,6 +450,8 @@ mod tests {
|
||||
&Language::Other("en".to_owned()),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
@@ -387,10 +518,13 @@ 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)),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +533,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10);
|
||||
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);
|
||||
@@ -417,8 +551,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10);
|
||||
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);
|
||||
@@ -466,7 +600,7 @@ mod tests {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
@@ -483,7 +617,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10);
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
@@ -500,4 +634,290 @@ mod tests {
|
||||
assert!(hd.seeders > uhd.seeders);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
|
||||
/// Issue #180: a movie passes one episode's worth and must score exactly
|
||||
/// as it did before packs learned to divide. Every term pinned to a
|
||||
/// literal so the normalisation cannot quietly move movie scoring.
|
||||
#[test]
|
||||
fn a_movie_scores_exactly_as_before_pack_normalisation() {
|
||||
let at_target = scored(ClaimedSource::WebDl, gib(22), 40);
|
||||
assert_eq!(
|
||||
at_target,
|
||||
Score {
|
||||
total: 1398,
|
||||
size: 1000,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
|
||||
let over_target = scored(ClaimedSource::WebDl, gib(30), 40);
|
||||
assert_eq!(
|
||||
over_target,
|
||||
Score {
|
||||
total: 918,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, 0);
|
||||
|
||||
assert_eq!(pack.size, episode.size);
|
||||
assert_eq!(pack.total, episode.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_below_the_floor_per_episode_is_rejected() {
|
||||
// 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, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
size_rule_for(gib(30), 10),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
|
||||
// The same total over three episodes is 10 GiB each and passes.
|
||||
assert_eq!(
|
||||
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, 0);
|
||||
|
||||
assert_eq!(zero, single);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() {
|
||||
let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]);
|
||||
|
||||
let episodes = EpisodeClaim::Episodes {
|
||||
season: 1,
|
||||
episodes: vec![2, 3, 4],
|
||||
};
|
||||
assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths),
|
||||
30
|
||||
);
|
||||
|
||||
// Unknowns are one episode: no claim, a daily episode, a season the
|
||||
// map has not revealed yet.
|
||||
assert_eq!(claimed_episode_count(None, &lengths), 1);
|
||||
assert_eq!(
|
||||
claimed_episode_count(
|
||||
Some(&EpisodeClaim::Daily {
|
||||
year: 2024,
|
||||
month: 5,
|
||||
day: 13
|
||||
}),
|
||||
&lengths
|
||||
),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// The observed case from issue #180: Rick and Morty S08 against a
|
||||
/// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB
|
||||
/// over, resolution step 300). Measured by pack total, the 1080p pack's
|
||||
/// smaller size penalty beat the 300-point resolution step; per episode,
|
||||
/// 2160p ranks first as `resolution_pref` says it should.
|
||||
#[test]
|
||||
fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() {
|
||||
let policy = Policy {
|
||||
size_bands: BTreeMap::from([
|
||||
(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: GIB / 4,
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
(
|
||||
Resolution::R2160p,
|
||||
SizeBand {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(6),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
]),
|
||||
..self::policy()
|
||||
};
|
||||
let episodes = 10;
|
||||
let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB
|
||||
let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB
|
||||
|
||||
let scored = |resolution, size| {
|
||||
let claims = NameClaims {
|
||||
resolution: Some(resolution),
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
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, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||
//!
|
||||
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
|
||||
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
|
||||
//! waiting for a file. One bad torrent is not a decision, a failure already
|
||||
//! dealt with ages out (#226), and a target that has since been acquired
|
||||
//! leaves at once (#238). The season lane reads that last condition off its
|
||||
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
|
||||
//! filters identically, or the two channels tell the operator different
|
||||
//! stories.
|
||||
//!
|
||||
//! Edge-triggered per title: it notifies once when the title enters either
|
||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||
//! again. A series notifies as its series, never per episode — a broken
|
||||
@@ -34,8 +43,8 @@ struct TvEntry {
|
||||
no_pt_source: Vec<i64>,
|
||||
/// Episodes two different releases hard-failed post-probe (§5.7).
|
||||
hard_failed_episodes: Vec<i64>,
|
||||
/// Seasons whose pack grab hard-failed, sending the season back to
|
||||
/// per-episode grabbing.
|
||||
/// Seasons two different pack releases hard-failed on (§5.7), sending the
|
||||
/// season back to per-episode grabbing.
|
||||
failed_season_packs: Vec<i64>,
|
||||
}
|
||||
|
||||
@@ -59,8 +68,8 @@ impl TvEntry {
|
||||
if !self.failed_season_packs.is_empty() {
|
||||
parts.push(plural(
|
||||
self.failed_season_packs.len(),
|
||||
"season pack hard-failed",
|
||||
"season packs hard-failed",
|
||||
"season hard-failed twice on different packs",
|
||||
"seasons hard-failed twice on different packs",
|
||||
));
|
||||
}
|
||||
parts.join("; ")
|
||||
@@ -134,8 +143,10 @@ impl AttentionAction {
|
||||
AND (SELECT count(DISTINCT g.release_id)
|
||||
FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
||||
AND g.state = 'failed') >= 2
|
||||
"#
|
||||
AND g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -209,9 +220,14 @@ fn tv_entry(
|
||||
|
||||
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
||||
/// episodes whose every candidate was rejected for language, episodes two
|
||||
/// different releases hard-failed post-probe, and seasons whose pack grab
|
||||
/// hard-failed. One entry per series, so the notification can be one per
|
||||
/// different releases hard-failed post-probe, and seasons two different packs
|
||||
/// hard-failed on. One entry per series, so the notification can be one per
|
||||
/// series however long the broken season is.
|
||||
///
|
||||
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
|
||||
/// only while `wanted` and not `available`, and a season only while at least
|
||||
/// one of its episodes is. A season pack that failed twice and then fell back
|
||||
/// to per-episode grabbing (§6.2) drops out as those episodes land.
|
||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||
let mut tv = HashMap::new();
|
||||
|
||||
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
)
|
||||
GROUP BY s.id, s.title, s.year, se.id
|
||||
"#
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -382,6 +408,54 @@ mod tests {
|
||||
series_id
|
||||
}
|
||||
|
||||
/// A wanted, missing episode: the least that keeps its season live for
|
||||
/// §5.7's liveness condition.
|
||||
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(number)
|
||||
.bind(format!("Episode {number}"))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// stamped `age_days` in the past, so §5.7's window can be exercised
|
||||
/// without waiting a month.
|
||||
async fn insert_aged_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(release_guid)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.bind(format!("-{age_days} days"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// standing in for what the import tick leaves behind post-probe.
|
||||
async fn insert_failed_grab(
|
||||
@@ -548,9 +622,10 @@ mod tests {
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
// The pack's failure sent this season back to per-episode grabbing;
|
||||
// it queues the same series, so it must not double the message.
|
||||
// The packs' failures sent this season back to per-episode grabbing;
|
||||
// they queue the same series, so it must not double the message.
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
@@ -631,4 +706,132 @@ mod tests {
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// §5.7: the season lane holds to the same two-distinct-releases bar the
|
||||
/// episode lane does, so one bad pack does not notify (#226).
|
||||
#[tokio::test]
|
||||
async fn one_failed_season_pack_does_not_notify() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"one failed pack is the blacklist working, not a decision"
|
||||
);
|
||||
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"two distinct packs hard-failed: the operator decides"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
|
||||
/// once every episode has landed the system worked, so the season leaves
|
||||
/// the queue at once rather than notifying for 30 days (#238).
|
||||
#[tokio::test]
|
||||
async fn a_fully_acquired_season_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_wanted_episode(&database, season_id, 2).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"two packs failed and the season still has episodes missing"
|
||||
);
|
||||
|
||||
// Per-episode grabbing got the first one. Still a gap, still queued.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"already notified, and still queued"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"every episode acquired: nothing left to decide"
|
||||
);
|
||||
|
||||
// Proof it actually left rather than merely staying quiet: a season
|
||||
// still queued would not notify again on re-entry.
|
||||
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"broken again: re-enters the queue and notifies"
|
||||
);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
|
||||
/// queue instead of sitting in it forever (#226).
|
||||
#[tokio::test]
|
||||
async fn season_failures_older_than_the_window_do_not_notify() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"failures older than the window are history, not attention"
|
||||
);
|
||||
|
||||
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"still breaking: back in the queue"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+163
-79
@@ -13,13 +13,14 @@
|
||||
//! - the `grabs` row is written from that response, so a crash between the add
|
||||
//! and the insert heals on the next tick instead of leaving an orphan.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
|
||||
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
|
||||
@@ -183,7 +184,7 @@ impl GrabAction {
|
||||
let tmdb_id =
|
||||
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
|
||||
let metadata = tmdb.movie(tmdb_id).await?;
|
||||
let changed = self.store_metadata(database, &movie, &metadata).await?;
|
||||
let changed = store_movie_metadata(database, movie.id, &metadata).await?;
|
||||
if changed {
|
||||
tracing::info!(
|
||||
movie_id = movie.id,
|
||||
@@ -219,82 +220,6 @@ impl GrabAction {
|
||||
))
|
||||
}
|
||||
|
||||
/// Write one refresh's fields to the row, guarded so unchanged data moves
|
||||
/// nothing. Returns whether anything did.
|
||||
async fn store_metadata(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie: &PendingMovie,
|
||||
metadata: &arr_meta::Movie,
|
||||
) -> Result<bool, GrabError> {
|
||||
let title = metadata.title.clone();
|
||||
let year = metadata.year().map(i64::from);
|
||||
let original_language =
|
||||
(!metadata.original_language.is_empty()).then_some(metadata.original_language.clone());
|
||||
let digital_release = metadata.digital_release.map(|date| date.to_string());
|
||||
// §6.2: the id RSS matching prefers, and the one Torznab movie
|
||||
// searches take. TMDB does not know one for every title.
|
||||
let imdb_id = metadata.imdb_id.clone();
|
||||
// §9.6: these three are the exception to "rich detail is not
|
||||
// persisted" — pure-SQL views render artwork without a TMDB call.
|
||||
let poster_path = metadata.poster_path.clone();
|
||||
let backdrop_path = metadata.backdrop_path.clone();
|
||||
let vote_average = metadata.vote_average;
|
||||
let title_ref = title.as_str();
|
||||
let original_language_ref = original_language.as_deref();
|
||||
let digital_release_ref = digital_release.as_deref();
|
||||
let imdb_id_ref = imdb_id.as_deref();
|
||||
let poster_path_ref = poster_path.as_deref();
|
||||
let backdrop_path_ref = backdrop_path.as_deref();
|
||||
let changed = sqlx::query!(
|
||||
r#"UPDATE movies
|
||||
SET title = ?, year = ?, original_language = ?, digital_release = ?,
|
||||
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
|
||||
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ? AND (
|
||||
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
|
||||
OR digital_release IS NOT ? OR imdb_id IS NOT ?
|
||||
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
|
||||
OR vote_average IS NOT ?
|
||||
)"#,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
movie.id,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 0;
|
||||
if !changed {
|
||||
// Still stamp the refresh even when nothing changed, or the TTL
|
||||
// gate above never engages and every tick pays for TMDB again.
|
||||
sqlx::query!(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
movie.id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Search every indexer for one title, cache each candidate with its
|
||||
/// verdict and score (§9.3), and return the eligible ones best first.
|
||||
///
|
||||
@@ -999,6 +924,85 @@ async fn pending_movies(database: &Db) -> Result<Vec<PendingMovie>, GrabError> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Write one refresh's fields to the movie row, guarded so unchanged data
|
||||
/// moves nothing. Returns whether anything did.
|
||||
///
|
||||
/// A free function rather than a [`GrabAction`] method because the metadata
|
||||
/// lane refreshes a title on demand (issue #176) with nothing but a TMDB
|
||||
/// client — grabbing needs Prowlarr, refreshing does not.
|
||||
pub(crate) async fn store_movie_metadata(
|
||||
database: &Db,
|
||||
movie_id: i64,
|
||||
metadata: &arr_meta::Movie,
|
||||
) -> Result<bool, GrabError> {
|
||||
let title = metadata.title.clone();
|
||||
let year = metadata.year().map(i64::from);
|
||||
let original_language =
|
||||
(!metadata.original_language.is_empty()).then_some(metadata.original_language.clone());
|
||||
let digital_release = metadata.digital_release.map(|date| date.to_string());
|
||||
// §6.2: the id RSS matching prefers, and the one Torznab movie
|
||||
// searches take. TMDB does not know one for every title.
|
||||
let imdb_id = metadata.imdb_id.clone();
|
||||
// §9.6: these three are the exception to "rich detail is not
|
||||
// persisted" — pure-SQL views render artwork without a TMDB call.
|
||||
let poster_path = metadata.poster_path.clone();
|
||||
let backdrop_path = metadata.backdrop_path.clone();
|
||||
let vote_average = metadata.vote_average;
|
||||
let title_ref = title.as_str();
|
||||
let original_language_ref = original_language.as_deref();
|
||||
let digital_release_ref = digital_release.as_deref();
|
||||
let imdb_id_ref = imdb_id.as_deref();
|
||||
let poster_path_ref = poster_path.as_deref();
|
||||
let backdrop_path_ref = backdrop_path.as_deref();
|
||||
let changed = sqlx::query!(
|
||||
r#"UPDATE movies
|
||||
SET title = ?, year = ?, original_language = ?, digital_release = ?,
|
||||
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
|
||||
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ? AND (
|
||||
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
|
||||
OR digital_release IS NOT ? OR imdb_id IS NOT ?
|
||||
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
|
||||
OR vote_average IS NOT ?
|
||||
)"#,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
movie_id,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 0;
|
||||
if !changed {
|
||||
// Still stamp the refresh even when nothing changed, or the TTL
|
||||
// gate above never engages and every tick pays for TMDB again.
|
||||
sqlx::query!(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
movie_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn search_due(movie: &PendingMovie) -> bool {
|
||||
backoff_elapsed(movie.search_attempts, movie.last_searched_at.as_deref())
|
||||
}
|
||||
@@ -1188,6 +1192,7 @@ pub(crate) async fn store_release(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
// A movie is one episode's worth and is never runtime-scaled (§5.5).
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1195,6 +1200,8 @@ pub(crate) async fn store_release(
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
@@ -1221,6 +1228,26 @@ pub(crate) async fn store_episode_release(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): the release is
|
||||
// measured per episode, and a season pack's length comes from the series
|
||||
// the covered episodes belong to. An unrevealed season divides by one.
|
||||
let claim = arr_parse::parse(&release.name).episode;
|
||||
let season_lengths = match episode_ids.first() {
|
||||
Some(&episode_id)
|
||||
if claim
|
||||
.as_ref()
|
||||
.is_some_and(arr_parse::EpisodeClaim::is_season_pack) =>
|
||||
{
|
||||
season_lengths_of(database, episode_id).await?
|
||||
}
|
||||
_ => 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,
|
||||
@@ -1228,6 +1255,8 @@ pub(crate) async fn store_episode_release(
|
||||
overrides,
|
||||
original_language,
|
||||
blacklist,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
)
|
||||
.await?;
|
||||
for episode_id in episode_ids {
|
||||
@@ -1243,6 +1272,55 @@ pub(crate) async fn store_episode_release(
|
||||
Ok((release_id, eligible))
|
||||
}
|
||||
|
||||
/// Per-season episode counts for the series one covered episode belongs to
|
||||
/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation.
|
||||
async fn season_lengths_of(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<BTreeMap<u32, u32>, GrabError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
|
||||
FROM seasons se
|
||||
LEFT JOIN episodes e ON e.season_id = se.id
|
||||
WHERE se.series_id = (SELECT s2.series_id FROM episodes e2
|
||||
JOIN seasons s2 ON s2.id = e2.season_id
|
||||
WHERE e2.id = ?)
|
||||
GROUP BY se.number"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
u32::try_from(row.number).unwrap_or_default(),
|
||||
u32::try_from(row.episodes).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The minutes-per-episode of the series one covered episode belongs to
|
||||
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
|
||||
/// series has no known runtime, which applies the bands unscaled.
|
||||
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
|
||||
let minutes = sqlx::query_scalar!(
|
||||
r#"SELECT s.runtime_minutes FROM series s
|
||||
WHERE s.id = (SELECT s2.series_id FROM episodes e
|
||||
JOIN seasons s2 ON s2.id = e.season_id
|
||||
WHERE e.id = ?)"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn classify_and_store(
|
||||
database: &Db,
|
||||
release: &SearchRelease,
|
||||
@@ -1250,6 +1328,8 @@ async fn classify_and_store(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let evaluation = evaluate(
|
||||
@@ -1258,12 +1338,16 @@ async fn classify_and_store(
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
);
|
||||
let scored = score(
|
||||
policy,
|
||||
Candidate::PreGrab(&parsed),
|
||||
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
|
||||
|
||||
@@ -25,9 +25,9 @@ use arr_db::Db;
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_probe::Prober;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::notify::Notifier;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
use arr_api::jellyfin::JellyfinClient;
|
||||
|
||||
/// A failure during one import tick.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -307,6 +307,8 @@ impl ImportAction {
|
||||
&original_language,
|
||||
Candidate::PostDownload(&feature.media),
|
||||
Some(feature.size),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
let waiver: Option<Rule> = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
@@ -476,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 {
|
||||
@@ -494,6 +500,8 @@ impl ImportAction {
|
||||
&original_language,
|
||||
Candidate::PostDownload(&assignment.file.media),
|
||||
Some(assignment.file.size),
|
||||
1,
|
||||
runtime_minutes,
|
||||
);
|
||||
let waiver = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
@@ -936,6 +944,10 @@ struct PendingTvImport {
|
||||
series_title: String,
|
||||
series_year: Option<i64>,
|
||||
original_language: Option<String>,
|
||||
/// §5.5: the series' minutes per episode, scaling the size bands the
|
||||
/// same way the pre-grab verdict scaled them. `None` applies them
|
||||
/// unscaled.
|
||||
runtime_minutes: Option<i64>,
|
||||
release_name: String,
|
||||
}
|
||||
|
||||
@@ -972,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
s.original_language,
|
||||
s.runtime_minutes,
|
||||
r.name AS "release_name!: String"
|
||||
FROM grabs g
|
||||
JOIN episodes e ON e.id = g.target_id
|
||||
@@ -995,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
original_language: row.original_language,
|
||||
runtime_minutes: row.runtime_minutes,
|
||||
release_name: row.release_name,
|
||||
}));
|
||||
|
||||
@@ -1009,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
s.original_language,
|
||||
s.runtime_minutes,
|
||||
r.name AS "release_name!: String"
|
||||
FROM grabs g
|
||||
JOIN seasons se ON se.id = g.target_id
|
||||
@@ -1031,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
original_language: row.original_language,
|
||||
runtime_minutes: row.runtime_minutes,
|
||||
release_name: row.release_name,
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ mod config;
|
||||
mod grab;
|
||||
mod import;
|
||||
mod indexers;
|
||||
mod jellyfin;
|
||||
mod manual;
|
||||
mod metadata;
|
||||
mod notify;
|
||||
mod reaper;
|
||||
pub mod reconcile;
|
||||
@@ -93,7 +93,7 @@ enum Error {
|
||||
#[error("transmission client: {0}")]
|
||||
Transmission(#[from] arr_dl::Error),
|
||||
#[error("jellyfin client: {0}")]
|
||||
Jellyfin(#[from] jellyfin::Error),
|
||||
Jellyfin(#[from] arr_api::jellyfin::Error),
|
||||
#[error("ntfy client: {0}")]
|
||||
Notify(#[from] notify::NotifyError),
|
||||
#[error("bind {addr}: {source}")]
|
||||
@@ -123,8 +123,13 @@ async fn run() -> Result<(), Error> {
|
||||
None
|
||||
};
|
||||
let notifier = Notifier::new(config.ntfy_url.clone())?;
|
||||
let api_jellyfin = jellyfin_client(&config)?;
|
||||
let (reconcile, manual_grab, manual_tv) =
|
||||
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?;
|
||||
// Issue #176: the on-demand half of the metadata lane needs its own
|
||||
// handle — the sweep's `SeriesRefreshAction` is owned by `ReconcileLoop`,
|
||||
// and the compat shim takes the other clone below.
|
||||
let metadata_tmdb = tmdb.clone();
|
||||
|
||||
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
|
||||
// needs its own TMDB client for `movie/lookup`.
|
||||
@@ -139,7 +144,9 @@ async fn run() -> Result<(), Error> {
|
||||
if let Some(tmdb_url) = config.tmdb_url {
|
||||
upstreams = upstreams.with_tmdb_url(tmdb_url);
|
||||
}
|
||||
let state = AppState::new(upstreams)?.with_database(database.clone());
|
||||
let state = AppState::new(upstreams)?
|
||||
.with_database(database.clone())
|
||||
.with_jellyfin(api_jellyfin);
|
||||
|
||||
let app = arr_api::router(state.clone())
|
||||
.merge(arr_compat::router(compat))
|
||||
@@ -162,12 +169,18 @@ async fn run() -> Result<(), Error> {
|
||||
// in the 64-slot buffer forever. Issue #132: the episode and season
|
||||
// channels are drained by the same lane.
|
||||
let mut manual_task = tokio::spawn(manual::run(
|
||||
state,
|
||||
database,
|
||||
state.clone(),
|
||||
database.clone(),
|
||||
manual_grab,
|
||||
manual_tv,
|
||||
shutdown_rx,
|
||||
shutdown_rx.clone(),
|
||||
));
|
||||
// Issue #176: adding a title queues a refresh here rather than waiting
|
||||
// for the daily sweep. Its own task, not an arm of `manual::run`: a
|
||||
// refresh can take a while against TMDB and must not sit in front of an
|
||||
// operator's manual search.
|
||||
let mut metadata_task =
|
||||
tokio::spawn(metadata::run(state, database, metadata_tmdb, shutdown_rx));
|
||||
let signal_tx = shutdown_tx.clone();
|
||||
let server = async move {
|
||||
axum::serve(listener, app)
|
||||
@@ -184,18 +197,28 @@ async fn run() -> Result<(), Error> {
|
||||
let _ = shutdown_tx.send(true);
|
||||
reconcile_task.await?;
|
||||
manual_task.await?;
|
||||
metadata_task.await?;
|
||||
result.map_err(Error::Serve)
|
||||
}
|
||||
result = &mut reconcile_task => {
|
||||
result?;
|
||||
let _ = shutdown_tx.send(true);
|
||||
manual_task.await?;
|
||||
metadata_task.await?;
|
||||
server.await.map_err(Error::Serve)
|
||||
}
|
||||
result = &mut manual_task => {
|
||||
result?;
|
||||
let _ = shutdown_tx.send(true);
|
||||
reconcile_task.await?;
|
||||
metadata_task.await?;
|
||||
server.await.map_err(Error::Serve)
|
||||
}
|
||||
result = &mut metadata_task => {
|
||||
result?;
|
||||
let _ = shutdown_tx.send(true);
|
||||
reconcile_task.await?;
|
||||
manual_task.await?;
|
||||
server.await.map_err(Error::Serve)
|
||||
}
|
||||
}
|
||||
@@ -273,10 +296,7 @@ fn reconcile_loop(
|
||||
} else {
|
||||
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
|
||||
}
|
||||
let jellyfin = jellyfin::JellyfinClient::new(
|
||||
config.jellyfin_url.clone(),
|
||||
config.jellyfin_api_key.clone(),
|
||||
)?;
|
||||
let jellyfin = jellyfin_client(config)?;
|
||||
// Grab before import, so a download that completes on this tick is
|
||||
// imported on this tick.
|
||||
reconcile = reconcile.register(
|
||||
@@ -422,3 +442,13 @@ async fn shutdown() {
|
||||
|
||||
tracing::info!("shutting down");
|
||||
}
|
||||
|
||||
/// The Jellyfin client, built fresh for each of its two independent callers:
|
||||
/// import's own reconcile action, and the subtitle API's manual grab and
|
||||
/// translate handlers (§7.5, §15).
|
||||
fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient, Error> {
|
||||
Ok(arr_api::jellyfin::JellyfinClient::new(
|
||||
config.jellyfin_url.clone(),
|
||||
config.jellyfin_api_key.clone(),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! The on-demand half of the metadata lane. See DESIGN.md §8 and issue #176.
|
||||
//!
|
||||
//! The scheduled sweep is daily (`Tick::Metadata`), which is right for
|
||||
//! keeping a library current and wrong for a title added a moment ago: a new
|
||||
//! series has no seasons at all until a refresh reveals them, and a new movie
|
||||
//! has no digital release date, which is what §6.2 gates targeted search on.
|
||||
//! Shortening the tick would not fix either — it would still leave a visible
|
||||
//! wait and would spend a TMDB call per title per tick.
|
||||
//!
|
||||
//! So `POST /api/series` and `POST /api/movies` send a [`MetadataCommand`]
|
||||
//! after the row is committed, exactly as the manual search and grab
|
||||
//! endpoints send theirs (issues #107 and #132), and this lane drains it.
|
||||
//! The add itself never waits on TMDB and never fails because of it; the
|
||||
//! worst a broken refresh can do is leave `metadata_refreshed_at` NULL, which
|
||||
//! is what the sweep already treats as due.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use arr_api::{AppState, MetadataCommand};
|
||||
use arr_db::Db;
|
||||
use arr_meta::TmdbClient;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::grab::{metadata_refresh_due, store_movie_metadata, GrabError};
|
||||
use crate::series_refresh::SeriesRefreshAction;
|
||||
|
||||
/// Run until every sender is dropped or `shutdown` fires.
|
||||
///
|
||||
/// `tmdb` is `None` when TMDB is not configured, which already disables the
|
||||
/// scheduled sweep (`main.rs` warns about it). Commands are still drained so
|
||||
/// the channel never fills.
|
||||
pub async fn run(
|
||||
state: AppState,
|
||||
database: Db,
|
||||
tmdb: Option<Arc<TmdbClient>>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
let series = tmdb
|
||||
.as_ref()
|
||||
.map(|tmdb| SeriesRefreshAction::new(Arc::clone(tmdb)));
|
||||
loop {
|
||||
let command = tokio::select! {
|
||||
biased;
|
||||
changed = shutdown.changed() => {
|
||||
if changed.is_err() || *shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
command = state.next_metadata_command() => command,
|
||||
};
|
||||
let Some(command) = command else {
|
||||
return;
|
||||
};
|
||||
let (Some(tmdb), Some(series)) = (tmdb.as_ref(), series.as_ref()) else {
|
||||
tracing::warn!("metadata refresh requested but TMDB is not configured");
|
||||
continue;
|
||||
};
|
||||
match command {
|
||||
MetadataCommand::Series { series_id } => {
|
||||
// A title deleted between the send and the drain is not an
|
||||
// error: `refresh_now` finds no row and does nothing.
|
||||
match series.refresh_now(&database, series_id).await {
|
||||
Ok(_) => {}
|
||||
// A failed refresh leaves `metadata_refreshed_at` NULL,
|
||||
// so the daily sweep retries the series. The add stands.
|
||||
Err(error) => {
|
||||
tracing::error!(series_id, %error, "on-demand series refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
MetadataCommand::Movie { movie_id } => {
|
||||
if let Err(error) = refresh_movie(tmdb, &database, movie_id).await {
|
||||
tracing::error!(movie_id, %error, "on-demand movie refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh one movie now, on the same terms as the series path: a row that
|
||||
/// is gone is not an error, and the sweep's TTL gate applies so a title
|
||||
/// something else already refreshed costs no second TMDB call.
|
||||
async fn refresh_movie(tmdb: &TmdbClient, database: &Db, movie_id: i64) -> Result<(), GrabError> {
|
||||
let movie = sqlx::query!(
|
||||
r#"SELECT tmdb_id AS "tmdb_id!: i64", metadata_refreshed_at
|
||||
FROM movies WHERE id = ?"#,
|
||||
movie_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
let Some(movie) = movie else {
|
||||
return Ok(());
|
||||
};
|
||||
if !metadata_refresh_due(movie.metadata_refreshed_at.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
let tmdb_id = u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie_id))?;
|
||||
let metadata = tmdb.movie(tmdb_id).await?;
|
||||
store_movie_metadata(database, movie_id, &metadata).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use arr_meta::TmdbClient;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
|
||||
const MOVIE_METADATA: &str = r#"{
|
||||
"id": 693134,
|
||||
"title": "Dune Part Two",
|
||||
"original_title": "Dune: Part Two",
|
||||
"original_language": "en",
|
||||
"origin_country": ["US"],
|
||||
"release_date": "2024-02-27",
|
||||
"poster_path": "/dune-two.jpg",
|
||||
"release_dates": {"results": [{"release_dates": [{
|
||||
"type": 4, "release_date": "2024-04-16T00:00:00.000Z"
|
||||
}]}]}
|
||||
}"#;
|
||||
|
||||
fn series_detail() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"status": "Returning Series",
|
||||
"seasons": [{"season_number": 1, "episode_count": 2}],
|
||||
"external_ids": {"tvdb_id": 361_391}
|
||||
})
|
||||
}
|
||||
|
||||
fn season_detail() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"season_number": 1,
|
||||
"episodes": [
|
||||
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// A TMDB that answers for the series, and one that answers for the
|
||||
/// movie. `status` lets a test serve an error instead.
|
||||
async fn tmdb_series(status: u16) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
let response = if status == 200 {
|
||||
ResponseTemplate::new(200).set_body_json(series_detail())
|
||||
} else {
|
||||
ResponseTemplate::new(status)
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(response)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728/season/1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(season_detail()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
async fn tmdb_movie(status: u16) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
let response = if status == 200 {
|
||||
ResponseTemplate::new(200).set_body_string(MOVIE_METADATA)
|
||||
} else {
|
||||
ResponseTemplate::new(status)
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.and(query_param("append_to_response", "release_dates"))
|
||||
.respond_with(response)
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
fn series_action(server: &MockServer) -> SeriesRefreshAction {
|
||||
SeriesRefreshAction::new(Arc::new(
|
||||
TmdbClient::builder("key".to_owned())
|
||||
.base_url(server.uri())
|
||||
.build()
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn movie_client(server: &MockServer) -> TmdbClient {
|
||||
TmdbClient::builder("key".to_owned())
|
||||
.base_url(format!("{}/3/", server.uri()))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A series and a movie as `POST /api/series` and `POST /api/movies`
|
||||
/// leave them: added, never refreshed.
|
||||
async fn added_titles() -> (TempDir, Db) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let database = Db::connect(directory.path().join("arr.db")).await.unwrap();
|
||||
database.migrate().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, root_id, auto_track)
|
||||
SELECT 82728, 'Bluey', id, 1 FROM roots WHERE kind = 'tv' AND audience = 'main'",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id)
|
||||
SELECT 693134, 'Dune Part Two', 2024, 'en', id
|
||||
FROM roots WHERE kind = 'movie' AND audience = 'main'",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
(directory, database)
|
||||
}
|
||||
|
||||
async fn series_stamp(database: &Db) -> Option<String> {
|
||||
sqlx::query_scalar("SELECT metadata_refreshed_at FROM series WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn movie_stamp(database: &Db) -> Option<String> {
|
||||
sqlx::query_scalar("SELECT metadata_refreshed_at FROM movies WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Issue #176: the whole point. A series added a moment ago has its
|
||||
/// seasons revealed by the command, not by a tick up to a day away.
|
||||
#[tokio::test]
|
||||
async fn an_added_series_is_refreshed_on_demand() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let server = tmdb_series(200).await;
|
||||
|
||||
series_action(&server)
|
||||
.refresh_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let episodes: i64 = sqlx::query_scalar("SELECT count(*) FROM episodes")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(episodes, 2, "the seasons TMDB knows must be revealed");
|
||||
assert!(series_stamp(&database).await.is_some());
|
||||
}
|
||||
|
||||
/// A movie's digital release date is what §6.2 gates targeted search on,
|
||||
/// and it only ever arrives from a refresh.
|
||||
#[tokio::test]
|
||||
async fn an_added_movie_is_refreshed_on_demand() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let server = tmdb_movie(200).await;
|
||||
|
||||
refresh_movie(&movie_client(&server), &database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (digital_release, poster): (Option<String>, Option<String>) =
|
||||
sqlx::query_as("SELECT digital_release, poster_path FROM movies WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(digital_release.as_deref(), Some("2024-04-16"));
|
||||
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
|
||||
assert!(movie_stamp(&database).await.is_some());
|
||||
}
|
||||
|
||||
/// A failed refresh must not roll the add back and must not stamp the
|
||||
/// row, or the scheduled sweep would treat the title as done.
|
||||
#[tokio::test]
|
||||
async fn a_failed_series_refresh_leaves_the_stamp_null_for_the_sweep() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let server = tmdb_series(500).await;
|
||||
|
||||
let result = series_action(&server).refresh_now(&database, 1).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(series_stamp(&database).await.is_none());
|
||||
let series: i64 = sqlx::query_scalar("SELECT count(*) FROM series")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(series, 1, "the add is not rolled back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_movie_refresh_leaves_the_stamp_null_for_the_sweep() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let server = tmdb_movie(500).await;
|
||||
|
||||
let result = refresh_movie(&movie_client(&server), &database, 1).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(movie_stamp(&database).await.is_none());
|
||||
let movies: i64 = sqlx::query_scalar("SELECT count(*) FROM movies")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(movies, 1, "the add is not rolled back");
|
||||
}
|
||||
|
||||
/// Add then delete before the lane drains: nothing to refresh, and that
|
||||
/// is not a failure.
|
||||
#[tokio::test]
|
||||
async fn a_command_naming_a_deleted_title_is_not_an_error() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let series = tmdb_series(200).await;
|
||||
let movie = tmdb_movie(200).await;
|
||||
|
||||
series_action(&series)
|
||||
.refresh_now(&database, 404)
|
||||
.await
|
||||
.unwrap();
|
||||
refresh_movie(&movie_client(&movie), &database, 404)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(series.received_requests().await.unwrap().is_empty());
|
||||
assert!(movie.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// The TTL gate the scheduled sweep uses applies here too: a title the
|
||||
/// sweep just refreshed does not pay for a second TMDB call.
|
||||
#[tokio::test]
|
||||
async fn a_title_the_sweep_just_refreshed_costs_no_second_call() {
|
||||
let (_dir, database) = added_titles().await;
|
||||
let series = tmdb_series(200).await;
|
||||
let movie = tmdb_movie(200).await;
|
||||
sqlx::query(
|
||||
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
series_action(&series)
|
||||
.refresh_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
refresh_movie(&movie_client(&movie), &database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(series.received_requests().await.unwrap().is_empty());
|
||||
assert!(movie.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ use arr_dl::TransmissionClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{
|
||||
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
|
||||
SeedingRules,
|
||||
backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope,
|
||||
GrabTarget, Grabber, SeedingRules,
|
||||
};
|
||||
use crate::indexers::IndexerDirectory;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
@@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
let pack_hard_failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
let failed_packs = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
@@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
.collect::<Vec<_>>(),
|
||||
now: std::time::SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
|
||||
pack_hard_failed,
|
||||
pack_backoff_active: failed_packs.failures > 0
|
||||
&& !backoff_elapsed(
|
||||
failed_packs.failures,
|
||||
failed_packs.last_failed_at.as_deref(),
|
||||
),
|
||||
}) == SeasonGrabMode::SeasonPack)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ fn is_upstream_ended(status: &str) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum RefreshError {
|
||||
pub(crate) enum RefreshError {
|
||||
#[error("database: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("tmdb: {0}")]
|
||||
@@ -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"#
|
||||
)
|
||||
@@ -101,6 +101,38 @@ impl SeriesRefreshAction {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// Refresh one series now, for the on-demand metadata lane (issue #176).
|
||||
///
|
||||
/// A series deleted between the command being sent and drained is not an
|
||||
/// error — there is simply nothing to refresh. The sweep's TTL gate
|
||||
/// applies here too, so a title the sweep already refreshed costs no
|
||||
/// second TMDB call.
|
||||
pub(crate) async fn refresh_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
series_id: i64,
|
||||
) -> Result<Option<Outcome>, RefreshError> {
|
||||
let stale = sqlx::query_as!(
|
||||
DueSeries,
|
||||
r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id,
|
||||
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, runtime_minutes
|
||||
FROM series WHERE id = ?"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
let Some(stale) = stale else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !metadata_refresh_due(stale.metadata_refreshed_at.as_deref()) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.refresh_series(database, &stale).await
|
||||
}
|
||||
|
||||
async fn refresh_series(
|
||||
&self,
|
||||
database: &Db,
|
||||
@@ -218,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!(
|
||||
@@ -515,6 +561,7 @@ struct DueSeries {
|
||||
poster_path: Option<String>,
|
||||
backdrop_path: Option<String>,
|
||||
vote_average: Option<f64>,
|
||||
runtime_minutes: Option<i64>,
|
||||
}
|
||||
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
|
||||
/// STRICT). A number past `u16::MAX` cannot match anything real and would
|
||||
@@ -785,6 +832,55 @@ mod tests {
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
|
||||
/// by, and a later refresh with TMDB's frequently-empty
|
||||
/// `episode_run_time` never blanks a known value.
|
||||
#[tokio::test]
|
||||
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
|
||||
let (_dir, database) = seeded_series(false).await;
|
||||
let server = MockServer::start().await;
|
||||
let body = |episode_run_time: serde_json::Value| {
|
||||
json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"status": "Returning Series",
|
||||
"episode_run_time": episode_run_time,
|
||||
"seasons": []
|
||||
})
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let runtime: Option<i64> =
|
||||
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runtime, Some(7));
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
expire_refresh(&database).await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let runtime: Option<i64> =
|
||||
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runtime, Some(7));
|
||||
}
|
||||
|
||||
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||
/// never tracks what was already there at add time: nothing is tracked,
|
||||
/// nothing arrives wanted.
|
||||
|
||||
@@ -79,6 +79,7 @@ impl TvGrabAction {
|
||||
&season,
|
||||
&blacklist,
|
||||
EPISODE_SEARCHES_PER_TICK - episode_searches,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -102,12 +103,15 @@ impl TvGrabAction {
|
||||
|
||||
/// Search and grab for one season. `None` means the season was skipped
|
||||
/// without spending an indexer call — nothing due, or no language yet.
|
||||
/// `ignore_pack_backoff` is the manual trigger's "try again now": it
|
||||
/// waives the failed-pack backoff window for this one invocation.
|
||||
async fn grab_season(
|
||||
&self,
|
||||
database: &Db,
|
||||
season: &PendingSeason,
|
||||
blacklist: &Blacklist,
|
||||
episode_budget: usize,
|
||||
ignore_pack_backoff: bool,
|
||||
) -> Result<Option<SeasonWork>, GrabError> {
|
||||
// §5.2: without the title's original language there is nothing to
|
||||
// evaluate a release against, and guessing is worse than waiting.
|
||||
@@ -141,7 +145,8 @@ impl TvGrabAction {
|
||||
air_dates: &air_dates,
|
||||
now,
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
||||
pack_hard_failed: pack_hard_failed(database, season.season_id).await?,
|
||||
pack_backoff_active: !ignore_pack_backoff
|
||||
&& pack_backoff_active(database, season.season_id).await?,
|
||||
});
|
||||
|
||||
let aired_due_gaps: Vec<&SeasonEpisode> = gaps
|
||||
@@ -369,6 +374,10 @@ impl TvGrabAction {
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Vec<TvCandidate>, GrabError> {
|
||||
// #182: a season-scoped sweep is the pack lane's own search, and the
|
||||
// release deck needs to know it completed to tell "nothing found"
|
||||
// from "nothing has run yet".
|
||||
let pack_sweep = matches!(selector, TvSelector::Season { .. });
|
||||
let target = TvTarget {
|
||||
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
|
||||
title: season.series_title.clone(),
|
||||
@@ -446,6 +455,9 @@ impl TvGrabAction {
|
||||
eligible: stored,
|
||||
});
|
||||
}
|
||||
if pack_sweep && any_searchable {
|
||||
record_pack_search(database, season.season_id).await?;
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
@@ -566,8 +578,10 @@ impl TvGrabAction {
|
||||
}
|
||||
|
||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
||||
/// reset every open episode's backoff and run the same pack-or-fall-back
|
||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
||||
/// reset every open episode's backoff, waive the failed-pack backoff
|
||||
/// (§6.2's escape hatch is this deck), and run the same
|
||||
/// pack-or-fall-back lane as the tick, scoped to this one season with
|
||||
/// no tick budgets.
|
||||
///
|
||||
/// A satisfied season — nothing open, or a grab already in flight —
|
||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||
@@ -625,7 +639,7 @@ impl TvGrabAction {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX, true)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1106,19 +1120,37 @@ async fn load_season_release(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a season-pack grab for this season already hard-failed — the
|
||||
/// fall-back-to-per-episode signal.
|
||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let failed = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
||||
) AS "failed!: bool""#,
|
||||
/// When the season's own pack search last completed (#182). Written only by
|
||||
/// a season-scoped sweep, so it answers the deck's question — did a pack
|
||||
/// search run — and nothing else.
|
||||
async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE seasons
|
||||
SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
season_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
Ok(failed)
|
||||
Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref()))
|
||||
}
|
||||
|
||||
/// A pack for exactly this season. Multi-season packs are never selected
|
||||
@@ -1463,38 +1495,41 @@ mod tests {
|
||||
/// The third acceptance case, selection side: a hard-failed pack put the
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn a_hard_failed_pack_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
// What the import tick left behind: a failed season grab and the
|
||||
// pack release on the blacklist (§6.3).
|
||||
/// Seed what the import tick leaves behind after a pack fails: one
|
||||
/// `failed` season grab per (infohash, age) pair.
|
||||
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
'https://tracker/pack.torrent', '{}', 'eligible')
|
||||
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)
|
||||
VALUES (?, 'season', ?, 'dead', 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
arr_db::blacklist::add(
|
||||
database.pool(),
|
||||
Some("dead"),
|
||||
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos",
|
||||
"dolby_vision_profile",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (index, age) in ages.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, ?, 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.bind(format!("dead{index}"))
|
||||
.bind(age)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed pack inside its §6.2 backoff window stays per-episode: the
|
||||
/// window quiets the pack lane, per-episode still makes progress.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
@@ -1520,6 +1555,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2: one failed pack backs the pack lane off for 1h, then the tick
|
||||
/// retries the pack instead of never trying again.
|
||||
#[tokio::test]
|
||||
async fn a_failed_pack_retries_after_its_backoff_window() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-2 hours"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() {
|
||||
let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&inside.1,
|
||||
inside.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"],
|
||||
)
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
|
||||
assert!(
|
||||
fake.torrents()
|
||||
.iter()
|
||||
.all(|torrent| !torrent.source.ends_with("pack.torrent")),
|
||||
"6 days into a 7d window the pack lane stays quiet"
|
||||
);
|
||||
|
||||
let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(
|
||||
&elapsed.1,
|
||||
elapsed.2,
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
|
||||
)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
action(&indexer, &downloader)
|
||||
.tick(&elapsed.1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
}
|
||||
|
||||
/// The escape hatch (§6.2, issue #181): a manual season search waives
|
||||
/// the failed-pack backoff and retries the pack right now.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_search_waives_the_pack_backoff() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await.last().unwrap(),
|
||||
&("season".to_owned(), season_id, "sent".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
}
|
||||
|
||||
/// §14: a season with an episode already on disk never grabs the pack,
|
||||
/// even when fully released. The remaining episodes come individually.
|
||||
#[tokio::test]
|
||||
@@ -1552,6 +1671,49 @@ mod tests {
|
||||
assert!(sources[1].ends_with("e03.torrent"));
|
||||
}
|
||||
|
||||
/// #182: the season deck cannot tell a queued pack sweep from one that
|
||||
/// ran and found nothing unless the pack search leaves a mark. Episode
|
||||
/// searches must not leave it — a season on the per-episode lane never
|
||||
/// asked for a pack, and pretending otherwise is the lie the deck told.
|
||||
#[tokio::test]
|
||||
async fn only_a_pack_sweep_stamps_the_season() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let stamp = |database: Db| async move {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert!(stamp(database.clone()).await.is_none());
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
let after_pack = stamp(database.clone()).await;
|
||||
assert!(
|
||||
after_pack.is_some(),
|
||||
"a fully released season takes the pack lane and its sweep is the deck's evidence"
|
||||
);
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
|
||||
// An airing season is on the per-episode lane: its episode searches
|
||||
// say nothing about whether a pack was ever looked for.
|
||||
let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await;
|
||||
action(&indexer, &downloader).tick(&airing).await.unwrap();
|
||||
let never: Option<String> =
|
||||
sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?")
|
||||
.bind(airing_id)
|
||||
.fetch_one(airing.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(never.is_none());
|
||||
}
|
||||
|
||||
/// An empty result backs the whole season off together (§6.2) instead of
|
||||
/// hammering the tracker every 30 s.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
-- no-transaction
|
||||
-- #73. movies.state used missing|grabbed|imported, predating arr-core's
|
||||
-- canonical MediaState vocabulary (missing|downloading|available) that
|
||||
-- series/episodes (0005) already follow.
|
||||
--
|
||||
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table. It runs
|
||||
-- outside a transaction (#155) so `PRAGMA foreign_keys = OFF` takes effect
|
||||
-- and dropping the old `movies` does not cascade into `movie_releases`.
|
||||
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table -- which
|
||||
-- means dropping the old copy. `movie_releases` references it with
|
||||
-- ON DELETE CASCADE, so the drop would take its rows down too; stash them
|
||||
-- first and restore them once the new `movies` exists with the same ids.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
|
||||
|
||||
CREATE TABLE movies_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -68,4 +68,6 @@ BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
|
||||
|
||||
DROP TABLE movie_releases_backup;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
-- no-transaction
|
||||
-- #108: a vanished torrent must not re-grab. Overrides #86, which reopened
|
||||
-- the gap (state = 'missing', wanted untouched) so the next tick re-grabbed
|
||||
-- the same release. Adds 'parked' so the daemon can clear `wanted` and mark
|
||||
-- the title honestly instead — distinct from 'missing' (an open gap) and
|
||||
-- 'available' (satisfied). SQLite cannot alter a CHECK, so both tables are
|
||||
-- rebuilt (see 0007).
|
||||
--
|
||||
-- #155: runs outside a transaction so `PRAGMA foreign_keys = OFF` takes
|
||||
-- effect and dropping the old tables does not cascade into their children.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
|
||||
|
||||
CREATE TABLE movies_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -67,6 +63,11 @@ BEGIN
|
||||
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||
END;
|
||||
|
||||
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
|
||||
DROP TABLE movie_releases_backup;
|
||||
|
||||
CREATE TABLE episode_releases_backup AS SELECT * FROM episode_releases;
|
||||
|
||||
CREATE TABLE episodes_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||
@@ -101,4 +102,5 @@ CREATE INDEX episodes_pending_search
|
||||
|
||||
CREATE INDEX episodes_state ON episodes (state);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
INSERT INTO episode_releases SELECT * FROM episode_releases_backup;
|
||||
DROP TABLE episode_releases_backup;
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
-- no-transaction
|
||||
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
|
||||
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
|
||||
-- search haystack, §7.4 filenames and the compat shim as if they were real
|
||||
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
|
||||
-- boundary now substitutes — #121's guarded update replaces it once TMDB
|
||||
-- fills the title in — and the rebuilt table enforces non-empty with a
|
||||
-- CHECK. The rebuild runs outside a transaction (#155) so
|
||||
-- `PRAGMA foreign_keys = OFF` takes effect and dropping the old `episodes`
|
||||
-- does not cascade into `episode_releases`.
|
||||
|
||||
-- fills the title in.
|
||||
--
|
||||
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
|
||||
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
|
||||
-- a transaction, where that pragma is a no-op); enforcement lives in code.
|
||||
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE episodes_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL CHECK (number >= 0),
|
||||
title TEXT NOT NULL CHECK (title <> ''),
|
||||
air_date TEXT,
|
||||
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
|
||||
state TEXT NOT NULL DEFAULT 'missing'
|
||||
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
|
||||
search_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_searched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
vanished INTEGER NOT NULL DEFAULT 0 CHECK (vanished IN (0, 1)),
|
||||
UNIQUE (season_id, number)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO episodes_new (
|
||||
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||
last_searched_at, created_at, updated_at, vanished
|
||||
)
|
||||
SELECT
|
||||
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||
last_searched_at, created_at, updated_at, vanished
|
||||
FROM episodes;
|
||||
|
||||
DROP TABLE episodes;
|
||||
ALTER TABLE episodes_new RENAME TO episodes;
|
||||
|
||||
CREATE INDEX episodes_pending_search
|
||||
ON episodes (last_searched_at)
|
||||
WHERE wanted = 1 AND state = 'missing';
|
||||
|
||||
CREATE INDEX episodes_state ON episodes (state);
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- #182. The season release deck cannot tell a queued pack sweep from one
|
||||
-- that ran and found nothing, so it blames backoff for both. Only the season
|
||||
-- knows when its own pack search last completed: episodes' `last_searched_at`
|
||||
-- moves for reasons that have nothing to do with the pack lane, and a season
|
||||
-- on the per-episode lane never touches the pack search at all.
|
||||
ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT;
|
||||
@@ -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);
|
||||
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
|
||||
/// The migrations embedded in the binary, so a deploy is one file.
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
|
||||
/// queue, as a SQLite time modifier.
|
||||
///
|
||||
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
|
||||
/// and the one season that wants attention sits behind the ones that do not.
|
||||
/// Callers pair it with the `grabbed_at` format:
|
||||
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
|
||||
pub const ATTENTION_WINDOW: &str = "-30 days";
|
||||
|
||||
/// How long a writer waits for the write lock before giving up.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
@@ -419,55 +428,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #153: an empty episode title takes the TBA placeholder during 0021's
|
||||
/// backfill, and the rebuilt table's CHECK rejects new empty titles.
|
||||
#[tokio::test]
|
||||
async fn empty_episode_titles_are_backfilled_and_then_rejected() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
MIGRATOR
|
||||
.run_to(20, db.pool())
|
||||
.await
|
||||
.expect("migrations before the #153 backfill");
|
||||
|
||||
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();
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, '')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("empty title, valid before 0021");
|
||||
|
||||
db.migrate().await.expect("remaining migrations");
|
||||
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM episodes")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("episode row survives the rebuild");
|
||||
assert_eq!(title, "TBA");
|
||||
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, '')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("the rebuilt column rejects the empty string");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
@@ -193,6 +193,7 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -268,6 +269,75 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
root_audience: row.root_audience,
|
||||
root_path: row.root_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The policy attached to one series' root, with the series' own
|
||||
/// overrides (§5.1).
|
||||
///
|
||||
/// `None` when the series does not exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the query fails, or a policy column does not hold the JSON its
|
||||
/// migration promises.
|
||||
pub async fn series_policy(&self, series_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.overrides AS "overrides!: String",
|
||||
r.id AS "root_id!: i64",
|
||||
r.kind AS "root_kind!: String",
|
||||
r.audience AS "root_audience!: String",
|
||||
r.path AS "root_path!: String",
|
||||
p.id AS "policy_id!: i64",
|
||||
p.name AS "policy_name!: String",
|
||||
p.required_audio AS "required_audio!: String",
|
||||
p.dub_blacklist AS "dub_blacklist!: String",
|
||||
p.hdr_rules AS "hdr_rules!: String",
|
||||
p.size_bands AS "size_bands!: String",
|
||||
p.resolution_pref AS "resolution_pref!: String",
|
||||
p.source_weights AS "source_weights!: String",
|
||||
p.score_weights AS "score_weights!: String"
|
||||
FROM series s
|
||||
JOIN roots r ON r.id = s.root_id
|
||||
JOIN policies p ON p.id = r.policy_id
|
||||
WHERE s.id = ?
|
||||
"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let overrides: OverridesJson = json("overrides", &row.overrides)?;
|
||||
let policy = PolicyColumns {
|
||||
id: row.policy_id,
|
||||
name: row.policy_name,
|
||||
required_audio: row.required_audio,
|
||||
dub_blacklist: row.dub_blacklist,
|
||||
hdr_rules: row.hdr_rules,
|
||||
size_bands: row.size_bands,
|
||||
resolution_pref: row.resolution_pref,
|
||||
source_weights: row.source_weights,
|
||||
score_weights: row.score_weights,
|
||||
}
|
||||
.to_policy()?;
|
||||
|
||||
Ok(Some(TitlePolicy {
|
||||
policy,
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -337,6 +407,7 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -428,6 +499,8 @@ struct OverridesJson {
|
||||
only_4k: bool,
|
||||
#[serde(default)]
|
||||
allow_english_audio: bool,
|
||||
#[serde(default)]
|
||||
allow_below_floor: bool,
|
||||
}
|
||||
|
||||
fn gib(value: u64) -> u64 {
|
||||
@@ -619,7 +692,13 @@ mod tests {
|
||||
|
||||
let one_and_a_half_gib = 1536 << 20;
|
||||
assert_eq!(
|
||||
arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib),
|
||||
arr_core::score::is_below_floor(
|
||||
&loaded.policy,
|
||||
Resolution::R1080p,
|
||||
one_and_a_half_gib,
|
||||
1,
|
||||
0
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
wiremock.workspace = true
|
||||
|
||||
|
||||
+261
-41
@@ -1,69 +1,289 @@
|
||||
//! A small time-to-live cache over raw response bodies.
|
||||
//! A time-to-live cache over raw response bodies, backed by files on disk.
|
||||
//!
|
||||
//! Keyed on a logical request key rather than the URL, so the API key never
|
||||
//! becomes part of a cache key. Bodies are stored unparsed: parsing again on a
|
||||
//! hit costs microseconds and keeps one cache serving every endpoint.
|
||||
//! becomes part of a cache key (see `client::request_url`). The key is
|
||||
//! hashed into the filename rather than embedded verbatim, so a search
|
||||
//! query or a title id never becomes readable in a directory listing.
|
||||
//!
|
||||
//! Bounded two ways: an entry older than `ttl` is dropped whether or not
|
||||
//! anyone reads it, and the directory as a whole is capped at `max_bytes` —
|
||||
//! age alone does not stop a busy day filling the disk. Both are enforced by
|
||||
//! [`Cache::sweep`], which runs after every write and is also safe to call
|
||||
//! on a schedule (the daily metadata tick, DESIGN.md §8).
|
||||
//!
|
||||
//! File IO here is synchronous. Bodies are TMDB JSON responses — at most a
|
||||
//! few hundred KB — and this runs once per miss, not in a hot loop, so
|
||||
//! blocking the calling task briefly is a fair trade for not pulling an
|
||||
//! async-fs dependency into a crate that otherwise doesn't need one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entry {
|
||||
stored_at: Instant,
|
||||
body: Arc<str>,
|
||||
}
|
||||
const FILE_EXT: &str = "tmdb-cache";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Cache {
|
||||
dir: PathBuf,
|
||||
ttl: Duration,
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
max_bytes: u64,
|
||||
/// Whether this cache created `dir` itself (the default, process-unique
|
||||
/// temp directory) rather than being pointed at one the caller owns. Only
|
||||
/// a directory we created is ours to delete.
|
||||
owns_dir: bool,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub(crate) fn new(ttl: Duration) -> Self {
|
||||
Self {
|
||||
pub(crate) fn new(
|
||||
dir: PathBuf,
|
||||
ttl: Duration,
|
||||
max_bytes: u64,
|
||||
owns_dir: bool,
|
||||
) -> io::Result<Self> {
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
ttl,
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
max_bytes,
|
||||
owns_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// The cached body for `key`, if one is present and still fresh.
|
||||
pub(crate) fn get(&self, key: &str) -> Option<Arc<str>> {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let entry = entries.get(key)?;
|
||||
if entry.stored_at.elapsed() < self.ttl {
|
||||
return Some(Arc::clone(&entry.body));
|
||||
let path = self.path_for(key);
|
||||
let metadata = fs::metadata(&path).ok()?;
|
||||
let age = metadata.modified().ok()?.elapsed().unwrap_or_default();
|
||||
if age >= self.ttl {
|
||||
let _ = fs::remove_file(&path);
|
||||
return None;
|
||||
}
|
||||
entries.remove(key);
|
||||
None
|
||||
fs::read_to_string(&path).ok().map(Arc::from)
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&self, key: String, body: Arc<str>) {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
entries.insert(
|
||||
key,
|
||||
Entry {
|
||||
stored_at: Instant::now(),
|
||||
body,
|
||||
},
|
||||
);
|
||||
/// Write `body` for `key`, atomically: a temp file plus a rename, so a
|
||||
/// killed process can never leave a half-written body that later reads
|
||||
/// as valid JSON.
|
||||
pub(crate) fn insert(&self, key: &str, body: &str) {
|
||||
let path = self.path_for(key);
|
||||
let tmp = self.tmp_path();
|
||||
if fs::write(&tmp, body).is_err() {
|
||||
return;
|
||||
}
|
||||
if fs::rename(&tmp, &path).is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return;
|
||||
}
|
||||
self.sweep();
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&self, key: &str) {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(key);
|
||||
let _ = fs::remove_file(self.path_for(key));
|
||||
}
|
||||
|
||||
/// Drop everything. The daily metadata refresh does not need this — entries
|
||||
/// expire on their own — but a forced refresh from the UI does.
|
||||
/// Drop everything. The daily metadata refresh does not need this —
|
||||
/// entries expire on their own — but a forced refresh from the UI does.
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clear();
|
||||
self.for_each_entry(|path, _modified, _len| {
|
||||
let _ = fs::remove_file(path);
|
||||
});
|
||||
}
|
||||
|
||||
/// Evict every expired entry, then, if the directory is still over
|
||||
/// `max_bytes`, the oldest survivors until it is not.
|
||||
pub(crate) fn sweep(&self) {
|
||||
let mut survivors = Vec::new();
|
||||
self.for_each_entry(|path, modified, len| {
|
||||
if modified.elapsed().unwrap_or_default() >= self.ttl {
|
||||
let _ = fs::remove_file(&path);
|
||||
} else {
|
||||
survivors.push((path, modified, len));
|
||||
}
|
||||
});
|
||||
|
||||
let mut total: u64 = survivors.iter().map(|(_, _, len)| len).sum();
|
||||
if total <= self.max_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
// Oldest first, so the size sweep behaves like the age one: the most
|
||||
// recently fetched title is the one that survives.
|
||||
survivors.sort_by_key(|(_, modified, _)| *modified);
|
||||
for (path, _, len) in survivors {
|
||||
if total <= self.max_bytes {
|
||||
break;
|
||||
}
|
||||
if fs::remove_file(&path).is_ok() {
|
||||
total = total.saturating_sub(len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn for_each_entry(&self, mut visit: impl FnMut(PathBuf, SystemTime, u64)) {
|
||||
let Ok(read_dir) = fs::read_dir(&self.dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != FILE_EXT) {
|
||||
continue;
|
||||
}
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
visit(path, modified, metadata.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, key: &str) -> PathBuf {
|
||||
self.dir
|
||||
.join(format!("{:016x}.{FILE_EXT}", fnv1a64(key.as_bytes())))
|
||||
}
|
||||
|
||||
fn tmp_path(&self) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
self.dir
|
||||
.join(format!("{}-{unique}.{FILE_EXT}.tmp", std::process::id()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Cache {
|
||||
fn drop(&mut self) {
|
||||
if self.owns_dir {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a, 64-bit. Not cryptographic — it only needs to pick a filename —
|
||||
/// but unlike `std`'s `DefaultHasher` it is stable across runs, so a
|
||||
/// restarted process still hits its own cache from before.
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
bytes.iter().fold(OFFSET_BASIS, |hash, &byte| {
|
||||
(hash ^ u64::from(byte)).wrapping_mul(PRIME)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
fn cache(ttl: Duration, max_bytes: u64) -> (tempfile::TempDir, Cache) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = Cache::new(dir.path().to_owned(), ttl, max_bytes, false).expect("cache");
|
||||
(dir, cache)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_returns_what_was_inserted() {
|
||||
let (_dir, cache) = cache(Duration::from_secs(60), 1024);
|
||||
cache.insert("movie/1", "hello");
|
||||
assert_eq!(cache.get("movie/1").as_deref(), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miss_is_none() {
|
||||
let (_dir, cache) = cache(Duration::from_secs(60), 1024);
|
||||
assert_eq!(cache.get("movie/absent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_entry_is_deleted_from_disk_not_just_hidden() {
|
||||
let (dir, cache) = cache(Duration::from_millis(1), 1024);
|
||||
cache.insert("movie/1", "hello");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
assert_eq!(cache.get("movie/1"), None, "a stale entry must not hit");
|
||||
|
||||
let files: Vec<_> = std::fs::read_dir(dir.path())
|
||||
.expect("read dir")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert!(
|
||||
files.is_empty(),
|
||||
"expired entry should have been removed from disk, found {files:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_evicts_expired_entries_without_being_read() {
|
||||
let (dir, cache) = cache(Duration::from_millis(1), 1024);
|
||||
cache.insert("movie/1", "hello");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
cache.sweep();
|
||||
|
||||
let count = std::fs::read_dir(dir.path())
|
||||
.expect("read dir")
|
||||
.filter_map(Result::ok)
|
||||
.count();
|
||||
assert_eq!(count, 0, "sweep should have deleted the expired entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_the_size_budget_evicts_the_oldest_entry_first() {
|
||||
// A generous TTL, so only the size budget can be doing the evicting.
|
||||
let (dir, cache) = cache(Duration::from_secs(60), 12);
|
||||
cache.insert("movie/old", "0123456789"); // 10 bytes, written first
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
cache.insert("movie/new", "0123456789"); // pushes total to 20 > 12
|
||||
|
||||
assert_eq!(
|
||||
cache.get("movie/old"),
|
||||
None,
|
||||
"the older entry should have been evicted to stay under budget"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.get("movie/new").as_deref(),
|
||||
Some("0123456789"),
|
||||
"the newer entry should survive"
|
||||
);
|
||||
|
||||
let total: u64 = std::fs::read_dir(dir.path())
|
||||
.expect("read dir")
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.metadata().expect("metadata").len())
|
||||
.sum();
|
||||
assert!(
|
||||
total <= 12,
|
||||
"directory should be back under budget, was {total}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_an_owned_cache_removes_its_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let owned_subdir = dir.path().join("owned");
|
||||
let cache =
|
||||
Cache::new(owned_subdir.clone(), Duration::from_secs(60), 1024, true).expect("cache");
|
||||
cache.insert("movie/1", "hello");
|
||||
drop(cache);
|
||||
|
||||
assert!(
|
||||
!owned_subdir.exists(),
|
||||
"an owned cache directory should be cleaned up on drop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_borrowed_cache_leaves_its_directory() {
|
||||
let (dir, cache) = cache(Duration::from_secs(60), 1024);
|
||||
cache.insert("movie/1", "hello");
|
||||
drop(cache);
|
||||
|
||||
assert!(
|
||||
dir.path().exists(),
|
||||
"a caller-supplied cache directory must survive the client that used it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The TMDB HTTP client.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{StatusCode, Url};
|
||||
@@ -21,12 +22,21 @@ pub const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3/";
|
||||
/// One day. Metadata refresh is a daily tick (§8), not a per-request cost.
|
||||
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_hours(24);
|
||||
|
||||
/// 64 MiB. Dokploy's samples for `arr.n62.casa` showed resident memory going
|
||||
/// from 16.1-16.5 MiB to 48.77 MiB across the TV-tracking and rich-metadata
|
||||
/// milestones (issue #158) — roughly 32 MiB of growth attributable to the
|
||||
/// same cache this now bounds, on one household's ordinary usage. Doubling
|
||||
/// that for a busier day, and rounding up, gives a ceiling that is real —
|
||||
/// unlike the memory it replaces — without being tight; `DEFAULT_CACHE_TTL`
|
||||
/// clears the whole directory every day regardless.
|
||||
pub const DEFAULT_CACHE_MAX_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How much of an unexpected response body is worth keeping in an error.
|
||||
const MAX_ERROR_BODY: usize = 512;
|
||||
|
||||
/// A TMDB client with an in-process response cache.
|
||||
/// A TMDB client with an on-disk response cache.
|
||||
///
|
||||
/// Deliberately not `Clone`: the cache lives inside it, so build one and share
|
||||
/// it behind an `Arc` rather than handing out copies that each miss.
|
||||
@@ -66,6 +76,8 @@ impl TmdbClient {
|
||||
api_key: api_key.into(),
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
cache_ttl: DEFAULT_CACHE_TTL,
|
||||
cache_dir: None,
|
||||
cache_max_bytes: DEFAULT_CACHE_MAX_BYTES,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
@@ -254,6 +266,15 @@ impl TmdbClient {
|
||||
self.cache.clear();
|
||||
}
|
||||
|
||||
/// Evict expired cache entries, and the oldest survivors if the
|
||||
/// directory is still over its size budget. `insert` already does this
|
||||
/// after every write, so calling this is only needed to reclaim space an
|
||||
/// idle cache is sitting on — the daily metadata tick (§8) is the
|
||||
/// intended caller.
|
||||
pub fn sweep_cache(&self) {
|
||||
self.cache.sweep();
|
||||
}
|
||||
|
||||
/// Fetch and decode, caching only what decoded.
|
||||
///
|
||||
/// Decoding before the insert matters: a malformed response that got into
|
||||
@@ -279,7 +300,7 @@ impl TmdbClient {
|
||||
|
||||
let body = self.fetch(url, path).await?;
|
||||
let value = serde_json::from_str(&body)?;
|
||||
self.cache.insert(cache_key, Arc::from(body));
|
||||
self.cache.insert(&cache_key, &body);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -364,6 +385,8 @@ pub struct TmdbClientBuilder {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
cache_ttl: Duration,
|
||||
cache_dir: Option<PathBuf>,
|
||||
cache_max_bytes: u64,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -383,6 +406,30 @@ impl TmdbClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Where cache files live.
|
||||
///
|
||||
/// Left unset, `build` picks a process-unique directory under the OS
|
||||
/// temp dir and removes it when the client is dropped — a warm cache
|
||||
/// never outlives one process, same as before this cache moved to disk.
|
||||
/// Production wants this pointed explicitly at a directory alongside the
|
||||
/// database (DESIGN.md §10), from bootstrap config, so a restart keeps a
|
||||
/// warm cache; a directory set this way is the caller's and is left
|
||||
/// alone on drop.
|
||||
#[must_use]
|
||||
pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
|
||||
self.cache_dir = Some(cache_dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Upper bound on total cache size in bytes, enforced by evicting the
|
||||
/// oldest entries first. See [`DEFAULT_CACHE_MAX_BYTES`] for how the
|
||||
/// default was chosen.
|
||||
#[must_use]
|
||||
pub fn cache_max_bytes(mut self, cache_max_bytes: u64) -> Self {
|
||||
self.cache_max_bytes = cache_max_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-request timeout.
|
||||
#[must_use]
|
||||
pub fn timeout(mut self, timeout: Duration) -> Self {
|
||||
@@ -395,7 +442,8 @@ impl TmdbClientBuilder {
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::BaseUrl`] if the base URL will not parse, [`Error::Transport`]
|
||||
/// if the HTTP client cannot be built.
|
||||
/// if the HTTP client cannot be built, [`Error::Cache`] if the cache
|
||||
/// directory cannot be created.
|
||||
pub fn build(self) -> Result<TmdbClient> {
|
||||
// Without a trailing slash `Url::join` replaces the last path segment
|
||||
// instead of appending, which silently drops the `/3`.
|
||||
@@ -410,11 +458,33 @@ impl TmdbClientBuilder {
|
||||
.user_agent(concat!("arr/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
|
||||
let (cache_dir, owns_cache_dir) = match self.cache_dir {
|
||||
Some(dir) => (dir, false),
|
||||
None => (default_cache_dir(), true),
|
||||
};
|
||||
let cache = Cache::new(
|
||||
cache_dir,
|
||||
self.cache_ttl,
|
||||
self.cache_max_bytes,
|
||||
owns_cache_dir,
|
||||
)?;
|
||||
|
||||
Ok(TmdbClient {
|
||||
http,
|
||||
base_url,
|
||||
api_key: self.api_key,
|
||||
cache: Cache::new(self.cache_ttl),
|
||||
cache,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A process-unique directory under the OS temp dir, used when the caller
|
||||
/// does not configure `cache_dir` explicitly. Unique per built client (not
|
||||
/// just per process) so that tests building several clients in the same
|
||||
/// process, against the same TMDB paths but different mock responses, do not
|
||||
/// share a cache and see each other's bodies.
|
||||
fn default_cache_dir() -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("arr-meta-cache-{}-{unique}", std::process::id()))
|
||||
}
|
||||
|
||||
@@ -53,4 +53,8 @@ pub enum Error {
|
||||
/// The configured base URL is not a URL.
|
||||
#[error("invalid TMDB base URL: {0}")]
|
||||
BaseUrl(String),
|
||||
|
||||
/// The on-disk response cache directory could not be created.
|
||||
#[error("TMDB cache directory: {0}")]
|
||||
Cache(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
//! - The digital release date gates targeted search (§6.2). A movie with no
|
||||
//! digital release date must get zero searches.
|
||||
//!
|
||||
//! Responses are cached in process with a time-to-live, defaulting to a day,
|
||||
//! Responses are cached on disk with a time-to-live, defaulting to a day,
|
||||
//! because metadata refresh is a daily tick (§8) and not a per-request cost.
|
||||
//! The cache directory is bounded by both age and total size (see
|
||||
//! [`DEFAULT_CACHE_MAX_BYTES`]) so a service meant to run for months does
|
||||
//! not grow without limit.
|
||||
|
||||
// `unused_crate_dependencies` is a per-target lint and the library's own test
|
||||
// target links the dev-dependencies without using them. The real uses are in
|
||||
@@ -21,7 +24,9 @@ mod client;
|
||||
mod error;
|
||||
mod model;
|
||||
|
||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||
pub use client::{
|
||||
TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_MAX_BYTES, DEFAULT_CACHE_TTL,
|
||||
};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{
|
||||
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
|
||||
|
||||
@@ -156,6 +156,10 @@ pub struct Series {
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
/// Minutes per episode, the first non-zero entry of TMDB's
|
||||
/// `episode_run_time`. Frequently empty for returning series — §5.5
|
||||
/// treats a missing runtime as the reference runtime.
|
||||
pub episode_runtime: Option<u32>,
|
||||
pub seasons: Vec<SeasonSummary>,
|
||||
}
|
||||
|
||||
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
episode_run_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
seasons: Vec<RawSeasonSummary>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
episode_runtime: raw
|
||||
.episode_run_time
|
||||
.into_iter()
|
||||
.find(|&minutes| minutes > 0),
|
||||
seasons: raw
|
||||
.seasons
|
||||
.into_iter()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
// Same per-target quirk as in `lib.rs`: an integration test links the library's
|
||||
// dependencies without using them directly.
|
||||
use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
|
||||
use {reqwest as _, serde as _, serde_json as _, tempfile as _, thiserror as _, tracing as _};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
+20
-12
@@ -218,10 +218,7 @@
|
||||
-->
|
||||
|
||||
<main class="deck" id="movie" hidden aria-label="movie detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="movie-back">back</button>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
</header>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
@@ -266,12 +263,19 @@
|
||||
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
class="control control-quiet control-icon"
|
||||
id="movie-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
aria-label="remove from the library"
|
||||
>
|
||||
remove
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.8 4.3h10.4" />
|
||||
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||
<path d="M6.6 7v4.2" />
|
||||
<path d="M9.4 7v4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
@@ -470,10 +474,7 @@
|
||||
-->
|
||||
|
||||
<main class="deck releases" id="series" hidden aria-label="series detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="series-back">back</button>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
</header>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
@@ -506,12 +507,19 @@
|
||||
<span class="movie-controls-space"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
class="control control-quiet control-icon"
|
||||
id="series-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="series-remove-panel"
|
||||
aria-label="remove from the library"
|
||||
>
|
||||
remove
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.8 4.3h10.4" />
|
||||
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||
<path d="M6.6 7v4.2" />
|
||||
<path d="M9.4 7v4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="series-remove-panel" hidden></div>
|
||||
|
||||
+512
-102
@@ -38,6 +38,7 @@ import {
|
||||
formatAudio,
|
||||
formatHdr,
|
||||
formatResolution,
|
||||
formatRetryWait,
|
||||
formatScore,
|
||||
formatSeeders,
|
||||
formatSize,
|
||||
@@ -49,15 +50,16 @@ import {
|
||||
movieFiles,
|
||||
movieReleases,
|
||||
movieSearchState,
|
||||
overridable,
|
||||
probedAttributeTags,
|
||||
queueSearch,
|
||||
removeMovie,
|
||||
ruleLabel,
|
||||
type SeasonPackState,
|
||||
sweepExpected,
|
||||
totalSize,
|
||||
type WaiveOutcome,
|
||||
waiveAndGrab,
|
||||
waiverOverride,
|
||||
} from "./releases";
|
||||
import { currentRoute, navigate, type Route } from "./router";
|
||||
import {
|
||||
@@ -87,6 +89,7 @@ import {
|
||||
fileAttributeTags,
|
||||
formatAirDate,
|
||||
isUnaired,
|
||||
refreshSeriesMetadata,
|
||||
removeEpisodeFiles,
|
||||
removeSeasonFiles,
|
||||
removeSeries,
|
||||
@@ -100,7 +103,7 @@ import {
|
||||
type TvTarget,
|
||||
waiveAndGrabTv,
|
||||
} from "./series";
|
||||
import { armedDelete, settingsMain } from "./settings";
|
||||
import { settingsMain } from "./settings";
|
||||
import "./style.css";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
@@ -390,14 +393,14 @@ function main() {
|
||||
must<HTMLElement>("#library"),
|
||||
{ kind: "library" },
|
||||
);
|
||||
// no origin click to restore focus to on a deep link — the series
|
||||
// view's back control is the closest stand-in
|
||||
// no origin click to restore focus to on a deep link — the library
|
||||
// rail button is the closest stand-in
|
||||
tvDeck.open({
|
||||
title,
|
||||
sub,
|
||||
seriesId,
|
||||
target,
|
||||
origin: must<HTMLButtonElement>("#series-back"),
|
||||
origin: must<HTMLElement>("#nav-library"),
|
||||
returnTo: must<HTMLElement>("#series"),
|
||||
parentRoute: { kind: "series", seriesId },
|
||||
});
|
||||
@@ -576,11 +579,21 @@ function searchMain(
|
||||
(hit): hit is LibrarySeriesHit => hit.kind === "series",
|
||||
);
|
||||
const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
|
||||
// Issue #168: an unreachable TMDB degrades only its own group — the
|
||||
// operator sees the library half plus why TMDB is missing, and can tell
|
||||
// that apart from a clean "nothing found".
|
||||
const tmdbDown = response.tmdb_status === "unavailable";
|
||||
if (response.library.length === 0 && response.tmdb.length === 0) {
|
||||
setStatus("no matches in library or on tmdb");
|
||||
setStatus(
|
||||
tmdbDown ? "no matches in library — tmdb unavailable" : "no matches in library or on tmdb",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
if (tmdbDown) {
|
||||
setStatus("tmdb unavailable — showing library matches only", "fault");
|
||||
} else {
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
if (response.library.length > 0) {
|
||||
refs.groups.library.section.hidden = false;
|
||||
@@ -1250,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- icon-only controls (#230) ----------------------------------------- */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const ICON_PATHS = {
|
||||
trash: [
|
||||
"M2.8 4.3h10.4",
|
||||
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
|
||||
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
|
||||
"M6.6 7v4.2",
|
||||
"M9.4 7v4.2",
|
||||
],
|
||||
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
|
||||
disk: [
|
||||
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
|
||||
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
|
||||
"M10.9 11.6h.01",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
|
||||
function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 16 16");
|
||||
svg.setAttribute("class", "icon");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
for (const d of ICON_PATHS[name]) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
|
||||
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", "icon icon-mark");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
if (name === "rt") {
|
||||
svg.setAttribute("viewBox", "0 0 20 20");
|
||||
const paths = [
|
||||
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
|
||||
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
|
||||
"M10 6.6c1.4.5 3 .2 4.1-.8",
|
||||
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
|
||||
];
|
||||
for (const d of paths) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
svg.setAttribute("viewBox", "0 0 44 20");
|
||||
const box = document.createElementNS(SVG_NS, "rect");
|
||||
box.setAttribute("x", "1");
|
||||
box.setAttribute("y", "1.5");
|
||||
box.setAttribute("width", "42");
|
||||
box.setAttribute("height", "17");
|
||||
box.setAttribute("rx", "4");
|
||||
const letters = document.createElementNS(SVG_NS, "text");
|
||||
letters.setAttribute("x", "22");
|
||||
letters.setAttribute("y", "13.2");
|
||||
letters.setAttribute("text-anchor", "middle");
|
||||
letters.setAttribute("font-size", "9");
|
||||
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
|
||||
svg.append(box, letters);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
|
||||
* visible word "confirm" while armed, so an accidental hit never destroys.
|
||||
*/
|
||||
function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control control-quiet control-icon";
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
button.replaceChildren(icon("trash"));
|
||||
let armed = false;
|
||||
let resetTimer: number | undefined;
|
||||
const disarm = () => {
|
||||
armed = false;
|
||||
window.clearTimeout(resetTimer);
|
||||
delete button.dataset.armed;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.replaceChildren(icon("trash"));
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
button.disabled = true;
|
||||
execute();
|
||||
return;
|
||||
}
|
||||
armed = true;
|
||||
button.dataset.armed = "true";
|
||||
button.textContent = "confirm";
|
||||
button.setAttribute("aria-label", `confirm — ${label}`);
|
||||
resetTimer = window.setTimeout(disarm, 4000);
|
||||
});
|
||||
button.addEventListener("blur", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/* ---- §9.6 painters shared by both detail pages ------------------------- */
|
||||
|
||||
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
|
||||
@@ -1268,20 +1393,21 @@ function starIcon(): SVGSVGElement {
|
||||
}
|
||||
|
||||
/** External links read as quiet controls; they leave the app entirely. */
|
||||
function externalLink(label: string, href: string): HTMLAnchorElement {
|
||||
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
|
||||
const link = document.createElement("a");
|
||||
link.className = "control control-quiet";
|
||||
link.className = "control control-quiet control-icon";
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = label;
|
||||
link.setAttribute("aria-label", label);
|
||||
link.title = label;
|
||||
link.append(mark);
|
||||
return link;
|
||||
}
|
||||
|
||||
function movieMain(views: HideableView[]): MovieView {
|
||||
const view = must<HTMLElement>("#movie");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#movie-back");
|
||||
const statusEl = must<HTMLElement>("#movie-status");
|
||||
const hero = must<HTMLElement>("#movie-hero");
|
||||
const poster = must<HTMLImageElement>("#movie-poster");
|
||||
@@ -1339,7 +1465,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);
|
||||
@@ -1401,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
}
|
||||
titleEl.textContent = movie.title;
|
||||
yearEl.textContent = movie.year === null ? "" : String(movie.year);
|
||||
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
|
||||
remove.title = `remove ${movie.title} from the library`;
|
||||
chipsEl.replaceChildren();
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
@@ -1593,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
trailer.textContent = "trailer";
|
||||
actionsEl.append(trailer);
|
||||
}
|
||||
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
|
||||
const linkTitle = current?.title ?? "this movie";
|
||||
actionsEl.append(
|
||||
externalLink(
|
||||
providerMark("tmdb"),
|
||||
`open ${linkTitle} on TMDB`,
|
||||
tmdbMovieLink(detail.tmdb_id),
|
||||
),
|
||||
);
|
||||
if (detail.imdb_id !== null) {
|
||||
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
|
||||
actionsEl.append(
|
||||
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
|
||||
);
|
||||
}
|
||||
if (current !== null) {
|
||||
actionsEl.append(
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
|
||||
externalLink(
|
||||
providerMark("rt"),
|
||||
`search Rotten Tomatoes for ${current.title}`,
|
||||
rottenTomatoesSearch(current.title, current.year),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1893,7 +2034,8 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
filesSection.hidden = true;
|
||||
diskRows.replaceChildren();
|
||||
sweep.disabled = false;
|
||||
back.focus();
|
||||
titleEl.setAttribute("tabindex", "-1");
|
||||
titleEl.focus();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -1916,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
back.addEventListener("click", close);
|
||||
|
||||
// capture + stopImmediatePropagation: one Escape steps back one layer —
|
||||
// the library and search decks also listen for Escape on this window
|
||||
window.addEventListener(
|
||||
@@ -2078,7 +2218,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
|
||||
interface ReleaseActions {
|
||||
reload: () => Promise<void>;
|
||||
notify: (text: string, tone?: "fault") => void;
|
||||
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */
|
||||
/** One click on grab — a plain grab, or waive-then-grab where the rule
|
||||
* that failed has an override (§9.3). */
|
||||
grab: (
|
||||
release: MovieRelease,
|
||||
bucket: "eligible" | "waived" | "rejected",
|
||||
@@ -2142,10 +2283,16 @@ 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.
|
||||
const waivable = releases.filter(overridable).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 =
|
||||
waivable > 0
|
||||
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
|
||||
: "none — every candidate was waived or rejected by policy";
|
||||
dom.eligible.rows.append(none);
|
||||
}
|
||||
for (const name of ["waived", "rejected"] as const) {
|
||||
@@ -2282,8 +2429,14 @@ function releaseRow(
|
||||
);
|
||||
}
|
||||
if (bucket !== "eligible") {
|
||||
// A rejected row always names its rule; a waived one cannot — the
|
||||
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
|
||||
// `waived` plainly rather than calling a classified row unclassified.
|
||||
const verdict = release.rejected_rule
|
||||
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
|
||||
: bucket;
|
||||
line.append(
|
||||
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
|
||||
chip(verdict, (span) => {
|
||||
span.dataset.verdict = bucket;
|
||||
}),
|
||||
);
|
||||
@@ -2300,11 +2453,14 @@ 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";
|
||||
grab.addEventListener("click", () => {
|
||||
grab.disabled = true;
|
||||
@@ -2788,6 +2944,24 @@ function tvReleasesMain(): TvReleasesView {
|
||||
} else {
|
||||
delete statusEl.dataset.tone;
|
||||
}
|
||||
delete statusEl.dataset.action;
|
||||
}
|
||||
|
||||
/**
|
||||
* A status that carries its own way out. A season held off the pack lane
|
||||
* cannot be helped by the head's re-search alone (#181, #182), so the
|
||||
* sentence that explains the wait also offers the retry that ends it.
|
||||
*/
|
||||
function setStatusAction(text: string, label: string, run: () => void) {
|
||||
const action = document.createElement("button");
|
||||
action.type = "button";
|
||||
action.className = "control";
|
||||
action.textContent = label;
|
||||
action.addEventListener("click", run);
|
||||
statusEl.hidden = false;
|
||||
statusEl.replaceChildren(document.createTextNode(text), action);
|
||||
delete statusEl.dataset.tone;
|
||||
statusEl.dataset.action = "";
|
||||
}
|
||||
|
||||
const actions: ReleaseActions = {
|
||||
@@ -2798,7 +2972,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
if (!current) {
|
||||
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
|
||||
}
|
||||
if (bucket === "waived") {
|
||||
if (bucket !== "eligible") {
|
||||
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
|
||||
}
|
||||
return current.target
|
||||
@@ -2830,23 +3004,93 @@ function tvReleasesMain(): TvReleasesView {
|
||||
return;
|
||||
}
|
||||
if (!paintBuckets(dom, outcome.releases, actions)) {
|
||||
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
||||
if (sweepIfEmpty) {
|
||||
startSweep(current);
|
||||
} else {
|
||||
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
||||
}
|
||||
clearBuckets(dom);
|
||||
await emptyVerdict(current, ticket, sweepIfEmpty);
|
||||
return;
|
||||
}
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued sweep is done when its releases appear in the table. Nothing
|
||||
* found is indistinguishable from still running, so the wait says so
|
||||
* honestly instead of promising either way.
|
||||
* An empty season deck is three different truths (#182), and until the
|
||||
* season could say which, it blamed backoff for all three: a pack sweep
|
||||
* still running, a pack sweep that ran and found nothing, and a season on
|
||||
* the per-episode lane, where no pack sweep is coming at all. The lane
|
||||
* answers the third; `last_pack_search_at` separates the first two, the
|
||||
* same way a movie's `last_searched_at` does (#177).
|
||||
*/
|
||||
function watchSweep(current: TvDeckRequest, ticket: number) {
|
||||
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
|
||||
const outcome = await current.target.packState?.();
|
||||
if (ticket !== sequence || request !== current) {
|
||||
return;
|
||||
}
|
||||
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
|
||||
sweep.disabled = false;
|
||||
describePerEpisode(current, outcome.state);
|
||||
return;
|
||||
}
|
||||
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
|
||||
sweep.disabled = false;
|
||||
setStatus(
|
||||
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// issue 167: an empty deck sweeps on open instead of describing a sweep
|
||||
if (sweepIfEmpty) {
|
||||
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
|
||||
return;
|
||||
}
|
||||
setStatus("no releases indexed yet — re-search queues a targeted sweep");
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the lane instead of the backoff. A season grabbing episode by
|
||||
* episode has no pack to show and never will while the reason holds, so
|
||||
* the deck says which reason it is and where the releases actually are.
|
||||
*/
|
||||
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
|
||||
const elsewhere = "open an episode for its releases";
|
||||
if (state.reason === "no_episodes") {
|
||||
setStatus(
|
||||
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.reason === "still_airing") {
|
||||
setStatus(
|
||||
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.reason === "episodes_on_disk") {
|
||||
setStatus(
|
||||
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const failures =
|
||||
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
|
||||
const quiet =
|
||||
state.pack_retry_at === null
|
||||
? "pack search is quiet until its backoff elapses"
|
||||
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
|
||||
setStatusAction(
|
||||
`${failures} — ${quiet}. episodes are grabbed one at a time meanwhile.`,
|
||||
"retry the pack now",
|
||||
() => {
|
||||
void startSweep(current, state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sweep is done when its releases appear, or when the season stamps the
|
||||
* pack search it just finished. `baseline` is that stamp as it read before
|
||||
* the sweep was queued: once it moves, an empty deck is a settled answer
|
||||
* rather than a pending one, and `load` says so.
|
||||
*/
|
||||
function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) {
|
||||
sweep.disabled = true;
|
||||
setStatus("sweeping indexers…", undefined, true);
|
||||
const deadline = Date.now() + TV_SWEEP_WAIT_MS;
|
||||
@@ -2864,10 +3108,19 @@ function tvReleasesMain(): TvReleasesView {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
const state = await current.target.packState?.();
|
||||
if (ticket !== sequence || request !== current) {
|
||||
return;
|
||||
}
|
||||
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
|
||||
sweep.disabled = false;
|
||||
await emptyVerdict(current, ticket, false);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
sweep.disabled = false;
|
||||
setStatus(
|
||||
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
|
||||
"sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2880,23 +3133,40 @@ function tvReleasesMain(): TvReleasesView {
|
||||
}, TV_SWEEP_POLL_MS);
|
||||
}
|
||||
|
||||
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */
|
||||
function startSweep(current: TvDeckRequest) {
|
||||
/**
|
||||
* Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
|
||||
* lane as it read a moment ago, so the watch is only entered when a pack
|
||||
* sweep is actually expected: on the per-episode lane the sweep searches
|
||||
* episodes, and waiting for a pack that is not coming is the lie #182 is
|
||||
* about. A failed pack is the exception — a manual search waives its
|
||||
* window and does try a pack (#181).
|
||||
*/
|
||||
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
|
||||
sweep.disabled = true;
|
||||
void (async () => {
|
||||
const outcome = await current.target.search();
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
sweep.disabled = false;
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
sequence += 1;
|
||||
window.clearTimeout(pollTimer);
|
||||
watchSweep(current, sequence);
|
||||
})();
|
||||
const before =
|
||||
known === undefined
|
||||
? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
|
||||
: known;
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
const outcome = await current.target.search();
|
||||
if (request !== current) {
|
||||
return;
|
||||
}
|
||||
sequence += 1;
|
||||
window.clearTimeout(pollTimer);
|
||||
if (outcome.kind === "error") {
|
||||
sweep.disabled = false;
|
||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
|
||||
sweep.disabled = false;
|
||||
setStatus("searching the season's episodes — open an episode for its releases");
|
||||
return;
|
||||
}
|
||||
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
|
||||
}
|
||||
|
||||
sweep.addEventListener("click", () => {
|
||||
@@ -2904,7 +3174,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
startSweep(current);
|
||||
void startSweep(current);
|
||||
});
|
||||
|
||||
function open(next: TvDeckRequest) {
|
||||
@@ -2971,10 +3241,17 @@ interface SeriesView {
|
||||
|
||||
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Issue #177: the on-demand refresh a new series' add sends (#176) lands
|
||||
* within seconds. Poll modestly and give up bounded so a page left open
|
||||
* does not spin forever.
|
||||
*/
|
||||
const SERIES_REFRESH_POLL_MS = 2000;
|
||||
const SERIES_REFRESH_WAIT_MS = 30_000;
|
||||
|
||||
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
const view = must<HTMLElement>("#series");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#series-back");
|
||||
const hero = must<HTMLElement>("#series-hero");
|
||||
const poster = must<HTMLImageElement>("#series-poster");
|
||||
const titleEl = must<HTMLElement>("#series-title");
|
||||
@@ -3005,6 +3282,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
// guards a stale fetch from painting over a newer view
|
||||
let sequence = 0;
|
||||
let removed: ((parent: Route) => void) | null = null;
|
||||
let refreshPollTimer: number | undefined;
|
||||
// set once the bounded poll for a pending metadata refresh times out
|
||||
let refreshGaveUp = false;
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
statusEl.hidden = text === null;
|
||||
@@ -3076,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
}
|
||||
titleEl.textContent = current.title;
|
||||
yearEl.textContent = current.year === null ? "" : String(current.year);
|
||||
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
|
||||
remove.title = `remove ${current.title} from the library`;
|
||||
chipsEl.replaceChildren();
|
||||
const root = roots.find((candidate) => candidate.id === current.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
|
||||
@@ -3178,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
trailer.textContent = "trailer";
|
||||
actionsEl.append(trailer);
|
||||
}
|
||||
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
|
||||
const linkTitle = series?.title ?? "this series";
|
||||
actionsEl.append(
|
||||
externalLink(
|
||||
providerMark("tmdb"),
|
||||
`open ${linkTitle} on TMDB`,
|
||||
tmdbSeriesLink(detail.tmdb_id),
|
||||
),
|
||||
);
|
||||
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
|
||||
if (detail.tvdb_id !== null) {
|
||||
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
|
||||
actionsEl.append(
|
||||
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
|
||||
);
|
||||
}
|
||||
if (series !== null) {
|
||||
actionsEl.append(
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
|
||||
externalLink(
|
||||
providerMark("rt"),
|
||||
`search Rotten Tomatoes for ${series.title}`,
|
||||
rottenTomatoesSearch(series.title, series.year),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
||||
const counts = seasonCounts(season);
|
||||
return countsChip(
|
||||
`${counts.available}/${counts.wanted} on disk`,
|
||||
const span = countsChip(
|
||||
`${counts.available}/${counts.wanted}`,
|
||||
counts.available,
|
||||
counts.wanted,
|
||||
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
||||
);
|
||||
span.prepend(icon("disk"));
|
||||
return span;
|
||||
}
|
||||
|
||||
function seasonRow(season: ApiSeason): HTMLLIElement {
|
||||
@@ -3264,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
deckBtn.textContent = "deck";
|
||||
deckBtn.className = "control control-icon";
|
||||
const deckLabel =
|
||||
season.number === 0
|
||||
? "open the specials release deck"
|
||||
: `open the season ${PAD_TWO(season.number)} release deck`;
|
||||
deckBtn.setAttribute("aria-label", deckLabel);
|
||||
deckBtn.title = deckLabel;
|
||||
deckBtn.append(icon("search"));
|
||||
deckBtn.addEventListener("click", () => {
|
||||
const currentId = seriesId;
|
||||
const currentTitle = series?.title ?? "";
|
||||
@@ -3298,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
// #174: files go, episodes stop being wanted, the season stays listed.
|
||||
// Only offered with files on disk — intent alone is the tracked toggle.
|
||||
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
|
||||
const clear = armedDelete("remove files", () => {
|
||||
const currentId = seriesId;
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
const clear = armedDeleteIcon(
|
||||
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
||||
() => {
|
||||
const currentId = seriesId;
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
// the control itself disappears with the files; the tracked
|
||||
// toggle is the season's control that survives the repaint
|
||||
focusKey = `track-${season.number}`;
|
||||
void load();
|
||||
});
|
||||
});
|
||||
clear.setAttribute(
|
||||
"aria-label",
|
||||
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
||||
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// the control itself disappears with the files; the tracked
|
||||
// toggle is the season's control that survives the repaint
|
||||
focusKey = `track-${season.number}`;
|
||||
void load();
|
||||
});
|
||||
},
|
||||
);
|
||||
line.append(clear);
|
||||
}
|
||||
@@ -3385,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
if (aired && onDisk) {
|
||||
// #174: the file goes and the episode stops being wanted; the row
|
||||
// stays listed. Same arm-then-confirm as a settings row.
|
||||
const clear = armedDelete("remove file", () => {
|
||||
void removeEpisodeFiles(episode.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// once missing, the row's want control is what remains to focus
|
||||
focusKey = `want-${episode.id}`;
|
||||
void load();
|
||||
});
|
||||
});
|
||||
clear.setAttribute(
|
||||
"aria-label",
|
||||
const clear = armedDeleteIcon(
|
||||
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
||||
() => {
|
||||
void removeEpisodeFiles(episode.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// once missing, the row's want control is what remains to focus
|
||||
focusKey = `want-${episode.id}`;
|
||||
void load();
|
||||
});
|
||||
},
|
||||
);
|
||||
actions.append(clear);
|
||||
} else if (!aired) {
|
||||
@@ -3430,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
deckBtn.textContent = "deck";
|
||||
deckBtn.className = "control control-icon";
|
||||
const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`;
|
||||
deckBtn.setAttribute("aria-label", deckLabel);
|
||||
deckBtn.title = deckLabel;
|
||||
deckBtn.append(icon("search"));
|
||||
deckBtn.addEventListener("click", () => {
|
||||
const currentId = seriesId;
|
||||
const currentTitle = series?.title ?? "";
|
||||
@@ -3456,6 +3760,42 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* A never-refreshed series (`metadata_refreshed_at` null) reads as a
|
||||
* refresh under way, since #176 queues one on add and it lands within
|
||||
* seconds. A refreshed series with no seasons is a settled, if rare,
|
||||
* empty state upstream — the two must not share a message (#177).
|
||||
*/
|
||||
function seasonsEmptyRow(): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
item.className = "rel rel-none readout dim";
|
||||
if (series !== null && series.metadata_refreshed_at === null) {
|
||||
if (refreshGaveUp) {
|
||||
item.append(document.createTextNode("metadata refresh has not landed yet — "));
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.className = "control control-quiet";
|
||||
retry.textContent = "retry";
|
||||
retry.addEventListener("click", () => {
|
||||
retry.disabled = true;
|
||||
void retryRefresh();
|
||||
});
|
||||
item.append(retry);
|
||||
} else {
|
||||
const lamp = document.createElement("span");
|
||||
lamp.className = "lamp";
|
||||
lamp.dataset.state = "probing";
|
||||
item.append(
|
||||
lamp,
|
||||
document.createTextNode(" refreshing metadata — seasons will appear here"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
item.textContent = "no seasons listed upstream";
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderSeasons() {
|
||||
seasonsList.replaceChildren();
|
||||
if (!seasons) {
|
||||
@@ -3465,13 +3805,80 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
seasonsList.append(seasonRow(season));
|
||||
}
|
||||
if (seasons.length === 0) {
|
||||
const none = document.createElement("li");
|
||||
none.className = "rel rel-none readout dim";
|
||||
none.textContent = "no seasons revealed yet — a metadata refresh fills this in";
|
||||
seasonsList.append(none);
|
||||
seasonsList.append(seasonsEmptyRow());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll while a refresh is pending, painting seasons as soon as they land
|
||||
* with no manual reload. Stops on arrival, on the refresh being recorded
|
||||
* done with none, or once `SERIES_REFRESH_WAIT_MS` passes — a page left
|
||||
* open must not poll forever (#177).
|
||||
*/
|
||||
function syncRefreshWatch(ticket: number) {
|
||||
window.clearTimeout(refreshPollTimer);
|
||||
const id = seriesId;
|
||||
if (id === null || series === null || seasons === null) {
|
||||
return;
|
||||
}
|
||||
const pending = series.metadata_refreshed_at === null && seasons.length === 0;
|
||||
if (!pending || refreshGaveUp) {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + SERIES_REFRESH_WAIT_MS;
|
||||
const tick = async () => {
|
||||
if (ticket !== sequence || seriesId !== id) {
|
||||
return;
|
||||
}
|
||||
const [detail, seasonsOutcome] = await Promise.all([fetchSeries(id), fetchSeasons(id)]);
|
||||
if (ticket !== sequence || seriesId !== id) {
|
||||
return;
|
||||
}
|
||||
if (detail && seasonsOutcome.kind === "seasons") {
|
||||
series = detail;
|
||||
seasons = seasonsOutcome.seasons;
|
||||
paintHeader();
|
||||
if (series.metadata_refreshed_at !== null || seasons.length > 0) {
|
||||
renderSeasons();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
refreshGaveUp = true;
|
||||
renderSeasons();
|
||||
return;
|
||||
}
|
||||
refreshPollTimer = window.setTimeout(() => {
|
||||
void tick();
|
||||
}, SERIES_REFRESH_POLL_MS);
|
||||
};
|
||||
refreshPollTimer = window.setTimeout(() => {
|
||||
void tick();
|
||||
}, SERIES_REFRESH_POLL_MS);
|
||||
}
|
||||
|
||||
/** The empty state's retry control: resend the on-demand command by hand. */
|
||||
async function retryRefresh() {
|
||||
const id = seriesId;
|
||||
if (id === null) {
|
||||
return;
|
||||
}
|
||||
const ticket = sequence;
|
||||
refreshGaveUp = false;
|
||||
renderSeasons();
|
||||
const outcome = await refreshSeriesMetadata(id);
|
||||
if (ticket !== sequence || seriesId !== id) {
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
refreshGaveUp = true;
|
||||
setStatus(`refresh failed — ${outcome.detail}`, "fault");
|
||||
renderSeasons();
|
||||
return;
|
||||
}
|
||||
syncRefreshWatch(ticket);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = seriesId;
|
||||
if (id === null) {
|
||||
@@ -3479,6 +3886,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
}
|
||||
sequence += 1;
|
||||
const ticket = sequence;
|
||||
window.clearTimeout(refreshPollTimer);
|
||||
setStatus("reading series…");
|
||||
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
|
||||
fetchSeries(id),
|
||||
@@ -3508,6 +3916,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
paintHeader();
|
||||
clearRichDetail();
|
||||
renderSeasons();
|
||||
syncRefreshWatch(ticket);
|
||||
setStatus(null);
|
||||
void loadMetadata(id, ticket);
|
||||
if (focusKey !== null) {
|
||||
@@ -3529,11 +3938,13 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
parentRoute = parent;
|
||||
expanded = new Set();
|
||||
focusKey = null;
|
||||
refreshGaveUp = false;
|
||||
deckEl.hidden = true;
|
||||
view.hidden = false;
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
back.focus();
|
||||
titleEl.setAttribute("tabindex", "-1");
|
||||
titleEl.focus();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -3542,6 +3953,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
seriesId = null;
|
||||
seasons = null;
|
||||
sequence += 1;
|
||||
window.clearTimeout(refreshPollTimer);
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
}
|
||||
@@ -3553,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
origin?.focus();
|
||||
}
|
||||
|
||||
back.addEventListener("click", close);
|
||||
|
||||
// capture, like every other layer: the tv deck's listener is registered
|
||||
// first, so one Esc steps back one layer
|
||||
window.addEventListener(
|
||||
|
||||
@@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string
|
||||
return `${Math.round(minutes / (24 * 60))} d ago`;
|
||||
}
|
||||
|
||||
/** Which lane a season's missing episodes take (#182, §6.2). */
|
||||
export type SeasonLane = "season_pack" | "per_episode";
|
||||
|
||||
/** What holds a season off the pack lane (#182). */
|
||||
export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff";
|
||||
|
||||
/**
|
||||
* Why the season deck holds what it holds, from
|
||||
* `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states,
|
||||
* not one: a pack sweep still running, a pack sweep that found nothing, and
|
||||
* a season on the per-episode lane, where no pack sweep is coming.
|
||||
*/
|
||||
export interface SeasonPackState {
|
||||
lane: SeasonLane;
|
||||
reason: PackLaneReason | null;
|
||||
pack_failures: number;
|
||||
pack_retry_at: string | null;
|
||||
last_pack_search_at: string | null;
|
||||
}
|
||||
|
||||
export type PackStateOutcome =
|
||||
| { kind: "state"; state: SeasonPackState }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function seasonPackState(
|
||||
seriesId: number,
|
||||
seasonNumber: number,
|
||||
): Promise<PackStateOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "state", state: (await response.json()) as SeasonPackState };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `pack_retry_at` as a coarse wait. The deck says how long the lane stays
|
||||
* quiet, so "it retries on its own" is a promise with a date on it.
|
||||
*/
|
||||
export function formatRetryWait(retryAt: string, now = Date.now()): string {
|
||||
const reopens = Date.parse(retryAt);
|
||||
if (Number.isNaN(reopens)) {
|
||||
return "later";
|
||||
}
|
||||
const minutes = Math.round((reopens - now) / 60_000);
|
||||
if (minutes < 2) {
|
||||
return "any moment";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
}
|
||||
if (minutes < 48 * 60) {
|
||||
return `${Math.round(minutes / 60)} h`;
|
||||
}
|
||||
return `${Math.round(minutes / (24 * 60))} d`;
|
||||
}
|
||||
|
||||
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
|
||||
export interface MovieFile {
|
||||
id: number;
|
||||
@@ -196,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise<A
|
||||
* The per-title override one click on a waived row writes (§5.2, §9.3).
|
||||
* Mapping a rule name to its override is bookkeeping, not policy — the
|
||||
* verdict itself always comes from the API.
|
||||
*
|
||||
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
|
||||
* the release stays out of automatic grabbing and imports on the record as
|
||||
* a §5.7 waiver. No band is right for every title, which is why the
|
||||
* override exists at all.
|
||||
*/
|
||||
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
|
||||
switch (rule) {
|
||||
@@ -203,11 +269,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
|
||||
return { allow_english_audio: true };
|
||||
case "resolution":
|
||||
return { only_4k: false };
|
||||
case "size":
|
||||
return { allow_below_floor: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one click on this row has an override to write (§9.3). */
|
||||
export function overridable(release: { rejected_rule: string | null }): boolean {
|
||||
return waiverOverride(release.rejected_rule) !== null;
|
||||
}
|
||||
|
||||
export type WaiveOutcome =
|
||||
| { kind: "done"; overrideWritten: boolean }
|
||||
| { kind: "error"; detail: string; overrideWritten: boolean };
|
||||
|
||||
@@ -66,10 +66,18 @@ export interface TmdbSeries {
|
||||
|
||||
export type TmdbResult = TmdbMovie | TmdbSeries;
|
||||
|
||||
/**
|
||||
* Why the tmdb group is what it is. "ok" with an empty tmdb array means
|
||||
* TMDB answered and found nothing; "unavailable" means TMDB could not be
|
||||
* reached — the library results still came back (issue #168).
|
||||
*/
|
||||
export type TmdbStatus = "ok" | "unavailable";
|
||||
|
||||
export interface SearchResponse {
|
||||
kind: SearchInputKind;
|
||||
library: LibraryResult[];
|
||||
tmdb: TmdbResult[];
|
||||
tmdb_status: TmdbStatus;
|
||||
manual: string | null;
|
||||
}
|
||||
|
||||
|
||||
+29
-2
@@ -3,8 +3,14 @@
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import type { MetadataTrailer } from "./movie";
|
||||
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
|
||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||
import type {
|
||||
ActionOutcome,
|
||||
MovieRelease,
|
||||
PackStateOutcome,
|
||||
ReleasesOutcome,
|
||||
WaiveOutcome,
|
||||
} from "./releases";
|
||||
import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases";
|
||||
|
||||
/** §4.2 derived status — displayed, never editable. */
|
||||
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
||||
@@ -16,6 +22,8 @@ export interface ApiSeries {
|
||||
year: number | null;
|
||||
root_id: number;
|
||||
blocked: boolean;
|
||||
/** Null until a metadata refresh has stamped it (#160, #177). */
|
||||
metadata_refreshed_at: string | null;
|
||||
status: SeriesStatus;
|
||||
wanted_episodes: number;
|
||||
available_episodes: number;
|
||||
@@ -79,6 +87,19 @@ export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue #177: resend the on-demand refresh a page gave up polling for. */
|
||||
export async function refreshSeriesMetadata(seriesId: number): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/refresh-metadata`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
|
||||
export interface EpisodeFile {
|
||||
episode_id: number;
|
||||
@@ -218,6 +239,11 @@ export interface TvTarget {
|
||||
releases: () => Promise<ReleasesOutcome>;
|
||||
grab: (releaseId: number) => Promise<ActionOutcome>;
|
||||
search: () => Promise<ActionOutcome>;
|
||||
/**
|
||||
* Why the deck is empty (#182). Seasons only: an episode deck has one
|
||||
* lane, so it has nothing to disambiguate.
|
||||
*/
|
||||
packState?: () => Promise<PackStateOutcome>;
|
||||
}
|
||||
|
||||
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
|
||||
@@ -226,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
|
||||
releases: () => fetchJson(`${base}/releases`),
|
||||
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
|
||||
search: () => post(`${base}/search`),
|
||||
packState: () => seasonPackState(seriesId, seasonNumber),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -470,6 +470,62 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- icon-only controls (#230) ---------------------------------------- */
|
||||
|
||||
/* One block, shared: title pages, decks and readouts here, the settings
|
||||
rows via #231. The glyph carries the action, the aria-label carries the
|
||||
meaning, and the geometry stays on the --control-h rail with the chips. */
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
height: 0.875rem;
|
||||
width: auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.control-icon {
|
||||
min-width: var(--control-h);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
|
||||
monochrome on currentColor — never a remote image */
|
||||
.icon-mark {
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.icon-mark rect {
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* the mark's letters live in viewBox units, so the size rides the SVG
|
||||
scale as an attribute; only voice and weight come from the tokens */
|
||||
.icon-mark text {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
font-family: var(--font-readout);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* a readout chip's glyph sits beside its figure, sized to the readout */
|
||||
.chip .icon {
|
||||
height: 0.75rem;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
/* rows widen every .control's padding; an icon control stays near-square */
|
||||
.season-line .control-icon,
|
||||
.ep-actions .control-icon,
|
||||
.movie-controls .control-icon {
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- unified search (§9.2) ------------------------------------------- */
|
||||
|
||||
.rail-search {
|
||||
@@ -528,6 +584,12 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* movie and series: the hero banner meets the rail — no top padding (#229) */
|
||||
.deck#movie,
|
||||
.deck#series {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.deck-status {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--ink-muted);
|
||||
@@ -537,6 +599,17 @@ body {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
/* a status that carries its own way out keeps the control on the sentence's
|
||||
own left edge, so the explanation reads first and the action follows it
|
||||
(#182). Long enough copy wraps the control to its own line, which is the
|
||||
reading order anyway. */
|
||||
.deck-status[data-action] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* a sweep in flight borrows the rail's probing-lamp idiom, inline */
|
||||
.deck-status .lamp {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user