Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d5cc120d1 | |||
| 2c293e7f60 | |||
| 8f7373d73d | |||
| 2495895717 | |||
| 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 | |||
| d8316ae0c8 |
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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 s.tmdb_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 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,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": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63"
|
||||
"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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+21
-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, metadata_refreshed_at FROM series ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,21 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +88,11 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
|
||||
"hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+2
-2
@@ -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\", 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",
|
||||
"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 DESC, e.number DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -82,5 +82,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
|
||||
"hash": "580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -52,6 +52,21 @@
|
||||
"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": {
|
||||
@@ -67,8 +82,11 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87"
|
||||
"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"
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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\", 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 '\\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "tag!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"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": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9"
|
||||
}
|
||||
+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 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": "af1b98d2aa84eac69323823e232b61897d4f6bef978e3bf46ba0d999f90c4acd"
|
||||
"hash": "8c61a4e4f6b3b8cc02cd4b6289f6889889ab06d9a77f26c4d745659c8e1f4a07"
|
||||
}
|
||||
+21
-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, s.metadata_refreshed_at 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,21 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +88,11 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
|
||||
"hash": "9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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 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": "61e9081eaedc7a69ada44b057c59553511e64634b75386d523f82c2d03d78f1a"
|
||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+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\", 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": "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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "tag!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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
@@ -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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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 DESC",
|
||||
"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": "f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+21
-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, metadata_refreshed_at FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,21 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +88,11 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
|
||||
"hash": "fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420"
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -130,7 +130,11 @@ anything.
|
||||
|
||||
Here, intent is `Episode.wanted` and `Movie.wanted` only.
|
||||
`Series.auto_track` is not intent — it is a rule one level up: when metadata
|
||||
reveals a new season, that season becomes tracked. An untracked series where you
|
||||
reveals a new season, that season becomes tracked. The rule applies from the
|
||||
second refresh onward: seasons revealed by a series' first refresh — the whole
|
||||
back catalogue present at add time — are never tracked by it. Only seasons that
|
||||
appear after the series was added are covered; back-catalogue seasons are picked
|
||||
by hand. An untracked series where you
|
||||
manually marked S02 needs no special case: three wanted episodes, nothing else.
|
||||
|
||||
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
|
||||
@@ -160,6 +164,12 @@ behind one toggle. A one-off season grab therefore disappears from the default
|
||||
view by itself once satisfied, and a tracked show reappears by itself when a new
|
||||
season is announced. Nothing to remember to flip.
|
||||
|
||||
Status chips carry state colour, not a single informational hue: green
|
||||
(`--signal-ok`) for on disk or complete, amber (`--signal-warn`) for wanted but
|
||||
not yet found, violet (`--status-airing`) for downloading, neutral for
|
||||
untracked. The word stays present in every chip, so state survives with colour
|
||||
removed.
|
||||
|
||||
Season 0 is invisible to all of it: derived status ignores season 0 episodes
|
||||
entirely, so a manually wanted special cannot pin a series at `incomplete` or
|
||||
hold back `ended`. The accepted consequence is that specials are visible and
|
||||
@@ -477,7 +487,10 @@ lower-priority crate** (§9.4), not the primary shape.
|
||||
|
||||
One box. Two grouped result sets: **in library** first (title match, and for TV
|
||||
also episode title, so `bluey hospital` finds the episode), **on TMDB** below.
|
||||
Enter on a TMDB result opens the add flow with root and policy pre-filled.
|
||||
An episode row surfaces only when the query matches something beyond the series
|
||||
title: a query satisfied by the series title alone returns the series row and
|
||||
nothing below it. Enter on a TMDB result opens the add flow with root and
|
||||
policy pre-filled.
|
||||
|
||||
The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`,
|
||||
which skips to the manual-grab flow.
|
||||
@@ -485,6 +498,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 +558,45 @@ 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, seasons ordered
|
||||
newest-first within the series and episodes newest-first within each season —
|
||||
the seasons you are deciding about now are at the top, not after nine rows of
|
||||
back catalogue.
|
||||
|
||||
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
|
||||
`vote_average` with its `vote_count`, rendered as amber stars (`--signal-warn`
|
||||
— the same state colour a wanted chip carries). 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.
|
||||
|
||||
**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);
|
||||
}
|
||||
}
|
||||
+207
-40
@@ -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")),
|
||||
@@ -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.
|
||||
|
||||
@@ -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,18 +224,20 @@ 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));
|
||||
|
||||
// §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)
|
||||
// Tokens may split across the series and episode titles, so every token
|
||||
// must land in the concatenation — and at least one must match the
|
||||
// episode title on its own, or a series-title-only query would list
|
||||
// every episode the series has.
|
||||
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 '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
@@ -227,7 +249,7 @@ pub async fn search(
|
||||
match result {
|
||||
TmdbResult::Movie(movie) => {
|
||||
let tmdb_id = i64::from(movie.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
|
||||
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));
|
||||
@@ -235,7 +257,7 @@ pub async fn search(
|
||||
}
|
||||
TmdbResult::Series(series) => {
|
||||
let tmdb_id = i64::from(series.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
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));
|
||||
@@ -291,6 +313,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(),
|
||||
@@ -558,6 +582,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,6 +598,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +614,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,7 +682,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,
|
||||
@@ -816,7 +846,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)
|
||||
@@ -843,6 +874,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
|
||||
@@ -914,15 +958,32 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
|
||||
// §9.2, amended: an episode row surfaces only when the query matches
|
||||
// something beyond the series title. `bluey` alone returns the series
|
||||
// row and no episode below it.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["title"], "Bluey");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
let library = response["library"].as_array().expect("library");
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "series");
|
||||
assert_eq!(library[0]["title"], "Bluey");
|
||||
assert_eq!(library[0]["tmdb_id"], 82_728);
|
||||
|
||||
// An episode-title-only query still finds the episode.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
let library = response["library"].as_array().expect("library");
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "episode");
|
||||
assert_eq!(library[0]["title"], "Hospital");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+396
-19
@@ -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,13 +102,21 @@ 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>,
|
||||
@@ -190,6 +203,14 @@ 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>,
|
||||
/// NULL until a metadata refresh has stamped it (#160): the fact the
|
||||
/// auto-track rule needs to tell a seeding refresh from a later one.
|
||||
metadata_refreshed_at: Option<String>,
|
||||
}
|
||||
|
||||
struct EpisodeRow {
|
||||
@@ -302,6 +323,8 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
|
||||
overrides: row.overrides.clone(),
|
||||
upstream_ended: row.upstream_ended,
|
||||
blocked: row.blocked,
|
||||
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),
|
||||
@@ -333,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
|
||||
}
|
||||
|
||||
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, metadata_refreshed_at FROM series WHERE id = ?"#, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeriesNotFound)
|
||||
@@ -387,11 +410,11 @@ 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, s.metadata_refreshed_at 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, metadata_refreshed_at FROM series ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
@@ -430,12 +453,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?;
|
||||
@@ -445,14 +475,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(
|
||||
@@ -629,7 +655,9 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
|
||||
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"#,
|
||||
// §9.6: newest-first, so season 0 lands last under plain numeric
|
||||
// descending order — exactly where the design puts it.
|
||||
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 DESC"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
@@ -637,7 +665,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
||||
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", 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"#,
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
@@ -650,12 +678,15 @@ 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(),
|
||||
@@ -716,6 +747,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()
|
||||
@@ -730,6 +770,9 @@ pub async fn create_season(
|
||||
|
||||
// §4.1. The request does not say whether the episodes are wanted; the
|
||||
// series' auto_track rule does, through the one function that owns it.
|
||||
// #160: whether a refresh has happened yet rides in from the row — a
|
||||
// hand-added season on a never-refreshed series is still seeding, and
|
||||
// §4.1 does not track what was there at add time.
|
||||
let mut revealed = [RefreshedSeason {
|
||||
season: arr_core::Season {
|
||||
id: SeasonId(0),
|
||||
@@ -755,7 +798,11 @@ pub async fn create_season(
|
||||
.collect(),
|
||||
is_new: true,
|
||||
}];
|
||||
apply_auto_track(&core_series(&series), &mut revealed);
|
||||
apply_auto_track(
|
||||
&core_series(&series),
|
||||
series.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
let [revealed] = revealed;
|
||||
|
||||
// One transaction: a rejected episode must not leave the season behind,
|
||||
@@ -876,7 +923,9 @@ 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,
|
||||
@@ -1140,6 +1189,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")),
|
||||
@@ -1292,6 +1374,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;
|
||||
@@ -1312,15 +1469,21 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// #160. A season added before the series' first metadata refresh is part
|
||||
/// of the back catalogue at add time, so the rule does not track it. Once
|
||||
/// a refresh has happened, a hand-revealed season is genuinely new and
|
||||
/// arrives tracked with its episodes wanted.
|
||||
#[tokio::test]
|
||||
async fn auto_track_decides_whether_a_new_season_arrives_wanted() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "kids").await;
|
||||
|
||||
let tracked = add_series(&base, root_id, true).await;
|
||||
let tracked_id = tracked["id"].as_i64().expect("id");
|
||||
// Seeding: no refresh has ever stamped this series.
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked["id"].as_i64().expect("id"),
|
||||
tracked_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
|
||||
@@ -1328,6 +1491,33 @@ mod tests {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], false);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == false),
|
||||
"§4.1: the back catalogue at add time is never auto-tracked"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(tracked_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("stamp refreshed");
|
||||
let season = add_season(
|
||||
&base,
|
||||
tracked_id,
|
||||
2,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(season["tracked"], true);
|
||||
assert!(
|
||||
season["episodes"]
|
||||
@@ -1335,7 +1525,7 @@ mod tests {
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == true),
|
||||
"§4.1: auto_track marks the episodes of a revealed season wanted"
|
||||
"§4.1: from the second refresh onward the rule tracks what is new"
|
||||
);
|
||||
|
||||
let untracked_id = sqlx::query_scalar::<_, i64>(
|
||||
@@ -1356,6 +1546,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;
|
||||
@@ -1390,6 +1602,120 @@ 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");
|
||||
let vanished = |number: i64| {
|
||||
seasons
|
||||
.iter()
|
||||
.find(|season| season["number"] == number)
|
||||
.expect("season")["vanished"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(vanished(1), false);
|
||||
assert_eq!(
|
||||
vanished(2),
|
||||
true,
|
||||
"#141: the conflict flag is not stopped at the database"
|
||||
);
|
||||
}
|
||||
|
||||
/// §9.6: seasons come back newest-first within a series and episodes
|
||||
/// newest-first within each season, with season 0 wherever descending
|
||||
/// numeric order puts it — last. The SPA renders this order as given,
|
||||
/// so it is the API's to get right (#162).
|
||||
#[tokio::test]
|
||||
async fn seasons_and_episodes_are_returned_newest_first() {
|
||||
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 (season, episodes) in [
|
||||
(
|
||||
0,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "Special one"},
|
||||
{"number": 2, "title": "Special two"}
|
||||
]),
|
||||
),
|
||||
(1, serde_json::json!([{"number": 1, "title": "Pilot"}])),
|
||||
(
|
||||
2,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 2, "title": "Two"},
|
||||
{"number": 3, "title": "Three"}
|
||||
]),
|
||||
),
|
||||
] {
|
||||
add_season(&base, series_id, season, episodes).await;
|
||||
}
|
||||
|
||||
let seasons: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.await
|
||||
.expect("list seasons")
|
||||
.json()
|
||||
.await
|
||||
.expect("seasons json");
|
||||
let seasons = seasons.as_array().expect("seasons array");
|
||||
let numbers: Vec<i64> = seasons
|
||||
.iter()
|
||||
.map(|season| season["number"].as_i64().expect("number"))
|
||||
.collect();
|
||||
assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series");
|
||||
|
||||
let episode_numbers = |index: usize| -> Vec<i64> {
|
||||
seasons[index]["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.map(|episode| episode["number"].as_i64().expect("episode number"))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
episode_numbers(0),
|
||||
[3, 2, 1],
|
||||
"§9.6: newest-first in a season"
|
||||
);
|
||||
assert_eq!(episode_numbers(1), [1]);
|
||||
assert_eq!(episode_numbers(2), [2, 1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intent_is_set_on_seasons_and_on_single_episodes() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -1956,6 +2282,57 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,14 @@ pub struct RefreshedSeason {
|
||||
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
|
||||
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
|
||||
/// still track it by hand.
|
||||
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
|
||||
if !series.auto_track {
|
||||
///
|
||||
/// `ever_refreshed` says whether the series has had a metadata refresh yet
|
||||
/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because
|
||||
/// `arr-core` has no IO; seasons revealed by a series' first refresh are its
|
||||
/// back catalogue as it stood at add time, and §4.1 never tracks those — the
|
||||
/// rule applies from the second refresh onward.
|
||||
pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) {
|
||||
if !series.auto_track || !ever_refreshed {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +120,7 @@ mod tests {
|
||||
refreshed_season(3, true, &[false, false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), &mut refresh);
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(!refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
|
||||
@@ -122,6 +128,39 @@ mod tests {
|
||||
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
|
||||
}
|
||||
|
||||
/// #160. The first refresh of a newly added series reveals the whole back
|
||||
/// catalogue at once; §4.1 never tracks those.
|
||||
#[test]
|
||||
fn the_seeding_refresh_tracks_nothing() {
|
||||
let mut refresh = [
|
||||
refreshed_season(1, true, &[false, false]),
|
||||
refreshed_season(2, true, &[false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), false, &mut refresh);
|
||||
|
||||
assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked));
|
||||
assert!(refresh
|
||||
.iter()
|
||||
.all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted)));
|
||||
}
|
||||
|
||||
/// #160. From the second refresh onward a season that genuinely did not
|
||||
/// exist when the series was added is tracked, and its episodes arrive
|
||||
/// wanted — while the seasons already on record stay as they were.
|
||||
#[test]
|
||||
fn a_later_refresh_tracks_a_genuinely_new_season() {
|
||||
let mut refresh = [
|
||||
refreshed_season(1, false, &[true, true]),
|
||||
refreshed_season(3, true, &[false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(refresh[1].season.tracked);
|
||||
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untracked_series_keeps_manual_leaf_intent_only() {
|
||||
let mut refresh = [
|
||||
@@ -129,7 +168,7 @@ mod tests {
|
||||
refreshed_season(3, true, &[false, false]),
|
||||
];
|
||||
|
||||
apply_auto_track(&series(false), &mut refresh);
|
||||
apply_auto_track(&series(false), true, &mut refresh);
|
||||
|
||||
assert!(refresh[0].episodes.iter().all(|episode| episode.wanted));
|
||||
assert!(!refresh[0].season.tracked);
|
||||
@@ -141,7 +180,7 @@ mod tests {
|
||||
fn auto_track_skips_season_zero() {
|
||||
let mut refresh = [refreshed_season(0, true, &[false])];
|
||||
|
||||
apply_auto_track(&series(true), &mut refresh);
|
||||
apply_auto_track(&series(true), true, &mut refresh);
|
||||
|
||||
assert!(!refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
|
||||
|
||||
+187
-32
@@ -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
|
||||
@@ -2417,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;
|
||||
|
||||
@@ -13,12 +13,15 @@
|
||||
//! - `upstream_ended` follows TMDB's status, which `ended` is derived from;
|
||||
//! - a missing `tvdb_id` is backfilled (#120).
|
||||
//!
|
||||
//! Episodes that vanish upstream follow two rules (#122): without a file of
|
||||
//! their own they are deleted outright, cascading through `episode_releases`;
|
||||
//! with one they are flagged `vanished` instead, because deleting the row
|
||||
//! would orphan a real file (`media_files` is polymorphic on its owner). A
|
||||
//! vanished number that reappears clears its flag again. Renumbering needs no
|
||||
//! matching of its own — it is just these two rules seen from both ends.
|
||||
//! Episodes and seasons that vanish upstream follow two rules (#122, #137):
|
||||
//! without a file of their own they are deleted outright, cascading through
|
||||
//! `episode_releases`; with one they are flagged `vanished` instead, because
|
||||
//! deleting the row would orphan a real file (`media_files` is polymorphic on
|
||||
//! its owner). A vanished number that reappears clears its flag again.
|
||||
//! Season 0 is exempt — TMDB drops and re-adds it routinely, and #118 already
|
||||
//! keeps specials out of status, so churning it is noise rather than signal.
|
||||
//! Renumbering needs no matching of its own — it is just these two rules seen
|
||||
//! from both ends.
|
||||
//!
|
||||
//! Refresh is idempotent: over unchanged TMDB data only the stamp moves.
|
||||
|
||||
@@ -71,7 +74,8 @@ impl SeriesRefreshAction {
|
||||
r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id,
|
||||
title AS "title!: String", year, original_language,
|
||||
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at
|
||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
|
||||
poster_path, backdrop_path, vote_average
|
||||
FROM series
|
||||
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
|
||||
)
|
||||
@@ -125,6 +129,11 @@ impl SeriesRefreshAction {
|
||||
.map(|season| (season.number, (season.id, season.tracked)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut revealed = Vec::new();
|
||||
let upstream_seasons = metadata
|
||||
.seasons
|
||||
.iter()
|
||||
.map(|summary| i64::from(summary.number))
|
||||
.collect::<HashSet<_>>();
|
||||
for summary in &metadata.seasons {
|
||||
if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) {
|
||||
changed |= self
|
||||
@@ -136,6 +145,15 @@ impl SeriesRefreshAction {
|
||||
summary.number,
|
||||
)
|
||||
.await?;
|
||||
// The season is back upstream — a TMDB reversal, or a
|
||||
// renumber seen from the other end. The conflict is over.
|
||||
let restored = sqlx::query!(
|
||||
"UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
|
||||
season_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
changed |= restored.rows_affected() > 0;
|
||||
} else {
|
||||
let detail = self.tmdb.season(tmdb_id, summary.number).await?;
|
||||
revealed.push(RefreshedSeason {
|
||||
@@ -150,13 +168,23 @@ impl SeriesRefreshAction {
|
||||
});
|
||||
}
|
||||
}
|
||||
apply_auto_track(&core_series(stale), &mut revealed);
|
||||
// #160: the fact of a previous refresh rides in from the row, so the
|
||||
// seeding refresh never tracks the back catalogue it reveals.
|
||||
apply_auto_track(
|
||||
&core_series(stale),
|
||||
stale.metadata_refreshed_at.is_some(),
|
||||
&mut revealed,
|
||||
);
|
||||
for refreshed in revealed {
|
||||
self.insert_revealed_season(&mut transaction, stale.id, refreshed)
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed |= self
|
||||
.reconcile_vanished_seasons(&mut transaction, &existing, &upstream_seasons)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
stamp_refreshed(database, stale.id).await?;
|
||||
Ok(changed.then(|| {
|
||||
@@ -201,6 +229,29 @@ impl SeriesRefreshAction {
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
// §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 poster_path_ref = poster_path.as_deref();
|
||||
let backdrop_path_ref = backdrop_path.as_deref();
|
||||
let artwork_moved =
|
||||
stale.poster_path != poster_path || stale.backdrop_path != backdrop_path;
|
||||
if artwork_moved || stale.vote_average != vote_average {
|
||||
sqlx::query!(
|
||||
"UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
poster_path_ref,
|
||||
backdrop_path_ref,
|
||||
vote_average,
|
||||
stale.id
|
||||
)
|
||||
.execute(&mut *executor)
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@@ -391,6 +442,52 @@ impl SeriesRefreshAction {
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// #137, one level up from `reconcile_vanished`. Seasons the library
|
||||
/// knows that TMDB no longer lists have vanished upstream. One with no
|
||||
/// file on any of its episodes is deleted — the foreign key cascades
|
||||
/// through its episodes and their `episode_releases` — and one with a
|
||||
/// file anywhere under it is flagged instead: dropping the rows would
|
||||
/// orphan a real file, the same trap #122 documents. Season 0 is exempt:
|
||||
/// TMDB drops and re-adds specials routinely, and §4.2 already keeps them
|
||||
/// out of derived status, so churning the flag is noise rather than
|
||||
/// signal.
|
||||
async fn reconcile_vanished_seasons(
|
||||
&self,
|
||||
executor: &mut sqlx::SqliteConnection,
|
||||
known: &HashMap<i64, (i64, bool)>,
|
||||
upstream: &HashSet<i64>,
|
||||
) -> Result<bool, RefreshError> {
|
||||
let mut changed = false;
|
||||
for (&number, &(season_id, _)) in known {
|
||||
if number == 0 || upstream.contains(&number) {
|
||||
continue;
|
||||
}
|
||||
let has_file = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id
|
||||
WHERE f.owner_kind = 'episode' AND e.season_id = ?
|
||||
) AS "exists!: bool""#,
|
||||
season_id
|
||||
)
|
||||
.fetch_one(&mut *executor)
|
||||
.await?;
|
||||
if has_file {
|
||||
sqlx::query!(
|
||||
"UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
|
||||
season_id
|
||||
)
|
||||
.execute(&mut *executor)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!("DELETE FROM seasons WHERE id = ?", season_id)
|
||||
.execute(&mut *executor)
|
||||
.await?;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SeriesRefreshAction {
|
||||
@@ -414,8 +511,11 @@ struct DueSeries {
|
||||
auto_track: bool,
|
||||
upstream_ended: bool,
|
||||
metadata_refreshed_at: Option<String>,
|
||||
/// §9.6 artwork columns, compared against TMDB's on each refresh.
|
||||
poster_path: Option<String>,
|
||||
backdrop_path: Option<String>,
|
||||
vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
|
||||
/// STRICT). A number past `u16::MAX` cannot match anything real and would
|
||||
/// never be grabbed anyway.
|
||||
@@ -502,6 +602,7 @@ mod tests {
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
use arr_meta::UNTITLED_EPISODE;
|
||||
|
||||
fn series_body(status: &str) -> serde_json::Value {
|
||||
json!({
|
||||
@@ -538,6 +639,48 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// The series detail as `mount` serves it, restricted to the given
|
||||
/// season numbers — a dropped number is how a vanished season presents.
|
||||
fn series_body_with(status: &str, numbers: &[u32]) -> serde_json::Value {
|
||||
json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"status": status,
|
||||
"seasons": numbers
|
||||
.iter()
|
||||
.map(|number| json!({"season_number": number, "episode_count": 0}))
|
||||
.collect::<Vec<_>>(),
|
||||
"external_ids": {"tvdb_id": 361_391}
|
||||
})
|
||||
}
|
||||
|
||||
/// Mounts only the season detail endpoints listed; a request for any
|
||||
/// other season would fail the test loudly.
|
||||
async fn mount_with(server: &MockServer, status: &str, numbers: &[u32]) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(series_body_with(status, numbers)),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
for (number, body) in [
|
||||
(1u32, season_one_body(&two_episodes())),
|
||||
(2, season_two_body()),
|
||||
] {
|
||||
if !numbers.contains(&number) {
|
||||
continue;
|
||||
}
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/tv/82728/season/{number}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// A TMDB serving one series with two seasons. `reset` between phases of
|
||||
/// a test and remount, counting requests by delta around each phase.
|
||||
async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer {
|
||||
@@ -606,8 +749,47 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// §9.6: the daily refresh writes the three stored artwork fields, so
|
||||
/// pure-SQL views render a poster without a TMDB call per row.
|
||||
#[tokio::test]
|
||||
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
|
||||
async fn refresh_writes_the_stored_artwork_fields() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"status": "Returning Series",
|
||||
"poster_path": "/bluey.jpg",
|
||||
"backdrop_path": "/bluey-wide.jpg",
|
||||
"vote_average": 8.417,
|
||||
"seasons": []
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
action(&server).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 series WHERE tmdb_id = 82728",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
|
||||
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||
/// never tracks what was already there at add time: nothing is tracked,
|
||||
/// nothing arrives wanted.
|
||||
#[tokio::test]
|
||||
async fn the_seeding_refresh_reveals_but_tracks_nothing() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
|
||||
@@ -619,14 +801,14 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let wanted: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, 3);
|
||||
assert_eq!(wanted, 0);
|
||||
// #120 backfill: the series was added before it had an id on file.
|
||||
let tvdb_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728")
|
||||
@@ -643,6 +825,50 @@ mod tests {
|
||||
assert_eq!(stamped, 1);
|
||||
}
|
||||
|
||||
/// #160. Once the series has been refreshed before, a season TMDB reveals
|
||||
/// that the library has never seen genuinely appeared after add time, so
|
||||
/// it is tracked and its episodes arrive wanted.
|
||||
#[tokio::test]
|
||||
async fn a_later_refresh_tracks_a_genuinely_new_season() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = MockServer::start().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tracked, 0, "the seeding refresh tracks nothing");
|
||||
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount(
|
||||
&server,
|
||||
"Returning Series",
|
||||
season_one_body(&two_episodes()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let rows: Vec<(i64, bool)> =
|
||||
sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, false), (2, true)]);
|
||||
let wanted: Vec<i64> = sqlx::query_scalar(
|
||||
"SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id
|
||||
WHERE s.number = 2 AND e.wanted = 1",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wanted, vec![1]);
|
||||
}
|
||||
|
||||
/// `auto_track` is a rule about reveals, not intent: without it nothing
|
||||
/// arrives wanted, even though everything is revealed.
|
||||
#[tokio::test]
|
||||
@@ -704,13 +930,13 @@ mod tests {
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, true), (2, true)]);
|
||||
assert_eq!(rows, vec![(1, false), (2, false)]);
|
||||
let counts: (i64, i64) =
|
||||
sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(counts, (3, 3));
|
||||
assert_eq!(counts, (3, 0));
|
||||
}
|
||||
|
||||
/// Episodes revealed into an existing season follow that season's rule:
|
||||
@@ -976,4 +1202,201 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(!vanished, "the conflict is over once TMDB lists it again");
|
||||
}
|
||||
|
||||
/// #153. TMDB has not named an unaired episode yet, so the refresh stores
|
||||
/// the placeholder instead of an empty string — and #121's guarded update
|
||||
/// swaps it for the real title once TMDB fills it in.
|
||||
#[tokio::test]
|
||||
async fn an_unnamed_episode_stores_the_placeholder_until_tmdb_names_it() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb(
|
||||
"Returning Series",
|
||||
season_one_body(&json!([
|
||||
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"episode_number": 2, "name": "", "air_date": null}
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
|
||||
let unnamed = season_one_episode(&database, 2).await;
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
|
||||
.bind(unnamed)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(title, UNTITLED_EPISODE);
|
||||
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount(
|
||||
&server,
|
||||
"Returning Series",
|
||||
season_one_body(&json!([
|
||||
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
|
||||
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
|
||||
.bind(unnamed)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(title, "Hospital");
|
||||
}
|
||||
|
||||
async fn season_two_episode(database: &Db) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id
|
||||
WHERE s.number = 2 AND e.number = 1",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// #137. TMDB dropped season 2 entirely: with no file under any of its
|
||||
/// episodes the season is deleted, and its episodes and their stored
|
||||
/// releases cascade with it.
|
||||
#[tokio::test]
|
||||
async fn a_vanished_season_without_files_is_deleted() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let episode = season_two_episode(&database).await;
|
||||
attach_release(&database, episode).await;
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcomes.len(),
|
||||
1,
|
||||
"a season removal is work worth reporting"
|
||||
);
|
||||
let seasons: Vec<i64> = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(seasons, vec![1]);
|
||||
let episodes: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(episodes, 0, "the vanished season's episodes cascade");
|
||||
let releases: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM episode_releases WHERE episode_id = ?")
|
||||
.bind(episode)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(releases, 0);
|
||||
}
|
||||
|
||||
/// #137. With a file anywhere under it the vanished season is never
|
||||
/// deleted: dropping the rows would orphan a real file. The season is
|
||||
/// flagged — the same conflict marker an episode gets (#122) — and its
|
||||
/// episodes and file stay put.
|
||||
#[tokio::test]
|
||||
async fn a_vanished_season_with_a_file_is_flagged_not_deleted() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let episode = season_two_episode(&database).await;
|
||||
attach_file(&database, episode).await;
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let (vanished, episodes, files): (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT s.vanished,
|
||||
(SELECT count(*) FROM episodes e WHERE e.season_id = s.id),
|
||||
(SELECT count(*) FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = ?)
|
||||
FROM seasons s WHERE s.number = 2",
|
||||
)
|
||||
.bind(episode)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(vanished, episodes, files),
|
||||
(1, 1, 1),
|
||||
"flagged as a conflict, episodes and file intact"
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotence at season level too: a season TMDB restores clears the
|
||||
/// flag again, the way a restored episode number does (#122).
|
||||
#[tokio::test]
|
||||
async fn a_restored_season_clears_the_vanished_flag() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let episode = season_two_episode(&database).await;
|
||||
attach_file(&database, episode).await;
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount_with(&server, "Returning Series", &[1]).await;
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
expire_refresh(&database).await;
|
||||
// TMDB puts the season back where it was.
|
||||
server.reset().await;
|
||||
mount(
|
||||
&server,
|
||||
"Returning Series",
|
||||
season_one_body(&two_episodes()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let vanished: bool = sqlx::query_scalar("SELECT vanished FROM seasons WHERE number = 2")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!vanished, "the conflict is over once TMDB lists it again");
|
||||
}
|
||||
|
||||
/// §4.2 keeps specials out of derived status, so nothing downstream can
|
||||
/// notice their absence — and TMDB drops and re-adds season 0 routinely.
|
||||
/// A vanished season 0 is therefore left alone either way.
|
||||
#[tokio::test]
|
||||
async fn a_vanished_season_zero_is_left_alone() {
|
||||
let (_dir, database) = seeded_series(true).await;
|
||||
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
sqlx::query("INSERT INTO seasons (series_id, number) SELECT id, 0 FROM series")
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
expire_refresh(&database).await;
|
||||
server.reset().await;
|
||||
mount_with(&server, "Returning Series", &[1, 2]).await;
|
||||
|
||||
let outcomes = action(&server).tick(&database).await.unwrap();
|
||||
|
||||
assert!(
|
||||
outcomes.is_empty(),
|
||||
"churning specials is not work to report"
|
||||
);
|
||||
let seasons: Vec<i64> = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(seasons, vec![0, 1, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, FindResults, 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.
|
||||
@@ -163,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, FindResults, 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(),
|
||||
@@ -296,7 +460,7 @@ impl From<RawSeason> for Season {
|
||||
/// What one `IMDb` id resolved to. An id names one title, so at most one of
|
||||
/// the two lists is non-empty — but TMDB answers both kinds in the same
|
||||
/// response, and both are surfaced rather than filtered here.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct FindResults {
|
||||
pub movies: Vec<MovieSearchResult>,
|
||||
pub series: Vec<SeriesSearchResult>,
|
||||
@@ -325,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 {
|
||||
@@ -337,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,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>,
|
||||
}
|
||||
|
||||
@@ -426,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,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,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 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": 82728,
|
||||
"name": "Bluey",
|
||||
"original_name": "Bluey",
|
||||
"tagline": "",
|
||||
"overview": "The slice-of-life adventures of an Australian cattle dog called Bluey and her family.",
|
||||
"status": "Returning Series",
|
||||
"homepage": "https://www.blueytv.com",
|
||||
"vote_average": 8.417,
|
||||
"vote_count": 118,
|
||||
"poster_path": "/58Pm1HTKHefFBCPVAVXOI0cDdIg.jpg",
|
||||
"backdrop_path": "/9K4mLtNKhEfFBCPVAVXOI0cDdIg.jpg",
|
||||
"first_air_date": "2018-10-01",
|
||||
"episode_run_time": [7],
|
||||
"genres": [
|
||||
{ "id": 16, "name": "Animation" },
|
||||
{ "id": 10751, "name": "Family" },
|
||||
{ "id": 10759, "name": "Action & Adventure" }
|
||||
],
|
||||
"credits": {
|
||||
"cast": [
|
||||
{ "id": 2134777, "name": "Melanie Zanetti", "character": "Chilli Heeler (voice)", "profile_path": "/zAnetti.jpg", "order": 1 },
|
||||
{ "id": 1760828, "name": "David McCormack", "character": "Bandit Heeler (voice)", "profile_path": "/dMcCormack.jpg", "order": 0 }
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"results": [
|
||||
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
|
||||
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
|
||||
]
|
||||
},
|
||||
"external_ids": {
|
||||
"imdb_id": "tt7614372",
|
||||
"tvdb_id": 361391
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 82728,
|
||||
"results": [
|
||||
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
|
||||
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use arr_meta::{Error, TmdbClient};
|
||||
use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE};
|
||||
use chrono::NaiveDate;
|
||||
use wiremock::matchers::{header_exists, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -20,6 +20,10 @@ const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only
|
||||
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
|
||||
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
|
||||
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
|
||||
const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json");
|
||||
const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json");
|
||||
const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json");
|
||||
const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json");
|
||||
|
||||
fn client(server: &MockServer) -> TmdbClient {
|
||||
TmdbClient::builder("test-key")
|
||||
@@ -213,6 +217,9 @@ async fn unreleased_movie_has_no_dates_and_no_imdb_id() {
|
||||
assert_eq!(movie.imdb_id, None);
|
||||
assert_eq!(movie.runtime, None);
|
||||
assert!(movie.origin_countries.is_empty());
|
||||
// TMDB reports vote_average: 0 for an unrated title — absence, not zero
|
||||
// (#156), so it is normalised away like any other zero-valued null.
|
||||
assert_eq!(movie.vote_average, None);
|
||||
assert!(!movie.is_digitally_released(date(2026, 8, 22)));
|
||||
}
|
||||
|
||||
@@ -464,6 +471,33 @@ async fn debug_output_does_not_leak_the_api_key() {
|
||||
assert!(rendered.contains("redacted"), "{rendered}");
|
||||
}
|
||||
|
||||
/// TMDB leaves an unaired episode's name as `""`. It must not reach the
|
||||
/// library as an empty string — search haystacks, §7.4 filenames and the
|
||||
/// compat shim all treat it as real text (#153) — so it becomes a placeholder
|
||||
/// that a later refresh replaces once TMDB names it.
|
||||
#[tokio::test]
|
||||
async fn season_episode_without_a_name_gets_the_placeholder_title() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728/season/1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"season_number": 1, "episodes": [
|
||||
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
|
||||
{"episode_number": 2, "name": "", "air_date": null}
|
||||
]}"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let season = client(&server)
|
||||
.season(82_728, 1)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(season.episodes[0].title, "Magic Xylophone");
|
||||
assert_eq!(season.episodes[1].title, UNTITLED_EPISODE);
|
||||
}
|
||||
|
||||
/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
|
||||
/// where TMDB keeps the mapping.
|
||||
#[tokio::test]
|
||||
@@ -529,3 +563,249 @@ async fn series_without_a_tvdb_id_maps_to_none() {
|
||||
|
||||
assert_eq!(ids.tvdb_id, None);
|
||||
}
|
||||
|
||||
// --- detail (#143, §9.6) ----------------------------------------------------
|
||||
|
||||
/// A whole detail page costs one upstream request: credits, videos and
|
||||
/// external ids ride along in the same response.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.and(query_param(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn movie_detail_parses_the_rich_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(movie.tmdb_id, 693_134);
|
||||
assert_eq!(
|
||||
movie.overview.as_deref(),
|
||||
Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.")
|
||||
);
|
||||
assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters."));
|
||||
assert_eq!(movie.genres.len(), 2);
|
||||
assert_eq!(movie.genres[0].id, 878);
|
||||
assert_eq!(movie.genres[0].name, "Science Fiction");
|
||||
assert!(movie.poster_path.is_some());
|
||||
assert!(movie.backdrop_path.is_some());
|
||||
assert!((movie.vote_average.expect("rated") - 8.152).abs() < f64::EPSILON);
|
||||
assert_eq!(movie.vote_count, 6_249);
|
||||
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
|
||||
assert_eq!(movie.status, "Released");
|
||||
assert_eq!(movie.runtime, Some(167));
|
||||
assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678"));
|
||||
}
|
||||
|
||||
/// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to
|
||||
/// remember to. The fixture carries 12 entries; only the first 10 by `order`
|
||||
/// survive.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_cast_is_truncated_to_ten_by_order() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(movie.cast.len(), 10);
|
||||
assert_eq!(movie.cast[0].tmdb_id, 5_530);
|
||||
assert_eq!(movie.cast[0].name, "Timothée Chalamet");
|
||||
assert_eq!(movie.cast[0].character, "Paul Atreides");
|
||||
assert_eq!(movie.cast[0].order, 0);
|
||||
// Truncation keeps the lowest `order` values, not the first rows sent.
|
||||
assert_eq!(movie.cast[9].name, "Souheila Yacoub");
|
||||
assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952));
|
||||
}
|
||||
|
||||
/// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a
|
||||
/// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before
|
||||
/// the official one; neither wins.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_picks_the_official_youtube_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
let trailer = movie.trailer.expect("a trailer is chosen");
|
||||
assert_eq!(trailer.key, "Way9Dexny3w");
|
||||
assert_eq!(trailer.site, "YouTube");
|
||||
assert_eq!(trailer.kind, "Trailer");
|
||||
assert!(trailer.official);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_detail_parses_the_rich_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728"))
|
||||
.and(query_param(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let series = client(&server)
|
||||
.series_detail(82_728)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(series.tmdb_id, 82_728);
|
||||
assert_eq!(series.status, "Returning Series");
|
||||
assert_eq!(series.episode_runtime, Some(7));
|
||||
assert_eq!(series.tvdb_id, Some(361_391));
|
||||
assert!(series.tagline.is_none());
|
||||
assert_eq!(series.genres[0].name, "Animation");
|
||||
// Cast comes out in TMDB's `order`, not in the order the rows arrived.
|
||||
assert_eq!(series.cast[0].name, "David McCormack");
|
||||
// No trailer exists for this series, so the teaser tier is what fires.
|
||||
let trailer = series.trailer.expect("the teaser is chosen");
|
||||
assert_eq!(trailer.key, "bluey_teaser");
|
||||
}
|
||||
|
||||
/// Detail goes through the same cache as everything else — two calls, one
|
||||
/// request.
|
||||
#[tokio::test]
|
||||
async fn movie_and_series_detail_are_served_from_the_cache() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmdb = client(&server);
|
||||
let _ = tmdb.movie_detail(693_134).await.expect("succeeds");
|
||||
let _ = tmdb.movie_detail(693_134).await.expect("cached");
|
||||
let _ = tmdb.series_detail(82_728).await.expect("succeeds");
|
||||
let _ = tmdb.series_detail(82_728).await.expect("cached");
|
||||
}
|
||||
|
||||
/// #144's search-row chip resolves through the videos-only call: one request,
|
||||
/// no full detail response.
|
||||
#[tokio::test]
|
||||
async fn movie_videos_fetches_only_the_videos_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds")
|
||||
.expect("an official trailer is chosen");
|
||||
|
||||
assert_eq!(trailer.key, "Way9Dexny3w");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_videos_applies_the_same_selection_rule() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.series_videos(82_728)
|
||||
.await
|
||||
.expect("lookup succeeds")
|
||||
.expect("the teaser is chosen");
|
||||
|
||||
assert_eq!(trailer.key, "bluey_teaser");
|
||||
}
|
||||
|
||||
/// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none.
|
||||
#[tokio::test]
|
||||
async fn vimeo_only_video_lists_yield_no_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/1/videos"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"id": 1, "results": [
|
||||
{"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true}
|
||||
]}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(1)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(trailer, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_video_list_yields_no_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(1)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(trailer, None);
|
||||
}
|
||||
|
||||
+165
-20
@@ -129,6 +129,10 @@
|
||||
<header class="deck-head library-bar">
|
||||
<h2 class="deck-label">library</h2>
|
||||
<span class="deck-count readout" id="library-summary"></span>
|
||||
<div class="view-toggle">
|
||||
<button type="button" class="view-btn" id="library-view-grid" aria-pressed="true">grid</button>
|
||||
<button type="button" class="view-btn" id="library-view-list" aria-pressed="false">list</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-toggle readout"
|
||||
@@ -171,7 +175,8 @@
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
kids titles with no qualifying pt release — parked here, sometimes for months, by
|
||||
design. allow english writes this title's override and the normal search proceeds.
|
||||
design. allow english writes a movie's override and its search proceeds; series wait
|
||||
for the detail view.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-no-pt"></ul>
|
||||
</section>
|
||||
@@ -182,32 +187,110 @@
|
||||
<span class="deck-count readout" id="count-decision"></span>
|
||||
</header>
|
||||
<p class="queue-note readout dim">
|
||||
hard-failed twice on different releases — open the releases and decide by hand.
|
||||
hard-failed twice on different releases — open the movie's page and decide by hand;
|
||||
series entries are display-only for now.
|
||||
</p>
|
||||
<ul class="deck-rows" id="rows-decision"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck releases" id="releases" hidden>
|
||||
<!--
|
||||
MOVIE PAGE (§9.6, issue 149): a library row opens /movies/{id} — one
|
||||
machined module carries what the film IS: backdrop hotlinked from TMDB
|
||||
under a scrim that sinks it into the console charcoal, poster framed at
|
||||
left, Rajdhani title with year, TMDB rating and vote count, runtime and
|
||||
genres, the tagline quoted, overview below. TRAILER resolves from the
|
||||
metadata response's own key (no second call); TMDB, IMDb and Rotten
|
||||
Tomatoes sit beside it — RT is a search URL built here, never a resolved
|
||||
page. LIBRARY controls follow on their own row: wanted, blocked, root,
|
||||
then search indexers and remove. CAST is the top 10 billed, each photo
|
||||
linking out to that person's TMDB page. ON DISK lists imported files
|
||||
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
|
||||
§9.3 deck closes the page unchanged — buckets, chips, score heat and
|
||||
rejection rules untouched; /movies/{id}/releases lands here with the
|
||||
deck section in view.
|
||||
-->
|
||||
|
||||
<main class="deck" id="movie" hidden aria-label="movie detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="releases-back">back</button>
|
||||
<div class="releases-id">
|
||||
<h2 class="releases-title" id="releases-title">—</h2>
|
||||
<span class="releases-year readout dim" id="releases-year">—</span>
|
||||
</div>
|
||||
<button type="button" class="control" id="releases-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="releases-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button type="button" class="control" id="movie-back">back</button>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
</header>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
<img class="movie-poster" id="movie-poster" alt="" hidden />
|
||||
<div class="movie-info">
|
||||
<div class="movie-idline">
|
||||
<h2 class="movie-title" id="movie-title">—</h2>
|
||||
<span class="movie-year readout dim" id="movie-year"></span>
|
||||
</div>
|
||||
<span class="row-chips movie-chips" id="movie-chips"></span>
|
||||
<p class="movie-rating readout" id="movie-rating" hidden></p>
|
||||
<p class="movie-meta readout dim" id="movie-meta" hidden></p>
|
||||
<p class="movie-tagline" id="movie-tagline" hidden></p>
|
||||
<p class="movie-overview" id="movie-overview" hidden></p>
|
||||
<div class="movie-actions" id="movie-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-controls" aria-label="library controls">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label">library</h3>
|
||||
</header>
|
||||
<div class="movie-controls">
|
||||
<button type="button" class="control control-quiet" id="movie-wanted" aria-pressed="false">
|
||||
wanted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="movie-blocked"
|
||||
aria-pressed="false"
|
||||
>
|
||||
blocked
|
||||
</button>
|
||||
<label class="movie-root-field">
|
||||
<span class="field-label readout dim" id="movie-root-label">root</span>
|
||||
<select class="form-input movie-root" id="movie-root" aria-labelledby="movie-root-label">
|
||||
</select>
|
||||
</label>
|
||||
<span class="movie-controls-space"></span>
|
||||
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="movie-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-cast">cast</h3>
|
||||
</header>
|
||||
<ul class="cast-grid" id="rows-cast"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-disk">on disk</h3>
|
||||
<span class="deck-count readout" id="count-disk"></span>
|
||||
</header>
|
||||
<ul class="deck-rows" id="rows-disk"></ul>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-releases">releases</h3>
|
||||
</header>
|
||||
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
|
||||
<header class="deck-head">
|
||||
@@ -275,6 +358,7 @@
|
||||
<ul class="deck-rows" id="rows-rejected"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck" id="settings" hidden aria-label="settings">
|
||||
@@ -306,6 +390,67 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
SERIES DETAIL (§4.1 + §4.2 + §9.3, issue 129): a series row anywhere
|
||||
opens /series/{id} — the title line carries the audience chip, the
|
||||
derived status and the wanted/on-disk count (season 0 excluded, per
|
||||
§4.2). Seasons are collapsible groups: engraved name, the tracked
|
||||
toggle (the one way an operator wants a season — no bulk backfill,
|
||||
§4.1), counts, search and deck. Episodes read as tag, title, air date,
|
||||
state, then the imported file's probed attribute chips — ffprobe truth,
|
||||
not name claims — with want/search/deck only where they can do
|
||||
something: an unaired episode offers no search button at all.
|
||||
-->
|
||||
|
||||
<main class="deck releases" id="series" hidden aria-label="series detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="series-back">back</button>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
</header>
|
||||
|
||||
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
<img class="movie-poster" id="series-poster" alt="" hidden />
|
||||
<div class="movie-info">
|
||||
<div class="movie-idline">
|
||||
<h2 class="movie-title" id="series-title">—</h2>
|
||||
<span class="movie-year readout dim" id="series-year"></span>
|
||||
</div>
|
||||
<!-- audience, episode counts and §4.2 status: operational state,
|
||||
outranking the artwork, folded into this one header (issue 150) -->
|
||||
<span class="row-chips movie-chips" id="series-chips"></span>
|
||||
<p class="movie-rating readout" id="series-rating" hidden></p>
|
||||
<p class="movie-meta readout dim" id="series-meta" hidden></p>
|
||||
<p class="movie-tagline" id="series-tagline" hidden></p>
|
||||
<p class="movie-overview" id="series-overview" hidden></p>
|
||||
<div class="movie-actions" id="series-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="series-cast" hidden aria-labelledby="label-series-cast">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-series-cast">cast</h3>
|
||||
</header>
|
||||
<ul class="cast-grid" id="rows-series-cast"></ul>
|
||||
</section>
|
||||
|
||||
<ul class="deck-rows seasons" id="rows-seasons"></ul>
|
||||
</main>
|
||||
|
||||
<main class="deck releases" id="tv-releases" hidden aria-label="season and episode releases">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="tv-releases-back">back</button>
|
||||
<div class="releases-id">
|
||||
<h2 class="releases-title" id="tv-releases-title">—</h2>
|
||||
<span class="releases-year readout dim" id="tv-releases-sub"></span>
|
||||
</div>
|
||||
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
|
||||
</header>
|
||||
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
|
||||
<div id="tv-buckets"></div>
|
||||
</main>
|
||||
|
||||
<main class="board">
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
|
||||
@@ -99,7 +99,13 @@ function checkCss(source, rel) {
|
||||
}
|
||||
|
||||
function checkPlain(source, rel) {
|
||||
source.split("\n").forEach((rawLine, index) => {
|
||||
// Comments are prose, not styling — `checkCss` already ignores them, and
|
||||
// without the same treatment here an issue reference like `#140` reads as a
|
||||
// three-digit hex colour and fails the gate.
|
||||
const stripped = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "))
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
stripped.split("\n").forEach((rawLine, index) => {
|
||||
const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token(");
|
||||
if (COLOR_LITERAL.test(neutral)) {
|
||||
errors.push(`${rel}:${index + 1}: literal colour outside the token block`);
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface LibrarySeries {
|
||||
auto_track: boolean;
|
||||
upstream_ended: boolean;
|
||||
blocked: boolean;
|
||||
/** Stored artwork (§9.6) — a grid tile renders without a TMDB call. */
|
||||
poster_path: string | null;
|
||||
/** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */
|
||||
vote_average: number | null;
|
||||
status: SeriesStatus;
|
||||
wanted_episodes: number;
|
||||
available_episodes: number;
|
||||
|
||||
+2172
-273
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
// Hand-written mirror of arr-api's /api/movies/{id}/metadata and PATCH
|
||||
// /api/movies/{id} schemas — same reasoning as search.ts: the generated
|
||||
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import { type ActionOutcome, errorDetail } from "./releases";
|
||||
|
||||
/** One of the top-billed cast members (§9.6). */
|
||||
export interface MetadataCastMember {
|
||||
tmdb_id: number;
|
||||
name: string;
|
||||
character: string;
|
||||
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
|
||||
profile_path: string | null;
|
||||
}
|
||||
|
||||
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
|
||||
export interface MetadataTrailer {
|
||||
youtube_key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Rich detail for one library movie's §9.6 page (#146). */
|
||||
export interface MovieMetadata {
|
||||
tmdb_id: number;
|
||||
overview: string | null;
|
||||
tagline: string | null;
|
||||
genres: string[];
|
||||
/** Minutes. */
|
||||
runtime: number | null;
|
||||
status: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
/** `null` when TMDB has no votes for the title (#156). */
|
||||
vote_average: number | null;
|
||||
vote_count: number;
|
||||
homepage: string | null;
|
||||
imdb_id: string | null;
|
||||
cast: MetadataCastMember[];
|
||||
trailer: MetadataTrailer | null;
|
||||
}
|
||||
|
||||
export type MetadataOutcome =
|
||||
| { kind: "metadata"; metadata: MovieMetadata }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
/** The click-resolved trailer behind a search row's chip (#148). */
|
||||
export type TrailerOutcome =
|
||||
| { kind: "trailer"; youtubeKey: string }
|
||||
| { kind: "none" }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
/**
|
||||
* `GET /api/trailer` (#144), fired from a chip click only (§9.6) — a page
|
||||
* of results costs zero extra calls. A 404 is TMDB simply having no video:
|
||||
* an ordinary outcome, never an error.
|
||||
*/
|
||||
export async function resolveTrailer(
|
||||
kind: "movie" | "tv",
|
||||
tmdbId: number,
|
||||
): Promise<TrailerOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/trailer?kind=${kind}&tmdb_id=${tmdbId}`);
|
||||
if (response.status === 404) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
const body = (await response.json()) as MetadataTrailer;
|
||||
return { kind: "trailer", youtubeKey: body.youtube_key };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** One request for everything above the release deck (§9.6). */
|
||||
export async function movieMetadata(movieId: number): Promise<MetadataOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}/metadata`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "metadata", metadata: (await response.json()) as MovieMetadata };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-title library controls (#149): wanted, blocked and root all ride
|
||||
* PATCH /api/movies/{id}. Partial bodies only — overrides stay untouched.
|
||||
*/
|
||||
export async function updateMovie(
|
||||
movieId: number,
|
||||
patch: { wanted?: boolean; blocked?: boolean; root_id?: number },
|
||||
): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/movies/${movieId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- §9.6 link composition -------------------------------------------- */
|
||||
|
||||
/** TMDB image URL from a path fragment — the browser picks the size. */
|
||||
export function tmdbImage(path: string | null, size: string): string | null {
|
||||
return path === null ? null : `https://image.tmdb.org/t/p/${size}${path}`;
|
||||
}
|
||||
|
||||
export function tmdbMovieLink(tmdbId: number): string {
|
||||
return `https://www.themoviedb.org/movie/${tmdbId}`;
|
||||
}
|
||||
|
||||
export function tmdbSeriesLink(tmdbId: number): string {
|
||||
return `https://www.themoviedb.org/tv/${tmdbId}`;
|
||||
}
|
||||
|
||||
/** A series has no imdb_id in this app; TVDB is its second id (§9.6). */
|
||||
export function tvdbLink(tvdbId: number): string {
|
||||
return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
|
||||
}
|
||||
|
||||
export function tmdbPersonLink(tmdbId: number): string {
|
||||
return `https://www.themoviedb.org/person/${tmdbId}`;
|
||||
}
|
||||
|
||||
export function imdbLink(imdbId: string): string {
|
||||
return `https://www.imdb.com/title/${imdbId}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Rotten Tomatoes link is a *search* URL built in the browser (§9.6) —
|
||||
* never a resolved title page, which this service has no id for.
|
||||
*/
|
||||
export function rottenTomatoesSearch(title: string, year: number | null): string {
|
||||
const query = year === null ? title : `${title} ${year}`;
|
||||
return `https://www.rottentomatoes.com/search?search=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
export function youtubeLink(key: string): string {
|
||||
return `https://www.youtube.com/watch?v=${key}`;
|
||||
}
|
||||
|
||||
/** `8.2` with one decimal, as the readout spells a TMDB rating. */
|
||||
export function formatRating(voteAverage: number): string {
|
||||
return voteAverage.toFixed(1);
|
||||
}
|
||||
|
||||
/** `4,821` — grouped votes, matching the issue's own mockup. */
|
||||
export function formatVoteCount(voteCount: number): string {
|
||||
return voteCount.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
/** `166 min · Science Fiction, Adventure`, skipping what detail left out. */
|
||||
export function formatMetaLine(runtime: number | null, genres: string[]): string {
|
||||
const parts: string[] = [];
|
||||
if (runtime !== null && runtime > 0) {
|
||||
parts.push(`${runtime} min`);
|
||||
}
|
||||
if (genres.length > 0) {
|
||||
parts.push(genres.join(", "));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The file's name inside its §7.4 folder — the audit surface the layout
|
||||
* section promises (`ls` shows what the file is). The folder itself stays
|
||||
* with the removal confirmation.
|
||||
*/
|
||||
export function fileName(path: string): string {
|
||||
return path.slice(path.lastIndexOf("/") + 1);
|
||||
}
|
||||
+22
-8
@@ -11,14 +11,27 @@ export interface AttentionMovie extends LibraryMovie {
|
||||
last_searched_at: string | null;
|
||||
}
|
||||
|
||||
/** An episode that put its series here, named for SxxEyy rendering. */
|
||||
export interface QueuedEpisode {
|
||||
id: number;
|
||||
season_number: number;
|
||||
episode_number: number;
|
||||
}
|
||||
|
||||
/** A season whose failed pack put its series here. */
|
||||
export interface QueuedSeason {
|
||||
id: number;
|
||||
number: number;
|
||||
}
|
||||
|
||||
/** A series in a TV attention queue, with what put it there (§9.5). */
|
||||
export interface SeriesAttention {
|
||||
series_id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
episodes: number[];
|
||||
seasons: number[];
|
||||
episodes: QueuedEpisode[];
|
||||
seasons: QueuedSeason[];
|
||||
}
|
||||
|
||||
export interface AttentionQueues {
|
||||
@@ -78,13 +91,14 @@ export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOu
|
||||
return { kind: "done", searchQueued: true };
|
||||
}
|
||||
|
||||
/** Entries across both queues — the rail badge's number.
|
||||
*
|
||||
* Deliberately movies only for now: the TV lanes are new and the QUEUES
|
||||
* deck does not render them yet (the backend landed in issue 126).
|
||||
*/
|
||||
/** Entries across all four lanes — the rail badge's number. */
|
||||
export function attentionTotal(queues: AttentionQueues): number {
|
||||
return queues.no_pt_source.length + queues.needs_decision.length;
|
||||
return (
|
||||
queues.no_pt_source.length +
|
||||
queues.needs_decision.length +
|
||||
queues.tv_no_pt_source.length +
|
||||
queues.tv_needs_decision.length
|
||||
);
|
||||
}
|
||||
|
||||
/** `searched 3×`, or null before the first attempt ever lands here. */
|
||||
|
||||
+34
-1
@@ -257,6 +257,39 @@ export function bucketOf(release: MovieRelease): Bucket {
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
/**
|
||||
* The probed attributes a file row shows as chips (§5.6, §7.4) — ffprobe
|
||||
* truth, not name claims. Shared by movie and episode file rows; the probe
|
||||
* JSON arrives untyped from the API, so the shape is narrowed here.
|
||||
*/
|
||||
export function probedAttributeTags(probed: unknown): string[] {
|
||||
if (probed === null || typeof probed !== "object") {
|
||||
return [];
|
||||
}
|
||||
const probe = probed as {
|
||||
resolution?: string | null;
|
||||
source?: string | null;
|
||||
hdr?: string | null;
|
||||
audio_tracks?: { language?: string | null }[] | null;
|
||||
};
|
||||
const tags: string[] = [];
|
||||
if (probe.resolution) {
|
||||
tags.push(probe.resolution);
|
||||
}
|
||||
if (probe.source) {
|
||||
tags.push(probe.source);
|
||||
}
|
||||
if (probe.hdr && probe.hdr !== "SDR") {
|
||||
tags.push(probe.hdr);
|
||||
}
|
||||
for (const track of probe.audio_tracks ?? []) {
|
||||
if (track.language) {
|
||||
tags.push(track.language);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
@@ -361,7 +394,7 @@ export function ruleLabel(rule: string | null): string {
|
||||
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
async function errorDetail(response: Response): Promise<string> {
|
||||
export async function errorDetail(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
return body.error ?? `http ${response.status}`;
|
||||
|
||||
+48
-1
@@ -8,7 +8,11 @@ export type Route =
|
||||
| { kind: "queues" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "search"; query: string }
|
||||
| { kind: "releases"; movieId: number };
|
||||
| { kind: "movie"; movieId: number }
|
||||
| { kind: "releases"; movieId: number }
|
||||
| { kind: "series"; seriesId: number }
|
||||
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
|
||||
| { kind: "episodeReleases"; episodeId: number };
|
||||
|
||||
export function parseRoute(url: URL): Route {
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
@@ -25,12 +29,47 @@ export function parseRoute(url: URL): Route {
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
return query === "" ? { kind: "board" } : { kind: "search", query };
|
||||
}
|
||||
if (segments.length === 2 && segments[0] === "movies") {
|
||||
const movieId = Number(segments[1]);
|
||||
if (Number.isInteger(movieId) && movieId > 0) {
|
||||
return { kind: "movie", movieId };
|
||||
}
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") {
|
||||
const movieId = Number(segments[1]);
|
||||
if (Number.isInteger(movieId) && movieId > 0) {
|
||||
return { kind: "releases", movieId };
|
||||
}
|
||||
}
|
||||
if (segments.length === 2 && segments[0] === "series") {
|
||||
const seriesId = Number(segments[1]);
|
||||
if (Number.isInteger(seriesId) && seriesId > 0) {
|
||||
return { kind: "series", seriesId };
|
||||
}
|
||||
}
|
||||
if (
|
||||
segments.length === 5 &&
|
||||
segments[0] === "series" &&
|
||||
segments[2] === "seasons" &&
|
||||
segments[4] === "releases"
|
||||
) {
|
||||
const seriesId = Number(segments[1]);
|
||||
const seasonNumber = Number(segments[3]);
|
||||
if (
|
||||
Number.isInteger(seriesId) &&
|
||||
seriesId > 0 &&
|
||||
Number.isInteger(seasonNumber) &&
|
||||
seasonNumber >= 0
|
||||
) {
|
||||
return { kind: "seasonReleases", seriesId, seasonNumber };
|
||||
}
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") {
|
||||
const episodeId = Number(segments[1]);
|
||||
if (Number.isInteger(episodeId) && episodeId > 0) {
|
||||
return { kind: "episodeReleases", episodeId };
|
||||
}
|
||||
}
|
||||
return { kind: "board" };
|
||||
}
|
||||
|
||||
@@ -46,8 +85,16 @@ export function routePath(route: Route): string {
|
||||
return "/settings";
|
||||
case "search":
|
||||
return `/search?q=${encodeURIComponent(route.query)}`;
|
||||
case "movie":
|
||||
return `/movies/${route.movieId}`;
|
||||
case "releases":
|
||||
return `/movies/${route.movieId}/releases`;
|
||||
case "series":
|
||||
return `/series/${route.seriesId}`;
|
||||
case "seasonReleases":
|
||||
return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`;
|
||||
case "episodeReleases":
|
||||
return `/episodes/${route.episodeId}/releases`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface LibraryMovie {
|
||||
wanted: boolean;
|
||||
state: "missing" | "downloading" | "available";
|
||||
blocked: boolean;
|
||||
/** Stored artwork (§9.6) — a row renders without a TMDB call. */
|
||||
poster_path: string | null;
|
||||
/** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */
|
||||
vote_average: number | null;
|
||||
/** The §5.7 rule relaxed to allow an import, when a file carries one. */
|
||||
waiver?: unknown;
|
||||
}
|
||||
@@ -28,6 +32,8 @@ export interface LibrarySeriesHit {
|
||||
original_language: string | null;
|
||||
root_id: number;
|
||||
blocked: boolean;
|
||||
poster_path: string | null;
|
||||
vote_average: number | null;
|
||||
}
|
||||
|
||||
export interface LibraryEpisodeHit {
|
||||
@@ -39,6 +45,11 @@ export interface LibraryEpisodeHit {
|
||||
tag: string;
|
||||
/** The episode title — what the search matched on. */
|
||||
title: string;
|
||||
/** The series' poster — an episode has no artwork of its own worth showing at row size. */
|
||||
poster_path: string | null;
|
||||
vote_average: number | null;
|
||||
/** The series' TMDB id — what the row's trailer chip resolves through. */
|
||||
series_tmdb_id: number;
|
||||
}
|
||||
|
||||
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
|
||||
@@ -51,6 +62,10 @@ export interface TmdbMovie {
|
||||
original_language: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
poster_path: string | null;
|
||||
/** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */
|
||||
vote_average: number | null;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TmdbSeries {
|
||||
@@ -60,6 +75,9 @@ export interface TmdbSeries {
|
||||
original_language: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
poster_path: string | null;
|
||||
vote_average: number | null;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export type TmdbResult = TmdbMovie | TmdbSeries;
|
||||
@@ -142,6 +160,8 @@ export interface ApiSeries {
|
||||
overrides: Record<string, unknown>;
|
||||
upstream_ended: boolean;
|
||||
blocked: boolean;
|
||||
poster_path: string | null;
|
||||
vote_average: number | null;
|
||||
status: string;
|
||||
wanted_episodes: number;
|
||||
available_episodes: number;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// Hand-written mirror of arr-api's series detail, season, episode and
|
||||
// release schemas — same reasoning as search.ts: the generated client
|
||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import type { MetadataCastMember, MetadataTrailer } from "./movie";
|
||||
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
|
||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||
|
||||
/** §4.2 derived status — displayed, never editable. */
|
||||
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
||||
|
||||
export interface ApiSeries {
|
||||
id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
root_id: number;
|
||||
blocked: boolean;
|
||||
status: SeriesStatus;
|
||||
wanted_episodes: number;
|
||||
available_episodes: number;
|
||||
}
|
||||
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
series_id: number;
|
||||
season_id: number;
|
||||
season_number: number;
|
||||
number: number;
|
||||
title: string;
|
||||
air_date: string | null;
|
||||
wanted: boolean;
|
||||
state: string;
|
||||
/** issue 122: gone upstream while a file of its own remained. */
|
||||
vanished: boolean;
|
||||
}
|
||||
|
||||
export interface ApiSeason {
|
||||
id: number;
|
||||
number: number;
|
||||
tracked: boolean;
|
||||
/** Gone upstream while a file under it remained — a conflict, not a state. */
|
||||
vanished: boolean;
|
||||
episodes: ApiEpisode[];
|
||||
}
|
||||
|
||||
export type SeasonsOutcome =
|
||||
| { kind: "seasons"; seasons: ApiSeason[] }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}`);
|
||||
return response.ok ? ((await response.json()) as ApiSeries) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
|
||||
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/episodes/${episodeId}`);
|
||||
return response.ok ? ((await response.json()) as ApiEpisode) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/seasons`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "seasons", seasons: (await response.json()) as ApiSeason[] };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
|
||||
export interface EpisodeFile {
|
||||
episode_id: number;
|
||||
path: string;
|
||||
size: number;
|
||||
probed: {
|
||||
resolution?: string | null;
|
||||
source?: string | null;
|
||||
hdr?: string | null;
|
||||
audio_tracks?: { language?: string | null }[] | null;
|
||||
} | null;
|
||||
waiver: string | null;
|
||||
}
|
||||
|
||||
export type SeriesFilesOutcome =
|
||||
| { kind: "files"; files: EpisodeFile[] }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function fetchSeriesFiles(seriesId: number): Promise<SeriesFilesOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/files`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "files", files: (await response.json()) as EpisodeFile[] };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The season's tracked toggle (§4.1): turning it on marks every revealed
|
||||
* episode wanted; turning it off withdraws nothing.
|
||||
*/
|
||||
export async function setSeasonTracked(
|
||||
seriesId: number,
|
||||
seasonNumber: number,
|
||||
tracked: boolean,
|
||||
): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ tracked }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setEpisodeWanted(episodeId: number, wanted: boolean): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/episodes/${episodeId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wanted }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- manual triggers and decks ---------------------------------------- */
|
||||
|
||||
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
|
||||
export interface TvTarget {
|
||||
releases: () => Promise<ReleasesOutcome>;
|
||||
grab: (releaseId: number) => Promise<ActionOutcome>;
|
||||
search: () => Promise<ActionOutcome>;
|
||||
}
|
||||
|
||||
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
|
||||
const base = `/api/series/${seriesId}/seasons/${seasonNumber}`;
|
||||
return {
|
||||
releases: () => fetchJson(`${base}/releases`),
|
||||
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
|
||||
search: () => post(`${base}/search`),
|
||||
};
|
||||
}
|
||||
|
||||
export function episodeTarget(episodeId: number): TvTarget {
|
||||
const base = `/api/episodes/${episodeId}`;
|
||||
return {
|
||||
releases: () => fetchJson(`${base}/releases`),
|
||||
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
|
||||
search: () => post(`${base}/search`),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url: string): Promise<ReleasesOutcome> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "results", releases: (await response.json()) as MovieRelease[] };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
async function post(url: string): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(url, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-click override on a waived TV row (§5.1, §9.3). Overrides sit on
|
||||
* the series — `episode_policy` and `season_policy` both read them there —
|
||||
* so the relaxed rule is written once, then the grab proceeds.
|
||||
*/
|
||||
export async function waiveAndGrabTv(
|
||||
seriesId: number,
|
||||
target: TvTarget,
|
||||
releaseId: number,
|
||||
rule: string | null,
|
||||
): Promise<WaiveOutcome> {
|
||||
const override = waiverOverride(rule);
|
||||
let overrideWritten = false;
|
||||
if (override) {
|
||||
try {
|
||||
const current = await fetch(`/api/series/${seriesId}`);
|
||||
if (!current.ok) {
|
||||
return { kind: "error", detail: await errorDetail(current), overrideWritten };
|
||||
}
|
||||
const series = (await current.json()) as { overrides: Record<string, unknown> };
|
||||
const patch = await fetch(`/api/series/${seriesId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ overrides: { ...series.overrides, ...override } }),
|
||||
});
|
||||
if (!patch.ok) {
|
||||
return { kind: "error", detail: await errorDetail(patch), overrideWritten };
|
||||
}
|
||||
overrideWritten = true;
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable", overrideWritten };
|
||||
}
|
||||
}
|
||||
const grabbed = await target.grab(releaseId);
|
||||
if (grabbed.kind === "error") {
|
||||
return { kind: "error", detail: grabbed.detail, overrideWritten };
|
||||
}
|
||||
return { kind: "done", overrideWritten };
|
||||
}
|
||||
|
||||
/* ---- §9.6 rich detail --------------------------------------------------- */
|
||||
|
||||
/** Rich detail for one library series' §9.6 page (#146). */
|
||||
export interface SeriesMetadata {
|
||||
tmdb_id: number;
|
||||
overview: string | null;
|
||||
tagline: string | null;
|
||||
genres: string[];
|
||||
/** Minutes per episode. */
|
||||
runtime: number | null;
|
||||
status: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
/** `null` when TMDB has no votes for the title (#156). */
|
||||
vote_average: number | null;
|
||||
vote_count: number;
|
||||
homepage: string | null;
|
||||
/** §9.6 links out to TVDB for series; absent when the id is unknown. */
|
||||
tvdb_id: number | null;
|
||||
cast: MetadataCastMember[];
|
||||
trailer: MetadataTrailer | null;
|
||||
}
|
||||
|
||||
export type SeriesMetadataOutcome =
|
||||
| { kind: "metadata"; metadata: SeriesMetadata }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
/** One request for everything above the season tree (§9.6). */
|
||||
export async function seriesMetadata(seriesId: number): Promise<SeriesMetadataOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/metadata`);
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "metadata", metadata: (await response.json()) as SeriesMetadata };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- display helpers --------------------------------------------------- */
|
||||
|
||||
/** Wanted / on-disk counts for one season, whatever its number. */
|
||||
export function seasonCounts(season: ApiSeason): { wanted: number; available: number } {
|
||||
let wanted = 0;
|
||||
let available = 0;
|
||||
for (const episode of season.episodes) {
|
||||
if (episode.wanted) {
|
||||
wanted += 1;
|
||||
if (episode.state === "available") {
|
||||
available += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wanted, available };
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2: do not search before the thing exists. No air date yet counts as
|
||||
* not aired — TMDB has not dated it, so neither will we.
|
||||
*/
|
||||
export function isUnaired(airDate: string | null, now = new Date()): boolean {
|
||||
if (airDate === null) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Date.parse(airDate);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return true;
|
||||
}
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
return parsed > today.getTime() + 86_399_999;
|
||||
}
|
||||
|
||||
/** `2026-08-10` as the readout spells it; unknown stays a dash. */
|
||||
export function formatAirDate(airDate: string | null): string {
|
||||
return airDate ?? "—";
|
||||
}
|
||||
|
||||
/** The probed attributes an episode file row shows as chips (§5.6, §7.4). */
|
||||
export function fileAttributeTags(file: EpisodeFile): string[] {
|
||||
return probedAttributeTags(file.probed);
|
||||
}
|
||||
+670
-4
@@ -699,12 +699,13 @@ body {
|
||||
transition: background 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.row-tmdb:hover:not(:disabled),
|
||||
.row-tmdb:focus-visible {
|
||||
.row-tmdb:hover:not(:disabled):not([aria-disabled="true"]),
|
||||
.row-tmdb:focus-visible:not([aria-disabled="true"]) {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.row-tmdb:disabled {
|
||||
.row-tmdb:disabled,
|
||||
.row-tmdb[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -730,6 +731,68 @@ body {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ---- rich search rows (#148): poster, rating chip, trailer chip -------- */
|
||||
|
||||
.row-rich {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* w92 is the right order of magnitude for a row; the blank is the same
|
||||
box, so the column edge holds while results stream in */
|
||||
.row-poster,
|
||||
.row-poster-blank {
|
||||
width: 2.75rem;
|
||||
aspect-ratio: 2 / 3;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.row-poster {
|
||||
display: block;
|
||||
background: var(--panel-raised);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chip[role="button"] {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms var(--ease-out),
|
||||
border-color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.chip[role="button"]:hover,
|
||||
.chip[role="button"]:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: oklch(from var(--accent) l c h / 45%);
|
||||
}
|
||||
|
||||
.row-trailer[data-state="pending"] {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* no video on TMDB is an ordinary outcome: quiet and dashed, not fault red */
|
||||
.row-trailer[data-state="none"] {
|
||||
border-style: dashed;
|
||||
color: var(--ink-faint);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row-trailer[data-state="blocked"] {
|
||||
color: var(--signal-warn);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- add flow (inline, root and policy pre-filled) -------------------- */
|
||||
|
||||
.add-panel {
|
||||
@@ -1096,9 +1159,281 @@ body {
|
||||
}
|
||||
|
||||
/* the toggle's own padding would inset its text past the chip column's
|
||||
right rag; drop it so SATISFIED sits flush with the chips below */
|
||||
right rag; with the shape toggle right of it, SATISFIED sits flush
|
||||
against its neighbour instead of the deck edge */
|
||||
.library-bar .bucket-toggle {
|
||||
padding: 0;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- library poster grid (§9.6, issue 151) ------------------------------ */
|
||||
|
||||
/* the shape toggle: a joined pair, engaged side lit like any view control */
|
||||
.view-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
min-height: 1.75rem;
|
||||
padding: 0 var(--space-2);
|
||||
font-family: var(--font-readout);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out),
|
||||
color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.view-btn + .view-btn {
|
||||
border-left-width: 0;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
.view-btn[aria-pressed="true"] {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* auto-fill keeps every tile the same width down to a phone, where three
|
||||
narrow-but-legible columns beat two oversized ones */
|
||||
.deck-rows.grid-rows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* the poster is framed evidence, like every other image on a panel */
|
||||
.card-art {
|
||||
display: block;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.card-poster {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
/* same ratio as a poster, carrying the title: the grid never has a hole */
|
||||
.card-blank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--panel-raised);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
}
|
||||
|
||||
.card-blank-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.card-id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
flex: 1;
|
||||
/* two clamped lines: a long title stays readable where a single
|
||||
ellipsised line at tile width would not be */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-year {
|
||||
flex: none;
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */
|
||||
|
||||
/* one season: a collapsible group with its rule, counts and actions */
|
||||
.season {
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
}
|
||||
|
||||
.season-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
}
|
||||
|
||||
/* the disclosure is a drawn chevron — no glyph standing in for an icon */
|
||||
.season-disclose {
|
||||
flex: none;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.season-disclose:hover,
|
||||
.season-disclose:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.season-disclose::before {
|
||||
content: "";
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-right: 2px solid var(--ink-muted);
|
||||
border-bottom: 2px solid var(--ink-muted);
|
||||
transform: rotate(-45deg) translate(-5%, -5%);
|
||||
transition: transform 200ms var(--ease-out);
|
||||
}
|
||||
|
||||
.season-disclose[aria-expanded="true"]::before {
|
||||
transform: rotate(45deg) translate(-5%, -5%);
|
||||
}
|
||||
|
||||
.season-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* the tracked toggle reads as the rule it is; pressed = on */
|
||||
.season-line .control,
|
||||
.ep-actions .control {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.season-space {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.episodes {
|
||||
margin: 0;
|
||||
padding: 0 0 var(--space-2) var(--space-8);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.episode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
}
|
||||
|
||||
.ep-tag {
|
||||
flex: none;
|
||||
width: 2.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.ep-title {
|
||||
min-width: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.ep-air {
|
||||
flex: none;
|
||||
width: 6.5rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.ep-chips {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ep-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.ep-unaired {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
|
||||
.chip[data-flag="vanished"] {
|
||||
color: var(--signal-warn);
|
||||
border-style: dashed;
|
||||
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||
}
|
||||
|
||||
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
|
||||
@@ -1155,6 +1490,281 @@ body {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---- title detail pages (§9.6, issues #149 and #150) ------------------- */
|
||||
|
||||
/* The hero is one machined module carrying what the title IS — shared by
|
||||
the movie and series pages, which is why its classes read `movie-*`.
|
||||
The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the
|
||||
console charcoal under a scrim, so the readouts keep their contrast — a
|
||||
photo never sits behind text bare. */
|
||||
|
||||
.movie-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
/* the backdrop layer: painted only when detail supplied one */
|
||||
.movie-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: var(--backdrop);
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
opacity: 0;
|
||||
transition: opacity 400ms var(--ease-out);
|
||||
}
|
||||
|
||||
.movie-hero.has-backdrop::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* the scrim: ground at the bottom, heavy everywhere the text sits */
|
||||
.movie-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
to top,
|
||||
oklch(from var(--ground) l c h / 92%) 0%,
|
||||
oklch(from var(--ground) l c h / 72%) 45%,
|
||||
oklch(from var(--ground) l c h / 40%) 100%
|
||||
),
|
||||
oklch(from var(--ground) l c h / 35%);
|
||||
}
|
||||
|
||||
.movie-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* the poster is framed evidence, like every other image on a panel */
|
||||
.movie-poster {
|
||||
flex: none;
|
||||
width: 10.5rem;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 12px oklch(from var(--lowlight) l c h / 75%);
|
||||
}
|
||||
|
||||
.movie-info {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.movie-idline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
/* the page's voice: bigger than a deck title, same display face */
|
||||
.movie-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.movie-year {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.movie-chips {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* rating and meta are readouts on one line each; the star is drawn */
|
||||
.movie-rating {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* a row chip is smaller than the detail page's rating readout */
|
||||
.row-rating .star {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
}
|
||||
|
||||
.movie-meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.movie-tagline {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.movie-overview {
|
||||
max-width: 65ch;
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.movie-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.movie-actions .add-note[data-tone="fault"] {
|
||||
margin: 0;
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
/* external links wear the control skin; anchors must drop the underline */
|
||||
.movie-actions a.control {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* library controls follow the hero on their own row: state left, errands right */
|
||||
.movie-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
}
|
||||
|
||||
.movie-controls .control {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
|
||||
.movie-controls .control[aria-pressed="true"] {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.movie-root-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.movie-root {
|
||||
width: auto;
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.movie-controls-space {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* cast: top billed, each linking out to TMDB (§9.6) */
|
||||
.cast-grid {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-1) 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
gap: var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.cast-link {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
justify-items: start;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cast-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cast-photo {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raised);
|
||||
transition: border-color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.cast-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* no profile photo: dead air, not a broken frame */
|
||||
.cast-photo-none::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40%;
|
||||
aspect-ratio: 1;
|
||||
margin: 30% auto 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.cast-link:hover .cast-photo,
|
||||
.cast-link:focus-visible .cast-photo {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.cast-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cast-character {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* on-disk rows share the deck's rel grammar: name leads, chips trail */
|
||||
.disk-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rel[data-tone="fault"] {
|
||||
color: var(--signal-fault);
|
||||
}
|
||||
|
||||
/* ---- small screens --------------------------------------------------- */
|
||||
|
||||
@media (max-width: 46rem) {
|
||||
@@ -1163,6 +1773,19 @@ body {
|
||||
gap: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
/* the library bar wraps to two lines at phone widths: identity and count
|
||||
on the first, shape and satisfied toggles on the second, each label
|
||||
whole instead of squeezed into a mid-word break */
|
||||
.library-bar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.library-bar .bucket-toggle,
|
||||
.view-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail-verdict {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
@@ -1194,6 +1817,20 @@ body {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* an episode row wraps: identity first, chips full-width, actions right */
|
||||
.ep-chips {
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.ep-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.episodes {
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.releases-id {
|
||||
order: -1;
|
||||
flex-basis: 100%;
|
||||
@@ -1214,6 +1851,35 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* the hero stacks: poster above the info block, both full width */
|
||||
.movie-body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.movie-poster {
|
||||
width: 8.5rem;
|
||||
}
|
||||
|
||||
.movie-info {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* state controls wrap to their own line, errands stay right-aligned */
|
||||
.movie-controls-space {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.movie-root {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cast-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
|
||||
}
|
||||
|
||||
.board {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user