Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71cbb9269b | |||
| 160c00c77f | |||
| 779d4b8504 | |||
| 64e711feec | |||
| a378b22306 | |||
| 04b2688330 | |||
| 91414a043f | |||
| a28b455fff | |||
| 9df120b92a | |||
| 03e7774869 | |||
| fea1a04c75 | |||
| 3956617d41 | |||
| 127338fd4d | |||
| 695f1882be | |||
| b0f56f59aa | |||
| 1eac85c427 | |||
| 50b68e24b6 | |||
| 69263da4f3 | |||
| b51386e004 | |||
| 17949c0848 | |||
| 4f848c27f0 |
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
|
||||
}
|
||||
+2
-2
@@ -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 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 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": [
|
||||
{
|
||||
@@ -34,5 +34,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207"
|
||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7"
|
||||
}
|
||||
+20
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,43 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -68,6 +73,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
@@ -76,5 +82,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256"
|
||||
"hash": "3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5"
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325"
|
||||
}
|
||||
+20
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,43 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -68,6 +73,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
@@ -76,5 +82,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a"
|
||||
"hash": "3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n 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 \"on_disk!: bool\"\n FROM episodes e\n WHERE e.season_id = ?\n ORDER BY e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "on_disk!: bool",
|
||||
"ordinal": 1,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1"
|
||||
}
|
||||
+20
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,43 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -68,6 +73,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
@@ -76,5 +82,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d"
|
||||
"hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467"
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT 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": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"episode!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\"\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.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY e.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_tmdb_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE (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') >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -28,5 +28,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42"
|
||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5"
|
||||
}
|
||||
+20
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,43 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -68,6 +73,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
@@ -76,5 +82,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8"
|
||||
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d"
|
||||
}
|
||||
@@ -478,7 +478,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
/// root, or the file itself when it sits in the root with no folder of its
|
||||
/// own. `None` when the file is not under the root at all, which is the
|
||||
/// guard that keeps a delete inside the library it belongs to.
|
||||
fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
pub(crate) fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(root);
|
||||
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
||||
let first = relative.components().next()?;
|
||||
|
||||
@@ -224,14 +224,23 @@ pub async fn search(
|
||||
let results = search_tmdb(&tmdb, kind, input).await?;
|
||||
if matches!(kind, SearchInputKind::ImdbId) {
|
||||
for result in &results {
|
||||
let TmdbResult::Movie(movie) = result else {
|
||||
continue;
|
||||
};
|
||||
let tmdb_id = i64::from(movie.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (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 tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Movie(found));
|
||||
match result {
|
||||
TmdbResult::Movie(movie) => {
|
||||
let tmdb_id = i64::from(movie.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (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 tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Movie(found));
|
||||
}
|
||||
}
|
||||
TmdbResult::Series(series) => {
|
||||
let tmdb_id = i64::from(series.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Series(found));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,14 +325,24 @@ async fn search_tmdb(
|
||||
}
|
||||
matches
|
||||
}
|
||||
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
|
||||
Ok(movies) => movies
|
||||
SearchInputKind::ImdbId => {
|
||||
let found = tmdb
|
||||
.find_by_imdb(input)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
let mut results: Vec<TmdbResult> = found
|
||||
.movies
|
||||
.into_iter()
|
||||
.map(|movie| TmdbResult::Movie(movie.into()))
|
||||
.collect(),
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(upstream_error(&error)),
|
||||
},
|
||||
.collect();
|
||||
results.extend(
|
||||
found
|
||||
.series
|
||||
.into_iter()
|
||||
.map(|series| TmdbResult::Series(series.into())),
|
||||
);
|
||||
results
|
||||
}
|
||||
SearchInputKind::Text => {
|
||||
let mut results: Vec<TmdbResult> = tmdb
|
||||
.search_movies(input, None)
|
||||
@@ -950,6 +969,52 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["title"], "1917");
|
||||
}
|
||||
|
||||
/// §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]
|
||||
async fn an_imdb_id_resolves_to_a_series() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/find/tt7614372"))
|
||||
.and(query_param("external_source", "imdb_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"movie_results": [],
|
||||
"tv_results": [{
|
||||
"id": 82_728, "name": "Bluey", "original_name": "Bluey",
|
||||
"original_language": "en", "first_air_date": "2018-10-01"
|
||||
}]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("TV root");
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["kind"], "imdb_id");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_releases_are_classified_and_name_rejection_rules() {
|
||||
let tmdb = MockServer::start().await;
|
||||
|
||||
+245
-78
@@ -196,6 +196,7 @@ struct EpisodeRow {
|
||||
series_id: i64,
|
||||
id: i64,
|
||||
season_id: i64,
|
||||
season_number: i64,
|
||||
number: i64,
|
||||
title: String,
|
||||
air_date: Option<String>,
|
||||
@@ -257,6 +258,7 @@ fn core_episode(row: &EpisodeRow) -> arr_core::Episode {
|
||||
arr_core::Episode {
|
||||
id: EpisodeId(row.id),
|
||||
season_id: SeasonId(row.season_id),
|
||||
season_number: u16::try_from(row.season_number).unwrap_or_default(),
|
||||
number: u16::try_from(row.number).unwrap_or_default(),
|
||||
title: row.title.clone(),
|
||||
air_date: air_date(row.air_date.as_deref()),
|
||||
@@ -277,31 +279,14 @@ fn status_name(status: SeriesStatus) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn core_season(id: i64, series_id: i64, number: i64) -> arr_core::Season {
|
||||
arr_core::Season {
|
||||
id: SeasonId(id),
|
||||
series_id: SeriesId(series_id),
|
||||
number: u16::try_from(number).unwrap_or_default(),
|
||||
tracked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_status(
|
||||
row: &SeriesRow,
|
||||
seasons: &[arr_core::Season],
|
||||
episodes: &[arr_core::Episode],
|
||||
now: SystemTime,
|
||||
) -> Series {
|
||||
fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series {
|
||||
// §4.2: season 0 is invisible to status, so it stays out of the counters
|
||||
// too. A series reading `complete` next to `42/52 eps` is the confusion
|
||||
// this avoids. `UNIQUE (series_id, number)` means there is at most one.
|
||||
let specials = seasons
|
||||
.iter()
|
||||
.find(|season| season.number == 0)
|
||||
.map(|season| season.id);
|
||||
// this avoids. The season number rides on each episode (#131), so no
|
||||
// parallel slice can be forgotten.
|
||||
let wanted = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
|
||||
.filter(|episode| episode.wanted && episode.season_number != 0);
|
||||
let available = wanted
|
||||
.clone()
|
||||
.filter(|episode| episode.state == MediaState::Available);
|
||||
@@ -317,53 +302,26 @@ fn with_status(
|
||||
overrides: row.overrides.clone(),
|
||||
upstream_ended: row.upstream_ended,
|
||||
blocked: row.blocked,
|
||||
status: status_name(derive_series_status(
|
||||
&core_series(row),
|
||||
seasons,
|
||||
episodes,
|
||||
now,
|
||||
))
|
||||
.to_owned(),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every season and episode in the library, keyed by the series they belong to.
|
||||
/// Every episode in the library, keyed by the series it belongs to.
|
||||
///
|
||||
/// One query each rather than one per series: the whole table is a few thousand
|
||||
/// One query rather than one per series: the whole table is a few thousand
|
||||
/// rows for a single household (§10), and the status of every listed series
|
||||
/// needs all of them anyway.
|
||||
async fn tv_by_series(
|
||||
state: &AppState,
|
||||
) -> Result<
|
||||
(
|
||||
HashMap<i64, Vec<arr_core::Season>>,
|
||||
HashMap<i64, Vec<arr_core::Episode>>,
|
||||
),
|
||||
ApiError,
|
||||
> {
|
||||
let season_rows = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons"#
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Episode>>, ApiError> {
|
||||
let rows = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id"#
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
let mut seasons: HashMap<i64, Vec<arr_core::Season>> = HashMap::new();
|
||||
for row in &season_rows {
|
||||
seasons.entry(row.series_id).or_default().push(core_season(
|
||||
row.id,
|
||||
row.series_id,
|
||||
row.number,
|
||||
));
|
||||
}
|
||||
let mut episodes: HashMap<i64, Vec<arr_core::Episode>> = HashMap::new();
|
||||
for row in &rows {
|
||||
episodes
|
||||
@@ -371,7 +329,7 @@ async fn tv_by_series(
|
||||
.or_default()
|
||||
.push(core_episode(row));
|
||||
}
|
||||
Ok((seasons, episodes))
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
|
||||
@@ -383,26 +341,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiErro
|
||||
|
||||
async fn load_series(state: &AppState, id: i64) -> Result<Series, ApiError> {
|
||||
let row = load_series_row(state, id).await?;
|
||||
let season_rows = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons WHERE series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
let seasons: Vec<_> = season_rows
|
||||
.iter()
|
||||
.map(|row| core_season(row.id, row.series_id, row.number))
|
||||
.collect();
|
||||
let episodes = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
let episodes: Vec<_> = episodes.iter().map(core_episode).collect();
|
||||
Ok(with_status(&row, &seasons, &episodes, SystemTime::now()))
|
||||
Ok(with_status(&row, &episodes, SystemTime::now()))
|
||||
}
|
||||
|
||||
async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
|
||||
@@ -448,20 +396,12 @@ pub async fn list(
|
||||
.await?
|
||||
};
|
||||
|
||||
let (seasons, episodes) = tv_by_series(&state).await?;
|
||||
let episodes = tv_by_series(&state).await?;
|
||||
let now = SystemTime::now();
|
||||
let no_seasons: Vec<arr_core::Season> = Vec::new();
|
||||
let no_episodes: Vec<arr_core::Episode> = Vec::new();
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
with_status(
|
||||
row,
|
||||
seasons.get(&row.id).unwrap_or(&no_seasons),
|
||||
episodes.get(&row.id).unwrap_or(&no_episodes),
|
||||
now,
|
||||
)
|
||||
})
|
||||
.map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&no_episodes), now))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -589,6 +529,28 @@ pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// The row is loaded first so a missing series is 404 before anything
|
||||
// touches the disk.
|
||||
load_series_row(&state, id).await?;
|
||||
remove_library_files(&state, id).await?;
|
||||
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
|
||||
// cascades from the seasons and episodes rows (which the series row's
|
||||
// delete does): leaving the rows behind would block re-importing the
|
||||
// same paths after a re-add. Owner tags go with the title they tagged.
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
|
||||
id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
let result = sqlx::query!("DELETE FROM series WHERE id = ?", id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
@@ -598,6 +560,73 @@ pub async fn delete(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Unlink everything this series put under its root. Mirrors the movie
|
||||
/// handler in `movies.rs`.
|
||||
///
|
||||
/// The service knows only what it wrote (§2), so the targets come from
|
||||
/// `media_files`, never from a scan and never from re-deriving the §7.4 name
|
||||
/// — a series renamed after import would derive a folder that does not exist
|
||||
/// while the real one stayed. Each episode file resolves to its title folder,
|
||||
/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles
|
||||
/// and artwork go with it.
|
||||
///
|
||||
/// The torrent is untouched (§7.3). It keeps seeding under its own rule and
|
||||
/// the reaper deletes it; a hardlinked file loses only its library name.
|
||||
///
|
||||
/// Failure leaves the database alone, so the operator sees the series still
|
||||
/// there and can retry rather than losing the record of what is on disk.
|
||||
async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> {
|
||||
let root = sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?;
|
||||
let paths = sqlx::query_scalar!(
|
||||
r#"SELECT 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 = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
let mut targets: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in &paths {
|
||||
let Some(target) = crate::movies::title_target(&root, path) else {
|
||||
// Outside its own root: not ours to delete. The row still goes,
|
||||
// so the operator sees the series leave and the file stay.
|
||||
tracing::warn!(%path, %root, "media file is outside its root, not deleted");
|
||||
continue;
|
||||
};
|
||||
if !targets.contains(&target) {
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
for target in targets {
|
||||
let metadata = match tokio::fs::symlink_metadata(&target).await {
|
||||
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())),
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target).await
|
||||
} else {
|
||||
tokio::fs::remove_file(&target).await
|
||||
};
|
||||
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())),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
|
||||
let seasons = sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
|
||||
@@ -607,7 +636,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
||||
.await?;
|
||||
let episodes = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
|
||||
series_id
|
||||
)
|
||||
@@ -714,6 +743,7 @@ pub async fn create_season(
|
||||
.map(|episode| arr_core::Episode {
|
||||
id: EpisodeId(0),
|
||||
season_id: SeasonId(0),
|
||||
season_number: u16::try_from(input.number).unwrap_or_default(),
|
||||
number: u16::try_from(episode.number).unwrap_or_default(),
|
||||
title: episode.title.clone(),
|
||||
air_date: air_date(episode.air_date.as_deref()),
|
||||
@@ -837,7 +867,7 @@ pub async fn update_season(
|
||||
async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
|
||||
let row = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#,
|
||||
id
|
||||
)
|
||||
@@ -1838,6 +1868,143 @@ mod tests {
|
||||
assert_eq!(episodes, 0);
|
||||
}
|
||||
|
||||
/// A series on disk for one test: the §7.4 title folder with a season
|
||||
/// subfolder holding one episode file and one sidecar subtitle.
|
||||
async fn library_on_disk(
|
||||
state: &AppState,
|
||||
episode_id: i64,
|
||||
root: &std::path::Path,
|
||||
) -> std::path::PathBuf {
|
||||
let folder = root.join("Bluey (2018) [tmdbid-82728]");
|
||||
let season = folder.join("Season 01");
|
||||
tokio::fs::create_dir_all(&season)
|
||||
.await
|
||||
.expect("create title folder");
|
||||
let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv");
|
||||
tokio::fs::write(&feature, b"episode").await.expect("write");
|
||||
tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs")
|
||||
.await
|
||||
.expect("write sidecar");
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_path = root.to_str().expect("utf-8 root");
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'")
|
||||
.bind(root_path)
|
||||
.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(feature.to_str().expect("utf-8 path"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
folder
|
||||
}
|
||||
|
||||
/// The §7.4 title folder is the unit of deletion, so the season
|
||||
/// subfolder and sidecars go with it — and the root is never touched.
|
||||
#[tokio::test]
|
||||
async fn deleting_a_series_removes_the_whole_title_folder() {
|
||||
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("id");
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let folder = library_on_disk(&state, episode_id, root.path()).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
!folder.exists(),
|
||||
"the title folder, its seasons and its sidecars are gone"
|
||||
);
|
||||
assert!(root.path().exists(), "the root survives its titles");
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
let orphans: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("count files");
|
||||
assert_eq!(orphans, 0, "the file rows go with the files");
|
||||
}
|
||||
|
||||
/// A missing series is 404 before anything touches the disk.
|
||||
#[tokio::test]
|
||||
async fn deleting_a_missing_series_is_a_404() {
|
||||
let (_dir, _state, base) = application().await;
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/999"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// The guard that keeps a delete inside the library: a path that is not
|
||||
/// under the series' root is left alone, whatever the row says.
|
||||
#[tokio::test]
|
||||
async fn a_series_file_outside_its_root_is_never_unlinked() {
|
||||
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("id");
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "Pilot"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
|
||||
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(root.path().to_str().expect("utf-8 root"))
|
||||
.bind(root_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("point the root at the tempdir");
|
||||
|
||||
let elsewhere = tempfile::tempdir().expect("elsewhere");
|
||||
let stray = elsewhere.path().join("not-ours.mkv");
|
||||
tokio::fs::write(&stray, b"stray").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(stray.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
stray.exists(),
|
||||
"a path outside the root is not ours to delete"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -288,6 +288,9 @@ pub struct Season {
|
||||
pub struct Episode {
|
||||
pub id: EpisodeId,
|
||||
pub season_id: SeasonId,
|
||||
/// The owning season's number, carried so status derivation never needs
|
||||
/// a second slice a caller can forget (#131).
|
||||
pub season_number: u16,
|
||||
pub number: u16,
|
||||
pub title: String,
|
||||
pub air_date: Option<SystemTime>,
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//! nearly free, because the feed is read again ten minutes later and the
|
||||
//! targeted search still runs. So every rule here is deliberately strict:
|
||||
//!
|
||||
//! - an ID the tracker itself supplied beats anything read off the name, and
|
||||
//! an ID that matches nothing wanted ends the comparison rather than
|
||||
//! falling back to the title;
|
||||
//! - an ID the tracker itself supplied beats anything read off the name,
|
||||
//! except a season or episode tag in the name, which makes the release a
|
||||
//! TV one whatever id came with it; and an ID that matches nothing wanted
|
||||
//! ends the comparison rather than falling back to the title;
|
||||
//! - two supplied IDs that disagree about which title this is are an
|
||||
//! ambiguity like any other, and match nothing;
|
||||
//! - a title match needs the years to agree, and a wanted title with a known
|
||||
@@ -75,6 +76,13 @@ pub fn match_movie(
|
||||
ids: &ReleaseIds,
|
||||
claims: &NameClaims,
|
||||
) -> Option<MovieMatch> {
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
// or the tracker-supplied id says. Movies are the only thing RSS grabs
|
||||
// today (§13, phase 4), and trackers do mislabel ids.
|
||||
if claims.episode.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
|
||||
if ids.tmdb_id.is_some() || imdb_id.is_some() {
|
||||
// A title matched by both ids is still one title, so each wanted
|
||||
@@ -95,11 +103,6 @@ pub fn match_movie(
|
||||
}));
|
||||
}
|
||||
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
// says. Movies are the only thing RSS grabs today (§13, phase 4).
|
||||
if claims.episode.is_some() {
|
||||
return None;
|
||||
}
|
||||
let title = match_key(claims.title.as_deref()?);
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
@@ -428,6 +431,35 @@ mod tests {
|
||||
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
||||
}
|
||||
|
||||
/// The same rule on the id lane (#139): trackers mislabel ids, so a
|
||||
/// season or episode tag in the name vetoes an id that matches too.
|
||||
#[test]
|
||||
fn an_episode_tag_beats_a_matching_supplied_id() {
|
||||
let wanted = vec![WantedMovie {
|
||||
id: MovieId(1),
|
||||
tmdb_id: Some(693_134),
|
||||
imdb_id: Some("tt15239678".to_owned()),
|
||||
title: "Dune: Part Two".to_owned(),
|
||||
year: Some(2024),
|
||||
}];
|
||||
let cases = [
|
||||
("Fallout.S01E03.1080p.WEB-DL", &ids(Some(693_134), None)),
|
||||
("Fallout.S01.1080p.BluRay-GROUP", &ids(Some(693_134), None)),
|
||||
(
|
||||
"Fallout.2024.S01E03.1080p.WEB-DL",
|
||||
&ids(None, Some("tt15239678")),
|
||||
),
|
||||
("Fallout.S01E03.1080p.WEB-DL", &ids(None, Some("15239678"))),
|
||||
];
|
||||
for (name, release_ids) in cases {
|
||||
assert_eq!(
|
||||
match_movie(&wanted, release_ids, &arr_parse::parse(name)),
|
||||
None,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_wanted_titles_that_both_match_are_an_ambiguity() {
|
||||
let wanted = vec![
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::{Episode, MediaState, Season, Series};
|
||||
use crate::{Episode, MediaState, Series};
|
||||
|
||||
const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24);
|
||||
|
||||
@@ -16,26 +16,19 @@ pub enum SeriesStatus {
|
||||
|
||||
/// Derives a series' status at `now` without persisting lifecycle intent.
|
||||
///
|
||||
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a
|
||||
/// season 0 listed here are ignored, so a manually wanted special cannot pin
|
||||
/// the series at `incomplete` or hold back `ended`. The seasons must be the
|
||||
/// series' own; an episode whose season is not listed counts like any other.
|
||||
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with
|
||||
/// `season_number` 0 are ignored, so a manually wanted special cannot pin
|
||||
/// the series at `incomplete` or hold back `ended`. The season number rides
|
||||
/// on each episode, so there is no second slice a caller can forget (#131).
|
||||
#[must_use]
|
||||
pub fn derive_series_status(
|
||||
series: &Series,
|
||||
seasons: &[Season],
|
||||
episodes: &[Episode],
|
||||
now: SystemTime,
|
||||
) -> SeriesStatus {
|
||||
// A series has at most one season 0, so this is a comparison rather than
|
||||
// a set: no allocation on a function the list view calls per series.
|
||||
let specials = seasons
|
||||
.iter()
|
||||
.find(|season| season.number == 0)
|
||||
.map(|season| season.id);
|
||||
let wanted = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
|
||||
.filter(|episode| episode.wanted && episode.season_number != 0);
|
||||
let complete = wanted
|
||||
.clone()
|
||||
.all(|episode| episode.state == MediaState::Available);
|
||||
@@ -107,6 +100,7 @@ mod tests {
|
||||
Episode {
|
||||
id: EpisodeId(i64::from(number)),
|
||||
season_id: SeasonId(1),
|
||||
season_number: 1,
|
||||
number,
|
||||
title: format!("Episode {number}"),
|
||||
air_date,
|
||||
@@ -117,23 +111,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The season every default-test episode sits in.
|
||||
fn seasons() -> [Season; 1] {
|
||||
[Season {
|
||||
id: SeasonId(1),
|
||||
series_id: SeriesId(1),
|
||||
number: 1,
|
||||
tracked: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn airing_when_a_wanted_episode_is_in_the_window() {
|
||||
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
||||
for air_date in [now - 14 * DAY, now + 14 * DAY] {
|
||||
let episodes = [episode(1, Some(air_date), MediaState::Missing)];
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Airing
|
||||
);
|
||||
}
|
||||
@@ -145,7 +129,7 @@ mod tests {
|
||||
MediaState::Missing,
|
||||
)];
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting
|
||||
);
|
||||
}
|
||||
@@ -156,7 +140,7 @@ mod tests {
|
||||
let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Incomplete
|
||||
);
|
||||
}
|
||||
@@ -167,7 +151,7 @@ mod tests {
|
||||
let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)];
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting
|
||||
);
|
||||
}
|
||||
@@ -178,7 +162,7 @@ mod tests {
|
||||
let episodes = [episode(1, Some(now), MediaState::Available)];
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(false, false), &episodes, now),
|
||||
SeriesStatus::Complete
|
||||
);
|
||||
}
|
||||
@@ -189,7 +173,7 @@ mod tests {
|
||||
let episodes = [episode(1, Some(now), MediaState::Available)];
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, true), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, true), &episodes, now),
|
||||
SeriesStatus::Ended
|
||||
);
|
||||
}
|
||||
@@ -199,14 +183,14 @@ mod tests {
|
||||
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
||||
let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(false, false), &episodes, now),
|
||||
SeriesStatus::Incomplete
|
||||
);
|
||||
|
||||
episodes[0].state = MediaState::Available;
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(false, false), &episodes, now),
|
||||
SeriesStatus::Complete
|
||||
);
|
||||
}
|
||||
@@ -216,14 +200,14 @@ mod tests {
|
||||
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
||||
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting
|
||||
);
|
||||
|
||||
episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing));
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &seasons(), &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Airing
|
||||
);
|
||||
}
|
||||
@@ -231,34 +215,30 @@ mod tests {
|
||||
#[test]
|
||||
fn missing_specials_do_not_hold_back_complete_or_ended() {
|
||||
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
||||
let specials = [Season {
|
||||
id: SeasonId(9),
|
||||
series_id: SeriesId(1),
|
||||
number: 0,
|
||||
tracked: false,
|
||||
}];
|
||||
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
|
||||
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
|
||||
special.season_id = SeasonId(9);
|
||||
special.season_number = 0;
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &specials, &episodes, now),
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting
|
||||
);
|
||||
|
||||
episodes.push(special);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &[], &episodes, now),
|
||||
SeriesStatus::Incomplete,
|
||||
"without knowing the seasons, the special pins the series"
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting,
|
||||
"#131: the season number rides on the episode, so the special is \
|
||||
invisible however the caller builds the slice"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, false), &specials, &episodes, now),
|
||||
derive_series_status(&series(false, false), &episodes, now),
|
||||
SeriesStatus::Complete,
|
||||
"§4.2: the only gap is a special"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, true), &specials, &episodes, now),
|
||||
derive_series_status(&series(false, true), &episodes, now),
|
||||
SeriesStatus::Ended
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ mod tests {
|
||||
Episode {
|
||||
id: EpisodeId(season * 100 + i64::from(number)),
|
||||
season_id: SeasonId(season),
|
||||
season_number: u16::try_from(season).unwrap_or_default(),
|
||||
number,
|
||||
title: format!("Episode {number}"),
|
||||
air_date: Some(SystemTime::UNIX_EPOCH),
|
||||
|
||||
@@ -130,7 +130,8 @@ impl AttentionAction {
|
||||
r#"
|
||||
SELECT id AS "id!: i64", title AS "title!: String", year
|
||||
FROM movies
|
||||
WHERE (SELECT count(DISTINCT g.release_id)
|
||||
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') >= 2
|
||||
@@ -249,6 +250,7 @@ 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 e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
@@ -561,4 +563,72 @@ mod tests {
|
||||
"both hard-fail conditions roll up to one series notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
let movie_id = insert_no_pt_source_movie(&database).await;
|
||||
insert_failed_grab(&database, "movie", movie_id, "first").await;
|
||||
insert_failed_grab(&database, "movie", movie_id, "second").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let first = action.tick(&database).await.unwrap();
|
||||
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
||||
|
||||
// Imported from a third release: satisfied, no decision needed.
|
||||
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
|
||||
.bind(movie_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let second = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_episode_imported_after_two_hard_fails_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();
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 1, 'Episode 0', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let first = action.tick(&database).await.unwrap();
|
||||
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
||||
|
||||
// Imported from a third release: satisfied, no decision needed.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let second = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+693
-56
@@ -9,19 +9,31 @@
|
||||
//!
|
||||
//! `blocked` is honoured the other way round from targeted search (§6.3): it
|
||||
//! stops a title being searched for, and leaves it matching RSS.
|
||||
//!
|
||||
//! One pass serves films and episodes alike: each feed item goes through the
|
||||
//! movie matcher first, then the episode one (`arr_core::matching`). A
|
||||
//! single-episode release grabs its wanted, missing episode directly; a
|
||||
//! season pack only grabs when [`season_grab_mode`] allows packs for that
|
||||
//! season — the same guard §14 gives targeted search, so a season behaves
|
||||
//! the same however a release is found (§6.2). Like everything else here,
|
||||
//! none of it backs off or counts attempts.
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use arr_core::matching::{match_movie, MatchKind, ReleaseIds, WantedMovie};
|
||||
use arr_core::{Language, MovieId};
|
||||
use arr_db::{Blacklist, Db, MoviePolicy};
|
||||
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
use arr_core::matching::{
|
||||
match_episode, match_movie, MatchKind, MatchShape, ReleaseIds, WantedEpisode, WantedMovie,
|
||||
};
|
||||
use arr_core::{EpisodeId, Language, MovieId};
|
||||
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{
|
||||
store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, SeedingRules,
|
||||
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
|
||||
SeedingRules,
|
||||
};
|
||||
use crate::indexers::IndexerDirectory;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
@@ -51,7 +63,8 @@ impl RssAction {
|
||||
|
||||
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
|
||||
let wanted = wanted_movies(database).await?;
|
||||
if wanted.is_empty() {
|
||||
let episodes = wanted_episodes(database).await?;
|
||||
if wanted.is_empty() && episodes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let indexers = self.indexers.searchable().await?;
|
||||
@@ -62,12 +75,12 @@ impl RssAction {
|
||||
|
||||
let releases = self.feeds(&indexers).await;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let winners = self
|
||||
.match_feeds(database, &wanted, releases, &blacklist)
|
||||
let (movies, tv) = self
|
||||
.match_feeds(database, &wanted, &episodes, releases, &blacklist)
|
||||
.await?;
|
||||
|
||||
let mut outcomes = Vec::new();
|
||||
for (movie, winner) in winners {
|
||||
for (movie, winner) in movies {
|
||||
let outcome = self
|
||||
.grabber
|
||||
.send_winner(
|
||||
@@ -86,6 +99,29 @@ impl RssAction {
|
||||
.await?;
|
||||
outcomes.extend(outcome);
|
||||
}
|
||||
for TvWinner {
|
||||
grabbable,
|
||||
scope,
|
||||
candidate: winner,
|
||||
} in tv
|
||||
{
|
||||
let outcome = self
|
||||
.grabber
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
scope,
|
||||
title: &grabbable.series_title,
|
||||
// Same property as the movie lane above.
|
||||
counts_as_attempt: false,
|
||||
},
|
||||
&grabbable.policy,
|
||||
&blacklist,
|
||||
winner,
|
||||
)
|
||||
.await?;
|
||||
outcomes.extend(outcome);
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
@@ -116,11 +152,13 @@ impl RssAction {
|
||||
&self,
|
||||
database: &Db,
|
||||
wanted: &[WantedMovie],
|
||||
episodes: &[WantedEpisodeRow],
|
||||
releases: Vec<SearchRelease>,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Vec<(Grabbable, Eligible)>, GrabError> {
|
||||
) -> Result<(Vec<(Grabbable, Eligible)>, Vec<TvWinner>), GrabError> {
|
||||
let mut policies: HashMap<i64, Option<Grabbable>> = HashMap::new();
|
||||
let mut best: HashMap<i64, Eligible> = HashMap::new();
|
||||
let mut tv = TvCandidates::default();
|
||||
|
||||
for release in releases {
|
||||
let claims = arr_parse::parse(&release.name);
|
||||
@@ -128,56 +166,239 @@ impl RssAction {
|
||||
tmdb_id: release.tmdb_id,
|
||||
imdb_id: release.imdb_id.clone(),
|
||||
};
|
||||
let Some(matched) = match_movie(wanted, &ids, &claims) else {
|
||||
continue;
|
||||
};
|
||||
let movie_id = matched.movie.0;
|
||||
let grabbable = match policies.entry(movie_id) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(load_grabbable(database, wanted, movie_id).await?)
|
||||
if let Some(matched) = match_movie(wanted, &ids, &claims) {
|
||||
let movie_id = matched.movie.0;
|
||||
let grabbable = match policies.entry(movie_id) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(load_grabbable(database, wanted, movie_id).await?)
|
||||
}
|
||||
};
|
||||
let Some(grabbable) = grabbable.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
tracing::info!(
|
||||
movie_id,
|
||||
release = release.name,
|
||||
by = match matched.kind {
|
||||
MatchKind::TmdbId => "tmdb id",
|
||||
MatchKind::ImdbId => "imdb id",
|
||||
MatchKind::TitleAndYear => "title and year",
|
||||
},
|
||||
"RSS result matched a wanted title"
|
||||
);
|
||||
if let Some(candidate) = store_release(
|
||||
database,
|
||||
movie_id,
|
||||
&release,
|
||||
&grabbable.policy.policy,
|
||||
&grabbable.policy.overrides,
|
||||
&grabbable.language,
|
||||
blacklist,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let incumbent = best.get(&movie_id);
|
||||
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
|
||||
best.insert(movie_id, candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(grabbable) = grabbable.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
tracing::info!(
|
||||
movie_id,
|
||||
release = release.name,
|
||||
by = match matched.kind {
|
||||
MatchKind::TmdbId => "tmdb id",
|
||||
MatchKind::ImdbId => "imdb id",
|
||||
MatchKind::TitleAndYear => "title and year",
|
||||
},
|
||||
"RSS result matched a wanted title"
|
||||
);
|
||||
let stored = store_release(
|
||||
database,
|
||||
movie_id,
|
||||
&release,
|
||||
&grabbable.policy.policy,
|
||||
&grabbable.policy.overrides,
|
||||
&grabbable.language,
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
let Some(candidate) = stored else {
|
||||
continue;
|
||||
};
|
||||
let incumbent = best.get(&movie_id);
|
||||
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
|
||||
best.insert(movie_id, candidate);
|
||||
}
|
||||
tv.offer(database, episodes, &release, &ids, &claims, blacklist)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut winners: Vec<(Grabbable, Eligible)> = best
|
||||
let tv_winners = tv.winners(database, episodes).await?;
|
||||
let mut movies: Vec<(Grabbable, Eligible)> = best
|
||||
.into_iter()
|
||||
.filter_map(|(movie_id, candidate)| {
|
||||
let grabbable = policies.get(&movie_id).and_then(Clone::clone)?;
|
||||
Some((grabbable, candidate))
|
||||
})
|
||||
.collect();
|
||||
winners.sort_by_key(|(movie, _)| movie.id);
|
||||
movies.sort_by_key(|(movie, _)| movie.id);
|
||||
Ok((movies, tv_winners))
|
||||
}
|
||||
}
|
||||
|
||||
/// The episode lane's state over one feed pass: the grab context per season
|
||||
/// and the best eligible candidate per wanted episode and per season's pack.
|
||||
#[derive(Default)]
|
||||
struct TvCandidates {
|
||||
policies: HashMap<i64, Option<TvGrabbable>>,
|
||||
best_single: HashMap<i64, Eligible>,
|
||||
/// Per season: the best pack alongside the open episodes it covers.
|
||||
best_pack: HashMap<i64, (Eligible, Vec<i64>)>,
|
||||
}
|
||||
|
||||
impl TvCandidates {
|
||||
/// Offer one feed item to every wanted episode. A release holds either
|
||||
/// exactly one episode or whole seasons, so each row it matches lands in
|
||||
/// exactly one bucket.
|
||||
async fn offer(
|
||||
&mut self,
|
||||
database: &Db,
|
||||
episodes: &[WantedEpisodeRow],
|
||||
release: &SearchRelease,
|
||||
ids: &ReleaseIds,
|
||||
claims: &arr_parse::NameClaims,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<(), GrabError> {
|
||||
let mut packs: HashMap<i64, Vec<i64>> = HashMap::new();
|
||||
let mut single: Option<(i64, MatchKind)> = None;
|
||||
for row in episodes {
|
||||
let Some(matched) = match_episode(&row.wanted, ids, claims) else {
|
||||
continue;
|
||||
};
|
||||
match matched.shape {
|
||||
MatchShape::SeasonPack => {
|
||||
packs
|
||||
.entry(row.season_id)
|
||||
.or_default()
|
||||
.push(row.wanted.id.0);
|
||||
}
|
||||
MatchShape::SingleEpisode => single = Some((row.wanted.id.0, matched.kind)),
|
||||
}
|
||||
}
|
||||
for (season_id, covered) in packs {
|
||||
let Some(candidate) = self
|
||||
.store_candidate(database, episodes, season_id, &covered, release, blacklist)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let incumbent = self.best_pack.get(&season_id);
|
||||
if incumbent.is_none_or(|(incumbent, _)| beats(&candidate, incumbent)) {
|
||||
self.best_pack.insert(season_id, (candidate, covered));
|
||||
}
|
||||
}
|
||||
if let Some((episode_id, kind)) = single {
|
||||
tracing::info!(
|
||||
episode_id,
|
||||
release = release.name,
|
||||
by = tv_match_kind(kind),
|
||||
"RSS result matched a wanted episode"
|
||||
);
|
||||
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(candidate) = self
|
||||
.store_candidate(
|
||||
database,
|
||||
episodes,
|
||||
row.season_id,
|
||||
&[episode_id],
|
||||
release,
|
||||
blacklist,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let incumbent = self.best_single.get(&episode_id);
|
||||
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
|
||||
self.best_single.insert(episode_id, candidate);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify and store a feed item against the open episodes it covers,
|
||||
/// loading the season's grab context on first use.
|
||||
async fn store_candidate(
|
||||
&mut self,
|
||||
database: &Db,
|
||||
episodes: &[WantedEpisodeRow],
|
||||
season_id: i64,
|
||||
covered: &[i64],
|
||||
release: &SearchRelease,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let grabbable = match self.policies.entry(season_id) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(load_tv_grabbable(database, episodes, season_id).await?)
|
||||
}
|
||||
};
|
||||
let Some(grabbable) = grabbable.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (_, stored) = store_episode_release(
|
||||
database,
|
||||
covered,
|
||||
release,
|
||||
&grabbable.policy.policy,
|
||||
&grabbable.policy.overrides,
|
||||
&grabbable.language,
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
/// Turn the per-season and per-episode winners into grabs. A season with
|
||||
/// an eligible pack only takes it when [`season_grab_mode`] allows packs
|
||||
/// there — the guard targeted search obeys (§6.2, §14) — and when it
|
||||
/// does, the season's singles stand down. Otherwise each open episode
|
||||
/// takes its own winner.
|
||||
async fn winners(
|
||||
&self,
|
||||
database: &Db,
|
||||
episodes: &[WantedEpisodeRow],
|
||||
) -> Result<Vec<TvWinner>, GrabError> {
|
||||
let mut winners = Vec::new();
|
||||
let mut covered_by_packs: HashSet<i64> = HashSet::new();
|
||||
|
||||
let mut seasons: Vec<i64> = self.best_pack.keys().copied().collect();
|
||||
seasons.sort_unstable();
|
||||
for season_id in seasons {
|
||||
let Some((candidate, episode_ids)) = self.best_pack.get(&season_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(grabbable) = self.policies.get(&season_id).and_then(Clone::clone) else {
|
||||
continue;
|
||||
};
|
||||
if !pack_allowed(database, season_id).await? {
|
||||
tracing::info!(
|
||||
season_id,
|
||||
series = grabbable.series_title,
|
||||
"RSS skips a season pack the season's grab mode refuses"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
covered_by_packs.extend(episode_ids.iter().copied());
|
||||
winners.push(TvWinner {
|
||||
grabbable,
|
||||
scope: GrabScope::Season {
|
||||
season_id,
|
||||
episode_ids: episode_ids.clone(),
|
||||
},
|
||||
candidate: candidate.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut singles: Vec<i64> = self.best_single.keys().copied().collect();
|
||||
singles.sort_unstable();
|
||||
for episode_id in singles {
|
||||
if covered_by_packs.contains(&episode_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(grabbable) = self.policies.get(&row.season_id).and_then(Clone::clone) else {
|
||||
continue;
|
||||
};
|
||||
let Some(candidate) = self.best_single.get(&episode_id) else {
|
||||
continue;
|
||||
};
|
||||
winners.push(TvWinner {
|
||||
grabbable,
|
||||
scope: GrabScope::Episode { episode_id },
|
||||
candidate: candidate.clone(),
|
||||
});
|
||||
}
|
||||
Ok(winners)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +476,196 @@ async fn wanted_movies(database: &Db) -> Result<Vec<WantedMovie>, GrabError> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A matched series season with everything a grab needs loaded.
|
||||
#[derive(Clone, Debug)]
|
||||
struct TvGrabbable {
|
||||
series_title: String,
|
||||
policy: TitlePolicy,
|
||||
language: Language,
|
||||
}
|
||||
|
||||
/// One winner of the episode lane: what to grab, for whom, and which
|
||||
/// release won.
|
||||
struct TvWinner {
|
||||
grabbable: TvGrabbable,
|
||||
scope: GrabScope,
|
||||
candidate: Eligible,
|
||||
}
|
||||
|
||||
/// A wanted episode with the season context matching and grabbing need.
|
||||
struct WantedEpisodeRow {
|
||||
wanted: WantedEpisode,
|
||||
season_id: i64,
|
||||
}
|
||||
|
||||
fn tv_match_kind(kind: MatchKind) -> &'static str {
|
||||
match kind {
|
||||
MatchKind::TmdbId => "tmdb id",
|
||||
MatchKind::ImdbId => "imdb id",
|
||||
MatchKind::TitleAndYear => "title",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every wanted episode still open — wanted, no file on disk, no live grab
|
||||
/// of its own or of its season. Same shape as the movie list: unlimited,
|
||||
/// unordered by recency, blocked series included on purpose (§6.3).
|
||||
async fn wanted_episodes(database: &Db) -> Result<Vec<WantedEpisodeRow>, GrabError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT e.id AS "id!: i64",
|
||||
e.number AS "episode!: i64",
|
||||
se.id AS "season_id!: i64",
|
||||
se.number AS "season!: i64",
|
||||
s.tmdb_id AS "series_tmdb_id!: i64",
|
||||
s.title AS "series_title!: String"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE e.wanted = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'episode' AND g.target_id = e.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'season' AND g.target_id = se.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
ORDER BY e.id
|
||||
"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| WantedEpisodeRow {
|
||||
wanted: WantedEpisode {
|
||||
id: EpisodeId(row.id),
|
||||
tmdb_id: u32::try_from(row.series_tmdb_id).ok(),
|
||||
// The series table carries no `IMDb` id yet, so this lane
|
||||
// matches on TMDB id or title until one is backfilled.
|
||||
imdb_id: None,
|
||||
title: row.series_title,
|
||||
season: u32::try_from(row.season).unwrap_or_default(),
|
||||
episode: u32::try_from(row.episode).unwrap_or_default(),
|
||||
},
|
||||
season_id: row.season_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The policy and original language a matched season needs, or `None` when
|
||||
/// it cannot be evaluated yet. Same refusals as [`load_grabbable`].
|
||||
async fn load_tv_grabbable(
|
||||
database: &Db,
|
||||
episodes: &[WantedEpisodeRow],
|
||||
season_id: i64,
|
||||
) -> Result<Option<TvGrabbable>, GrabError> {
|
||||
let Some(series_title) = episodes
|
||||
.iter()
|
||||
.find(|row| row.season_id == season_id)
|
||||
.map(|row| row.wanted.title.clone())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(policy) = database.season_policy(season_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// §5.2, same as films.
|
||||
let language: Option<String> = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT s.original_language
|
||||
FROM seasons se
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE se.id = ?
|
||||
"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?
|
||||
.flatten();
|
||||
let Some(language) = language else {
|
||||
tracing::warn!(
|
||||
season_id,
|
||||
series = series_title,
|
||||
"no original language yet; not grabbing from RSS"
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(TvGrabbable {
|
||||
series_title,
|
||||
policy,
|
||||
language: arr_db::policy::language(&language),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether §6.2's guard lets a season pack be grabbed here: exactly what
|
||||
/// [`season_grab_mode`] requires of targeted search, computed over every
|
||||
/// episode the season is known to hold (§14, amended in #117).
|
||||
async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
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 "on_disk!: bool"
|
||||
FROM episodes e
|
||||
WHERE e.season_id = ?
|
||||
ORDER BY e.number
|
||||
"#,
|
||||
season_id
|
||||
)
|
||||
.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""#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(season_grab_mode(&SeasonGrabFacts {
|
||||
air_dates: &episodes
|
||||
.iter()
|
||||
.map(|episode| air_date_time(episode.air_date.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
now: std::time::SystemTime::now(),
|
||||
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
|
||||
pack_hard_failed,
|
||||
}) == SeasonGrabMode::SeasonPack)
|
||||
}
|
||||
|
||||
/// An `air_date` as TMDB writes it (`YYYY-MM-DD`), or a full timestamp if
|
||||
/// one ever arrives that way. Unknown is unaired.
|
||||
fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
|
||||
let value = value?;
|
||||
let timestamp = if let Ok(date) = value.parse::<chrono::NaiveDate>() {
|
||||
date.and_time(chrono::NaiveTime::MIN).and_utc().timestamp()
|
||||
} else {
|
||||
value
|
||||
.parse::<chrono::DateTime<chrono::Utc>>()
|
||||
.ok()?
|
||||
.timestamp()
|
||||
};
|
||||
let seconds = u64::try_from(timestamp.abs()).ok()?;
|
||||
if timestamp < 0 {
|
||||
std::time::UNIX_EPOCH.checked_sub(std::time::Duration::from_secs(seconds))
|
||||
} else {
|
||||
std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds))
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy and original language a matched title needs, or `None` when
|
||||
/// the title cannot be evaluated yet.
|
||||
async fn load_grabbable(
|
||||
@@ -312,6 +723,48 @@ mod tests {
|
||||
/// asked for.
|
||||
const FEED: &str = include_str!("../tests/fixtures/rss.xml");
|
||||
|
||||
/// A season pack and its three episodes, all eligible under the seeded
|
||||
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
|
||||
const TV_FEED: &str = r#"<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP</title>
|
||||
<guid>tv-pack</guid><link>https://indexer.invalid/download/tv-pack</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-pack" length="85899345920"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="120"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
|
||||
<guid>tv-e01</guid><link>https://indexer.invalid/download/tv-e01</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-e01" length="10737418240"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="80"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
|
||||
<guid>tv-e02</guid><link>https://indexer.invalid/download/tv-e02</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-e02" length="10737418240"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="80"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
|
||||
<guid>tv-e03</guid><link>https://indexer.invalid/download/tv-e03</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-e03" length="10737418240"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="80"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>The.Fallout.2021.S01E03.1080p.WEB-DL-GROUP</title>
|
||||
<guid>tv-near-miss</guid><link>https://indexer.invalid/download/tv-near-miss</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-near-miss" length="10737418240"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="80"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"#;
|
||||
|
||||
const INDEXERS: [i64; 2] = [7, 9];
|
||||
|
||||
/// Enough of Transmission to add a torrent and list nothing back.
|
||||
@@ -350,9 +803,9 @@ mod tests {
|
||||
|
||||
/// Two indexers, both advertising a text search, both serving the same
|
||||
/// feed to an empty query.
|
||||
async fn prowlarr() -> MockServer {
|
||||
async fn prowlarr(feed: &str) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server);
|
||||
let feed = test_downloads::rewrite(feed, "https://indexer.invalid/download/", &server);
|
||||
test_downloads::mount(&server).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/indexer"))
|
||||
@@ -430,6 +883,59 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fallout S01 with wanted episodes airing at the given dates. Returns
|
||||
/// the season id and the episode ids in episode order.
|
||||
async fn wanted_series(database: &Db, air_dates: &[&str]) -> (i64, Vec<i64>) {
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id)
|
||||
SELECT 106379, 'Fallout', 2024, 'en', id
|
||||
FROM roots WHERE kind = 'tv' AND audience = 'main'",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let season_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO seasons (series_id, number) VALUES (1, 1) RETURNING id",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut episodes = Vec::new();
|
||||
for (index, air_date) in air_dates.iter().enumerate() {
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO episodes (season_id, number, title, air_date, wanted)
|
||||
VALUES (?, ?, ?, ?, 1) RETURNING id",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(i64::try_from(index).unwrap() + 1)
|
||||
.bind(format!("Episode {}", index + 1))
|
||||
.bind(air_date)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
episodes.push(id);
|
||||
}
|
||||
(season_id, episodes)
|
||||
}
|
||||
|
||||
/// `(target_kind, target_id, state)` for every TV grab that was made.
|
||||
async fn tv_grabs(database: &Db) -> Vec<(String, i64, String)> {
|
||||
sqlx::query_as::<_, (String, i64, String)>(
|
||||
"SELECT target_kind, target_id, state FROM grabs
|
||||
WHERE target_kind IN ('episode', 'season') ORDER BY target_kind, target_id",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn episode_states(database: &Db) -> Vec<String> {
|
||||
sqlx::query_scalar::<_, String>("SELECT state FROM episodes ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `(movie title, grabbed release name)` for every grab that was made.
|
||||
async fn grabbed(database: &Db) -> Vec<(String, String)> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
@@ -462,7 +968,7 @@ mod tests {
|
||||
(438_631, "Dune", 2021, false),
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
@@ -506,7 +1012,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_near_miss_is_not_grabbed() {
|
||||
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
@@ -525,7 +1031,7 @@ mod tests {
|
||||
(9_999_999, "Dune: Part Three", 2024, false),
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
@@ -542,7 +1048,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a_blocked_title_still_matches_rss() {
|
||||
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
@@ -560,7 +1066,7 @@ mod tests {
|
||||
(438_631, "Dune", 2021, false),
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
@@ -582,4 +1088,135 @@ mod tests {
|
||||
assert_eq!(feeds, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-episode release in the feed closes a wanted, missing
|
||||
/// episode's gap, with no targeted-search attempt spent (§6.2). The
|
||||
/// season here is airing, so its pack is not eligible ([`season_grab_mode`]).
|
||||
#[tokio::test]
|
||||
async fn a_missing_episode_is_grabbed_from_rss() {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await;
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 2);
|
||||
assert_eq!(
|
||||
tv_grabs(&database).await,
|
||||
episodes
|
||||
.iter()
|
||||
.map(|id| ("episode".to_owned(), *id, "sent".to_owned()))
|
||||
.collect::<Vec<_>>(),
|
||||
"each gap takes its own single; the pack and the near miss stay out"
|
||||
);
|
||||
assert_eq!(fake.added.lock().unwrap().len(), 2);
|
||||
let attempts: Vec<(i64, Option<String>)> =
|
||||
sqlx::query_as("SELECT search_attempts, last_searched_at FROM episodes ORDER BY id")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(attempts
|
||||
.iter()
|
||||
.all(|(count, at)| *count == 0 && at.is_none()));
|
||||
}
|
||||
|
||||
/// The acceptance case: one feed pass over a fully aired season with
|
||||
/// nothing on disk takes the pack alone, and the singles stand down.
|
||||
#[tokio::test]
|
||||
async fn a_completed_season_takes_one_pack_from_rss() {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (season_id, _episodes) =
|
||||
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||
// Only the pack is wanted here: strip the singles so the pass has to
|
||||
// pick between shapes on coverage, not on availability.
|
||||
let pack_only = TV_FEED.replace(
|
||||
"Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
|
||||
"Unrelated.S01E01.2160p.WEB-DL-GROUP</title>",
|
||||
);
|
||||
let pack_only = pack_only.replace(
|
||||
"Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
|
||||
"Unrelated.S01E02.2160p.WEB-DL-GROUP</title>",
|
||||
);
|
||||
let pack_only = pack_only.replace(
|
||||
"Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
|
||||
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
|
||||
);
|
||||
let indexer = prowlarr(&pack_only).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(fake.added.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
tv_grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading"; 3],
|
||||
"the pack flips every episode it covers"
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2 with #117's guard: an episode already on disk keeps the season
|
||||
/// per-episode here too — the pack is skipped and the open gaps take
|
||||
/// their singles.
|
||||
#[tokio::test]
|
||||
async fn a_pack_for_a_season_with_a_file_on_disk_is_skipped() {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (_season_id, episodes) =
|
||||
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size)
|
||||
VALUES ('episode', ?, '/tv/fallout/s01e01.mkv', 1)",
|
||||
)
|
||||
.bind(episodes[0])
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
let grabs = tv_grabs(&database).await;
|
||||
assert!(
|
||||
!grabs.iter().any(|(kind, _, _)| kind == "season"),
|
||||
"no pack over an episode already on disk"
|
||||
);
|
||||
// Episode 1 is not wanted any more — it has a file — and the near
|
||||
// miss never matched, so exactly the two open gaps are filled.
|
||||
assert_eq!(
|
||||
grabs,
|
||||
vec![
|
||||
("episode".to_owned(), episodes[1], "sent".to_owned()),
|
||||
("episode".to_owned(), episodes[2], "sent".to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.3: `blocked` stops targeted search for a series and leaves RSS
|
||||
/// matching on. The one-episode season here is fully aired, so what
|
||||
/// matches is its pack — the same release targeted search would take.
|
||||
#[tokio::test]
|
||||
async fn a_blocked_series_still_matches_rss() {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (season_id, _episodes) = wanted_series(&database, &["2024-04-11"]).await;
|
||||
sqlx::query("UPDATE series SET blocked = 1")
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(
|
||||
tv_grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ impl SeriesRefreshAction {
|
||||
|
||||
let mut fresh = Vec::new();
|
||||
let mut changed = false;
|
||||
let season = season_number(number);
|
||||
for source in &detail.episodes {
|
||||
let number = i64::from(source.number);
|
||||
if let Some(&(id, ref title, ref air_date, vanished)) = known.get(&number) {
|
||||
@@ -311,6 +312,7 @@ impl SeriesRefreshAction {
|
||||
fresh.push(CoreEpisode {
|
||||
id: EpisodeId(0),
|
||||
season_id: SeasonId(season_id),
|
||||
season_number: season,
|
||||
number: season_number(source.number),
|
||||
title: source.title.clone(),
|
||||
air_date: source.air_date.map(system_time),
|
||||
@@ -449,6 +451,7 @@ fn core_episodes(series_id: i64, detail: &TmdbSeasonDetail) -> Vec<CoreEpisode>
|
||||
.map(|source| CoreEpisode {
|
||||
id: EpisodeId(0),
|
||||
season_id: SeasonId(series_id),
|
||||
season_number: season_number(detail.number),
|
||||
number: season_number(source.number),
|
||||
title: source.title.clone(),
|
||||
air_date: source.air_date.map(system_time),
|
||||
|
||||
@@ -9,8 +9,8 @@ use serde::de::DeserializeOwned;
|
||||
use crate::cache::Cache;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{
|
||||
ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage,
|
||||
RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
|
||||
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
};
|
||||
|
||||
/// TMDB's v3 API root.
|
||||
@@ -94,14 +94,20 @@ impl TmdbClient {
|
||||
|
||||
/// Resolve an `IMDb` title id through TMDB's external-id index.
|
||||
///
|
||||
/// TMDB answers with both kinds in one response, so one call surfaces
|
||||
/// whichever the id names — the way a raw TMDB id resolves (§9.2).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Any of [`Error`]; see its variants for what callers should distinguish.
|
||||
pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result<Vec<MovieSearchResult>> {
|
||||
pub async fn find_by_imdb(&self, imdb_id: &str) -> Result<FindResults> {
|
||||
let path = format!("find/{}", imdb_id.trim());
|
||||
let params = [("external_source", "imdb_id".to_owned())];
|
||||
let page: RawFindPage = self.get_json(&path, ¶ms).await?;
|
||||
Ok(page.movie_results.into_iter().map(Into::into).collect())
|
||||
Ok(FindResults {
|
||||
movies: page.movie_results.into_iter().map(Into::into).collect(),
|
||||
series: page.tv_results.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a TVDB series id through TMDB's external-id index.
|
||||
|
||||
@@ -24,5 +24,5 @@ mod model;
|
||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{
|
||||
Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
|
||||
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
|
||||
};
|
||||
|
||||
@@ -293,6 +293,15 @@ impl From<RawSeason> for Season {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one `IMDb` id resolved to. An id names one title, so at most one of
|
||||
/// the two lists is non-empty — but TMDB answers both kinds in the same
|
||||
/// response, and both are surfaced rather than filtered here.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct FindResults {
|
||||
pub movies: Vec<MovieSearchResult>,
|
||||
pub series: Vec<SeriesSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawFindPage {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"movie_results": [],
|
||||
"tv_results": [
|
||||
{
|
||||
"id": 82728,
|
||||
"name": "Bluey",
|
||||
"original_name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"overview": "The slice-of-life adventures of an Australian cattle dog.",
|
||||
"poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
|
||||
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
|
||||
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
|
||||
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
|
||||
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
|
||||
|
||||
fn client(server: &MockServer) -> TmdbClient {
|
||||
TmdbClient::builder("test-key")
|
||||
@@ -483,6 +484,33 @@ async fn series_external_ids_carries_the_tvdb_id() {
|
||||
assert_eq!(ids.tvdb_id, Some(361_391));
|
||||
}
|
||||
|
||||
/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a
|
||||
/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results`
|
||||
/// half is what this reads.
|
||||
#[tokio::test]
|
||||
async fn find_by_imdb_resolves_series() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/find/tt7614372"))
|
||||
.and(query_param("external_source", "imdb_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let results = client(&server)
|
||||
.find_by_imdb("tt7614372")
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert!(results.movies.is_empty());
|
||||
assert_eq!(results.series.len(), 1);
|
||||
let series = &results.series[0];
|
||||
assert_eq!(series.tmdb_id, 82_728);
|
||||
assert_eq!(series.title, "Bluey");
|
||||
assert_eq!(series.year(), Some(2018));
|
||||
}
|
||||
|
||||
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
|
||||
/// can tell "unknown" from a real id and fall back to the text query.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -55,11 +55,19 @@ pub(crate) fn classify_pair(a: &str, b: &str) -> Option<(Marker, Strength)> {
|
||||
("season", n) if !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()) => {
|
||||
(Marker::Junk, Strength::Strong)
|
||||
}
|
||||
("seasons", n) if is_season_count(n) => (Marker::Junk, Strength::Strong),
|
||||
_ => return None,
|
||||
};
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `1`, `1-3`: the count after a spelled-out `Seasons`. Each part is 1-2
|
||||
/// digits so a trailing year (`Four Seasons 2024`) never reads as a count.
|
||||
fn is_season_count(n: &str) -> bool {
|
||||
n.split('-')
|
||||
.all(|part| (1..=2).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn classify_exact(t: &str) -> Option<(Marker, Strength)> {
|
||||
use Strength::{Strong, Weak};
|
||||
|
||||
@@ -343,6 +343,42 @@ fn cases() -> Vec<Case> {
|
||||
..NameClaims::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
// The spelled-out plural range closes the title too.
|
||||
name: "Fallout.Seasons.1-3.1080p.BluRay-GROUP",
|
||||
want: NameClaims {
|
||||
title: s("Fallout"),
|
||||
resolution: Some(Resolution::P1080),
|
||||
source: Some(Source::BluRay),
|
||||
group: s("GROUP"),
|
||||
episode: Some(EpisodeClaim::Seasons { first: 1, last: 3 }),
|
||||
..NameClaims::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
// A bare number after the plural is a single season, like
|
||||
// the singular form.
|
||||
name: "Show.Name.Seasons.1.1080p.WEB-DL-GROUP",
|
||||
want: NameClaims {
|
||||
title: s("Show Name"),
|
||||
resolution: Some(Resolution::P1080),
|
||||
source: Some(Source::WebDl),
|
||||
group: s("GROUP"),
|
||||
episode: Some(EpisodeClaim::Season { season: 1 }),
|
||||
..NameClaims::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
// A year-sized number after `Seasons` is a year, not a count.
|
||||
name: "The.Four.Seasons.2024.1080p.WEB-DL",
|
||||
want: NameClaims {
|
||||
title: s("The Four Seasons"),
|
||||
year: Some(2024),
|
||||
resolution: Some(Resolution::P1080),
|
||||
source: Some(Source::WebDl),
|
||||
..NameClaims::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM",
|
||||
want: NameClaims {
|
||||
|
||||
+4
-2
@@ -171,7 +171,8 @@
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
kids titles with no qualifying pt release — parked here, sometimes for months, by
|
||||
design. allow english writes this title's override and the normal search proceeds.
|
||||
design. allow english writes a movie's override and its search proceeds; series wait
|
||||
for the detail view.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-no-pt"></ul>
|
||||
</section>
|
||||
@@ -182,7 +183,8 @@
|
||||
<span class="deck-count readout" id="count-decision"></span>
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
hard-failed twice on different releases — open the releases and decide by hand.
|
||||
hard-failed twice on different releases — open a movie's releases and decide by hand;
|
||||
series entries are display-only for now.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-decision"></ul>
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
attemptsLabel,
|
||||
attentionTotal,
|
||||
fetchAttention,
|
||||
type SeriesAttention,
|
||||
} from "./queues";
|
||||
import {
|
||||
bucketOf,
|
||||
@@ -1826,6 +1827,15 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
|
||||
for (const movie of queues.needs_decision) {
|
||||
groups.decision.rows.append(libraryRow(movie, roots, openReleases));
|
||||
}
|
||||
// the TV lanes share the two sections: one queue per reason, whatever
|
||||
// the kind. Series entries are display-only — their release deck and
|
||||
// overrides arrive with the series detail view (issues 129 and 39).
|
||||
for (const series of queues.tv_no_pt_source) {
|
||||
groups.noPt.rows.append(seriesAttentionRow(series));
|
||||
}
|
||||
for (const series of queues.tv_needs_decision) {
|
||||
groups.decision.rows.append(seriesAttentionRow(series));
|
||||
}
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
emptyLine(group);
|
||||
}
|
||||
@@ -1911,6 +1921,38 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
|
||||
return { hide, open, refreshBadge };
|
||||
}
|
||||
|
||||
/**
|
||||
* One TV lane entry: the series plus chips counting the episodes or
|
||||
* seasons that put it there. The API carries internal row ids, not SxxEyy
|
||||
* numbers, so counts are what can be named honestly. Inert — see above.
|
||||
*/
|
||||
function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
if (entry.episodes.length > 0) {
|
||||
const n = entry.episodes.length;
|
||||
chips.append(
|
||||
chip(`${n} ${n === 1 ? "episode" : "episodes"}`, (span) => {
|
||||
span.setAttribute("aria-label", `${n} ${n === 1 ? "episode" : "episodes"} waiting`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (entry.seasons.length > 0) {
|
||||
const n = entry.seasons.length;
|
||||
chips.append(
|
||||
chip(`${n} ${n === 1 ? "season" : "seasons"}`, (span) => {
|
||||
span.setAttribute("aria-label", `${n} ${n === 1 ? "season" : "seasons"} waiting`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
row.append(rowTitle(entry.title, entry.year), chips);
|
||||
item.append(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* One no-PT-source entry: the line opens the release deck as evidence; the
|
||||
* §5.2 one click sits beside it and empties the item.
|
||||
|
||||
+7
-6
@@ -78,13 +78,14 @@ export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOu
|
||||
return { kind: "done", searchQueued: true };
|
||||
}
|
||||
|
||||
/** Entries across both queues — the rail badge's number.
|
||||
*
|
||||
* Deliberately movies only for now: the TV lanes are new and the QUEUES
|
||||
* deck does not render them yet (the backend landed in issue 126).
|
||||
*/
|
||||
/** Entries across all four lanes — the rail badge's number. */
|
||||
export function attentionTotal(queues: AttentionQueues): number {
|
||||
return queues.no_pt_source.length + queues.needs_decision.length;
|
||||
return (
|
||||
queues.no_pt_source.length +
|
||||
queues.needs_decision.length +
|
||||
queues.tv_no_pt_source.length +
|
||||
queues.tv_needs_decision.length
|
||||
);
|
||||
}
|
||||
|
||||
/** `searched 3×`, or null before the first attempt ever lands here. */
|
||||
|
||||
Reference in New Issue
Block a user