Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40620d93b4 | |||
| 1c351eb867 | |||
| a6cdd0ab0b | |||
| 365c2a7587 | |||
| 07741c80fe | |||
| 82b294ab1b | |||
| ed210b29d3 | |||
| b413718fd1 | |||
| 9a03fc9963 | |||
| fdaaeccb40 | |||
| be2c5f7e3c | |||
| 5e2bb5dff7 | |||
| 30382585ea | |||
| c9e73cb23c | |||
| 8af85606a0 | |||
| 08e3c7bce7 | |||
| 22f5d6b463 | |||
| 60fcc9f3d5 | |||
| b59e30dbdb | |||
| 8478c0f8a9 | |||
| 82f87d61f1 | |||
| 80a63ea2e1 | |||
| 9bd037d1b6 | |||
| cbb21418a4 | |||
| 689487bf82 | |||
| 2e4e1e7547 | |||
| 1acdcaca23 | |||
| 26dd0dc5d3 | |||
| e9a5095557 | |||
| 0ed0791fd4 | |||
| 23f5781714 | |||
| 877dbae7cb | |||
| cf36a4302c | |||
| 06a68372e6 | |||
| 4762f2612f | |||
| 468f31f0b0 | |||
| 571ec42e92 | |||
| db72cb4bcd | |||
| d5013d4ca8 | |||
| 7f9729fb57 | |||
| e335a1be73 | |||
| 82100ef7ab | |||
| 6826170431 | |||
| 4efc7b1833 | |||
| 353608ae63 | |||
| d86d975073 | |||
| 9129728622 | |||
| e4c115803b | |||
| 1d31ea7d1b | |||
| 7f7f26dbc7 | |||
| 11d2952863 | |||
| 00ff4db26a | |||
| 409967eef6 | |||
| 943f18cdf5 | |||
| 1af73caa34 | |||
| 71cbb9269b | |||
| 160c00c77f | |||
| 779d4b8504 | |||
| 64e711feec | |||
| a378b22306 | |||
| 04b2688330 | |||
| 91414a043f | |||
| a28b455fff | |||
| 9df120b92a | |||
| 03e7774869 | |||
| fea1a04c75 | |||
| 3956617d41 | |||
| d8316ae0c8 | |||
| 127338fd4d | |||
| 695f1882be | |||
| 8a77049fdb | |||
| 39e6bce2a0 | |||
| b0f56f59aa | |||
| 1eac85c427 | |||
| 50b68e24b6 | |||
| 69263da4f3 | |||
| b51386e004 | |||
| 17949c0848 | |||
| 4f848c27f0 | |||
| e4c49b75de | |||
| 00d0ec7f16 | |||
| adfc60154d | |||
| 8d79c7c378 | |||
| 34ccbeeb5a | |||
| 8b9a6d5a16 | |||
| 3bcc612f9a | |||
| 384e260d72 | |||
| 0c58aae707 | |||
| 808145d73b |
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT tmdb_id FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "tmdb_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (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 = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6a25900e3815470be997f96bfee10f2079242e8d755da6387dbb8a0f40fe6ea1"
|
||||
"hash": "16778fe075b61babcb7f5436113e91bf5f86e9a968d7b61f12daa022cc27f4d1"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", mf.path AS \"path!: String\", mf.size AS \"size!: i64\", mf.probed AS \"probed?: serde_json::Value\", json_extract(mf.waiver, '$.rule') AS \"waiver?: 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 = ? ORDER BY mf.path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "probed?: serde_json::Value",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id\n WHERE f.owner_kind = 'episode' AND e.season_id = ?\n ) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"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\", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.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"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 8,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "268dc95276f57bf3c457c98d172ef5e6f9acf430ae419ca68dc3800b0390b5b7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
|
||||
}
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -27,6 +27,21 @@
|
||||
"name": "title!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 6,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "series_tmdb_id!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -37,8 +52,11 @@
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
|
||||
"hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"title!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 11
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE season_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM episodes WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c"
|
||||
}
|
||||
+22
-10
@@ -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.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,38 +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": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882"
|
||||
"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"
|
||||
}
|
||||
+22
-10
@@ -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.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,38 +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": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb"
|
||||
"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
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM media_files\n WHERE owner_kind = 'episode' AND owner_id = ?\n ) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"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.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 17
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language,\n (\n EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND 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 )\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 ) AS \"is_gap!: bool\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n AND s.blocked = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "is_gap!: bool",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (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 NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7"
|
||||
"hash": "53a2b5bcc6a753b8ef0a5f4110a69cc8ece28966cfa003f4bfe1190c55831a23"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "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",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
"name": "tracked!: bool",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -31,8 +36,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e"
|
||||
"hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT m.id AS \"id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\", m.title AS \"title!: String\", m.year, m.original_language, m.root_id AS \"root_id!: i64\", m.wanted AS \"wanted!: bool\", m.overrides AS \"overrides!: serde_json::Value\", m.state AS \"state!: String\", m.blocked AS \"blocked!: bool\", m.search_attempts AS \"search_attempts!: i64\", m.last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id",
|
||||
"query": "SELECT m.id AS \"id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\", m.title AS \"title!: String\", m.year, m.original_language, m.root_id AS \"root_id!: i64\", m.wanted AS \"wanted!: bool\", m.overrides AS \"overrides!: serde_json::Value\", m.state AS \"state!: String\", m.blocked AS \"blocked!: bool\", m.search_attempts AS \"search_attempts!: i64\", m.last_searched_at, m.poster_path, m.vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5fc9aa28b8329895c03c7be43024069d8a065ddd37ea77bb3058aa8ec1cab52e"
|
||||
"hash": "6394f95e9e437ed3c46bb325c2d946ce75f94e00bf0d3aa6ac54811080725cc2"
|
||||
}
|
||||
+22
-10
@@ -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.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,38 +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": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26"
|
||||
"hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
|
||||
"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\", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 8,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -49,8 +59,10 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863"
|
||||
"hash": "6f41ffaa0679bbbe51ea5d54fbdbf757e0f85d1d86ee6322253f223fae49265f"
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "backdrop_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -27,6 +27,11 @@
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -37,8 +42,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193"
|
||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"number!: i64\",\n e.air_date,\n e.wanted AS \"wanted!: bool\",\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\",\n 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 ) AS \"in_flight!: bool\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?\n AND s.blocked = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_file!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "in_flight!: bool",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (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 ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "61e9081eaedc7a69ada44b057c59553511e64634b75386d523f82c2d03d78f1a"
|
||||
"hash": "8c61a4e4f6b3b8cc02cd4b6289f6889889ab06d9a77f26c4d745659c8e1f4a07"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
"Right": 13
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054"
|
||||
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\"\n FROM seasons WHERE series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -9,12 +9,12 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "tracked!: bool",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
@@ -28,5 +28,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10"
|
||||
"hash": "9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "af1b98d2aa84eac69323823e232b61897d4f6bef978e3bf46ba0d999f90c4acd"
|
||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
|
||||
"hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date,\n vanished AS \"vanished!: bool\"\n FROM episodes WHERE season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7"
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN episode_releases er ON er.release_id = r.id\n WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "indexer_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "guid!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "name!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "download_url!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
|
||||
"hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (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 = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6"
|
||||
"hash": "ad1a3b886179c638051ed9bf34ddbc153097fd7626f68b316d810ccc8305222d"
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, 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.tmdb_id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM seasons WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
|
||||
"hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT tmdb_id FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "tmdb_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN season_releases sr ON sr.release_id = r.id\n WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "indexer_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "guid!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "name!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "download_url!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET tvdb_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e"
|
||||
}
|
||||
+16
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
|
||||
"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\", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,20 +37,32 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 8,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7"
|
||||
"hash": "da5d4fba049300621dc1c3d44ce008835310ae18c9904cea0080267186d64a34"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET title = ?, air_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND (title IS NOT ? OR air_date IS NOT ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375"
|
||||
}
|
||||
+22
-10
@@ -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.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,38 +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": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372"
|
||||
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "af0b8b088dbcbe51203888836bb844d004d18baa022c6d77ae5e1e551f8cb096"
|
||||
"hash": "f2ae7a2dec9da2a2bfebb2a4dbd8e4e1db0fc5f15cee6a9c78af077ee1277153"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 11
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "44886371d0d5966ce83ca4b10f6e4d424b5eb3598a8c9940e59ab23b4fae95f5"
|
||||
"hash": "f986006f35b44d5e0ba9ad7ff9aa6aa695ee3b54e15b7259df2cddaba4ce2644"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET upstream_ended = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85"
|
||||
}
|
||||
@@ -55,6 +55,12 @@ It is authored once by that skill and then treated as the source of truth;
|
||||
components consume tokens rather than literal values. `.impeccable/live/config.json`
|
||||
points the skill's live browser iteration at `web/index.html`.
|
||||
|
||||
Drive the browser with the `agent-browser` CLI, never `google-chrome --headless`
|
||||
directly. Raw headless Chrome times out on every `http://127.0.0.1` URL in this
|
||||
VM, which reads as "the machine cannot reach localhost" and tempts a session
|
||||
into skipping the visual pass. It is the tool, not the machine: `agent-browser
|
||||
open` and `agent-browser snapshot` against the same URL work.
|
||||
|
||||
`~/tea/arcada` has the same setup and is worth reading for the shape of a
|
||||
finished `design.json`.
|
||||
|
||||
|
||||
@@ -485,6 +485,11 @@ which skips to the manual-grab flow.
|
||||
There is never a moment where the user has to know whether they are searching or
|
||||
adding.
|
||||
|
||||
Result rows are enriched: a poster thumbnail and a rating, under the same rules
|
||||
as title detail (§9.6) — images hotlinked from path fragments, the rating being
|
||||
TMDB's `vote_average` with its `vote_count`. A trailer chip renders per row and
|
||||
resolves only when clicked (§9.6).
|
||||
|
||||
### 9.3 Manual search results
|
||||
|
||||
Radarr's manual search is unusable because the raw release name is the dominant
|
||||
@@ -540,6 +545,46 @@ notifying on everything and being muted within a week.
|
||||
|
||||
Not notified: grabs, searches, downloads starting or finishing, soft fails.
|
||||
|
||||
### 9.6 Title detail
|
||||
|
||||
One detail surface per kind: `/movies/{id}` and `/series/{id}` (#129). For a
|
||||
movie the release deck becomes a section of the page, and `/movies/{id}/releases`
|
||||
keeps resolving; series keeps its season-and-episode shape.
|
||||
|
||||
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
|
||||
`vote_average` with its `vote_count`. No OMDb, no IMDb or Rotten Tomatoes
|
||||
scores — each would need a second upstream, a second key and a second thing
|
||||
that can be down.
|
||||
|
||||
**Images are hotlinked** from `image.tmdb.org`. The API returns TMDB path
|
||||
fragments, never URLs; the browser composes the URL and chooses the size. No
|
||||
image proxy and no image cache in the service.
|
||||
|
||||
**Rich detail is not persisted.** It is served through arr-meta's existing
|
||||
24-hour response cache. The single exception is `poster_path`, `backdrop_path`
|
||||
and `vote_average`, stored on `movies` and `series` and written by the daily
|
||||
metadata refresh (§8), so library views render without a TMDB call.
|
||||
|
||||
**Cast** is the top 10 billed — profile photo, actor name, character name —
|
||||
linking out to that person's TMDB page. This does not contradict §2: nothing
|
||||
about a person is stored, tracked or searched on. The non-goal is following
|
||||
people, not naming them.
|
||||
|
||||
**External links** are TMDB always, IMDb for movies, TVDB for series — all from
|
||||
ids the app already holds — plus a Rotten Tomatoes *search* link, which is a
|
||||
query URL, not a resolved title page.
|
||||
|
||||
**Trailers resolve on click.** TMDB's search responses carry no videos, so a
|
||||
trailer key costs a detail call. Rendering one chip per title and resolving the
|
||||
one clicked keeps that cost at one call, and the 24h cache makes a repeat free.
|
||||
|
||||
**Library view.** A poster grid by default with a list toggle; the list keeps
|
||||
the derived-status columns §4.2 built it around.
|
||||
|
||||
Out of scope here, because they are the adjacent scope most likely to creep:
|
||||
watch providers, recommendations or similar titles, collections, person pages
|
||||
inside the app, review text.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||
|
||||
mod health;
|
||||
mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
@@ -14,6 +15,7 @@ mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
mod state;
|
||||
mod trailer;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
@@ -23,6 +25,7 @@ use utoipa_axum::routes;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
pub use health::{Check, Health, HealthReport, Status};
|
||||
pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata};
|
||||
pub use movies::{
|
||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||
};
|
||||
@@ -39,6 +42,7 @@ pub use series::{
|
||||
pub use state::{
|
||||
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
|
||||
};
|
||||
pub use trailer::{Trailer, TrailerKind};
|
||||
|
||||
/// Where the generated document is served, and where `just gen-client` reads
|
||||
/// it back from when it is fetched rather than dumped from the binary.
|
||||
@@ -81,6 +85,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(movies::attention))
|
||||
.routes(routes!(movies::list_owners))
|
||||
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
||||
.routes(routes!(metadata::movie_metadata))
|
||||
.routes(routes!(series::list, series::create))
|
||||
.routes(routes!(series::get, series::update, series::delete))
|
||||
.routes(routes!(series::seasons, series::create_season))
|
||||
@@ -92,12 +97,15 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(metadata::series_metadata))
|
||||
.routes(routes!(series::list_owners))
|
||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||
.routes(routes!(owners::list, owners::create))
|
||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||
.routes(routes!(search::search))
|
||||
.routes(routes!(search::releases))
|
||||
.routes(routes!(trailer::trailer))
|
||||
.routes(routes!(roots::list, roots::create))
|
||||
.routes(routes!(roots::get, roots::update, roots::delete))
|
||||
.routes(routes!(policies::list, policies::create))
|
||||
@@ -312,6 +320,8 @@ mod tests {
|
||||
("/api/movies/{movie_id}/search", "post"),
|
||||
("/api/movies/{movie_id}/releases", "get"),
|
||||
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
||||
("/api/movies/{movie_id}/metadata", "get"),
|
||||
("/api/series/{series_id}/metadata", "get"),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/search",
|
||||
"post",
|
||||
@@ -325,6 +335,7 @@ mod tests {
|
||||
"post",
|
||||
),
|
||||
("/api/queues/attention", "get"),
|
||||
("/api/trailer", "get"),
|
||||
("/api/series", "get"),
|
||||
("/api/policies", "get"),
|
||||
("/api/policies", "post"),
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Title detail metadata for the §9.6 pages (#146). One endpoint per kind,
|
||||
//! one upstream call each through `arr-meta`'s detail methods, cached 24h and
|
||||
//! never persisted. Library titles only: search rows already carry what a
|
||||
//! results list needs.
|
||||
|
||||
use arr_meta::{MovieDetail, SeriesDetail};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::movies::{pool, ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One of the top-billed cast members on a detail page.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataCastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
/// Path fragment exactly as TMDB sends it (§9.6): the browser composes
|
||||
/// the URL and picks the size.
|
||||
pub profile_path: Option<String>,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MetadataTrailer {
|
||||
pub youtube_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Rich detail for one library movie's §9.6 page. Image paths are fragments,
|
||||
/// never composed URLs; the Rotten Tomatoes link is deliberately absent — it
|
||||
/// is a browser-built search URL, not an identifier this service holds.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MovieMetadata {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
/// Minutes.
|
||||
pub runtime: Option<u32>,
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
/// Absent when TMDB has no votes for the title (#156).
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
/// Rich detail for one library series' §9.6 page.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeriesMetadata {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
/// Episode length in minutes.
|
||||
pub runtime: Option<u32>,
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
/// Absent when TMDB has no votes for the title (#156).
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
/// Top 10 billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<MetadataCastMember>,
|
||||
pub trailer: Option<MetadataTrailer>,
|
||||
}
|
||||
|
||||
impl From<MovieDetail> for MovieMetadata {
|
||||
fn from(detail: MovieDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: detail.tmdb_id,
|
||||
overview: detail.overview,
|
||||
tagline: detail.tagline,
|
||||
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
|
||||
runtime: detail.runtime,
|
||||
status: detail.status,
|
||||
poster_path: detail.poster_path,
|
||||
backdrop_path: detail.backdrop_path,
|
||||
vote_average: detail.vote_average,
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
imdb_id: detail.imdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SeriesDetail> for SeriesMetadata {
|
||||
fn from(detail: SeriesDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: detail.tmdb_id,
|
||||
overview: detail.overview,
|
||||
tagline: detail.tagline,
|
||||
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
|
||||
runtime: detail.episode_runtime,
|
||||
status: detail.status,
|
||||
poster_path: detail.poster_path,
|
||||
backdrop_path: detail.backdrop_path,
|
||||
vote_average: detail.vote_average,
|
||||
vote_count: detail.vote_count,
|
||||
homepage: detail.homepage,
|
||||
tvdb_id: detail.tvdb_id,
|
||||
cast: detail.cast.into_iter().map(Into::into).collect(),
|
||||
trailer: detail.trailer.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::CastMember> for MetadataCastMember {
|
||||
fn from(member: arr_meta::CastMember) -> Self {
|
||||
Self {
|
||||
tmdb_id: member.tmdb_id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: member.profile_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::Video> for MetadataTrailer {
|
||||
fn from(video: arr_meta::Video) -> Self {
|
||||
Self {
|
||||
youtube_key: video.key,
|
||||
name: video.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tmdb_id(raw: i64) -> Result<u32, ApiError> {
|
||||
u32::try_from(raw).map_err(|_| ApiError::Invalid("stored tmdb_id is out of range".into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/metadata", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
responses(
|
||||
(status = 200, body = MovieMetadata),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn movie_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<MovieMetadata>, ApiError> {
|
||||
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM movies WHERE id = ?", id)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let detail = tmdb
|
||||
.movie_detail(tmdb_id(tmdb_row)?)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
Ok(Json(detail.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/metadata", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
responses(
|
||||
(status = 200, body = SeriesMetadata),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn series_metadata(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<SeriesMetadata>, ApiError> {
|
||||
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM series WHERE id = ?", id)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if matches!(error, sqlx::Error::RowNotFound) {
|
||||
ApiError::SeriesNotFound
|
||||
} else {
|
||||
error.into()
|
||||
}
|
||||
})?;
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let detail = tmdb
|
||||
.series_detail(tmdb_id(tmdb_row)?)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
Ok(Json(detail.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// App against a mocked TMDB, with a real migrated database so library
|
||||
/// rows exist to resolve local ids against.
|
||||
async fn application(tmdb: &MockServer) -> (tempfile::TempDir, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("tmdb-key".into())),
|
||||
)
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
}
|
||||
|
||||
async fn add_movie(base: &str) -> serde_json::Value {
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||
"original_language": "en", "root_id": 1
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie");
|
||||
assert_eq!(response.status(), 201);
|
||||
response.json().await.expect("movie json")
|
||||
}
|
||||
|
||||
async fn add_series(base: &str) -> serde_json::Value {
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("roots json");
|
||||
let tv_root = roots
|
||||
.iter()
|
||||
.find(|root| root["kind"] == "tv")
|
||||
.and_then(|root| root["id"].as_i64())
|
||||
.expect("a seeded TV root");
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 82_728, "title": "The Last of Us",
|
||||
"original_language": "en", "root_id": tv_root
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create series");
|
||||
assert_eq!(response.status(), 201);
|
||||
response.json().await.expect("series json")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_metadata_resolves_through_one_detail_call() {
|
||||
let tmdb = MockServer::start().await;
|
||||
// The add path fetches artwork off `movie()` (#145), which is a
|
||||
// different query against the same path. Match on the appended
|
||||
// resources so the count below is the detail call alone.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134"))
|
||||
.and(query_param("append_to_response", "release_dates"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134, "title": "Dune Part Two", "vote_average": 8.417
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134"))
|
||||
.and(query_param(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"overview": "Paul Atreides unites with the Fremen.",
|
||||
"tagline": "Long live the fighters.",
|
||||
"genres": [{"id": 878, "name": "Science Fiction"}, {"id": 12, "name": "Adventure"}],
|
||||
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"poster_path": "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg",
|
||||
"vote_average": 8.417,
|
||||
"vote_count": 3_512,
|
||||
"homepage": "https://www.duneparttwo.com",
|
||||
"status": "Released",
|
||||
"runtime": 166,
|
||||
"imdb_id": "tt15239678",
|
||||
"credits": {"cast": [
|
||||
{"id": 9_058_792, "name": "Timothée Chalamet", "character": "Paul Atreides",
|
||||
"profile_path": "/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg", "order": 0},
|
||||
{"id": 1_320_062, "name": "Zendaya", "character": "Chani",
|
||||
"profile_path": "/ultRzqcGdWXWX3rjg1up9HcsoGi.jpg", "order": 1}
|
||||
]},
|
||||
"videos": {"results": [
|
||||
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Fan Trailer", "official": false},
|
||||
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Dune: Part Two | Official Trailer", "official": true}
|
||||
]}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
let movie = add_movie(&base).await;
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
|
||||
let body: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
|
||||
assert_eq!(body["tmdb_id"], 693_134);
|
||||
assert_eq!(body["overview"], "Paul Atreides unites with the Fremen.");
|
||||
assert_eq!(body["tagline"], "Long live the fighters.");
|
||||
assert_eq!(body["genres"][0], "Science Fiction");
|
||||
assert_eq!(body["runtime"], 166);
|
||||
assert_eq!(body["status"], "Released");
|
||||
assert_eq!(body["imdb_id"], "tt15239678");
|
||||
assert_eq!(body["vote_average"], serde_json::json!(8.417));
|
||||
assert_eq!(body["vote_count"], 3_512);
|
||||
// §9.6: path fragments exactly as TMDB sends them, never composed URLs.
|
||||
assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg");
|
||||
assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg");
|
||||
assert_eq!(
|
||||
body["cast"][0]["profile_path"],
|
||||
"/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg"
|
||||
);
|
||||
assert_eq!(body["cast"][0]["character"], "Paul Atreides");
|
||||
assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_metadata_carries_the_tvdb_id_and_episode_runtime() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"overview": "Twenty years after civilization fell.",
|
||||
"tagline": "When you're lost in the darkness, look for the light.",
|
||||
"genres": [{"id": 18, "name": "Drama"}],
|
||||
"backdrop_path": "/eMhDKZscBd07OLpAeeAyu3N3U8c.jpg",
|
||||
"poster_path": "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
|
||||
"vote_average": 8.5,
|
||||
"vote_count": 2_911,
|
||||
"homepage": "",
|
||||
"status": "Returning Series",
|
||||
"episode_run_time": [55, 57],
|
||||
"external_ids": {"tvdb_id": 392_256},
|
||||
"credits": {"cast": [
|
||||
{"id": 1_320_062, "name": "Pedro Pascal", "character": "Joel Miller",
|
||||
"profile_path": "/zEmkctzuGBEbxFfOtpTAg9jtkTu.jpg", "order": 0}
|
||||
]},
|
||||
"videos": {"results": [
|
||||
{"key": "uLtkt8Bonuu", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Official Trailer", "official": true}
|
||||
]}
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
let series = add_series(&base).await;
|
||||
let series_id = series["id"].as_i64().expect("series id");
|
||||
|
||||
let body: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/metadata"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
|
||||
assert_eq!(body["tmdb_id"], 82_728);
|
||||
assert_eq!(body["tvdb_id"], 392_256);
|
||||
// TMDB sends a list of episode lengths; the API reports one runtime.
|
||||
assert_eq!(body["runtime"], 55);
|
||||
assert_eq!(body["status"], "Returning Series");
|
||||
// TMDB uses "" where null is meant; it stays null on the way out.
|
||||
assert_eq!(body["homepage"], serde_json::Value::Null);
|
||||
assert_eq!(body["poster_path"], "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg");
|
||||
assert_eq!(body["trailer"]["youtube_key"], "uLtkt8Bonuu");
|
||||
}
|
||||
|
||||
/// An id that is not in the library is a 404 before TMDB is asked —
|
||||
/// search rows are addressed through #144's trailer endpoint instead.
|
||||
#[tokio::test]
|
||||
async fn an_unknown_local_id_is_a_forty_forty_without_touching_tmdb() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let (_dir, base) = application(&tmdb).await;
|
||||
|
||||
let movie = reqwest::get(format!("{base}/api/movies/99/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(movie.status(), 404);
|
||||
assert_eq!(
|
||||
movie.json::<serde_json::Value>().await.expect("json")["error"],
|
||||
"movie not found"
|
||||
);
|
||||
|
||||
let series = reqwest::get(format!("{base}/api/series/99/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(series.status(), 404);
|
||||
assert_eq!(
|
||||
series.json::<serde_json::Value>().await.expect("json")["error"],
|
||||
"series not found"
|
||||
);
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_tmdb_key_is_a_fifty_three_like_the_rest_of_the_surface() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let movie = add_movie(&base).await;
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
}
|
||||
+208
-41
@@ -25,6 +25,11 @@ pub struct Movie {
|
||||
pub blocked: bool,
|
||||
pub search_attempts: i64,
|
||||
pub last_searched_at: Option<String>,
|
||||
/// Path fragment, not a URL; stored at add time and refreshed with
|
||||
/// metadata (#145), so pure-SQL views never need TMDB (§9.6).
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// The relaxed rule recorded when a file was imported under a waiver
|
||||
/// (§5.7), so the UI can show "English, no dub" instead of a clean match.
|
||||
pub waiver: Option<serde_json::Value>,
|
||||
@@ -100,6 +105,22 @@ pub struct AttentionQueues {
|
||||
pub tv_needs_decision: Vec<SeriesAttention>,
|
||||
}
|
||||
|
||||
/// An episode that put its series in a TV attention queue, named for the
|
||||
/// operator (`SxxEyy` comes from season and episode numbers, #140).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueuedEpisode {
|
||||
pub id: i64,
|
||||
pub season_number: i64,
|
||||
pub episode_number: i64,
|
||||
}
|
||||
|
||||
/// A season whose failed pack put its series in a TV attention queue.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueuedSeason {
|
||||
pub id: i64,
|
||||
pub number: i64,
|
||||
}
|
||||
|
||||
/// A series in a TV attention queue, with what put it there. One row per
|
||||
/// series — the notification rolls up the same way (§9.5).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -109,9 +130,9 @@ pub struct SeriesAttention {
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
/// Episodes that put the series in this queue.
|
||||
pub episodes: Vec<i64>,
|
||||
pub episodes: Vec<QueuedEpisode>,
|
||||
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
|
||||
pub seasons: Vec<i64>,
|
||||
pub seasons: Vec<QueuedSeason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -133,6 +154,9 @@ pub enum ApiError {
|
||||
OwnerNotFound,
|
||||
PolicyNotFound,
|
||||
RootNotFound,
|
||||
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
|
||||
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
||||
NoTrailer,
|
||||
Conflict(String),
|
||||
Invalid(String),
|
||||
Unavailable,
|
||||
@@ -153,6 +177,7 @@ impl IntoResponse for ApiError {
|
||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
|
||||
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
|
||||
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
|
||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||
Self::Unavailable => (
|
||||
@@ -224,7 +249,7 @@ pub(crate) fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
|
||||
}
|
||||
|
||||
async fn load_movie(state: &AppState, id: i64) -> Result<Movie, ApiError> {
|
||||
Ok(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 id = ?"#, id)
|
||||
Ok(sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id = ?"#, id)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?)
|
||||
}
|
||||
@@ -249,11 +274,11 @@ pub async fn list(
|
||||
Query(query): Query<ListMoviesQuery>,
|
||||
) -> Result<Json<Vec<Movie>>, ApiError> {
|
||||
let movies = if let Some(owner_id) = query.owner_id {
|
||||
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
|
||||
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at, m.poster_path, m.vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
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 ORDER BY title, year, id"#)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
@@ -290,10 +315,18 @@ pub async fn create(
|
||||
let overrides = serde_json::to_string(&input.overrides)
|
||||
.map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||
let title = input.title.trim();
|
||||
// §9.6: the three stored artwork fields are filled from TMDB here so a
|
||||
// title added today has a poster before tomorrow's refresh. Best effort:
|
||||
// a TMDB outage must not block an add.
|
||||
let artwork = lookup_movie_artwork(&state, input.tmdb_id).await;
|
||||
let poster_path = artwork.as_ref().and_then(|a| a.0.clone());
|
||||
let backdrop_path = artwork.as_ref().and_then(|a| a.1.clone());
|
||||
let vote_average = artwork.as_ref().and_then(|a| a.2);
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, title, input.year, input.original_language, input.root_id,
|
||||
input.wanted, input.blocked, overrides
|
||||
input.wanted, input.blocked, overrides,
|
||||
poster_path, backdrop_path, vote_average,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
@@ -303,6 +336,17 @@ pub async fn create(
|
||||
))
|
||||
}
|
||||
|
||||
/// §9.6 artwork for a new movie row, straight off the detail response.
|
||||
/// `None` when TMDB is not configured or cannot be reached.
|
||||
async fn lookup_movie_artwork(
|
||||
state: &AppState,
|
||||
tmdb_id: i64,
|
||||
) -> Option<(Option<String>, Option<String>, Option<f64>)> {
|
||||
let client = crate::search::tmdb_client(state).ok()?;
|
||||
let movie = client.movie(u32::try_from(tmdb_id).ok()?).await.ok()?;
|
||||
Some((movie.poster_path, movie.backdrop_path, movie.vote_average))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
@@ -478,7 +522,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()?;
|
||||
@@ -640,10 +684,10 @@ pub async fn grab(
|
||||
)
|
||||
)]
|
||||
pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQueues>, ApiError> {
|
||||
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
||||
@@ -667,7 +711,9 @@ async fn tv_attention(
|
||||
let no_pt_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year, e.id AS "episode_id!: i64"
|
||||
s.title AS "title!: String", s.year,
|
||||
e.id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
@@ -688,13 +734,15 @@ async fn tv_attention(
|
||||
let episode_hard_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64"
|
||||
s.title AS "title!: String", s.year,
|
||||
g.target_id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM grabs g
|
||||
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
)
|
||||
@@ -703,12 +751,13 @@ async fn tv_attention(
|
||||
let season_pack_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64"
|
||||
s.title AS "title!: String", s.year,
|
||||
g.target_id AS "season_id!: i64", se.number AS "season_number!: i64"
|
||||
FROM grabs g
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
||||
"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
@@ -720,9 +769,11 @@ async fn tv_attention(
|
||||
&mut tv_no_pt_source,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
row.title,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -732,54 +783,93 @@ async fn tv_attention(
|
||||
&mut tv_needs_decision,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
row.title,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
for row in season_pack_fails {
|
||||
match tv_needs_decision
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == row.series_id)
|
||||
{
|
||||
Some(entry) => entry.seasons.push(row.season_id),
|
||||
None => tv_needs_decision.push(SeriesAttention {
|
||||
series_id: row.series_id,
|
||||
tmdb_id: row.tmdb_id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
episodes: Vec::new(),
|
||||
seasons: vec![row.season_id],
|
||||
}),
|
||||
}
|
||||
merge_season(
|
||||
&mut tv_needs_decision,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.season_id,
|
||||
row.season_number,
|
||||
);
|
||||
}
|
||||
|
||||
Ok((tv_no_pt_source, tv_needs_decision))
|
||||
}
|
||||
|
||||
/// One more qualifying season for its series, creating the series' entry on
|
||||
/// first sight.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merge_season(
|
||||
entries: &mut Vec<SeriesAttention>,
|
||||
series_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
season_id: i64,
|
||||
season_number: i64,
|
||||
) {
|
||||
match entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == series_id)
|
||||
{
|
||||
Some(entry) => entry.seasons.push(QueuedSeason {
|
||||
id: season_id,
|
||||
number: season_number,
|
||||
}),
|
||||
None => entries.push(SeriesAttention {
|
||||
series_id,
|
||||
tmdb_id,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
episodes: Vec::new(),
|
||||
seasons: vec![QueuedSeason {
|
||||
id: season_id,
|
||||
number: season_number,
|
||||
}],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// One more qualifying episode for its series, creating the series' entry on
|
||||
/// first sight.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merge_episode(
|
||||
entries: &mut Vec<SeriesAttention>,
|
||||
series_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: String,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
episode_id: i64,
|
||||
season_number: i64,
|
||||
episode_number: i64,
|
||||
) {
|
||||
let episode = QueuedEpisode {
|
||||
id: episode_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
};
|
||||
if let Some(entry) = entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == series_id)
|
||||
{
|
||||
entry.episodes.push(episode_id);
|
||||
entry.episodes.push(episode);
|
||||
return;
|
||||
}
|
||||
entries.push(SeriesAttention {
|
||||
series_id,
|
||||
tmdb_id,
|
||||
title,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
episodes: vec![episode_id],
|
||||
episodes: vec![episode],
|
||||
seasons: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -913,6 +1003,73 @@ mod tests {
|
||||
response.json().await.expect("movie json")
|
||||
}
|
||||
|
||||
/// §9.6: the three stored artwork fields come off the detail response at
|
||||
/// add time, so a title added today has a poster before tomorrow's
|
||||
/// refresh.
|
||||
#[tokio::test]
|
||||
async fn creating_a_movie_stores_artwork_from_tmdb() {
|
||||
let tmdb = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/movie/693134"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"title": "Dune Part Two",
|
||||
"poster_path": "/dune-two.jpg",
|
||||
"backdrop_path": "/dune-two-wide.jpg",
|
||||
"vote_average": 8.152
|
||||
})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("key".into())),
|
||||
)
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let served = state.clone();
|
||||
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 693_134, "title": "Dune Part Two",
|
||||
"original_language": "en", "root_id": 2
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let movie_id = response.json::<serde_json::Value>().await.expect("json")["id"]
|
||||
.as_i64()
|
||||
.expect("movie id");
|
||||
|
||||
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
|
||||
sqlx::query_as(
|
||||
"SELECT poster_path, backdrop_path, vote_average FROM movies WHERE id = ?",
|
||||
)
|
||||
.bind(movie_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("movie row");
|
||||
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
|
||||
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
|
||||
assert_eq!(vote, Some(8.152));
|
||||
}
|
||||
|
||||
/// §5.7: a soft-failed import is imported and waived, and the waiver
|
||||
/// reaches the API — a file that merely plays must never read as a clean
|
||||
/// match.
|
||||
@@ -1350,21 +1507,31 @@ mod tests {
|
||||
.json()
|
||||
.await
|
||||
.expect("queues json");
|
||||
let episode_ids: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number")
|
||||
.bind(season_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("episode ids");
|
||||
|
||||
assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id);
|
||||
assert_eq!(
|
||||
queues["tv_no_pt_source"][0]["episodes"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(2)
|
||||
queues["tv_no_pt_source"][0]["episodes"],
|
||||
serde_json::json!([
|
||||
{ "id": episode_ids[0], "season_number": 1, "episode_number": 1 },
|
||||
{ "id": episode_ids[1], "season_number": 1, "episode_number": 2 },
|
||||
])
|
||||
);
|
||||
assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id);
|
||||
assert_eq!(
|
||||
queues["tv_needs_decision"][0]["episodes"],
|
||||
serde_json::json!([episode_id])
|
||||
serde_json::json!([
|
||||
{ "id": episode_id, "season_number": 1, "episode_number": 3 },
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
queues["tv_needs_decision"][0]["seasons"],
|
||||
serde_json::json!([season_id])
|
||||
serde_json::json!([{ "id": season_id, "number": 1 }])
|
||||
);
|
||||
|
||||
// Blocking the series empties its no-PT-source lane.
|
||||
|
||||
+128
-21
@@ -59,6 +59,10 @@ pub struct LibrarySeries {
|
||||
pub original_language: Option<String>,
|
||||
pub root_id: i64,
|
||||
pub blocked: bool,
|
||||
/// Stored artwork (§9.6), so a row renders without a TMDB call.
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -70,6 +74,14 @@ pub struct LibraryEpisode {
|
||||
pub tag: String,
|
||||
/// The episode title — what the search matched on.
|
||||
pub title: String,
|
||||
/// The series' poster — an episode has no artwork of its own worth
|
||||
/// showing at row size.
|
||||
pub poster_path: Option<String>,
|
||||
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// The series' TMDB id — the episode row's trailer chip resolves through
|
||||
/// it (#148); an episode has no videos of its own worth listing.
|
||||
pub series_tmdb_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
@@ -91,6 +103,10 @@ pub struct TmdbMovie {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -108,6 +124,10 @@ pub struct TmdbSeries {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
/// One release's score, kept as its terms so the UI can explain a ranking
|
||||
@@ -184,13 +204,13 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
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 = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
.collect()
|
||||
} else {
|
||||
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 NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
@@ -204,10 +224,10 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
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 = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
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", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
} else {
|
||||
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 NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
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", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
@@ -215,7 +235,7 @@ pub async fn search(
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so the haystack
|
||||
// is both joined and every token must land somewhere in it.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
@@ -224,14 +244,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, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE 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", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Series(found));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +311,8 @@ async fn search_tmdb(
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
})]
|
||||
}
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
@@ -316,14 +347,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)
|
||||
@@ -539,6 +580,8 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,6 +596,8 @@ impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,6 +612,8 @@ impl From<arr_meta::Series> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,7 +680,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
|
||||
.map_err(|_| ApiError::Unavailable)
|
||||
}
|
||||
|
||||
fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
match error {
|
||||
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
|
||||
_ => ApiError::Unavailable,
|
||||
@@ -797,7 +844,8 @@ mod tests {
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
|
||||
"id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two",
|
||||
"original_language": "en", "release_date": "2024-02-27"
|
||||
"original_language": "en", "release_date": "2024-02-27",
|
||||
"poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000
|
||||
}]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
@@ -824,6 +872,19 @@ mod tests {
|
||||
assert_eq!(response["library"][0]["title"], "Dune");
|
||||
assert_eq!(response["tmdb"][0]["kind"], "movie");
|
||||
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
|
||||
// §9.6: both halves carry artwork and a rating, read from stored
|
||||
// columns and TMDB's search body — no upstream call per row.
|
||||
assert_eq!(
|
||||
response["library"][0]["poster_path"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
response["library"][0]["vote_average"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg");
|
||||
assert_eq!(response["tmdb"][0]["vote_average"], 8.1);
|
||||
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
|
||||
}
|
||||
|
||||
/// §9.2: the in-library set matches series titles and episode titles, so
|
||||
@@ -950,6 +1011,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;
|
||||
|
||||
+529
-93
@@ -52,6 +52,11 @@ pub struct Series {
|
||||
/// Whether the show finished upstream, which `ended` is derived from.
|
||||
pub upstream_ended: bool,
|
||||
pub blocked: bool,
|
||||
/// Path fragment, not a URL; stored at add time and refreshed with
|
||||
/// metadata (#145), so pure-SQL views never need TMDB (§9.6).
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
|
||||
pub status: String,
|
||||
/// Episodes currently marked wanted (§4.1 — the only intent).
|
||||
@@ -97,19 +102,31 @@ pub struct Season {
|
||||
pub number: i64,
|
||||
/// §4.1. Whether new episodes of this season arrive wanted.
|
||||
pub tracked: bool,
|
||||
/// #141. The season vanished upstream while files remained under it,
|
||||
/// which is why the row still exists. A conflict for the operator to
|
||||
/// resolve; nothing was deleted from disk.
|
||||
pub vanished: bool,
|
||||
pub episodes: Vec<Episode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Episode {
|
||||
pub id: i64,
|
||||
/// The owning series — the episode deck route needs it without walking
|
||||
/// seasons first.
|
||||
pub series_id: i64,
|
||||
pub season_id: i64,
|
||||
pub season_number: i64,
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
/// §4.1. The only intent in the TV aggregate.
|
||||
pub wanted: bool,
|
||||
pub state: String,
|
||||
/// #122. The episode vanished upstream while a file of its own remained,
|
||||
/// which is why the row still exists. A conflict for the operator to
|
||||
/// resolve; nothing was deleted from disk.
|
||||
pub vanished: bool,
|
||||
pub search_attempts: i64,
|
||||
pub last_searched_at: Option<String>,
|
||||
}
|
||||
@@ -186,17 +203,24 @@ struct SeriesRow {
|
||||
overrides: serde_json::Value,
|
||||
upstream_ended: bool,
|
||||
blocked: bool,
|
||||
/// Stored at add time and refreshed with metadata (#145), so pure-SQL
|
||||
/// views never need TMDB (§9.6).
|
||||
poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
struct EpisodeRow {
|
||||
series_id: i64,
|
||||
id: i64,
|
||||
season_id: i64,
|
||||
season_number: i64,
|
||||
number: i64,
|
||||
title: String,
|
||||
air_date: Option<String>,
|
||||
wanted: bool,
|
||||
state: String,
|
||||
vanished: bool,
|
||||
search_attempts: i64,
|
||||
last_searched_at: Option<String>,
|
||||
}
|
||||
@@ -252,6 +276,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()),
|
||||
@@ -272,31 +297,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);
|
||||
@@ -312,53 +320,28 @@ 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(),
|
||||
poster_path: row.poster_path.clone(),
|
||||
vote_average: row.vote_average,
|
||||
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.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
|
||||
@@ -366,11 +349,11 @@ 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> {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series WHERE id = ?"#, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeriesNotFound)
|
||||
@@ -378,26 +361,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.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> {
|
||||
@@ -434,29 +407,21 @@ pub async fn list(
|
||||
Query(query): Query<ListSeriesQuery>,
|
||||
) -> Result<Json<Vec<Series>>, ApiError> {
|
||||
let rows = if let Some(owner_id) = query.owner_id {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.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(),
|
||||
))
|
||||
}
|
||||
@@ -485,12 +450,19 @@ pub async fn create(
|
||||
let title = input.title.trim();
|
||||
// §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not
|
||||
// knowing one must not block adding the series — the search falls back
|
||||
// to the title text query until a refresh fills it in (#121).
|
||||
let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await;
|
||||
// to the title text query until a refresh fills it in (#121). The same
|
||||
// response carries §9.6's stored artwork fields, so a series added today
|
||||
// has a poster before tomorrow's refresh. Best effort either way.
|
||||
let tmdb_series = lookup_tmdb_series(&state, input.tmdb_id).await;
|
||||
let tvdb_id = tmdb_series.as_ref().and_then(|s| s.tvdb_id).map(i64::from);
|
||||
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
|
||||
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
|
||||
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
|
||||
input.auto_track, input.upstream_ended, input.blocked, overrides
|
||||
input.auto_track, input.upstream_ended, input.blocked, overrides,
|
||||
poster_path, backdrop_path, vote_average,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
@@ -500,14 +472,10 @@ pub async fn create(
|
||||
))
|
||||
}
|
||||
|
||||
/// Best effort: `None` when TMDB has no id or cannot be reached.
|
||||
async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option<i64> {
|
||||
/// Best effort: `None` when TMDB has no such id or cannot be reached.
|
||||
async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option<arr_meta::Series> {
|
||||
let client = tmdb_client(state).ok()?;
|
||||
let ids = client
|
||||
.series_external_ids(u32::try_from(tmdb_id).ok()?)
|
||||
.await
|
||||
.ok()?;
|
||||
ids.tvdb_id.map(i64::from)
|
||||
client.series(u32::try_from(tmdb_id).ok()?).await.ok()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -584,6 +552,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?;
|
||||
@@ -593,16 +583,83 @@ 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"#,
|
||||
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.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.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
|
||||
)
|
||||
@@ -616,17 +673,21 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
||||
series_id: season.series_id,
|
||||
number: season.number,
|
||||
tracked: season.tracked,
|
||||
vanished: season.vanished,
|
||||
episodes: episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.season_id == season.id)
|
||||
.map(|episode| Episode {
|
||||
id: episode.id,
|
||||
series_id,
|
||||
season_id: episode.season_id,
|
||||
season_number: episode.season_number,
|
||||
number: episode.number,
|
||||
title: episode.title.clone(),
|
||||
air_date: episode.air_date.clone(),
|
||||
wanted: episode.wanted,
|
||||
state: episode.state.clone(),
|
||||
vanished: episode.vanished,
|
||||
search_attempts: episode.search_attempts,
|
||||
last_searched_at: episode.last_searched_at.clone(),
|
||||
})
|
||||
@@ -681,6 +742,15 @@ pub async fn create_season(
|
||||
"episode number cannot be negative".into(),
|
||||
));
|
||||
}
|
||||
// The column rejects the empty string (#153); say so before the database
|
||||
// has to.
|
||||
if input
|
||||
.episodes
|
||||
.iter()
|
||||
.any(|episode| episode.title.trim().is_empty())
|
||||
{
|
||||
return Err(ApiError::Invalid("episode title cannot be empty".into()));
|
||||
}
|
||||
let mut numbers: Vec<i64> = input
|
||||
.episodes
|
||||
.iter()
|
||||
@@ -708,6 +778,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()),
|
||||
@@ -831,7 +902,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.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
|
||||
)
|
||||
@@ -840,12 +911,15 @@ async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
|
||||
.ok_or(ApiError::EpisodeNotFound)?;
|
||||
Ok(Episode {
|
||||
id: row.id,
|
||||
series_id: row.series_id,
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
air_date: row.air_date,
|
||||
wanted: row.wanted,
|
||||
state: row.state,
|
||||
vanished: row.vanished,
|
||||
search_attempts: row.search_attempts,
|
||||
last_searched_at: row.last_searched_at,
|
||||
})
|
||||
@@ -1103,6 +1177,39 @@ pub async fn grab_season_release(
|
||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||
}
|
||||
|
||||
/// One imported episode file, keyed to its episode so the detail view can
|
||||
/// attach the file's probed §7.4 attributes to the episode row.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct EpisodeFile {
|
||||
pub episode_id: i64,
|
||||
pub path: String,
|
||||
pub size: i64,
|
||||
pub probed: Option<serde_json::Value>,
|
||||
/// The §5.7 rule relaxed to allow this import, when one was.
|
||||
pub waiver: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/files", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
responses(
|
||||
(status = 200, body = [EpisodeFile]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<EpisodeFile>>, ApiError> {
|
||||
load_series_row(&state, id).await?;
|
||||
let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: 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 = ? ORDER BY mf.path"#, id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/series/{series_id}/owners", tag = "series",
|
||||
params(("series_id" = i64, Path, description = "Series row id")),
|
||||
@@ -1255,6 +1362,81 @@ mod tests {
|
||||
response.json().await.expect("season json")
|
||||
}
|
||||
|
||||
/// §9.6: the three stored artwork fields come off the series detail
|
||||
/// response at add time — the same call that used to fetch only the
|
||||
/// TVDB id — so a series added today has a poster before tomorrow's
|
||||
/// refresh.
|
||||
#[tokio::test]
|
||||
async fn creating_a_series_stores_artwork_from_tmdb() {
|
||||
let tmdb = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/tv/82728"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"status": "Returning Series",
|
||||
"poster_path": "/bluey.jpg",
|
||||
"backdrop_path": "/bluey-wide.jpg",
|
||||
"vote_average": 8.417,
|
||||
"external_ids": {"tvdb_id": 361_391}
|
||||
})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect database");
|
||||
database.migrate().await.expect("migrate database");
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("key".into())),
|
||||
)
|
||||
.expect("state")
|
||||
.with_database(database);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let served = state.clone();
|
||||
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 82_728, "title": "Bluey",
|
||||
"original_language": "en", "root_id": 3
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create series");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
|
||||
.as_i64()
|
||||
.expect("series id");
|
||||
|
||||
let (tvdb_id, poster, backdrop, vote): (
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<f64>,
|
||||
) = sqlx::query_as(
|
||||
"SELECT tvdb_id, poster_path, backdrop_path, vote_average FROM series WHERE id = ?",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("series row");
|
||||
assert_eq!(tvdb_id, Some(361_391));
|
||||
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
|
||||
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_must_sit_on_a_tv_root() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -1319,6 +1501,28 @@ mod tests {
|
||||
assert_eq!(season["episodes"][0]["wanted"], false);
|
||||
}
|
||||
|
||||
/// #153. An empty episode title is rejected up front — the column and the
|
||||
/// TMDB boundary both refuse it, so the API must too.
|
||||
#[tokio::test]
|
||||
async fn an_empty_episode_title_is_rejected() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
for title in ["", " "] {
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.json(&serde_json::json!({"number": 1, "episodes": [
|
||||
{"number": 1, "title": title}
|
||||
]}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create season");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -1353,6 +1557,50 @@ mod tests {
|
||||
assert_eq!(season["number"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_vanished_season_reaches_the_seasons_endpoint() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let clean = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "One"}]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(clean["vanished"], false);
|
||||
add_season(
|
||||
&base,
|
||||
series_id,
|
||||
2,
|
||||
serde_json::json!([{"number": 1, "title": "One"}]),
|
||||
)
|
||||
.await;
|
||||
|
||||
sqlx::query("UPDATE seasons SET vanished = 1 WHERE series_id = ? AND number = 2")
|
||||
.bind(series_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("flag season vanished");
|
||||
|
||||
let seasons: serde_json::Value = reqwest::Client::new()
|
||||
.get(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.send()
|
||||
.await
|
||||
.expect("list seasons")
|
||||
.json()
|
||||
.await
|
||||
.expect("seasons json");
|
||||
let seasons = seasons.as_array().expect("seasons array");
|
||||
assert_eq!(seasons[0]["vanished"], false);
|
||||
assert_eq!(
|
||||
seasons[1]["vanished"], true,
|
||||
"#141: the conflict flag is not stopped at the database"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intent_is_set_on_seasons_and_on_single_episodes() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -1831,6 +2079,194 @@ 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 detail view reads its episode files through the series, keyed by
|
||||
/// episode id, so one request carries every §7.4 attribute tag it shows.
|
||||
#[tokio::test]
|
||||
async fn series_files_are_keyed_by_episode() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "kids").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": "The Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"number": 2, "title": "Hospital", "air_date": "2018-10-02"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let episodes = season["episodes"].as_array().expect("episodes");
|
||||
let first = episodes[0]["id"].as_i64().expect("episode id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
sqlx::query(
|
||||
r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
|
||||
VALUES ('episode', ?, ?, 7, ?, ?)",
|
||||
)
|
||||
.bind(first)
|
||||
.bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv")
|
||||
.bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#)
|
||||
.bind(r#"{"rule":"required_audio"}"#)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/series/{series_id}/files"))
|
||||
.await
|
||||
.expect("fetch files");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let files: serde_json::Value = response.json().await.expect("files json");
|
||||
let rows = files.as_array().expect("array");
|
||||
assert_eq!(rows.len(), 1, "only imported episodes carry a file");
|
||||
assert_eq!(rows[0]["episode_id"], first);
|
||||
assert_eq!(rows[0]["probed"]["resolution"], "1080p");
|
||||
assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT");
|
||||
assert_eq!(rows[0]["waiver"], "required_audio");
|
||||
|
||||
let missing = reqwest::get(format!("{base}/api/series/999/files"))
|
||||
.await
|
||||
.expect("fetch files");
|
||||
assert_eq!(missing.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!(
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::movies::{ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct TrailerQuery {
|
||||
kind: TrailerKind,
|
||||
tmdb_id: u32,
|
||||
}
|
||||
|
||||
/// Which namespace the TMDB id names. Movie and series ids are independent
|
||||
/// numbering spaces at TMDB.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrailerKind {
|
||||
Movie,
|
||||
Tv,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing (§9.6), resolved on click rather than
|
||||
/// prefetched: TMDB's search responses carry no videos.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Trailer {
|
||||
pub youtube_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/trailer", tag = "search", params(TrailerQuery),
|
||||
responses(
|
||||
(status = 200, body = Trailer),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn trailer(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TrailerQuery>,
|
||||
) -> Result<Json<Trailer>, ApiError> {
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let video = match query.kind {
|
||||
TrailerKind::Movie => tmdb.movie_videos(query.tmdb_id).await,
|
||||
TrailerKind::Tv => tmdb.series_videos(query.tmdb_id).await,
|
||||
}
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
match video {
|
||||
Some(video) => Ok(Json(Trailer {
|
||||
youtube_key: video.key,
|
||||
name: video.name,
|
||||
})),
|
||||
None => Err(ApiError::NoTrailer),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Serve the app against a mocked TMDB. No database: the handler never
|
||||
/// touches one, because the chip addresses titles that have no local row.
|
||||
async fn application(tmdb: &MockServer) -> String {
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("tmdb-key".into())),
|
||||
)
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_trailer_resolves_through_the_videos_call() {
|
||||
let tmdb = MockServer::start().await;
|
||||
// One request, videos only — §9.6 keeps the click cost at one call.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"results": [
|
||||
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Fan Trailer", "official": false},
|
||||
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Official Trailer", "official": true}
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "Way9Dexny3w");
|
||||
assert_eq!(response["name"], "Official Trailer");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_trailer_resolves_through_the_same_rule() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"results": [
|
||||
{"key": "abc123", "site": "YouTube", "type": "Teaser",
|
||||
"name": "Series Teaser", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=tv&tmdb_id=82728"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "abc123");
|
||||
assert_eq!(response["name"], "Series Teaser");
|
||||
}
|
||||
|
||||
/// A title TMDB knows but has no `YouTube` trailer for is an ordinary
|
||||
/// outcome: the browser renders "no trailer", so 404, never a 5xx.
|
||||
#[tokio::test]
|
||||
async fn a_title_without_a_trailer_is_a_forty_forty() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 1,
|
||||
"results": [
|
||||
{"key": "clip", "site": "Vimeo", "type": "Clip",
|
||||
"name": "A Clip", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 404);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["error"], "no trailer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upstream_outage_is_not_mistaken_for_no_trailer() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_tmdb_key_behaves_like_the_rest_of_the_search_surface() {
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
}
|
||||
@@ -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![
|
||||
@@ -621,6 +653,10 @@ mod tests {
|
||||
"Fallout.S01.1080p.BluRay.x264-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
(
|
||||
"Fallout.S01-S01.1080p.BluRay.x264-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
];
|
||||
for (name, shape) in cases {
|
||||
assert_eq!(
|
||||
@@ -639,6 +675,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `COMPLETE` before the season tag used to stay in the title and
|
||||
/// break the exact-title comparison (#135).
|
||||
#[test]
|
||||
fn a_pack_marker_before_the_tag_still_matches() {
|
||||
let wanted = WantedEpisode {
|
||||
id: EpisodeId(10),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
title: "Bluey".to_owned(),
|
||||
season: 1,
|
||||
episode: 3,
|
||||
};
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"),
|
||||
),
|
||||
Some(EpisodeMatch {
|
||||
episode: EpisodeId(10),
|
||||
kind: MatchKind::TitleAndYear,
|
||||
shape: MatchShape::SeasonPack,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation, case and accents in the series title do not block the
|
||||
/// match.
|
||||
#[test]
|
||||
@@ -808,9 +870,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `S01-S01` does not parse as a claim (`arr-parse` takes only a real
|
||||
/// range), but the lifted rule is what a degenerate range would mean:
|
||||
/// one season's pack, not a multi-season one.
|
||||
/// A degenerate range means one season's pack, not a multi-season one.
|
||||
#[test]
|
||||
fn a_degenerate_season_range_is_this_seasons_pack() {
|
||||
use super::{is_single_episode, is_this_seasons_pack};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+194
-37
@@ -183,6 +183,50 @@ impl GrabAction {
|
||||
let tmdb_id =
|
||||
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
|
||||
let metadata = tmdb.movie(tmdb_id).await?;
|
||||
let changed = self.store_metadata(database, &movie, &metadata).await?;
|
||||
if changed {
|
||||
tracing::info!(
|
||||
movie_id = movie.id,
|
||||
"metadata changed; reset targeted search backoff"
|
||||
);
|
||||
}
|
||||
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
|
||||
if !released {
|
||||
tracing::debug!(
|
||||
movie_id = movie.id,
|
||||
"digital release has not happened; skipping targeted search"
|
||||
);
|
||||
}
|
||||
let title = metadata.title.clone();
|
||||
Ok((
|
||||
PendingMovie {
|
||||
id: movie.id,
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title,
|
||||
year: metadata.year().map(i64::from),
|
||||
original_language: (!metadata.original_language.is_empty())
|
||||
.then_some(metadata.original_language.clone()),
|
||||
search_attempts: if changed { 0 } else { movie.search_attempts },
|
||||
last_searched_at: if changed {
|
||||
None
|
||||
} else {
|
||||
movie.last_searched_at
|
||||
},
|
||||
digital_release: metadata.digital_release.map(|date| date.to_string()),
|
||||
metadata_refreshed_at: None,
|
||||
},
|
||||
released,
|
||||
))
|
||||
}
|
||||
|
||||
/// Write one refresh's fields to the row, guarded so unchanged data moves
|
||||
/// nothing. Returns whether anything did.
|
||||
async fn store_metadata(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie: &PendingMovie,
|
||||
metadata: &arr_meta::Movie,
|
||||
) -> Result<bool, GrabError> {
|
||||
let title = metadata.title.clone();
|
||||
let year = metadata.year().map(i64::from);
|
||||
let original_language =
|
||||
@@ -191,43 +235,53 @@ impl GrabAction {
|
||||
// §6.2: the id RSS matching prefers, and the one Torznab movie
|
||||
// searches take. TMDB does not know one for every title.
|
||||
let imdb_id = metadata.imdb_id.clone();
|
||||
// §9.6: these three are the exception to "rich detail is not
|
||||
// persisted" — pure-SQL views render artwork without a TMDB call.
|
||||
let poster_path = metadata.poster_path.clone();
|
||||
let backdrop_path = metadata.backdrop_path.clone();
|
||||
let vote_average = metadata.vote_average;
|
||||
let title_ref = title.as_str();
|
||||
let original_language_ref = original_language.as_deref();
|
||||
let digital_release_ref = digital_release.as_deref();
|
||||
let imdb_id_ref = imdb_id.as_deref();
|
||||
let poster_path_ref = poster_path.as_deref();
|
||||
let backdrop_path_ref = backdrop_path.as_deref();
|
||||
let changed = sqlx::query!(
|
||||
r#"UPDATE movies
|
||||
SET title = ?, year = ?, original_language = ?, digital_release = ?,
|
||||
imdb_id = ?,
|
||||
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
|
||||
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ? AND (
|
||||
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
|
||||
OR digital_release IS NOT ? OR imdb_id IS NOT ?
|
||||
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
|
||||
OR vote_average IS NOT ?
|
||||
)"#,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
movie.id,
|
||||
title_ref,
|
||||
year,
|
||||
original_language_ref,
|
||||
digital_release_ref,
|
||||
imdb_id_ref,
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 0;
|
||||
if changed {
|
||||
tracing::info!(
|
||||
movie_id = movie.id,
|
||||
"metadata changed; reset targeted search backoff"
|
||||
);
|
||||
} else {
|
||||
if !changed {
|
||||
// Still stamp the refresh even when nothing changed, or the TTL
|
||||
// gate above never engages and every tick pays for TMDB again.
|
||||
sqlx::query!(
|
||||
@@ -238,31 +292,7 @@ impl GrabAction {
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
|
||||
if !released {
|
||||
tracing::debug!(
|
||||
movie_id = movie.id,
|
||||
"digital release has not happened; skipping targeted search"
|
||||
);
|
||||
}
|
||||
Ok((
|
||||
PendingMovie {
|
||||
id: movie.id,
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title,
|
||||
year,
|
||||
original_language,
|
||||
search_attempts: if changed { 0 } else { movie.search_attempts },
|
||||
last_searched_at: if changed {
|
||||
None
|
||||
} else {
|
||||
movie.last_searched_at
|
||||
},
|
||||
digital_release,
|
||||
metadata_refreshed_at: None,
|
||||
},
|
||||
released,
|
||||
))
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Search every indexer for one title, cache each candidate with its
|
||||
@@ -908,7 +938,7 @@ struct PendingMovie {
|
||||
/// The eligible view of a stored release, ranked for selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Eligible {
|
||||
id: i64,
|
||||
pub(crate) id: i64,
|
||||
pub(crate) indexer_id: i64,
|
||||
pub(crate) guid: String,
|
||||
pub(crate) name: String,
|
||||
@@ -1121,7 +1151,7 @@ pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&st
|
||||
last_searched_at.with_timezone(&chrono::Utc) + backoff <= chrono::Utc::now()
|
||||
}
|
||||
|
||||
fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
|
||||
pub(crate) fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
|
||||
let Some(refreshed_at) = metadata_refreshed_at else {
|
||||
return true;
|
||||
};
|
||||
@@ -1179,7 +1209,9 @@ pub(crate) async fn store_release(
|
||||
}
|
||||
|
||||
/// The TV counterpart: one release row, associated with every episode the
|
||||
/// claim covers — a season pack matches the whole season.
|
||||
/// claim covers — a season pack matches the whole season. Returns the
|
||||
/// release id alongside the eligible view; the id is what links a season
|
||||
/// pack into `season_releases`, which lists rejected candidates too.
|
||||
pub(crate) async fn store_episode_release(
|
||||
database: &Db,
|
||||
episode_ids: &[i64],
|
||||
@@ -1188,7 +1220,7 @@ pub(crate) async fn store_episode_release(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1208,7 +1240,7 @@ pub(crate) async fn store_episode_release(
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(eligible)
|
||||
Ok((release_id, eligible))
|
||||
}
|
||||
|
||||
async fn classify_and_store(
|
||||
@@ -2415,6 +2447,131 @@ mod tests {
|
||||
assert_eq!(targeted_searches(&indexer).await, 3);
|
||||
}
|
||||
|
||||
/// §9.6: the daily refresh also writes the three stored artwork fields,
|
||||
/// so pure-SQL views render a poster without a TMDB call per row.
|
||||
#[tokio::test]
|
||||
async fn metadata_refresh_writes_the_stored_artwork_fields() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = empty_prowlarr().await;
|
||||
let artwork_metadata = RELEASED_METADATA.replace(
|
||||
r#""original_language": "en","#,
|
||||
r#""original_language": "en",
|
||||
"poster_path": "/dune-two.jpg",
|
||||
"backdrop_path": "/dune-two-wide.jpg",
|
||||
"vote_average": 8.152,"#,
|
||||
);
|
||||
let metadata = tmdb(&artwork_metadata).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
|
||||
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
|
||||
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
|
||||
assert_eq!(vote, Some(8.152));
|
||||
|
||||
// Idempotent: an unchanged refresh neither rewrites nor re-reports,
|
||||
// same as the fields above it.
|
||||
sqlx::query(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let unchanged: (Option<String>, Option<String>, Option<f64>) =
|
||||
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unchanged, (poster, backdrop, vote));
|
||||
}
|
||||
|
||||
/// TMDB reports `vote_average: 0` for a title nobody has rated — absence,
|
||||
/// not zero (#156). It must store as NULL, be overwritten when votes
|
||||
/// arrive, and go back to NULL if they are withdrawn.
|
||||
#[tokio::test]
|
||||
async fn metadata_refresh_stores_an_unrated_title_as_null() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = empty_prowlarr().await;
|
||||
let unrated = || {
|
||||
RELEASED_METADATA.replace(
|
||||
r#""original_language": "en","#,
|
||||
r#""original_language": "en",
|
||||
"poster_path": "/dune-two.jpg",
|
||||
"backdrop_path": "/dune-two-wide.jpg",
|
||||
"vote_average": 0.0,"#,
|
||||
)
|
||||
};
|
||||
let rated = || {
|
||||
RELEASED_METADATA.replace(
|
||||
r#""original_language": "en","#,
|
||||
r#""original_language": "en",
|
||||
"poster_path": "/dune-two.jpg",
|
||||
"backdrop_path": "/dune-two-wide.jpg",
|
||||
"vote_average": 8.152,"#,
|
||||
)
|
||||
};
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let vote: Option<f64> =
|
||||
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(vote, None);
|
||||
|
||||
// Votes arrive: the NULL is overwritten.
|
||||
sqlx::query(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
action_with_tmdb(&indexer, &downloader, &tmdb(&rated()).await)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let vote: Option<f64> =
|
||||
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(vote, Some(8.152));
|
||||
|
||||
// And withdrawn again.
|
||||
sqlx::query(
|
||||
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let vote: Option<f64> =
|
||||
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(vote, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_changes_reset_a_title_backoff() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod notify;
|
||||
mod reaper;
|
||||
pub mod reconcile;
|
||||
mod rss;
|
||||
mod series_refresh;
|
||||
mod tv_grab;
|
||||
mod web;
|
||||
|
||||
@@ -31,6 +32,7 @@ use notify::Notifier;
|
||||
use reaper::ReaperAction;
|
||||
use reconcile::{ReconcileLoop, Tick};
|
||||
use rss::RssAction;
|
||||
use series_refresh::SeriesRefreshAction;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tv_grab::TvGrabAction;
|
||||
|
||||
@@ -121,7 +123,7 @@ async fn run() -> Result<(), Error> {
|
||||
None
|
||||
};
|
||||
let notifier = Notifier::new(config.ntfy_url.clone())?;
|
||||
let (reconcile, manual_grab) =
|
||||
let (reconcile, manual_grab, manual_tv) =
|
||||
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?;
|
||||
|
||||
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
|
||||
@@ -155,10 +157,17 @@ async fn run() -> Result<(), Error> {
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone()));
|
||||
// Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so
|
||||
// the manual-search and manual-grab endpoints were a no-op — the command
|
||||
// sat in the 64-slot buffer forever.
|
||||
let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx));
|
||||
// Issue #107: nothing else drains `AppState`'s command channels, so the
|
||||
// manual-search and manual-grab endpoints were a no-op — the command sat
|
||||
// in the 64-slot buffer forever. Issue #132: the episode and season
|
||||
// channels are drained by the same lane.
|
||||
let mut manual_task = tokio::spawn(manual::run(
|
||||
state,
|
||||
database,
|
||||
manual_grab,
|
||||
manual_tv,
|
||||
shutdown_rx,
|
||||
));
|
||||
let signal_tx = shutdown_tx.clone();
|
||||
let server = async move {
|
||||
axum::serve(listener, app)
|
||||
@@ -196,17 +205,18 @@ async fn run() -> Result<(), Error> {
|
||||
/// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not
|
||||
/// configured stays unregistered rather than failing every tick.
|
||||
///
|
||||
/// Also returns a second, independent `GrabAction` for `manual::run` (issue
|
||||
/// #107) — the manual trigger needs the same search-and-grab path on demand
|
||||
/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register`
|
||||
/// takes ownership of the one it ticks.
|
||||
/// Also returns independent action instances for `manual::run` (issues #107
|
||||
/// and #132) — the manual trigger needs the same search-and-grab paths on
|
||||
/// demand rather than on the reconcile tick's schedule, and
|
||||
/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie
|
||||
/// one needs TMDB too; TV grabbing does not (§6.2).
|
||||
fn reconcile_loop(
|
||||
database: &Db,
|
||||
config: &Config,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
tmdb: Option<&Arc<TmdbClient>>,
|
||||
notifier: &Notifier,
|
||||
) -> Result<(ReconcileLoop, Option<GrabAction>), Error> {
|
||||
) -> Result<(ReconcileLoop, Option<GrabAction>, Option<TvGrabAction>), Error> {
|
||||
let reconcile = ReconcileLoop::new(database.clone());
|
||||
let seeding = SeedingRules::new(
|
||||
SeedingLimits {
|
||||
@@ -233,7 +243,7 @@ fn reconcile_loop(
|
||||
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
|
||||
.transpose()?;
|
||||
|
||||
let (mut reconcile, manual_grab) = register_movie_grab(
|
||||
let (reconcile, manual_grab) = register_movie_grab(
|
||||
reconcile,
|
||||
prowlarr.as_ref(),
|
||||
transmission,
|
||||
@@ -241,20 +251,8 @@ fn reconcile_loop(
|
||||
&seeding,
|
||||
tmdb,
|
||||
);
|
||||
// TV grabbing needs no TMDB at grab time: air dates are already on the
|
||||
// episode rows, which is the same gate the digital release date is for
|
||||
// movies (§6.2).
|
||||
if let Some(prowlarr) = prowlarr.as_ref() {
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
let (mut reconcile, manual_tv) =
|
||||
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding);
|
||||
// RSS needs no TMDB: it matches what the feeds already carry against the
|
||||
// wanted list (§6.2).
|
||||
if let Some(prowlarr) = prowlarr {
|
||||
@@ -268,6 +266,13 @@ fn reconcile_loop(
|
||||
),
|
||||
);
|
||||
}
|
||||
// §8: metadata refresh is its own daily lane, staggered against the
|
||||
// other ticks. Without TMDB there is nothing to refresh from.
|
||||
if let Some(tmdb) = tmdb {
|
||||
reconcile = reconcile.register(Tick::Metadata, SeriesRefreshAction::new(Arc::clone(tmdb)));
|
||||
} else {
|
||||
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
|
||||
}
|
||||
let jellyfin = jellyfin::JellyfinClient::new(
|
||||
config.jellyfin_url.clone(),
|
||||
config.jellyfin_api_key.clone(),
|
||||
@@ -313,7 +318,34 @@ fn reconcile_loop(
|
||||
}
|
||||
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
|
||||
Ok((reconcile, manual_grab))
|
||||
Ok((reconcile, manual_grab, manual_tv))
|
||||
}
|
||||
|
||||
/// Register the TV grab lane on `reconcile` and hand back a second,
|
||||
/// independent instance for `manual::run` (issue #132). `None` when Prowlarr
|
||||
/// is not configured. TV grabbing needs no TMDB at grab time: air dates are
|
||||
/// already on the episode rows, which is the same gate the digital release
|
||||
/// date is for movies (§6.2).
|
||||
fn register_tv_grab(
|
||||
mut reconcile: ReconcileLoop,
|
||||
prowlarr: Option<&arr_indexer::ProwlarrClient>,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
config: &Config,
|
||||
seeding: &SeedingRules,
|
||||
) -> (ReconcileLoop, Option<TvGrabAction>) {
|
||||
let Some(prowlarr) = prowlarr else {
|
||||
return (reconcile, None);
|
||||
};
|
||||
let tv_grab_action = || {
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
)
|
||||
};
|
||||
reconcile = reconcile.register(Tick::Reconcile, tv_grab_action());
|
||||
(reconcile, Some(tv_grab_action()))
|
||||
}
|
||||
|
||||
/// Register the movie grab lane on `reconcile` and hand back a second,
|
||||
|
||||
+102
-20
@@ -1,23 +1,33 @@
|
||||
//! Drains `AppState`'s `MovieCommand` channel — the daemon-side consumer
|
||||
//! DESIGN.md §6.2 and §9.3 assume exists (issue #107). `Search` resets the
|
||||
//! movie's backoff and re-runs the targeted-search + grab lane immediately;
|
||||
//! Drains `AppState`'s three command channels — the daemon-side consumer
|
||||
//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search`
|
||||
//! resets backoff and re-runs the targeted-search + grab lane immediately;
|
||||
//! `Grab` sends an already-chosen release straight to Transmission, skipping
|
||||
//! search.
|
||||
//! search. Movies, episodes and seasons share one lane because they share
|
||||
//! one operator waiting on a 202.
|
||||
|
||||
use arr_api::{AppState, MovieCommand};
|
||||
use arr_api::{AppState, EpisodeCommand, MovieCommand, SeasonCommand};
|
||||
use arr_db::Db;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::grab::{GrabAction, GrabError};
|
||||
use crate::tv_grab::TvGrabAction;
|
||||
|
||||
/// Run until every sender is dropped or `shutdown` fires. `grab` is `None`
|
||||
/// when Prowlarr or TMDB is not configured (`main.rs` warns about this
|
||||
/// already for the reconcile lane) — commands are still drained so the
|
||||
/// channel never fills, they just cannot be acted on.
|
||||
/// Which channel a drained command came off.
|
||||
enum Command {
|
||||
Movie(MovieCommand),
|
||||
Episode(EpisodeCommand),
|
||||
Season(SeasonCommand),
|
||||
}
|
||||
|
||||
/// Run until every sender is dropped or `shutdown` fires. Either action is
|
||||
/// `None` when Prowlarr (or TMDB, for movies) is not configured (`main.rs`
|
||||
/// warns about this already for the reconcile lanes) — commands are still
|
||||
/// drained so the channels never fill, they just cannot be acted on.
|
||||
pub async fn run(
|
||||
state: AppState,
|
||||
database: Db,
|
||||
grab: Option<GrabAction>,
|
||||
movies: Option<GrabAction>,
|
||||
tv: Option<TvGrabAction>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
@@ -29,22 +39,54 @@ pub async fn run(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
command = state.next_movie_command() => command,
|
||||
command = state.next_movie_command() => command.map(Command::Movie),
|
||||
command = state.next_episode_command() => command.map(Command::Episode),
|
||||
command = state.next_season_command() => command.map(Command::Season),
|
||||
};
|
||||
let Some(command) = command else {
|
||||
return;
|
||||
};
|
||||
let Some(grab) = &grab else {
|
||||
tracing::warn!("manual movie command received but Prowlarr or TMDB is not configured");
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = handle(grab, &database, command).await {
|
||||
tracing::error!(%error, "manual movie command failed");
|
||||
match command {
|
||||
Command::Movie(command) => {
|
||||
let Some(movies) = &movies else {
|
||||
tracing::warn!(
|
||||
"manual movie command received but Prowlarr or TMDB is not configured"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = handle_movie(movies, &database, command).await {
|
||||
tracing::error!(%error, "manual movie command failed");
|
||||
}
|
||||
}
|
||||
Command::Episode(command) => {
|
||||
let Some(tv) = &tv else {
|
||||
tracing::warn!(
|
||||
"manual episode command received but Prowlarr is not configured"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = handle_episode(tv, &database, command).await {
|
||||
tracing::error!(%error, "manual episode command failed");
|
||||
}
|
||||
}
|
||||
Command::Season(command) => {
|
||||
let Some(tv) = &tv else {
|
||||
tracing::warn!("manual season command received but Prowlarr is not configured");
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = handle_season(tv, &database, command).await {
|
||||
tracing::error!(%error, "manual season command failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Result<(), GrabError> {
|
||||
async fn handle_movie(
|
||||
grab: &GrabAction,
|
||||
database: &Db,
|
||||
command: MovieCommand,
|
||||
) -> Result<(), GrabError> {
|
||||
match command {
|
||||
MovieCommand::Search { movie_id } => {
|
||||
grab.search_now(database, movie_id).await?;
|
||||
@@ -60,6 +102,46 @@ async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_episode(
|
||||
tv: &TvGrabAction,
|
||||
database: &Db,
|
||||
command: EpisodeCommand,
|
||||
) -> Result<(), GrabError> {
|
||||
match command {
|
||||
EpisodeCommand::Search { episode_id } => {
|
||||
tv.search_episode_now(database, episode_id).await?;
|
||||
}
|
||||
EpisodeCommand::Grab {
|
||||
episode_id,
|
||||
release_id,
|
||||
} => {
|
||||
tv.grab_episode_release_now(database, episode_id, release_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_season(
|
||||
tv: &TvGrabAction,
|
||||
database: &Db,
|
||||
command: SeasonCommand,
|
||||
) -> Result<(), GrabError> {
|
||||
match command {
|
||||
SeasonCommand::Search { season_id } => {
|
||||
tv.search_season_now(database, season_id).await?;
|
||||
}
|
||||
SeasonCommand::Grab {
|
||||
season_id,
|
||||
release_id,
|
||||
} => {
|
||||
tv.grab_season_release_now(database, season_id, release_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
@@ -160,7 +242,7 @@ mod tests {
|
||||
let transmission = transmission().await;
|
||||
let grab = grab_action(&indexer, &transmission);
|
||||
|
||||
handle(&grab, &database, MovieCommand::Search { movie_id: 1 })
|
||||
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -218,7 +300,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
handle(
|
||||
handle_movie(
|
||||
&grab,
|
||||
&database,
|
||||
MovieCommand::Grab {
|
||||
|
||||
+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())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -415,7 +415,7 @@ impl TvGrabAction {
|
||||
if covered.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let stored = store_episode_release(
|
||||
let (stored_id, stored) = store_episode_release(
|
||||
database,
|
||||
&covered,
|
||||
&release,
|
||||
@@ -425,6 +425,22 @@ impl TvGrabAction {
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
// A pack for exactly this season belongs on the season deck too
|
||||
// (§9.3, issue #125) — including when it is rejected, which the
|
||||
// deck shows greyed out.
|
||||
if claim
|
||||
.as_ref()
|
||||
.is_some_and(|claim| is_this_seasons_pack(claim, season.season_number_u32()))
|
||||
{
|
||||
sqlx::query!(
|
||||
"INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING",
|
||||
season.season_id,
|
||||
stored_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
candidates.push(TvCandidate {
|
||||
claim,
|
||||
eligible: stored,
|
||||
@@ -432,6 +448,229 @@ impl TvGrabAction {
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// The manual episode trigger (§6.2, §9.3, issue #132): reset the
|
||||
/// episode's backoff so selection cannot skip it, then sweep the
|
||||
/// indexers scoped to this one episode instead of the tick's
|
||||
/// `EPISODE_SEARCHES_PER_TICK` batch.
|
||||
///
|
||||
/// Same shape as the movie trigger: a gap gets search and grab; an
|
||||
/// already-satisfied episode gets its deck refreshed and no grab; a
|
||||
/// blocked series is refused outright (§6.3). An unaired episode is not
|
||||
/// a gap yet — nothing to close until it airs (§6.2).
|
||||
pub(crate) async fn search_episode_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<(), GrabError> {
|
||||
let Some(episode) = manual_episode(database, episode_id).await? else {
|
||||
tracing::info!(
|
||||
episode_id,
|
||||
"manual episode search refused: unknown or blocked episode"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(original_language) = episode.season.original_language.as_deref() else {
|
||||
tracing::warn!(
|
||||
series = episode.season.series_title,
|
||||
"no original language yet; not searching"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
let Some(loaded) = database.episode_policy(episode_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
reset_episode_backoff(database, episode_id).await?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let episodes = season_episodes(database, episode.season.season_id).await?;
|
||||
let selector = TvSelector::Episode {
|
||||
season: episode.season.season_number_u32(),
|
||||
episode: u32::try_from(episode.number).unwrap_or_default(),
|
||||
};
|
||||
|
||||
if !episode.is_gap() {
|
||||
let candidates = self
|
||||
.search(
|
||||
database,
|
||||
&episode.season,
|
||||
&episodes,
|
||||
selector,
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
record_episode_search(database, &[episode_id]).await?;
|
||||
tracing::info!(
|
||||
episode_id,
|
||||
eligible = candidates.len(),
|
||||
"episode release deck refreshed; not grabbing a satisfied episode"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !episode.aired() {
|
||||
tracing::info!(episode_id, "episode has not aired; nothing to search for");
|
||||
return Ok(());
|
||||
}
|
||||
let Some(this_one) = episodes.iter().find(|row| row.id == episode_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.grab_episodes(
|
||||
database,
|
||||
&episode.season,
|
||||
&episodes,
|
||||
std::slice::from_ref(&this_one),
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
usize::MAX,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manual one-click episode grab (§9.3, issue #132): the release is
|
||||
/// already chosen off the episode deck, so this skips search and scoring
|
||||
/// and sends it straight to Transmission. A manual grab may take a
|
||||
/// `waived` release (§9.3), never a `rejected` one.
|
||||
pub(crate) async fn grab_episode_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let Some(loaded) = database.episode_policy(episode_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(title) = episode_series_title(database, episode_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(release) = load_episode_release(database, episode_id, release_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
self.grabber
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
scope: GrabScope::Episode { episode_id },
|
||||
title: &title,
|
||||
counts_as_attempt: false,
|
||||
},
|
||||
&loaded,
|
||||
&blacklist,
|
||||
release,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
||||
/// reset every open episode's backoff and run the same pack-or-fall-back
|
||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
||||
///
|
||||
/// A satisfied season — nothing open, or a grab already in flight —
|
||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||
pub(crate) async fn search_season_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
) -> Result<(), GrabError> {
|
||||
let Some(season) = manual_season(database, season_id).await? else {
|
||||
tracing::info!(
|
||||
season_id,
|
||||
"manual season search refused: unknown or blocked season"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(original_language) = season.original_language.as_deref() else {
|
||||
tracing::warn!(
|
||||
series = season.series_title,
|
||||
"no original language yet; not searching"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
let Some(loaded) = database.season_policy(season_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
reset_season_backoff(database, season_id).await?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
|
||||
if !season.is_gap {
|
||||
let episodes = season_episodes(database, season_id).await?;
|
||||
let open: Vec<i64> = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
|
||||
.map(|episode| episode.id)
|
||||
.collect();
|
||||
let candidates = self
|
||||
.search(
|
||||
database,
|
||||
&season,
|
||||
&episodes,
|
||||
TvSelector::Season {
|
||||
season: season.season_number_u32(),
|
||||
},
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
record_episode_search(database, &open).await?;
|
||||
tracing::info!(
|
||||
season_id,
|
||||
eligible = candidates.len(),
|
||||
"season release deck refreshed; not grabbing a satisfied season"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manual one-click season grab (§9.3, issue #125): a chosen pack
|
||||
/// goes straight to Transmission against the season's still-open gaps.
|
||||
pub(crate) async fn grab_season_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let Some(loaded) = database.season_policy(season_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(title) = season_series_title(database, season_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(release) = load_season_release(database, season_id, release_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let episode_ids: Vec<i64> = season_episodes(database, season_id)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
|
||||
.map(|episode| episode.id)
|
||||
.collect();
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
self.grabber
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
scope: GrabScope::Season {
|
||||
season_id,
|
||||
episode_ids,
|
||||
},
|
||||
title: &title,
|
||||
counts_as_attempt: false,
|
||||
},
|
||||
&loaded,
|
||||
&blacklist,
|
||||
release,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for TvGrabAction {
|
||||
@@ -593,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result<Vec<SeasonEpis
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One episode named by a manual command (#132), with the series context a
|
||||
/// targeted search needs. Same gap facts as [`season_episodes`] plus the
|
||||
/// lane decision. Blocked refuses upstream of here.
|
||||
#[derive(Debug)]
|
||||
struct ManualEpisode {
|
||||
number: i64,
|
||||
air_date: Option<String>,
|
||||
wanted: bool,
|
||||
has_file: bool,
|
||||
in_flight: bool,
|
||||
season: PendingSeason,
|
||||
}
|
||||
|
||||
impl ManualEpisode {
|
||||
/// Wanted with neither a file nor a grab in flight — a gap the manual
|
||||
/// trigger may close. Anything else only gets a deck refresh.
|
||||
fn is_gap(&self) -> bool {
|
||||
self.wanted && !self.has_file && !self.in_flight
|
||||
}
|
||||
|
||||
/// Unknown is unaired (§6.2): nothing to close until it airs.
|
||||
fn aired(&self) -> bool {
|
||||
air_date_time(self.air_date.as_deref())
|
||||
.is_some_and(|date| date <= std::time::SystemTime::now())
|
||||
}
|
||||
}
|
||||
|
||||
/// Same columns as [`pending_seasons`], scoped to one id, without the tick's
|
||||
/// batch limit and without the gap filter — the manual trigger names the
|
||||
/// season, so what remains decides the lane here instead of hiding it.
|
||||
async fn manual_episode(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<Option<ManualEpisode>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT e.id AS "id!: i64",
|
||||
e.number AS "number!: i64",
|
||||
e.air_date,
|
||||
e.wanted AS "wanted!: bool",
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
) AS "has_file!: bool",
|
||||
EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'episode' AND g.target_id = e.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
) AS "in_flight!: bool",
|
||||
se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.tvdb_id AS series_tvdb_id,
|
||||
s.original_language
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE e.id = ?
|
||||
AND s.blocked = 0
|
||||
"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| ManualEpisode {
|
||||
number: row.number,
|
||||
air_date: row.air_date,
|
||||
wanted: row.wanted,
|
||||
has_file: row.has_file,
|
||||
in_flight: row.in_flight,
|
||||
season: PendingSeason {
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_title: row.series_title,
|
||||
series_tvdb_id: row.series_tvdb_id,
|
||||
original_language: row.original_language,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// A season addressed by a manual command, with whether it is still a gap.
|
||||
async fn manual_season(
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
) -> Result<Option<PendingSeasonGap>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.tvdb_id AS series_tvdb_id,
|
||||
s.original_language,
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND 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')
|
||||
)
|
||||
) AS "is_gap!: bool"
|
||||
FROM seasons se
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE se.id = ?
|
||||
AND s.blocked = 0
|
||||
"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| PendingSeasonGap {
|
||||
season: PendingSeason {
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_title: row.series_title,
|
||||
series_tvdb_id: row.series_tvdb_id,
|
||||
original_language: row.original_language,
|
||||
},
|
||||
is_gap: row.is_gap,
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`PendingSeason`] plus the lane decision for the manual trigger.
|
||||
struct PendingSeasonGap {
|
||||
season: PendingSeason,
|
||||
is_gap: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for PendingSeasonGap {
|
||||
type Target = PendingSeason;
|
||||
|
||||
fn deref(&self) -> &PendingSeason {
|
||||
&self.season
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditional, unlike the tick's backoff gate: the manual trigger's whole
|
||||
/// point is to ignore the exponential backoff (§6.2).
|
||||
async fn reset_episode_backoff(database: &Db, episode_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A manual season search is one attempt against every open episode at
|
||||
/// once, so they all start from zero together.
|
||||
async fn reset_season_backoff(database: &Db, season_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE season_id = ?",
|
||||
season_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn episode_series_title(database: &Db, episode_id: i64) -> Result<Option<String>, GrabError> {
|
||||
let title = sqlx::query_scalar!(
|
||||
r#"SELECT s.title AS "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.id = ?"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
async fn season_series_title(database: &Db, season_id: i64) -> Result<Option<String>, GrabError> {
|
||||
let title = sqlx::query_scalar!(
|
||||
r#"SELECT s.title AS "title!: String"
|
||||
FROM seasons se
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE se.id = ?"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
/// The chosen release, still associated with the episode and not
|
||||
/// hard-failed — a manual grab may take a `waived` release (§9.3).
|
||||
async fn load_episode_release(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.indexer_id AS "indexer_id!: i64",
|
||||
r.guid AS "guid!: String",
|
||||
r.name AS "name!: String",
|
||||
r.download_url AS "download_url!: String",
|
||||
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
|
||||
FROM releases r
|
||||
JOIN episode_releases er ON er.release_id = r.id
|
||||
WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
|
||||
"#,
|
||||
episode_id,
|
||||
release_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| Eligible {
|
||||
id: row.id,
|
||||
indexer_id: row.indexer_id,
|
||||
guid: row.guid,
|
||||
name: row.name,
|
||||
download_url: row.download_url,
|
||||
score: row.score,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The season counterpart: chosen off the season deck (issue #125).
|
||||
async fn load_season_release(
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.indexer_id AS "indexer_id!: i64",
|
||||
r.guid AS "guid!: String",
|
||||
r.name AS "name!: String",
|
||||
r.download_url AS "download_url!: String",
|
||||
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
|
||||
FROM releases r
|
||||
JOIN season_releases sr ON sr.release_id = r.id
|
||||
WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
|
||||
"#,
|
||||
season_id,
|
||||
release_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| Eligible {
|
||||
id: row.id,
|
||||
indexer_id: row.indexer_id,
|
||||
guid: row.guid,
|
||||
name: row.name,
|
||||
download_url: row.download_url,
|
||||
score: row.score,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a season-pack grab for this season already hard-failed — the
|
||||
/// fall-back-to-per-episode signal.
|
||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
@@ -1099,4 +1612,209 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(searches_after_first, searches_after_second);
|
||||
}
|
||||
|
||||
/// Issue #132: an `EpisodeCommand::Search` must do the work, not merely
|
||||
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
|
||||
/// backoff, nowhere near elapsed) so a tick would skip this episode;
|
||||
/// ending at 1 rather than 6 proves the reset happened, and a torrent in
|
||||
/// Transmission proves the grab did.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_search_searches_and_grabs_now() {
|
||||
let (_dir, database, _season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET search_attempts = 5,
|
||||
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')
|
||||
WHERE number = 1",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_episode_now(&database, episode_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sources: Vec<String> = fake
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.map(|torrent| torrent.source)
|
||||
.collect();
|
||||
assert_eq!(sources.len(), 1, "{sources:?}");
|
||||
assert!(sources[0].ends_with("e01.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
|
||||
);
|
||||
let attempts: i64 = sqlx::query_scalar("SELECT search_attempts FROM episodes WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts, 0,
|
||||
"reset from 5; a completed grab records no further attempt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
|
||||
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
|
||||
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
|
||||
?, '{}', 900, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(format!("{}/dl/e01.torrent", indexer.uri()))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
|
||||
.bind(episode_id)
|
||||
.bind(release_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.grab_episode_release_now(&database, episode_id, release_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
|
||||
);
|
||||
let searched = indexer
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|request| {
|
||||
request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "t" && value == "search")
|
||||
});
|
||||
assert!(!searched, "a chosen release must not trigger a search");
|
||||
}
|
||||
|
||||
/// Issue #132: a `SeasonCommand::Search` runs the pack lane now, against
|
||||
/// the season deck's own table — the backoff reset is what proves it was
|
||||
/// really drained rather than dropped on the floor.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_search_takes_the_pack_now() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET search_attempts = 5,
|
||||
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
let packs: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT r.name FROM season_releases sr JOIN releases r ON r.id = sr.release_id",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(packs.contains(&"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos".to_owned()));
|
||||
let attempts: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT search_attempts FROM episodes ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts,
|
||||
vec![0, 0, 0],
|
||||
"reset from 5; a completed grab records no further attempt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
?, '{}', 900, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(format!("{}/dl/pack.torrent", indexer.uri()))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.grab_season_release_now(&database, season_id, release_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
let searched = indexer
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|request| {
|
||||
request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "t" && value == "search")
|
||||
});
|
||||
assert!(!searched, "a chosen release must not trigger a search");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- §8: the daily series metadata refresh is gated on this stamp, exactly as
|
||||
-- the movie lane gates on `movies.metadata_refreshed_at` (0009). NULL means
|
||||
-- never refreshed, which makes a newly added series immediately due.
|
||||
ALTER TABLE series ADD COLUMN metadata_refreshed_at TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- #122. TMDB deletes and renumbers episodes. An episode that vanished
|
||||
-- upstream and has no file is deleted outright by the refresh; one that has
|
||||
-- a file is never deleted — `media_files.path` is UNIQUE and its owner is
|
||||
-- polymorphic, so dropping the row would orphan a real file — and instead
|
||||
-- carries this flag as the conflict the operator resolves.
|
||||
ALTER TABLE episodes ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (vanished IN (0, 1));
|
||||
@@ -0,0 +1,6 @@
|
||||
-- #137. TMDB deletes whole seasons as well as episodes (#122). A season that
|
||||
-- vanished upstream with a file anywhere under it cannot be deleted — the
|
||||
-- file row would orphan — so it is flagged instead, the same conflict marker
|
||||
-- episodes carry. Seasons without any file cascade away outright.
|
||||
ALTER TABLE seasons ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (vanished IN (0, 1));
|
||||
@@ -0,0 +1,11 @@
|
||||
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
|
||||
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
|
||||
-- search haystack, §7.4 filenames and the compat shim as if they were real
|
||||
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
|
||||
-- boundary now substitutes — #121's guarded update replaces it once TMDB
|
||||
-- fills the title in.
|
||||
--
|
||||
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
|
||||
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
|
||||
-- a transaction, where that pragma is a no-op); enforcement lives in code.
|
||||
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- §9.6: the three fields rich detail keeps out of the database are the ones
|
||||
-- pure-SQL views need — the in-library half of unified search (§9.2) and the
|
||||
-- library grid would otherwise cost one TMDB call per row. Nullable, no
|
||||
-- default: a title added before its first metadata refresh has no artwork
|
||||
-- yet, and TMDB itself has entries with no poster.
|
||||
ALTER TABLE movies ADD COLUMN poster_path TEXT;
|
||||
ALTER TABLE movies ADD COLUMN backdrop_path TEXT;
|
||||
ALTER TABLE movies ADD COLUMN vote_average REAL;
|
||||
ALTER TABLE series ADD COLUMN poster_path TEXT;
|
||||
ALTER TABLE series ADD COLUMN backdrop_path TEXT;
|
||||
ALTER TABLE series ADD COLUMN vote_average REAL;
|
||||
@@ -228,6 +228,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// §9.6: the stored artwork columns are nullable, and `REAL` is a valid
|
||||
/// STRICT type — a rating must not be squeezed into an integer.
|
||||
#[tokio::test]
|
||||
async fn title_artwork_columns_are_nullable_and_take_reals() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (tmdb_id, title, root_id)
|
||||
SELECT 1, 'No Artwork Yet', id FROM roots WHERE kind = 'movie' LIMIT 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("row without artwork");
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = '/dune.jpg', backdrop_path = '/dune-wide.jpg',
|
||||
vote_average = 8.152
|
||||
WHERE tmdb_id = 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("artwork write");
|
||||
|
||||
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
|
||||
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("movie row");
|
||||
assert_eq!(poster.as_deref(), Some("/dune.jpg"));
|
||||
assert_eq!(backdrop.as_deref(), Some("/dune-wide.jpg"));
|
||||
assert_eq!(vote, Some(8.152));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_keys_are_enforced() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
@@ -9,8 +9,10 @@ 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,
|
||||
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
|
||||
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
|
||||
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
|
||||
SeriesSearchResult, Video,
|
||||
};
|
||||
|
||||
/// TMDB's v3 API root.
|
||||
@@ -94,14 +96,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.
|
||||
@@ -157,6 +165,65 @@ impl TmdbClient {
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one movie's §9.6 page.
|
||||
///
|
||||
/// One HTTP call: credits, videos and external ids come back appended to
|
||||
/// the same response. Served through the same cache as [`Self::movie`];
|
||||
/// nothing here is persisted (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
|
||||
let path = format!("movie/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawMovieDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one series' §9.6 page.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_detail(&self, tmdb_id: u32) -> Result<SeriesDetail> {
|
||||
let path = format!("tv/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawSeriesDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// The chosen trailer for a movie, fetching only the videos list.
|
||||
///
|
||||
/// #144's search-row chip resolves through this, where a full detail
|
||||
/// response would be waste (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("movie/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// The chosen trailer for a series, fetching only the videos list.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("tv/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// External ids for one TV series, of which the TVDB id is the one this
|
||||
/// project needs (§6.1).
|
||||
///
|
||||
|
||||
@@ -24,5 +24,6 @@ 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,
|
||||
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
|
||||
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,9 @@ use serde::{Deserialize, Serialize};
|
||||
const RELEASE_TYPE_DIGITAL: u8 = 4;
|
||||
|
||||
/// One row of a TMDB search.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// `vote_average` is the one float, so this is `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovieSearchResult {
|
||||
/// TMDB's own id, the key everything else hangs off.
|
||||
pub tmdb_id: u32,
|
||||
@@ -30,6 +32,11 @@ pub struct MovieSearchResult {
|
||||
pub overview: Option<String>,
|
||||
/// Path fragment, not a URL. TMDB's image base is a separate concern.
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10, when TMDB has votes for it; zero normalised
|
||||
/// away like [`Movie::vote_average`].
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
impl MovieSearchResult {
|
||||
@@ -41,7 +48,9 @@ impl MovieSearchResult {
|
||||
}
|
||||
|
||||
/// A movie as TMDB describes it, reduced to what this project uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// `vote_average` is the one float, so this is `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Movie {
|
||||
/// TMDB's own id.
|
||||
pub tmdb_id: u32,
|
||||
@@ -77,6 +86,15 @@ pub struct Movie {
|
||||
pub overview: Option<String>,
|
||||
/// Path fragment, not a URL.
|
||||
pub poster_path: Option<String>,
|
||||
/// Path fragment, not a URL. §9.6 stores this on the row alongside
|
||||
/// [`Movie::poster_path`] and `vote_average`.
|
||||
pub backdrop_path: Option<String>,
|
||||
/// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
|
||||
/// artwork (§9.6). TMDB sends `0` where "no rating yet" is meant, so a
|
||||
/// zero rating is normalised away here rather than stored as one.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
impl Movie {
|
||||
@@ -97,7 +115,7 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeriesSearchResult {
|
||||
pub tmdb_id: u32,
|
||||
pub title: String,
|
||||
@@ -105,6 +123,10 @@ pub struct SeriesSearchResult {
|
||||
pub first_air_date: Option<NaiveDate>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// Zero normalised away, like [`Movie::vote_average`].
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
impl SeriesSearchResult {
|
||||
@@ -114,7 +136,9 @@ impl SeriesSearchResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// TMDB's slice for series. `vote_average` is the one float, so this is
|
||||
/// `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Series {
|
||||
pub tmdb_id: u32,
|
||||
pub tvdb_id: Option<u32>,
|
||||
@@ -124,6 +148,14 @@ pub struct Series {
|
||||
pub status: String,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// §9.6 stores this on the row alongside `poster_path` and
|
||||
/// [`Series::vote_average`].
|
||||
pub backdrop_path: Option<String>,
|
||||
/// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
|
||||
/// artwork (§9.6); zero is normalised away like [`Movie::vote_average`].
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
pub seasons: Vec<SeasonSummary>,
|
||||
}
|
||||
|
||||
@@ -140,6 +172,10 @@ pub struct SeasonSummary {
|
||||
pub episode_count: u32,
|
||||
}
|
||||
|
||||
/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces
|
||||
/// it once TMDB fills the title in.
|
||||
pub const UNTITLED_EPISODE: &str = "TBA";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Season {
|
||||
pub number: u32,
|
||||
@@ -153,6 +189,113 @@ pub struct Episode {
|
||||
pub air_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Cast is truncated to the top 10 billed, in the crate, so no caller has to
|
||||
/// remember to (§9.6).
|
||||
const CAST_LIMIT: usize = 10;
|
||||
|
||||
/// A genre as a detail response carries it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One of the top-billed cast members on a detail page. `profile_path` is a
|
||||
/// path fragment — §9.6 hotlinks images and the browser composes the URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
pub profile_path: Option<String>,
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// One entry of a title's video list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Video {
|
||||
pub key: String,
|
||||
/// `YouTube`, `Vimeo`, … Only `YouTube` ever becomes a trailer (§9.6).
|
||||
pub site: String,
|
||||
/// TMDB calls this field `type`: `Trailer`, `Teaser`, `Clip`, …
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub official: bool,
|
||||
}
|
||||
|
||||
/// Rich movie detail for the §9.6 page. One upstream request via
|
||||
/// `append_to_response`, served through the same cache as everything else;
|
||||
/// nothing here is persisted.
|
||||
///
|
||||
/// No float fields are involved in equality except `vote_average`, so this is
|
||||
/// `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovieDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
|
||||
/// where "no votes yet" is meant, and a detail page must not show it as
|
||||
/// a rating.
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
pub runtime: Option<u32>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<CastMember>,
|
||||
/// The one trailer worth showing, chosen by the §9.6 rule.
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeriesDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
|
||||
/// where "no votes yet" is meant, and a detail page must not show it as
|
||||
/// a rating.
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
/// Episode length in minutes; TMDB sends a list per episode, this takes
|
||||
/// the first.
|
||||
pub episode_runtime: Option<u32>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
pub cast: Vec<CastMember>,
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
/// The trailer rule from §9.6, shared by the detail calls and the videos-only
|
||||
/// calls #144 reads. Preference order: an official `YouTube` trailer, then any
|
||||
/// `YouTube` trailer, then any `YouTube` teaser, then nothing. Within a tier
|
||||
/// the first match wins, which keeps the result deterministic for a given
|
||||
/// response.
|
||||
#[must_use]
|
||||
pub(crate) fn select_trailer(videos: &[Video]) -> Option<Video> {
|
||||
let pick = |want: &dyn Fn(&Video) -> bool| {
|
||||
videos
|
||||
.iter()
|
||||
.find(|video| video.site == "YouTube" && want(video))
|
||||
.cloned()
|
||||
};
|
||||
pick(&|video| video.kind == "Trailer" && video.official)
|
||||
.or_else(|| pick(&|video| video.kind == "Trailer"))
|
||||
.or_else(|| pick(&|video| video.kind == "Teaser"))
|
||||
}
|
||||
|
||||
// --- TMDB wire types -------------------------------------------------------
|
||||
//
|
||||
// Private on purpose. TMDB's field names stop here.
|
||||
@@ -182,6 +325,10 @@ pub(crate) struct RawSeriesSearchResult {
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
}
|
||||
|
||||
impl From<RawSeriesSearchResult> for SeriesSearchResult {
|
||||
@@ -193,6 +340,8 @@ impl From<RawSeriesSearchResult> for SeriesSearchResult {
|
||||
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +362,12 @@ pub(crate) struct RawSeries {
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
seasons: Vec<RawSeasonSummary>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
@@ -248,6 +403,9 @@ impl From<RawSeries> for Series {
|
||||
status: raw.status,
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
seasons: raw
|
||||
.seasons
|
||||
.into_iter()
|
||||
@@ -285,7 +443,13 @@ impl From<RawSeason> for Season {
|
||||
.into_iter()
|
||||
.map(|episode| Episode {
|
||||
number: episode.episode_number,
|
||||
title: episode.name,
|
||||
// TMDB leaves an unaired episode's name empty; a placeholder
|
||||
// keeps "" out of search, filenames and the compat shim.
|
||||
title: if episode.name.is_empty() {
|
||||
UNTITLED_EPISODE.to_owned()
|
||||
} else {
|
||||
episode.name
|
||||
},
|
||||
air_date: episode.air_date.as_deref().and_then(parse_date),
|
||||
})
|
||||
.collect(),
|
||||
@@ -293,6 +457,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)]
|
||||
pub struct FindResults {
|
||||
pub movies: Vec<MovieSearchResult>,
|
||||
pub series: Vec<SeriesSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawFindPage {
|
||||
#[serde(default)]
|
||||
@@ -316,6 +489,10 @@ pub(crate) struct RawSearchResult {
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
}
|
||||
|
||||
impl From<RawSearchResult> for MovieSearchResult {
|
||||
@@ -328,6 +505,8 @@ impl From<RawSearchResult> for MovieSearchResult {
|
||||
release_date: raw.release_date.as_deref().and_then(parse_date),
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +537,12 @@ pub(crate) struct RawMovie {
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
release_dates: Option<RawReleaseDates>,
|
||||
}
|
||||
|
||||
@@ -417,6 +602,9 @@ impl From<RawMovie> for Movie {
|
||||
status: raw.status,
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,3 +637,215 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
|
||||
fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
/// TMDB uses `0` where "no votes yet" is meant, so an unrated title never
|
||||
/// carries a rating rather than carrying zero.
|
||||
fn rating(value: f64) -> Option<f64> {
|
||||
(value != 0.0).then_some(value)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawMovieDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
runtime: Option<u32>,
|
||||
#[serde(default)]
|
||||
imdb_id: Option<String>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawSeriesDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
episode_run_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawGenre {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCredits {
|
||||
#[serde(default)]
|
||||
cast: Vec<RawCastMember>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCastMember {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
character: String,
|
||||
#[serde(default)]
|
||||
profile_path: Option<String>,
|
||||
order: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawVideoList {
|
||||
#[serde(default)]
|
||||
results: Vec<RawVideo>,
|
||||
}
|
||||
|
||||
impl RawVideoList {
|
||||
pub(crate) fn into_videos(self) -> Vec<Video> {
|
||||
self.results.into_iter().map(Into::into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVideo {
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
site: String,
|
||||
#[serde(rename = "type", default)]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
official: bool,
|
||||
}
|
||||
|
||||
impl From<RawVideo> for Video {
|
||||
fn from(raw: RawVideo) -> Self {
|
||||
Self {
|
||||
key: raw.key,
|
||||
site: raw.site,
|
||||
kind: raw.kind,
|
||||
name: raw.name,
|
||||
official: raw.official,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
|
||||
let mut cast: Vec<CastMember> = raw
|
||||
.map(|credits| {
|
||||
credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|member| CastMember {
|
||||
tmdb_id: member.id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: non_empty(member.profile_path),
|
||||
order: member.order,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// TMDB's own ordering is by `order`; sorting makes the truncation hold
|
||||
// even if a fixture or future API revision sends them shuffled.
|
||||
cast.sort_by_key(|member| member.order);
|
||||
cast.truncate(CAST_LIMIT);
|
||||
cast
|
||||
}
|
||||
|
||||
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
|
||||
let videos: Vec<Video> = raw
|
||||
.map(|list| list.results.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default();
|
||||
select_trailer(&videos)
|
||||
}
|
||||
|
||||
impl From<RawMovieDetail> for MovieDetail {
|
||||
fn from(raw: RawMovieDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
runtime: raw.runtime,
|
||||
imdb_id: non_empty(raw.imdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawSeriesDetail> for SeriesDetail {
|
||||
fn from(raw: RawSeriesDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
episode_runtime: raw.episode_run_time.into_iter().next(),
|
||||
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": 693134,
|
||||
"imdb_id": "tt15239678",
|
||||
"title": "Dune: Part Two",
|
||||
"original_title": "Dune: Part Two",
|
||||
"tagline": "Long live the fighters.",
|
||||
"overview": "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.",
|
||||
"status": "Released",
|
||||
"runtime": 167,
|
||||
"homepage": "https://www.dunemovie.com",
|
||||
"vote_average": 8.152,
|
||||
"vote_count": 6249,
|
||||
"poster_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"genres": [
|
||||
{ "id": 878, "name": "Science Fiction" },
|
||||
{ "id": 12, "name": "Adventure" }
|
||||
],
|
||||
"credits": {
|
||||
"cast": [
|
||||
{ "id": 5530, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/x3UkVAsKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 0 },
|
||||
{ "id": 3559977, "name": "Zendaya", "character": "Chani", "profile_path": "/xaWu0DjKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 1 },
|
||||
{ "id": 37614, "name": "Rebecca Ferguson", "character": "Lady Jessica", "profile_path": "/fPM5vM7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 2 },
|
||||
{ "id": 1110844, "name": "Austin Butler", "character": "Feyd-Rautha Harkonnen", "profile_path": null, "order": 3 },
|
||||
{ "id": 22451, "name": "Josh Brolin", "character": "Gurney Halleck", "profile_path": "/qR11vJKKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 4 },
|
||||
{ "id": 593015, "name": "Florence Pugh", "character": "Princess Irulan", "profile_path": "/mP55vB7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 5 },
|
||||
{ "id": 1244949, "name": "Dave Bautista", "character": "Glossu Rabban Harkonnen", "profile_path": null, "order": 6 },
|
||||
{ "id": 11220, "name": "Christopher Walken", "character": "Emperor Shaddam IV", "profile_path": "/wP55vC7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 7 },
|
||||
{ "id": 38673, "name": "Léa Seydoux", "character": "Lady Margot Fenring", "profile_path": null, "order": 8 },
|
||||
{ "id": 2880644, "name": "Souheila Yacoub", "character": "Shishakli", "profile_path": "/nP55vD7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 9 },
|
||||
{ "id": 10952, "name": "Stellan Skarsgård", "character": "Baron Vladimir Harkonnen", "profile_path": null, "order": 10 },
|
||||
{ "id": 33940, "name": "Charlotte Rampling", "character": "Reverend Mother Gaius Helen Mohiam", "profile_path": null, "order": 11 }
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"results": [
|
||||
{ "key": "n9xhJrPXop4", "site": "YouTube", "type": "Teaser", "name": "Official Teaser", "official": true },
|
||||
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Dune Part Two Fan Trailer", "official": false },
|
||||
{ "key": "clip_vimeo_id", "site": "Vimeo", "type": "Trailer", "name": "Trailer (Vimeo)", "official": true },
|
||||
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
|
||||
]
|
||||
},
|
||||
"external_ids": {
|
||||
"imdb_id": "tt15239678"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 693134,
|
||||
"results": [
|
||||
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false },
|
||||
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user