Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6cdd0ab0b | |||
| 365c2a7587 | |||
| 07741c80fe | |||
| 82b294ab1b | |||
| ed210b29d3 | |||
| b413718fd1 | |||
| 9a03fc9963 | |||
| fdaaeccb40 | |||
| be2c5f7e3c | |||
| 5e2bb5dff7 | |||
| 30382585ea | |||
| c9e73cb23c | |||
| 08e3c7bce7 |
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+20
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -27,6 +27,21 @@
|
||||
"name": "title!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 6,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "series_tmdb_id!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -37,8 +52,11 @@
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
|
||||
"hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
|
||||
"hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
|
||||
"hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -64,9 +64,19 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"name": "poster_path",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 13,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: serde_json::Value",
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +95,10 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6"
|
||||
"hash": "ad1a3b886179c638051ed9bf34ddbc153097fd7626f68b316d810ccc8305222d"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,16 @@
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 12,
|
||||
"type_info": "Float"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -73,8 +83,10 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
|
||||
"hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
@@ -46,7 +46,8 @@ pub struct MovieMetadata {
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// 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.
|
||||
@@ -68,7 +69,8 @@ pub struct SeriesMetadata {
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// 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.
|
||||
|
||||
@@ -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>,
|
||||
@@ -244,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?)
|
||||
}
|
||||
@@ -269,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?
|
||||
};
|
||||
@@ -316,7 +321,7 @@ pub async fn create(
|
||||
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().map(|a| a.2);
|
||||
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, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, title, input.year, input.original_language, input.root_id,
|
||||
@@ -336,7 +341,7 @@ pub async fn create(
|
||||
async fn lookup_movie_artwork(
|
||||
state: &AppState,
|
||||
tmdb_id: i64,
|
||||
) -> Option<(Option<String>, Option<String>, f64)> {
|
||||
) -> 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))
|
||||
@@ -679,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?;
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct LibrarySeries {
|
||||
pub original_language: Option<String>,
|
||||
pub root_id: i64,
|
||||
pub blocked: bool,
|
||||
/// Stored artwork (§9.6), so a row renders without a TMDB call.
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -70,6 +74,14 @@ pub struct LibraryEpisode {
|
||||
pub tag: String,
|
||||
/// The episode title — what the search matched on.
|
||||
pub title: String,
|
||||
/// The series' poster — an episode has no artwork of its own worth
|
||||
/// showing at row size.
|
||||
pub poster_path: Option<String>,
|
||||
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// The series' TMDB id — the episode row's trailer chip resolves through
|
||||
/// it (#148); an episode has no videos of its own worth listing.
|
||||
pub series_tmdb_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
@@ -91,6 +103,10 @@ pub struct TmdbMovie {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -108,6 +124,10 @@ pub struct TmdbSeries {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
/// One release's score, kept as its terms so the UI can explain a ranking
|
||||
@@ -184,13 +204,13 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
.collect()
|
||||
} else {
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
@@ -204,10 +224,10 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
} else {
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
@@ -215,7 +235,7 @@ pub async fn search(
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so the haystack
|
||||
// is both joined and every token must land somewhere in it.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
@@ -227,7 +247,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 +255,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 +311,8 @@ async fn search_tmdb(
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
})]
|
||||
}
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
@@ -558,6 +580,8 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,6 +596,8 @@ impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +612,8 @@ impl From<arr_meta::Series> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,7 +844,8 @@ mod tests {
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
|
||||
"id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two",
|
||||
"original_language": "en", "release_date": "2024-02-27"
|
||||
"original_language": "en", "release_date": "2024-02-27",
|
||||
"poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000
|
||||
}]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
@@ -843,6 +872,19 @@ mod tests {
|
||||
assert_eq!(response["library"][0]["title"], "Dune");
|
||||
assert_eq!(response["tmdb"][0]["kind"], "movie");
|
||||
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
|
||||
// §9.6: both halves carry artwork and a rating, read from stored
|
||||
// columns and TMDB's search body — no upstream call per row.
|
||||
assert_eq!(
|
||||
response["library"][0]["poster_path"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
response["library"][0]["vote_average"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg");
|
||||
assert_eq!(response["tmdb"][0]["vote_average"], 8.1);
|
||||
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
|
||||
}
|
||||
|
||||
/// §9.2: the in-library set matches series titles and episode titles, so
|
||||
|
||||
@@ -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).
|
||||
@@ -198,6 +203,11 @@ struct SeriesRow {
|
||||
overrides: serde_json::Value,
|
||||
upstream_ended: bool,
|
||||
blocked: bool,
|
||||
/// Stored at add time and refreshed with metadata (#145), so pure-SQL
|
||||
/// views never need TMDB (§9.6).
|
||||
poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
struct EpisodeRow {
|
||||
@@ -310,6 +320,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),
|
||||
@@ -341,7 +353,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 FROM series WHERE id = ?"#, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeriesNotFound)
|
||||
@@ -395,11 +407,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 FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#)
|
||||
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?
|
||||
};
|
||||
@@ -445,7 +457,7 @@ pub async fn create(
|
||||
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().map(|s| s.vote_average);
|
||||
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, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
|
||||
|
||||
@@ -2497,6 +2497,81 @@ mod tests {
|
||||
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;
|
||||
|
||||
@@ -232,7 +232,7 @@ impl SeriesRefreshAction {
|
||||
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 != Some(vote_average) {
|
||||
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')
|
||||
|
||||
@@ -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 {
|
||||
@@ -82,8 +89,12 @@ pub struct Movie {
|
||||
/// 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. Stored with the artwork (§9.6).
|
||||
pub vote_average: f64,
|
||||
/// 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 {
|
||||
@@ -104,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,
|
||||
@@ -112,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 {
|
||||
@@ -136,8 +151,11 @@ pub struct Series {
|
||||
/// §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. Stored with the artwork (§9.6).
|
||||
pub vote_average: f64,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -220,7 +238,10 @@ pub struct MovieDetail {
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// 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,
|
||||
@@ -241,7 +262,10 @@ pub struct SeriesDetail {
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// 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,
|
||||
@@ -301,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 {
|
||||
@@ -312,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,6 +366,8 @@ pub(crate) struct RawSeries {
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
seasons: Vec<RawSeasonSummary>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
@@ -372,7 +404,8 @@ impl From<RawSeries> for Series {
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
seasons: raw
|
||||
.seasons
|
||||
.into_iter()
|
||||
@@ -427,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>,
|
||||
@@ -456,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 {
|
||||
@@ -468,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,6 +541,8 @@ pub(crate) struct RawMovie {
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
release_dates: Option<RawReleaseDates>,
|
||||
}
|
||||
|
||||
@@ -562,7 +603,8 @@ impl From<RawMovie> for Movie {
|
||||
overview: non_empty(raw.overview),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,6 +638,12 @@ 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,
|
||||
@@ -762,7 +810,7 @@ impl From<RawMovieDetail> for MovieDetail {
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
@@ -790,7 +838,7 @@ impl From<RawSeriesDetail> for SeriesDetail {
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
|
||||
@@ -217,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)));
|
||||
}
|
||||
|
||||
@@ -610,7 +613,7 @@ async fn movie_detail_parses_the_rich_fields() {
|
||||
assert_eq!(movie.genres[0].name, "Science Fiction");
|
||||
assert!(movie.poster_path.is_some());
|
||||
assert!(movie.backdrop_path.is_some());
|
||||
assert!((movie.vote_average - 8.152).abs() < f64::EPSILON);
|
||||
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");
|
||||
|
||||
+130
-25
@@ -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"
|
||||
@@ -183,33 +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 a movie's 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">
|
||||
@@ -277,6 +358,7 @@
|
||||
<ul class="deck-rows" id="rows-rejected"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck" id="settings" hidden aria-label="settings">
|
||||
@@ -323,13 +405,36 @@
|
||||
<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>
|
||||
<div class="releases-id">
|
||||
<h2 class="releases-title" id="series-title">—</h2>
|
||||
<span class="releases-year readout dim" id="series-year"></span>
|
||||
<span class="row-chips series-chips" id="series-chips"></span>
|
||||
</div>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
</header>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1093
-184
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);
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Route =
|
||||
| { kind: "queues" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "search"; query: string }
|
||||
| { kind: "movie"; movieId: number }
|
||||
| { kind: "releases"; movieId: number }
|
||||
| { kind: "series"; seriesId: number }
|
||||
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
|
||||
@@ -28,6 +29,12 @@ 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) {
|
||||
@@ -78,6 +85,8 @@ 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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
+44
-22
@@ -2,8 +2,9 @@
|
||||
// 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, waiverOverride } from "./releases";
|
||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||
|
||||
/** §4.2 derived status — displayed, never editable. */
|
||||
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
||||
@@ -239,6 +240,46 @@ export async function waiveAndGrabTv(
|
||||
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. */
|
||||
@@ -277,26 +318,7 @@ export function formatAirDate(airDate: string | null): string {
|
||||
return airDate ?? "—";
|
||||
}
|
||||
|
||||
/** The probed attributes a file row shows as chips (§5.6, §7.4). */
|
||||
/** The probed attributes an episode file row shows as chips (§5.6, §7.4). */
|
||||
export function fileAttributeTags(file: EpisodeFile): string[] {
|
||||
const probed = file.probed;
|
||||
if (!probed) {
|
||||
return [];
|
||||
}
|
||||
const tags: string[] = [];
|
||||
if (probed.resolution) {
|
||||
tags.push(probed.resolution);
|
||||
}
|
||||
if (probed.source) {
|
||||
tags.push(probed.source);
|
||||
}
|
||||
if (probed.hdr && probed.hdr !== "SDR") {
|
||||
tags.push(probed.hdr);
|
||||
}
|
||||
for (const track of probed.audio_tracks ?? []) {
|
||||
if (track.language) {
|
||||
tags.push(track.language);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
return probedAttributeTags(file.probed);
|
||||
}
|
||||
|
||||
+532
-9
@@ -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,18 +1159,161 @@ 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) --------------------------- */
|
||||
|
||||
/* the title line carries its readouts beside the name, not pushed right */
|
||||
.series-chips {
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
/* one season: a collapsible group with its rule, counts and actions */
|
||||
.season {
|
||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||
@@ -1284,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) {
|
||||
@@ -1292,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;
|
||||
@@ -1357,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