Compare commits

...

63 Commits

Author SHA1 Message Date
Miguel Palhas 4d5cc120d1 fix(api): newest-first season and episode order
DESIGN.md §9.6 now puts the current season at the top: seasons
descend by number within a series (season 0 lands last under plain
numeric descending) and episodes descend within each season. Changed
the two queries in load_seasons; the SPA renders this order as given.

arr-compat checked and untouched: it reads seasons with its own
ORDER BY number query straight from the database, never through
arr-api, and SeasonResource carries season_number, which Jellyseerr
matches on rather than position. Sonarr's real API also returns
seasons ascending, so the shim keeps the contract it emulates.

The existing vanished-flag test indexed seasons positionally; it now
looks them up by number so it tests the flag, not the order. New test
asserts [2, 1, 0] for seasons and descending episodes within each.
.sqlx regenerated via just db-prepare. just ci green locally.
2026-08-24 12:09:52 +01:00
Miguel Palhas 2c293e7f60 fix(api): episode rows need a token beyond series title
Per amended DESIGN.md 9.2, an episode row now also requires at
least one query token to match the episode title on its own, so
a series-title-only query lists the series and no episodes.
2026-08-24 11:59:18 +01:00
Miguel Palhas 8f7373d73d fix(core): auto-track skips the seeding refresh
A series' first metadata refresh reveals its whole back catalogue, and
apply_auto_track flagged every season as new — adding Rick and Morty
tracked S01-S09 and wanted 91 episodes (#160).

Per DESIGN.md 4.1 the rule applies from the second refresh onward.
The caller passes whether metadata_refreshed_at is set; arr-core stays
IO-free and decides. Wired through the daemon's daily refresh and the
API's create-season endpoint.
2026-08-24 11:40:44 +01:00
Miguel Palhas 2495895717 docs: record five rulings from the deployed build 2026-08-24 11:28:05 +01:00
Miguel Palhas 40620d93b4 fix(web): series page loads; cast art falls back
ci / web (push) Successful in 34s
e2e / e2e (push) Successful in 57s
ci / rust (push) Successful in 1m9s
2026-08-24 03:21:42 +01:00
Miguel Palhas 1c351eb867 Merge #151: library poster grid with list toggle
Closes #151
2026-08-24 03:05:59 +01:00
Miguel Palhas a6cdd0ab0b feat(web): library poster grid with list toggle 2026-08-24 03:04:06 +01:00
Miguel Palhas 365c2a7587 Merge #148: rich search result rows
Closes #148
2026-08-24 01:57:25 +01:00
Miguel Palhas 07741c80fe feat(web): rich search rows (#148)
Every search row — library and TMDB alike — gains a w92 poster
thumbnail with a same-size blank fallback, a TMDB rating chip
(hidden when there are no votes), and a trailer chip that resolves
GET /api/trailer on click only, opening the tab inside the handler.
Episode hits carry the series' TMDB id so their chip resolves too.
2026-08-24 01:55:00 +01:00
Miguel Palhas 82b294ab1b Merge #150: series detail metadata header
Closes #150
2026-08-24 00:40:22 +01:00
Miguel Palhas ed210b29d3 feat(web): series detail metadata header 2026-08-24 00:37:53 +01:00
Miguel Palhas b413718fd1 fix(web): unrated movies show no rating 2026-08-24 00:01:17 +01:00
Miguel Palhas 9a03fc9963 Merge #149: movie detail page
Closes #149
2026-08-24 00:00:22 +01:00
Miguel Palhas fdaaeccb40 feat(web): movie detail page at /movies/{id} 2026-08-24 00:00:18 +01:00
Miguel Palhas be2c5f7e3c Merge #147: posters and ratings on search results
Closes #147
2026-08-23 22:45:49 +01:00
Miguel Palhas 5e2bb5dff7 feat(api): carry poster and rating through search and lists
Widen the unified search so both §9.2 halves can render a thumbnail
and a rating: TMDB results gain vote_average/vote_count from the
search bodies, library results read the #145 artwork columns, and
episodes take their series' art. GET /api/movies and /api/series gain
the same two fields for the #151 grid. Mirrors in web updated.
2026-08-23 22:45:08 +01:00
Miguel Palhas 30382585ea fix(meta,api): detail ratings are optional too 2026-08-23 22:26:04 +01:00
Miguel Palhas c9e73cb23c Merge #156: unrated titles store NULL
Closes #156
2026-08-23 22:24:05 +01:00
Miguel Palhas 8af85606a0 test(api): match detail call by its appended resources 2026-08-23 22:22:14 +01:00
Miguel Palhas 08e3c7bce7 fix(meta): store unrated titles as NULL, not zero
TMDB reports vote_average: 0 where no rating exists (#156). Normalise
it to None at the arr-meta edge, like non_empty does for "", and let
the Option flow through the daemon refresh and API add paths so the
nullable columns from #145 do their job.
2026-08-23 22:21:57 +01:00
Miguel Palhas 22f5d6b463 Merge #146: title metadata endpoints
Closes #146
2026-08-23 22:19:03 +01:00
Miguel Palhas 60fcc9f3d5 feat(api): metadata endpoints for library titles 2026-08-23 22:17:51 +01:00
Miguel Palhas b59e30dbdb Merge #145: store poster, backdrop and rating
Closes #145
2026-08-23 22:13:37 +01:00
Miguel Palhas 8478c0f8a9 feat(db): store poster, backdrop and rating on titles
§9.6 keeps rich detail out of the database except for the three fields
pure-SQL views need. Adds poster_path, backdrop_path and vote_average to
movies and series, written by the daily metadata refresh in both lanes
and filled at add time from the TMDB response the create flows already
fetch.
2026-08-23 22:12:05 +01:00
Miguel Palhas 82f87d61f1 Merge #144: trailer endpoint by TMDB id
Closes #144
2026-08-23 22:04:42 +01:00
Miguel Palhas 80a63ea2e1 feat(api): resolve trailer chips by TMDB id 2026-08-23 22:03:04 +01:00
Miguel Palhas 9bd037d1b6 Merge #143: rich title detail in arr-meta
Closes #143
2026-08-23 21:34:00 +01:00
Miguel Palhas cbb21418a4 feat(meta): rich detail calls for movies and series
movie_detail/series_detail fetch credits, videos and external ids in
one upstream request via append_to_response; cast is truncated to the
top 10 billed in the crate and a trailer is chosen by rule (official
YouTube trailer, any YouTube trailer, YouTube teaser, none).
movie_videos/series_videos serve #144's search-row chip from the
videos endpoint alone. Everything rides the existing 24h cache.
2026-08-23 21:33:17 +01:00
Miguel Palhas 689487bf82 Merge #142: DESIGN.md title detail surface
Closes #142
2026-08-23 21:23:45 +01:00
Miguel Palhas 2e4e1e7547 docs(design): title detail and enriched search rows 2026-08-23 21:22:50 +01:00
Miguel Palhas 1acdcaca23 Merge TV tracking milestone
ci / web (push) Successful in 27s
e2e / e2e (push) Successful in 2m5s
ci / rust (push) Successful in 2m9s
Closes the phase/6-tv gap set: daily series metadata refresh, tracked-season
intent, RSS episode and season-pack matching, season release decks, the TV
attention queue, and the series detail view. 27 issues.

See milestone 'TV tracking'.
2026-08-23 21:10:52 +01:00
Miguel Palhas 26dd0dc5d3 Merge #153: placeholder for empty episode titles
Closes #153
2026-08-23 20:58:09 +01:00
Miguel Palhas e9a5095557 feat(api): reject empty episode titles 2026-08-23 20:55:30 +01:00
Miguel Palhas 0ed0791fd4 fix(db): backfill empty episode titles to TBA 2026-08-23 20:55:16 +01:00
Miguel Palhas 23f5781714 feat(meta): substitute TBA for unnamed episodes 2026-08-23 20:55:16 +01:00
Miguel Palhas 877dbae7cb Merge #152: URL-addressed TV release decks
Closes #152
2026-08-23 20:49:01 +01:00
Miguel Palhas cf36a4302c feat(web): route season and episode release decks 2026-08-23 20:44:28 +01:00
Miguel Palhas 06a68372e6 feat(api): expose series and season on episodes 2026-08-23 20:44:28 +01:00
Miguel Palhas 4762f2612f feat(web): show the vanished flag on seasons 2026-08-23 20:32:30 +01:00
Miguel Palhas 468f31f0b0 Merge #141: expose seasons.vanished on the API
Closes #141
2026-08-23 20:31:13 +01:00
Miguel Palhas 571ec42e92 feat(api): expose seasons.vanished (#141)
Season objects from the series endpoints now carry the conflict flag
#137 writes, the way episodes already do.
2026-08-23 20:27:34 +01:00
Miguel Palhas db72cb4bcd docs: use agent-browser for the visual pass 2026-08-23 20:24:00 +01:00
Miguel Palhas d5013d4ca8 Merge #129: series detail view
Closes #129
2026-08-23 20:21:15 +01:00
Miguel Palhas 7f9729fb57 style(web): issue refs without hex-looking hashes 2026-08-23 20:18:51 +01:00
Miguel Palhas e335a1be73 style(api): drop needless raw string hashes 2026-08-23 20:13:54 +01:00
Miguel Palhas 82100ef7ab feat(web): series detail view with season and episode decks 2026-08-23 20:12:06 +01:00
Miguel Palhas 6826170431 feat(web): series data layer and /series route 2026-08-23 20:12:06 +01:00
Miguel Palhas 4efc7b1833 fix(web): token linter ignores comments 2026-08-23 19:54:42 +01:00
Miguel Palhas 353608ae63 Merge #140: name episodes in the attention queue
Closes #140
2026-08-23 19:52:48 +01:00
Miguel Palhas d86d975073 fix(web): drop issue refs that trip the token linter 2026-08-23 19:51:52 +01:00
Miguel Palhas 9129728622 refactor(api): extract attention merge helpers 2026-08-23 19:48:59 +01:00
Miguel Palhas e4c115803b style: fix test formatting 2026-08-23 19:46:16 +01:00
Miguel Palhas 1d31ea7d1b feat(web): render SxxEyy chips in attention queues 2026-08-23 19:45:33 +01:00
Miguel Palhas 7f7f26dbc7 feat(api): name attention episodes and seasons 2026-08-23 19:45:33 +01:00
Miguel Palhas 11d2952863 Merge #137: reconcile whole seasons removed upstream
Closes #137
2026-08-23 19:36:37 +01:00
Miguel Palhas 00ff4db26a style: cargo fmt 2026-08-23 19:35:07 +01:00
Miguel Palhas 409967eef6 feat(daemon): reconcile vanished seasons (#137) 2026-08-23 19:34:55 +01:00
Miguel Palhas 943f18cdf5 feat(db): add seasons.vanished conflict flag 2026-08-23 19:34:55 +01:00
Miguel Palhas 1af73caa34 Merge #133: render TV lanes in the attention queue
Closes #133
2026-08-23 19:27:35 +01:00
Miguel Palhas 71cbb9269b fix(web): reword comment that tripped the token gate 2026-08-23 19:26:43 +01:00
Miguel Palhas 160c00c77f feat(web): render tv lanes in attention queues 2026-08-23 19:23:19 +01:00
Miguel Palhas 779d4b8504 Merge #139: episode tag beats a movie id match
Closes #139
2026-08-23 19:17:47 +01:00
Miguel Palhas d8316ae0c8 feat(api): series files keyed by episode id 2026-08-23 18:41:36 +01:00
71 changed files with 7262 additions and 655 deletions
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9"
}
@@ -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"
}
@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", mf.path AS \"path!: String\", mf.size AS \"size!: i64\", mf.probed AS \"probed?: serde_json::Value\", json_extract(mf.waiver, '$.rule') AS \"waiver?: String\" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "probed?: serde_json::Value",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "waiver?: String",
"ordinal": 4,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
null
]
},
"hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id\n WHERE f.owner_kind = 'episode' AND e.season_id = ?\n ) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "episode_number!: i64",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -57,6 +57,21 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -73,8 +88,11 @@
false,
false,
false,
false
false,
true,
true,
true
]
},
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
"hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC",
"describe": {
"columns": [
{
@@ -82,5 +82,5 @@
true
]
},
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
"hash": "580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a"
}
@@ -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"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": {
"columns": [
{
@@ -52,6 +52,21 @@
"name": "metadata_refreshed_at",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
@@ -67,8 +82,11 @@
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87"
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
"describe": {
"columns": [
{
@@ -27,6 +27,11 @@
"name": "season_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer"
}
],
"parameters": {
@@ -37,8 +42,9 @@
false,
false,
true,
false,
false
]
},
"hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193"
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "tag!: String",
"ordinal": 3,
"type_info": "Null"
},
{
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 7,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
null,
false,
true,
true,
false
]
},
"hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
@@ -57,6 +57,21 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -73,8 +88,11 @@
false,
false,
false,
false
false,
true,
true,
true
]
},
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
"hash": "9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f"
}
@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
"Right": 13
},
"nullable": []
},
"hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054"
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
}
@@ -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"
}
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "episode_number!: i64",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false,
false,
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
}
@@ -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"
}
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "tag!: String",
"ordinal": 3,
"type_info": "Null"
},
{
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
null,
false
]
},
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
}
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM seasons WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number DESC",
"describe": {
"columns": [
{
@@ -22,6 +22,11 @@
"name": "tracked!: bool",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "vanished!: bool",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
@@ -31,8 +36,9 @@
true,
false,
false,
false,
false
]
},
"hash": "ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e"
"hash": "f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?",
"describe": {
"columns": [
{
@@ -57,6 +57,21 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
@@ -73,8 +88,11 @@
false,
false,
false,
false
false,
true,
true,
true
]
},
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
"hash": "fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420"
}
+6
View File
@@ -55,6 +55,12 @@ It is authored once by that skill and then treated as the source of truth;
components consume tokens rather than literal values. `.impeccable/live/config.json`
points the skill's live browser iteration at `web/index.html`.
Drive the browser with the `agent-browser` CLI, never `google-chrome --headless`
directly. Raw headless Chrome times out on every `http://127.0.0.1` URL in this
VM, which reads as "the machine cannot reach localhost" and tempts a session
into skipping the visual pass. It is the tool, not the machine: `agent-browser
open` and `agent-browser snapshot` against the same URL work.
`~/tea/arcada` has the same setup and is worth reading for the shape of a
finished `design.json`.
+59 -2
View File
@@ -130,7 +130,11 @@ anything.
Here, intent is `Episode.wanted` and `Movie.wanted` only.
`Series.auto_track` is not intent — it is a rule one level up: when metadata
reveals a new season, that season becomes tracked. An untracked series where you
reveals a new season, that season becomes tracked. The rule applies from the
second refresh onward: seasons revealed by a series' first refresh — the whole
back catalogue present at add time — are never tracked by it. Only seasons that
appear after the series was added are covered; back-catalogue seasons are picked
by hand. An untracked series where you
manually marked S02 needs no special case: three wanted episodes, nothing else.
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
@@ -160,6 +164,12 @@ behind one toggle. A one-off season grab therefore disappears from the default
view by itself once satisfied, and a tracked show reappears by itself when a new
season is announced. Nothing to remember to flip.
Status chips carry state colour, not a single informational hue: green
(`--signal-ok`) for on disk or complete, amber (`--signal-warn`) for wanted but
not yet found, violet (`--status-airing`) for downloading, neutral for
untracked. The word stays present in every chip, so state survives with colour
removed.
Season 0 is invisible to all of it: derived status ignores season 0 episodes
entirely, so a manually wanted special cannot pin a series at `incomplete` or
hold back `ended`. The accepted consequence is that specials are visible and
@@ -477,7 +487,10 @@ lower-priority crate** (§9.4), not the primary shape.
One box. Two grouped result sets: **in library** first (title match, and for TV
also episode title, so `bluey hospital` finds the episode), **on TMDB** below.
Enter on a TMDB result opens the add flow with root and policy pre-filled.
An episode row surfaces only when the query matches something beyond the series
title: a query satisfied by the series title alone returns the series row and
nothing below it. Enter on a TMDB result opens the add flow with root and
policy pre-filled.
The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`,
which skips to the manual-grab flow.
@@ -485,6 +498,11 @@ which skips to the manual-grab flow.
There is never a moment where the user has to know whether they are searching or
adding.
Result rows are enriched: a poster thumbnail and a rating, under the same rules
as title detail (§9.6) — images hotlinked from path fragments, the rating being
TMDB's `vote_average` with its `vote_count`. A trailer chip renders per row and
resolves only when clicked (§9.6).
### 9.3 Manual search results
Radarr's manual search is unusable because the raw release name is the dominant
@@ -540,6 +558,45 @@ notifying on everything and being muted within a week.
Not notified: grabs, searches, downloads starting or finishing, soft fails.
### 9.6 Title detail
One detail surface per kind: `/movies/{id}` and `/series/{id}` (#129). For a
movie the release deck becomes a section of the page, and `/movies/{id}/releases`
keeps resolving; series keeps its season-and-episode shape, seasons ordered
newest-first within the series and episodes newest-first within each season —
the seasons you are deciding about now are at the top, not after nine rows of
back catalogue.
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
`vote_average` with its `vote_count`, rendered as amber stars (`--signal-warn`
— the same state colour a wanted chip carries). No OMDb, no IMDb or Rotten
Tomatoes scores — each would need a second upstream, a second key and a second
thing that can be down.
**Images are hotlinked** from `image.tmdb.org`. The API returns TMDB path
fragments, never URLs; the browser composes the URL and chooses the size. No
image proxy and no image cache in the service.
**Rich detail is not persisted.** It is served through arr-meta's existing
24-hour response cache. The single exception is `poster_path`, `backdrop_path`
and `vote_average`, stored on `movies` and `series` and written by the daily
metadata refresh (§8), so library views render without a TMDB call.
**External links** are TMDB always, IMDb for movies, TVDB for series — all from
ids the app already holds — plus a Rotten Tomatoes *search* link, which is a
query URL, not a resolved title page.
**Trailers resolve on click.** TMDB's search responses carry no videos, so a
trailer key costs a detail call. Rendering one chip per title and resolving the
one clicked keeps that cost at one call, and the 24h cache makes a repeat free.
**Library view.** A poster grid by default with a list toggle; the list keeps
the derived-status columns §4.2 built it around.
Out of scope here, because they are the adjacent scope most likely to creep:
watch providers, recommendations or similar titles, collections, person pages
inside the app, review text.
## 10. Persistence
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
+11
View File
@@ -7,6 +7,7 @@
//! to compile, and the gate in DESIGN.md §12 fails with it.
mod health;
mod metadata;
mod movies;
mod owners;
mod policies;
@@ -14,6 +15,7 @@ mod roots;
mod search;
mod series;
mod state;
mod trailer;
use axum::routing::get;
use axum::{Json, Router};
@@ -23,6 +25,7 @@ use utoipa_axum::routes;
use utoipa_scalar::{Scalar, Servable};
pub use health::{Check, Health, HealthReport, Status};
pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata};
pub use movies::{
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
};
@@ -39,6 +42,7 @@ pub use series::{
pub use state::{
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
};
pub use trailer::{Trailer, TrailerKind};
/// Where the generated document is served, and where `just gen-client` reads
/// it back from when it is fetched rather than dumped from the binary.
@@ -81,6 +85,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(movies::attention))
.routes(routes!(movies::list_owners))
.routes(routes!(movies::tag_owner, movies::untag_owner))
.routes(routes!(metadata::movie_metadata))
.routes(routes!(series::list, series::create))
.routes(routes!(series::get, series::update, series::delete))
.routes(routes!(series::seasons, series::create_season))
@@ -92,12 +97,15 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::search_season))
.routes(routes!(series::season_releases))
.routes(routes!(series::grab_season_release))
.routes(routes!(series::files))
.routes(routes!(metadata::series_metadata))
.routes(routes!(series::list_owners))
.routes(routes!(series::tag_owner, series::untag_owner))
.routes(routes!(owners::list, owners::create))
.routes(routes!(owners::get, owners::update, owners::delete))
.routes(routes!(search::search))
.routes(routes!(search::releases))
.routes(routes!(trailer::trailer))
.routes(routes!(roots::list, roots::create))
.routes(routes!(roots::get, roots::update, roots::delete))
.routes(routes!(policies::list, policies::create))
@@ -312,6 +320,8 @@ mod tests {
("/api/movies/{movie_id}/search", "post"),
("/api/movies/{movie_id}/releases", "get"),
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
("/api/movies/{movie_id}/metadata", "get"),
("/api/series/{series_id}/metadata", "get"),
(
"/api/series/{series_id}/seasons/{season_number}/search",
"post",
@@ -325,6 +335,7 @@ mod tests {
"post",
),
("/api/queues/attention", "get"),
("/api/trailer", "get"),
("/api/series", "get"),
("/api/policies", "get"),
("/api/policies", "post"),
+466
View File
@@ -0,0 +1,466 @@
//! Title detail metadata for the §9.6 pages (#146). One endpoint per kind,
//! one upstream call each through `arr-meta`'s detail methods, cached 24h and
//! never persisted. Library titles only: search rows already carry what a
//! results list needs.
use arr_meta::{MovieDetail, SeriesDetail};
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use crate::movies::{pool, ApiError, ErrorBody};
use crate::search::{tmdb_client, upstream_error};
use crate::state::AppState;
/// One of the top-billed cast members on a detail page.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MetadataCastMember {
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
pub tmdb_id: u32,
pub name: String,
pub character: String,
/// Path fragment exactly as TMDB sends it (§9.6): the browser composes
/// the URL and picks the size.
pub profile_path: Option<String>,
}
/// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MetadataTrailer {
pub youtube_key: String,
pub name: String,
}
/// Rich detail for one library movie's §9.6 page. Image paths are fragments,
/// never composed URLs; the Rotten Tomatoes link is deliberately absent — it
/// is a browser-built search URL, not an identifier this service holds.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MovieMetadata {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<String>,
/// Minutes.
pub runtime: Option<u32>,
pub status: String,
pub poster_path: Option<String>,
pub backdrop_path: Option<String>,
/// Absent when TMDB has no votes for the title (#156).
pub vote_average: Option<f64>,
pub vote_count: u32,
pub homepage: Option<String>,
/// §9.6 links out to `IMDb` for movies.
pub imdb_id: Option<String>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
/// Rich detail for one library series' §9.6 page.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeriesMetadata {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<String>,
/// Episode length in minutes.
pub runtime: Option<u32>,
pub status: String,
pub poster_path: Option<String>,
pub backdrop_path: Option<String>,
/// Absent when TMDB has no votes for the title (#156).
pub vote_average: Option<f64>,
pub vote_count: u32,
pub homepage: Option<String>,
/// §9.6 links out to TVDB for series.
pub tvdb_id: Option<u32>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
impl From<MovieDetail> for MovieMetadata {
fn from(detail: MovieDetail) -> Self {
Self {
tmdb_id: detail.tmdb_id,
overview: detail.overview,
tagline: detail.tagline,
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
runtime: detail.runtime,
status: detail.status,
poster_path: detail.poster_path,
backdrop_path: detail.backdrop_path,
vote_average: detail.vote_average,
vote_count: detail.vote_count,
homepage: detail.homepage,
imdb_id: detail.imdb_id,
cast: detail.cast.into_iter().map(Into::into).collect(),
trailer: detail.trailer.map(Into::into),
}
}
}
impl From<SeriesDetail> for SeriesMetadata {
fn from(detail: SeriesDetail) -> Self {
Self {
tmdb_id: detail.tmdb_id,
overview: detail.overview,
tagline: detail.tagline,
genres: detail.genres.into_iter().map(|genre| genre.name).collect(),
runtime: detail.episode_runtime,
status: detail.status,
poster_path: detail.poster_path,
backdrop_path: detail.backdrop_path,
vote_average: detail.vote_average,
vote_count: detail.vote_count,
homepage: detail.homepage,
tvdb_id: detail.tvdb_id,
cast: detail.cast.into_iter().map(Into::into).collect(),
trailer: detail.trailer.map(Into::into),
}
}
}
impl From<arr_meta::CastMember> for MetadataCastMember {
fn from(member: arr_meta::CastMember) -> Self {
Self {
tmdb_id: member.tmdb_id,
name: member.name,
character: member.character,
profile_path: member.profile_path,
}
}
}
impl From<arr_meta::Video> for MetadataTrailer {
fn from(video: arr_meta::Video) -> Self {
Self {
youtube_key: video.key,
name: video.name,
}
}
}
fn tmdb_id(raw: i64) -> Result<u32, ApiError> {
u32::try_from(raw).map_err(|_| ApiError::Invalid("stored tmdb_id is out of range".into()))
}
#[utoipa::path(
get, path = "/api/movies/{movie_id}/metadata", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie row id")),
responses(
(status = 200, body = MovieMetadata),
(status = 404, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn movie_metadata(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<MovieMetadata>, ApiError> {
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM movies WHERE id = ?", id)
.fetch_one(pool(&state)?)
.await?;
let tmdb = tmdb_client(&state)?;
let detail = tmdb
.movie_detail(tmdb_id(tmdb_row)?)
.await
.map_err(|error| upstream_error(&error))?;
Ok(Json(detail.into()))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/metadata", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
responses(
(status = 200, body = SeriesMetadata),
(status = 404, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn series_metadata(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<SeriesMetadata>, ApiError> {
let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM series WHERE id = ?", id)
.fetch_one(pool(&state)?)
.await
.map_err(|error| {
if matches!(error, sqlx::Error::RowNotFound) {
ApiError::SeriesNotFound
} else {
error.into()
}
})?;
let tmdb = tmdb_client(&state)?;
let detail = tmdb
.series_detail(tmdb_id(tmdb_row)?)
.await
.map_err(|error| upstream_error(&error))?;
Ok(Json(detail.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{router, Upstreams};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// App against a mocked TMDB, with a real migrated database so library
/// rows exist to resolve local ids against.
async fn application(tmdb: &MockServer) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("tmdb-key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
(dir, format!("http://{address}"))
}
async fn add_movie(base: &str) -> serde_json::Value {
let response = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1
}))
.send()
.await
.expect("create movie");
assert_eq!(response.status(), 201);
response.json().await.expect("movie json")
}
async fn add_series(base: &str) -> serde_json::Value {
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
.json()
.await
.expect("roots json");
let tv_root = roots
.iter()
.find(|root| root["kind"] == "tv")
.and_then(|root| root["id"].as_i64())
.expect("a seeded TV root");
let response = reqwest::Client::new()
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "The Last of Us",
"original_language": "en", "root_id": tv_root
}))
.send()
.await
.expect("create series");
assert_eq!(response.status(), 201);
response.json().await.expect("series json")
}
#[tokio::test]
async fn a_movie_metadata_resolves_through_one_detail_call() {
let tmdb = MockServer::start().await;
// The add path fetches artwork off `movie()` (#145), which is a
// different query against the same path. Match on the appended
// resources so the count below is the detail call alone.
Mock::given(method("GET"))
.and(path("/movie/693134"))
.and(query_param("append_to_response", "release_dates"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134, "title": "Dune Part Two", "vote_average": 8.417
})))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/movie/693134"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134,
"overview": "Paul Atreides unites with the Fremen.",
"tagline": "Long live the fighters.",
"genres": [{"id": 878, "name": "Science Fiction"}, {"id": 12, "name": "Adventure"}],
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"poster_path": "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg",
"vote_average": 8.417,
"vote_count": 3_512,
"homepage": "https://www.duneparttwo.com",
"status": "Released",
"runtime": 166,
"imdb_id": "tt15239678",
"credits": {"cast": [
{"id": 9_058_792, "name": "Timothée Chalamet", "character": "Paul Atreides",
"profile_path": "/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg", "order": 0},
{"id": 1_320_062, "name": "Zendaya", "character": "Chani",
"profile_path": "/ultRzqcGdWXWX3rjg1up9HcsoGi.jpg", "order": 1}
]},
"videos": {"results": [
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
"name": "Fan Trailer", "official": false},
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
"name": "Dune: Part Two | Official Trailer", "official": true}
]}
})))
.expect(1)
.mount(&tmdb)
.await;
let (_dir, base) = application(&tmdb).await;
let movie = add_movie(&base).await;
let movie_id = movie["id"].as_i64().expect("movie id");
let body: serde_json::Value =
reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
.await
.expect("request")
.json()
.await
.expect("json");
assert_eq!(body["tmdb_id"], 693_134);
assert_eq!(body["overview"], "Paul Atreides unites with the Fremen.");
assert_eq!(body["tagline"], "Long live the fighters.");
assert_eq!(body["genres"][0], "Science Fiction");
assert_eq!(body["runtime"], 166);
assert_eq!(body["status"], "Released");
assert_eq!(body["imdb_id"], "tt15239678");
assert_eq!(body["vote_average"], serde_json::json!(8.417));
assert_eq!(body["vote_count"], 3_512);
// §9.6: path fragments exactly as TMDB sends them, never composed URLs.
assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg");
assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg");
assert_eq!(
body["cast"][0]["profile_path"],
"/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg"
);
assert_eq!(body["cast"][0]["character"], "Paul Atreides");
assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w");
tmdb.verify().await;
}
#[tokio::test]
async fn a_series_metadata_carries_the_tvdb_id_and_episode_runtime() {
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 82_728,
"overview": "Twenty years after civilization fell.",
"tagline": "When you're lost in the darkness, look for the light.",
"genres": [{"id": 18, "name": "Drama"}],
"backdrop_path": "/eMhDKZscBd07OLpAeeAyu3N3U8c.jpg",
"poster_path": "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
"vote_average": 8.5,
"vote_count": 2_911,
"homepage": "",
"status": "Returning Series",
"episode_run_time": [55, 57],
"external_ids": {"tvdb_id": 392_256},
"credits": {"cast": [
{"id": 1_320_062, "name": "Pedro Pascal", "character": "Joel Miller",
"profile_path": "/zEmkctzuGBEbxFfOtpTAg9jtkTu.jpg", "order": 0}
]},
"videos": {"results": [
{"key": "uLtkt8Bonuu", "site": "YouTube", "type": "Trailer",
"name": "Official Trailer", "official": true}
]}
})))
.mount(&tmdb)
.await;
let (_dir, base) = application(&tmdb).await;
let series = add_series(&base).await;
let series_id = series["id"].as_i64().expect("series id");
let body: serde_json::Value =
reqwest::get(format!("{base}/api/series/{series_id}/metadata"))
.await
.expect("request")
.json()
.await
.expect("json");
assert_eq!(body["tmdb_id"], 82_728);
assert_eq!(body["tvdb_id"], 392_256);
// TMDB sends a list of episode lengths; the API reports one runtime.
assert_eq!(body["runtime"], 55);
assert_eq!(body["status"], "Returning Series");
// TMDB uses "" where null is meant; it stays null on the way out.
assert_eq!(body["homepage"], serde_json::Value::Null);
assert_eq!(body["poster_path"], "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg");
assert_eq!(body["trailer"]["youtube_key"], "uLtkt8Bonuu");
}
/// An id that is not in the library is a 404 before TMDB is asked —
/// search rows are addressed through #144's trailer endpoint instead.
#[tokio::test]
async fn an_unknown_local_id_is_a_forty_forty_without_touching_tmdb() {
let tmdb = MockServer::start().await;
let (_dir, base) = application(&tmdb).await;
let movie = reqwest::get(format!("{base}/api/movies/99/metadata"))
.await
.expect("request");
assert_eq!(movie.status(), 404);
assert_eq!(
movie.json::<serde_json::Value>().await.expect("json")["error"],
"movie not found"
);
let series = reqwest::get(format!("{base}/api/series/99/metadata"))
.await
.expect("request");
assert_eq!(series.status(), 404);
assert_eq!(
series.json::<serde_json::Value>().await.expect("json")["error"],
"series not found"
);
tmdb.verify().await;
}
#[tokio::test]
async fn no_tmdb_key_is_a_fifty_three_like_the_rest_of_the_surface() {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
let base = format!("http://{address}");
let movie = add_movie(&base).await;
let movie_id = movie["id"].as_i64().expect("movie id");
let response = reqwest::get(format!("{base}/api/movies/{movie_id}/metadata"))
.await
.expect("request");
assert_eq!(response.status(), 503);
}
}
+207 -40
View File
@@ -25,6 +25,11 @@ pub struct Movie {
pub blocked: bool,
pub search_attempts: i64,
pub last_searched_at: Option<String>,
/// Path fragment, not a URL; stored at add time and refreshed with
/// metadata (#145), so pure-SQL views never need TMDB (§9.6).
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// The relaxed rule recorded when a file was imported under a waiver
/// (§5.7), so the UI can show "English, no dub" instead of a clean match.
pub waiver: Option<serde_json::Value>,
@@ -100,6 +105,22 @@ pub struct AttentionQueues {
pub tv_needs_decision: Vec<SeriesAttention>,
}
/// An episode that put its series in a TV attention queue, named for the
/// operator (`SxxEyy` comes from season and episode numbers, #140).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct QueuedEpisode {
pub id: i64,
pub season_number: i64,
pub episode_number: i64,
}
/// A season whose failed pack put its series in a TV attention queue.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct QueuedSeason {
pub id: i64,
pub number: i64,
}
/// A series in a TV attention queue, with what put it there. One row per
/// series — the notification rolls up the same way (§9.5).
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -109,9 +130,9 @@ pub struct SeriesAttention {
pub title: String,
pub year: Option<i64>,
/// Episodes that put the series in this queue.
pub episodes: Vec<i64>,
pub episodes: Vec<QueuedEpisode>,
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
pub seasons: Vec<i64>,
pub seasons: Vec<QueuedSeason>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -133,6 +154,9 @@ pub enum ApiError {
OwnerNotFound,
PolicyNotFound,
RootNotFound,
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
/// Ordinary, so it must stay distinguishable from an upstream failure.
NoTrailer,
Conflict(String),
Invalid(String),
Unavailable,
@@ -153,6 +177,7 @@ impl IntoResponse for ApiError {
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
Self::Unavailable => (
@@ -224,7 +249,7 @@ pub(crate) fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
}
async fn load_movie(state: &AppState, id: i64) -> Result<Movie, ApiError> {
Ok(sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id = ?"#, id)
Ok(sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id = ?"#, id)
.fetch_one(pool(state)?)
.await?)
}
@@ -249,11 +274,11 @@ pub async fn list(
Query(query): Query<ListMoviesQuery>,
) -> Result<Json<Vec<Movie>>, ApiError> {
let movies = if let Some(owner_id) = query.owner_id {
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at, m.poster_path, m.vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = m.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
.fetch_all(pool(&state)?)
.await?
} else {
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies ORDER BY title, year, id"#)
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
@@ -290,10 +315,18 @@ pub async fn create(
let overrides = serde_json::to_string(&input.overrides)
.map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = input.title.trim();
// §9.6: the three stored artwork fields are filled from TMDB here so a
// title added today has a poster before tomorrow's refresh. Best effort:
// a TMDB outage must not block an add.
let artwork = lookup_movie_artwork(&state, input.tmdb_id).await;
let poster_path = artwork.as_ref().and_then(|a| a.0.clone());
let backdrop_path = artwork.as_ref().and_then(|a| a.1.clone());
let vote_average = artwork.as_ref().and_then(|a| a.2);
let result = sqlx::query!(
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, title, input.year, input.original_language, input.root_id,
input.wanted, input.blocked, overrides
input.wanted, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
)
.execute(pool(&state)?)
.await?;
@@ -303,6 +336,17 @@ pub async fn create(
))
}
/// §9.6 artwork for a new movie row, straight off the detail response.
/// `None` when TMDB is not configured or cannot be reached.
async fn lookup_movie_artwork(
state: &AppState,
tmdb_id: i64,
) -> Option<(Option<String>, Option<String>, Option<f64>)> {
let client = crate::search::tmdb_client(state).ok()?;
let movie = client.movie(u32::try_from(tmdb_id).ok()?).await.ok()?;
Some((movie.poster_path, movie.backdrop_path, movie.vote_average))
}
#[utoipa::path(
get, path = "/api/movies/{movie_id}", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie row id")),
@@ -640,10 +684,10 @@ pub async fn grab(
)
)]
pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQueues>, ApiError> {
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -667,7 +711,9 @@ async fn tv_attention(
let no_pt_rows = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, e.id AS "episode_id!: i64"
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
@@ -688,13 +734,15 @@ async fn tv_attention(
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64"
s.title AS "title!: String", s.year,
g.target_id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM grabs g
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#
)
@@ -703,12 +751,13 @@ async fn tv_attention(
let season_pack_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64"
s.title AS "title!: String", s.year,
g.target_id AS "season_id!: i64", se.number AS "season_number!: i64"
FROM grabs g
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
"#
)
.fetch_all(database)
@@ -720,9 +769,11 @@ async fn tv_attention(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
row.title,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
@@ -732,54 +783,93 @@ async fn tv_attention(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
row.title,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
for row in season_pack_fails {
match tv_needs_decision
.iter_mut()
.find(|entry| entry.series_id == row.series_id)
{
Some(entry) => entry.seasons.push(row.season_id),
None => tv_needs_decision.push(SeriesAttention {
series_id: row.series_id,
tmdb_id: row.tmdb_id,
title: row.title,
year: row.year,
episodes: Vec::new(),
seasons: vec![row.season_id],
}),
}
merge_season(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.season_id,
row.season_number,
);
}
Ok((tv_no_pt_source, tv_needs_decision))
}
/// One more qualifying season for its series, creating the series' entry on
/// first sight.
#[allow(clippy::too_many_arguments)]
fn merge_season(
entries: &mut Vec<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
season_id: i64,
season_number: i64,
) {
match entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
{
Some(entry) => entry.seasons.push(QueuedSeason {
id: season_id,
number: season_number,
}),
None => entries.push(SeriesAttention {
series_id,
tmdb_id,
title: title.to_owned(),
year,
episodes: Vec::new(),
seasons: vec![QueuedSeason {
id: season_id,
number: season_number,
}],
}),
}
}
/// One more qualifying episode for its series, creating the series' entry on
/// first sight.
#[allow(clippy::too_many_arguments)]
fn merge_episode(
entries: &mut Vec<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: String,
title: &str,
year: Option<i64>,
episode_id: i64,
season_number: i64,
episode_number: i64,
) {
let episode = QueuedEpisode {
id: episode_id,
season_number,
episode_number,
};
if let Some(entry) = entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
{
entry.episodes.push(episode_id);
entry.episodes.push(episode);
return;
}
entries.push(SeriesAttention {
series_id,
tmdb_id,
title,
title: title.to_owned(),
year,
episodes: vec![episode_id],
episodes: vec![episode],
seasons: Vec::new(),
});
}
@@ -913,6 +1003,73 @@ mod tests {
response.json().await.expect("movie json")
}
/// §9.6: the three stored artwork fields come off the detail response at
/// add time, so a title added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_movie_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/movie/693134"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134,
"title": "Dune Part Two",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two",
"original_language": "en", "root_id": 2
}))
.send()
.await
.expect("create movie");
assert_eq!(response.status(), StatusCode::CREATED);
let movie_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("movie id");
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM movies WHERE id = ?",
)
.bind(movie_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
}
/// §5.7: a soft-failed import is imported and waived, and the waiver
/// reaches the API — a file that merely plays must never read as a clean
/// match.
@@ -1350,21 +1507,31 @@ mod tests {
.json()
.await
.expect("queues json");
let episode_ids: Vec<i64> =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number")
.bind(season_id)
.fetch_all(pool)
.await
.expect("episode ids");
assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id);
assert_eq!(
queues["tv_no_pt_source"][0]["episodes"]
.as_array()
.map(Vec::len),
Some(2)
queues["tv_no_pt_source"][0]["episodes"],
serde_json::json!([
{ "id": episode_ids[0], "season_number": 1, "episode_number": 1 },
{ "id": episode_ids[1], "season_number": 1, "episode_number": 2 },
])
);
assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id);
assert_eq!(
queues["tv_needs_decision"][0]["episodes"],
serde_json::json!([episode_id])
serde_json::json!([
{ "id": episode_id, "season_number": 1, "episode_number": 3 },
])
);
assert_eq!(
queues["tv_needs_decision"][0]["seasons"],
serde_json::json!([season_id])
serde_json::json!([{ "id": season_id, "number": 1 }])
);
// Blocking the series empties its no-PT-source lane.
+75 -14
View File
@@ -59,6 +59,10 @@ pub struct LibrarySeries {
pub original_language: Option<String>,
pub root_id: i64,
pub blocked: bool,
/// Stored artwork (§9.6), so a row renders without a TMDB call.
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -70,6 +74,14 @@ pub struct LibraryEpisode {
pub tag: String,
/// The episode title — what the search matched on.
pub title: String,
/// The series' poster — an episode has no artwork of its own worth
/// showing at row size.
pub poster_path: Option<String>,
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// The series' TMDB id — the episode row's trailer chip resolves through
/// it (#148); an episode has no videos of its own worth listing.
pub series_tmdb_id: i64,
}
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
@@ -91,6 +103,10 @@ pub struct TmdbMovie {
pub year: Option<i32>,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -108,6 +124,10 @@ pub struct TmdbSeries {
pub year: Option<i32>,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
}
/// One release's score, kept as its terms so the UI can explain a ranking
@@ -184,13 +204,13 @@ pub async fn search(
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
.into_iter()
.map(LibraryResult::Movie)
.collect()
} else {
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
.fetch_all(database.pool()).await?
.into_iter()
.map(LibraryResult::Movie)
@@ -204,18 +224,20 @@ pub async fn search(
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
} else {
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
.fetch_all(database.pool()).await?
};
library.extend(series_rows.into_iter().map(LibraryResult::Series));
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so the haystack
// is both joined and every token must land somewhere in it.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
// Tokens may split across the series and episode titles, so every token
// must land in the concatenation — and at least one must match the
// episode title on its own, or a series-title-only query would list
// every episode the series has.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
.fetch_all(database.pool())
.await?;
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
@@ -227,7 +249,7 @@ pub async fn search(
match result {
TmdbResult::Movie(movie) => {
let tmdb_id = i64::from(movie.tmdb_id);
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Movie(found));
@@ -235,7 +257,7 @@ pub async fn search(
}
TmdbResult::Series(series) => {
let tmdb_id = i64::from(series.tmdb_id);
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Series(found));
@@ -291,6 +313,8 @@ async fn search_tmdb(
year,
overview: movie.overview,
poster_path: movie.poster_path,
vote_average: movie.vote_average,
vote_count: movie.vote_count,
})]
}
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
@@ -558,6 +582,8 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
year,
overview: movie.overview,
poster_path: movie.poster_path,
vote_average: movie.vote_average,
vote_count: movie.vote_count,
}
}
}
@@ -572,6 +598,8 @@ impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
@@ -586,6 +614,8 @@ impl From<arr_meta::Series> for TmdbSeries {
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
@@ -652,7 +682,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
.map_err(|_| ApiError::Unavailable)
}
fn upstream_error(error: &arr_meta::Error) -> ApiError {
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
match error {
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
_ => ApiError::Unavailable,
@@ -816,7 +846,8 @@ mod tests {
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
"id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two",
"original_language": "en", "release_date": "2024-02-27"
"original_language": "en", "release_date": "2024-02-27",
"poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000
}]})),
)
.mount(&tmdb)
@@ -843,6 +874,19 @@ mod tests {
assert_eq!(response["library"][0]["title"], "Dune");
assert_eq!(response["tmdb"][0]["kind"], "movie");
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
// §9.6: both halves carry artwork and a rating, read from stored
// columns and TMDB's search body — no upstream call per row.
assert_eq!(
response["library"][0]["poster_path"],
serde_json::Value::Null
);
assert_eq!(
response["library"][0]["vote_average"],
serde_json::Value::Null
);
assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg");
assert_eq!(response["tmdb"][0]["vote_average"], 8.1);
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
}
/// §9.2: the in-library set matches series titles and episode titles, so
@@ -914,15 +958,32 @@ mod tests {
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
// §9.2, amended: an episode row surfaces only when the query matches
// something beyond the series title. `bluey` alone returns the series
// row and no episode below it.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["library"][0]["kind"], "series");
assert_eq!(response["library"][0]["title"], "Bluey");
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
let library = response["library"].as_array().expect("library");
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "series");
assert_eq!(library[0]["title"], "Bluey");
assert_eq!(library[0]["tmdb_id"], 82_728);
// An episode-title-only query still finds the episode.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
.await
.expect("search")
.json()
.await
.expect("json");
let library = response["library"].as_array().expect("library");
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "episode");
assert_eq!(library[0]["title"], "Hospital");
}
#[tokio::test]
+396 -19
View File
@@ -52,6 +52,11 @@ pub struct Series {
/// Whether the show finished upstream, which `ended` is derived from.
pub upstream_ended: bool,
pub blocked: bool,
/// Path fragment, not a URL; stored at add time and refreshed with
/// metadata (#145), so pure-SQL views never need TMDB (§9.6).
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
pub status: String,
/// Episodes currently marked wanted (§4.1 — the only intent).
@@ -97,13 +102,21 @@ pub struct Season {
pub number: i64,
/// §4.1. Whether new episodes of this season arrive wanted.
pub tracked: bool,
/// #141. The season vanished upstream while files remained under it,
/// which is why the row still exists. A conflict for the operator to
/// resolve; nothing was deleted from disk.
pub vanished: bool,
pub episodes: Vec<Episode>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Episode {
pub id: i64,
/// The owning series — the episode deck route needs it without walking
/// seasons first.
pub series_id: i64,
pub season_id: i64,
pub season_number: i64,
pub number: i64,
pub title: String,
pub air_date: Option<String>,
@@ -190,6 +203,14 @@ struct SeriesRow {
overrides: serde_json::Value,
upstream_ended: bool,
blocked: bool,
/// Stored at add time and refreshed with metadata (#145), so pure-SQL
/// views never need TMDB (§9.6).
poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
vote_average: Option<f64>,
/// NULL until a metadata refresh has stamped it (#160): the fact the
/// auto-track rule needs to tell a seeding refresh from a later one.
metadata_refreshed_at: Option<String>,
}
struct EpisodeRow {
@@ -302,6 +323,8 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
overrides: row.overrides.clone(),
upstream_ended: row.upstream_ended,
blocked: row.blocked,
poster_path: row.poster_path.clone(),
vote_average: row.vote_average,
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
@@ -333,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeriesNotFound)
@@ -387,11 +410,11 @@ pub async fn list(
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<Vec<Series>>, ApiError> {
let rows = if let Some(owner_id) = query.owner_id {
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
.fetch_all(pool(&state)?)
.await?
} else {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
@@ -430,12 +453,19 @@ pub async fn create(
let title = input.title.trim();
// §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not
// knowing one must not block adding the series — the search falls back
// to the title text query until a refresh fills it in (#121).
let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await;
// to the title text query until a refresh fills it in (#121). The same
// response carries §9.6's stored artwork fields, so a series added today
// has a poster before tomorrow's refresh. Best effort either way.
let tmdb_series = lookup_tmdb_series(&state, input.tmdb_id).await;
let tvdb_id = tmdb_series.as_ref().and_then(|s| s.tvdb_id).map(i64::from);
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
let result = sqlx::query!(
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides
input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
)
.execute(pool(&state)?)
.await?;
@@ -445,14 +475,10 @@ pub async fn create(
))
}
/// Best effort: `None` when TMDB has no id or cannot be reached.
async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option<i64> {
/// Best effort: `None` when TMDB has no such id or cannot be reached.
async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option<arr_meta::Series> {
let client = tmdb_client(state).ok()?;
let ids = client
.series_external_ids(u32::try_from(tmdb_id).ok()?)
.await
.ok()?;
ids.tvdb_id.map(i64::from)
client.series(u32::try_from(tmdb_id).ok()?).await.ok()
}
#[utoipa::path(
@@ -629,7 +655,9 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
let seasons = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
// §9.6: newest-first, so season 0 lands last under plain numeric
// descending order — exactly where the design puts it.
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number DESC"#,
series_id
)
.fetch_all(pool(state)?)
@@ -637,7 +665,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#,
series_id
)
.fetch_all(pool(state)?)
@@ -650,12 +678,15 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
series_id: season.series_id,
number: season.number,
tracked: season.tracked,
vanished: season.vanished,
episodes: episodes
.iter()
.filter(|episode| episode.season_id == season.id)
.map(|episode| Episode {
id: episode.id,
series_id,
season_id: episode.season_id,
season_number: episode.season_number,
number: episode.number,
title: episode.title.clone(),
air_date: episode.air_date.clone(),
@@ -716,6 +747,15 @@ pub async fn create_season(
"episode number cannot be negative".into(),
));
}
// The column rejects the empty string (#153); say so before the database
// has to.
if input
.episodes
.iter()
.any(|episode| episode.title.trim().is_empty())
{
return Err(ApiError::Invalid("episode title cannot be empty".into()));
}
let mut numbers: Vec<i64> = input
.episodes
.iter()
@@ -730,6 +770,9 @@ pub async fn create_season(
// §4.1. The request does not say whether the episodes are wanted; the
// series' auto_track rule does, through the one function that owns it.
// #160: whether a refresh has happened yet rides in from the row — a
// hand-added season on a never-refreshed series is still seeding, and
// §4.1 does not track what was there at add time.
let mut revealed = [RefreshedSeason {
season: arr_core::Season {
id: SeasonId(0),
@@ -755,7 +798,11 @@ pub async fn create_season(
.collect(),
is_new: true,
}];
apply_auto_track(&core_series(&series), &mut revealed);
apply_auto_track(
&core_series(&series),
series.metadata_refreshed_at.is_some(),
&mut revealed,
);
let [revealed] = revealed;
// One transaction: a rejected episode must not leave the season behind,
@@ -876,7 +923,9 @@ async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
.ok_or(ApiError::EpisodeNotFound)?;
Ok(Episode {
id: row.id,
series_id: row.series_id,
season_id: row.season_id,
season_number: row.season_number,
number: row.number,
title: row.title,
air_date: row.air_date,
@@ -1140,6 +1189,39 @@ pub async fn grab_season_release(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
/// One imported episode file, keyed to its episode so the detail view can
/// attach the file's probed §7.4 attributes to the episode row.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EpisodeFile {
pub episode_id: i64,
pub path: String,
pub size: i64,
pub probed: Option<serde_json::Value>,
/// The §5.7 rule relaxed to allow this import, when one was.
pub waiver: Option<String>,
}
#[utoipa::path(
get, path = "/api/series/{series_id}/files", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
responses(
(status = 200, body = [EpisodeFile]),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn files(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<EpisodeFile>>, ApiError> {
load_series_row(&state, id).await?;
let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id)
.fetch_all(pool(&state)?)
.await?;
Ok(Json(files))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/owners", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
@@ -1292,6 +1374,81 @@ mod tests {
response.json().await.expect("season json")
}
/// §9.6: the three stored artwork fields come off the series detail
/// response at add time — the same call that used to fetch only the
/// TVDB id — so a series added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_series_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/tv/82728"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 82_728,
"name": "Bluey",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"external_ids": {"tvdb_id": 361_391}
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey",
"original_language": "en", "root_id": 3
}))
.send()
.await
.expect("create series");
assert_eq!(response.status(), StatusCode::CREATED);
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("series id");
let (tvdb_id, poster, backdrop, vote): (
Option<i64>,
Option<String>,
Option<String>,
Option<f64>,
) = sqlx::query_as(
"SELECT tvdb_id, poster_path, backdrop_path, vote_average FROM series WHERE id = ?",
)
.bind(series_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("series row");
assert_eq!(tvdb_id, Some(361_391));
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
#[tokio::test]
async fn series_must_sit_on_a_tv_root() {
let (_dir, state, base) = application().await;
@@ -1312,15 +1469,21 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
/// #160. A season added before the series' first metadata refresh is part
/// of the back catalogue at add time, so the rule does not track it. Once
/// a refresh has happened, a hand-revealed season is genuinely new and
/// arrives tracked with its episodes wanted.
#[tokio::test]
async fn auto_track_decides_whether_a_new_season_arrives_wanted() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "kids").await;
let tracked = add_series(&base, root_id, true).await;
let tracked_id = tracked["id"].as_i64().expect("id");
// Seeding: no refresh has ever stamped this series.
let season = add_season(
&base,
tracked["id"].as_i64().expect("id"),
tracked_id,
1,
serde_json::json!([
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
@@ -1328,6 +1491,33 @@ mod tests {
]),
)
.await;
assert_eq!(season["tracked"], false);
assert!(
season["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == false),
"§4.1: the back catalogue at add time is never auto-tracked"
);
sqlx::query(
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
)
.bind(tracked_id)
.execute(state.database().expect("database").pool())
.await
.expect("stamp refreshed");
let season = add_season(
&base,
tracked_id,
2,
serde_json::json!([
{"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"}
]),
)
.await;
assert_eq!(season["tracked"], true);
assert!(
season["episodes"]
@@ -1335,7 +1525,7 @@ mod tests {
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true),
"§4.1: auto_track marks the episodes of a revealed season wanted"
"§4.1: from the second refresh onward the rule tracks what is new"
);
let untracked_id = sqlx::query_scalar::<_, i64>(
@@ -1356,6 +1546,28 @@ mod tests {
assert_eq!(season["episodes"][0]["wanted"], false);
}
/// #153. An empty episode title is rejected up front — the column and the
/// TMDB boundary both refuse it, so the API must too.
#[tokio::test]
async fn an_empty_episode_title_is_rejected() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
for title in ["", " "] {
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 1, "episodes": [
{"number": 1, "title": title}
]}))
.send()
.await
.expect("create season");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[tokio::test]
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
let (_dir, state, base) = application().await;
@@ -1390,6 +1602,120 @@ mod tests {
assert_eq!(season["number"], 1);
}
#[tokio::test]
async fn a_vanished_season_reaches_the_seasons_endpoint() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let clean = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
assert_eq!(clean["vanished"], false);
add_season(
&base,
series_id,
2,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
sqlx::query("UPDATE seasons SET vanished = 1 WHERE series_id = ? AND number = 2")
.bind(series_id)
.execute(state.database().expect("database").pool())
.await
.expect("flag season vanished");
let seasons: serde_json::Value = reqwest::Client::new()
.get(format!("{base}/api/series/{series_id}/seasons"))
.send()
.await
.expect("list seasons")
.json()
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
let vanished = |number: i64| {
seasons
.iter()
.find(|season| season["number"] == number)
.expect("season")["vanished"]
.clone()
};
assert_eq!(vanished(1), false);
assert_eq!(
vanished(2),
true,
"#141: the conflict flag is not stopped at the database"
);
}
/// §9.6: seasons come back newest-first within a series and episodes
/// newest-first within each season, with season 0 wherever descending
/// numeric order puts it — last. The SPA renders this order as given,
/// so it is the API's to get right (#162).
#[tokio::test]
async fn seasons_and_episodes_are_returned_newest_first() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
for (season, episodes) in [
(
0,
serde_json::json!([
{"number": 1, "title": "Special one"},
{"number": 2, "title": "Special two"}
]),
),
(1, serde_json::json!([{"number": 1, "title": "Pilot"}])),
(
2,
serde_json::json!([
{"number": 1, "title": "One"},
{"number": 2, "title": "Two"},
{"number": 3, "title": "Three"}
]),
),
] {
add_season(&base, series_id, season, episodes).await;
}
let seasons: serde_json::Value =
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
.await
.expect("list seasons")
.json()
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
let numbers: Vec<i64> = seasons
.iter()
.map(|season| season["number"].as_i64().expect("number"))
.collect();
assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series");
let episode_numbers = |index: usize| -> Vec<i64> {
seasons[index]["episodes"]
.as_array()
.expect("episodes")
.iter()
.map(|episode| episode["number"].as_i64().expect("episode number"))
.collect()
};
assert_eq!(
episode_numbers(0),
[3, 2, 1],
"§9.6: newest-first in a season"
);
assert_eq!(episode_numbers(1), [1]);
assert_eq!(episode_numbers(2), [2, 1]);
}
#[tokio::test]
async fn intent_is_set_on_seasons_and_on_single_episodes() {
let (_dir, state, base) = application().await;
@@ -1956,6 +2282,57 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
/// The detail view reads its episode files through the series, keyed by
/// episode id, so one request carries every §7.4 attribute tag it shows.
#[tokio::test]
async fn series_files_are_keyed_by_episode() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "kids").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
{"number": 2, "title": "Hospital", "air_date": "2018-10-02"}
]),
)
.await;
let episodes = season["episodes"].as_array().expect("episodes");
let first = episodes[0]["id"].as_i64().expect("episode id");
let pool = state.database().expect("database").pool();
sqlx::query(
r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
VALUES ('episode', ?, ?, 7, ?, ?)",
)
.bind(first)
.bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv")
.bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#)
.bind(r#"{"rule":"required_audio"}"#)
.execute(pool)
.await
.expect("media file");
let response = reqwest::get(format!("{base}/api/series/{series_id}/files"))
.await
.expect("fetch files");
assert_eq!(response.status(), StatusCode::OK);
let files: serde_json::Value = response.json().await.expect("files json");
let rows = files.as_array().expect("array");
assert_eq!(rows.len(), 1, "only imported episodes carry a file");
assert_eq!(rows[0]["episode_id"], first);
assert_eq!(rows[0]["probed"]["resolution"], "1080p");
assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT");
assert_eq!(rows[0]["waiver"], "required_audio");
let missing = reqwest::get(format!("{base}/api/series/999/files"))
.await
.expect("fetch files");
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
/// The guard that keeps a delete inside the library: a path that is not
/// under the series' root is left alone, whatever the row says.
#[tokio::test]
+206
View File
@@ -0,0 +1,206 @@
use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::movies::{ApiError, ErrorBody};
use crate::search::{tmdb_client, upstream_error};
use crate::state::AppState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct TrailerQuery {
kind: TrailerKind,
tmdb_id: u32,
}
/// Which namespace the TMDB id names. Movie and series ids are independent
/// numbering spaces at TMDB.
#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TrailerKind {
Movie,
Tv,
}
/// The one trailer worth showing (§9.6), resolved on click rather than
/// prefetched: TMDB's search responses carry no videos.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Trailer {
pub youtube_key: String,
pub name: String,
}
#[utoipa::path(
get, path = "/api/trailer", tag = "search", params(TrailerQuery),
responses(
(status = 200, body = Trailer),
(status = 404, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn trailer(
State(state): State<AppState>,
Query(query): Query<TrailerQuery>,
) -> Result<Json<Trailer>, ApiError> {
let tmdb = tmdb_client(&state)?;
let video = match query.kind {
TrailerKind::Movie => tmdb.movie_videos(query.tmdb_id).await,
TrailerKind::Tv => tmdb.series_videos(query.tmdb_id).await,
}
.map_err(|error| upstream_error(&error))?;
match video {
Some(video) => Ok(Json(Trailer {
youtube_key: video.key,
name: video.name,
})),
None => Err(ApiError::NoTrailer),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{router, Upstreams};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Serve the app against a mocked TMDB. No database: the handler never
/// touches one, because the chip addresses titles that have no local row.
async fn application(tmdb: &MockServer) -> String {
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("tmdb-key".into())),
)
.expect("state");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
format!("http://{address}")
}
#[tokio::test]
async fn a_movie_trailer_resolves_through_the_videos_call() {
let tmdb = MockServer::start().await;
// One request, videos only — §9.6 keeps the click cost at one call.
Mock::given(method("GET"))
.and(path("/movie/693134/videos"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134,
"results": [
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
"name": "Fan Trailer", "official": false},
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
"name": "Official Trailer", "official": true}
]
})))
.expect(1)
.mount(&tmdb)
.await;
let base = application(&tmdb).await;
let response: serde_json::Value =
reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
.await
.expect("request")
.json()
.await
.expect("json");
assert_eq!(response["youtube_key"], "Way9Dexny3w");
assert_eq!(response["name"], "Official Trailer");
tmdb.verify().await;
}
#[tokio::test]
async fn a_series_trailer_resolves_through_the_same_rule() {
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tv/82728/videos"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 82_728,
"results": [
{"key": "abc123", "site": "YouTube", "type": "Teaser",
"name": "Series Teaser", "official": true}
]
})))
.mount(&tmdb)
.await;
let base = application(&tmdb).await;
let response: serde_json::Value =
reqwest::get(format!("{base}/api/trailer?kind=tv&tmdb_id=82728"))
.await
.expect("request")
.json()
.await
.expect("json");
assert_eq!(response["youtube_key"], "abc123");
assert_eq!(response["name"], "Series Teaser");
}
/// A title TMDB knows but has no `YouTube` trailer for is an ordinary
/// outcome: the browser renders "no trailer", so 404, never a 5xx.
#[tokio::test]
async fn a_title_without_a_trailer_is_a_forty_forty() {
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/movie/1/videos"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 1,
"results": [
{"key": "clip", "site": "Vimeo", "type": "Clip",
"name": "A Clip", "official": true}
]
})))
.mount(&tmdb)
.await;
let base = application(&tmdb).await;
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
.await
.expect("request");
assert_eq!(response.status(), 404);
let body: serde_json::Value = response.json().await.expect("json");
assert_eq!(body["error"], "no trailer");
}
#[tokio::test]
async fn an_upstream_outage_is_not_mistaken_for_no_trailer() {
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/movie/1/videos"))
.respond_with(ResponseTemplate::new(500))
.mount(&tmdb)
.await;
let base = application(&tmdb).await;
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
.await
.expect("request");
assert_eq!(response.status(), 503);
}
#[tokio::test]
async fn no_tmdb_key_behaves_like_the_rest_of_the_search_surface() {
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
.await
.expect("request");
assert_eq!(response.status(), 503);
}
}
+44 -5
View File
@@ -13,8 +13,14 @@ pub struct RefreshedSeason {
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
/// still track it by hand.
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
if !series.auto_track {
///
/// `ever_refreshed` says whether the series has had a metadata refresh yet
/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because
/// `arr-core` has no IO; seasons revealed by a series' first refresh are its
/// back catalogue as it stood at add time, and §4.1 never tracks those — the
/// rule applies from the second refresh onward.
pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) {
if !series.auto_track || !ever_refreshed {
return;
}
@@ -114,7 +120,7 @@ mod tests {
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(true), &mut refresh);
apply_auto_track(&series(true), true, &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
@@ -122,6 +128,39 @@ mod tests {
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
}
/// #160. The first refresh of a newly added series reveals the whole back
/// catalogue at once; §4.1 never tracks those.
#[test]
fn the_seeding_refresh_tracks_nothing() {
let mut refresh = [
refreshed_season(1, true, &[false, false]),
refreshed_season(2, true, &[false]),
];
apply_auto_track(&series(true), false, &mut refresh);
assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked));
assert!(refresh
.iter()
.all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted)));
}
/// #160. From the second refresh onward a season that genuinely did not
/// exist when the series was added is tracked, and its episodes arrive
/// wanted — while the seasons already on record stay as they were.
#[test]
fn a_later_refresh_tracks_a_genuinely_new_season() {
let mut refresh = [
refreshed_season(1, false, &[true, true]),
refreshed_season(3, true, &[false]),
];
apply_auto_track(&series(true), true, &mut refresh);
assert!(refresh[1].season.tracked);
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
}
#[test]
fn untracked_series_keeps_manual_leaf_intent_only() {
let mut refresh = [
@@ -129,7 +168,7 @@ mod tests {
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(false), &mut refresh);
apply_auto_track(&series(false), true, &mut refresh);
assert!(refresh[0].episodes.iter().all(|episode| episode.wanted));
assert!(!refresh[0].season.tracked);
@@ -141,7 +180,7 @@ mod tests {
fn auto_track_skips_season_zero() {
let mut refresh = [refreshed_season(0, true, &[false])];
apply_auto_track(&series(true), &mut refresh);
apply_auto_track(&series(true), true, &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
+187 -32
View File
@@ -183,6 +183,50 @@ impl GrabAction {
let tmdb_id =
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
let metadata = tmdb.movie(tmdb_id).await?;
let changed = self.store_metadata(database, &movie, &metadata).await?;
if changed {
tracing::info!(
movie_id = movie.id,
"metadata changed; reset targeted search backoff"
);
}
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
if !released {
tracing::debug!(
movie_id = movie.id,
"digital release has not happened; skipping targeted search"
);
}
let title = metadata.title.clone();
Ok((
PendingMovie {
id: movie.id,
tmdb_id: movie.tmdb_id,
title,
year: metadata.year().map(i64::from),
original_language: (!metadata.original_language.is_empty())
.then_some(metadata.original_language.clone()),
search_attempts: if changed { 0 } else { movie.search_attempts },
last_searched_at: if changed {
None
} else {
movie.last_searched_at
},
digital_release: metadata.digital_release.map(|date| date.to_string()),
metadata_refreshed_at: None,
},
released,
))
}
/// Write one refresh's fields to the row, guarded so unchanged data moves
/// nothing. Returns whether anything did.
async fn store_metadata(
&self,
database: &Db,
movie: &PendingMovie,
metadata: &arr_meta::Movie,
) -> Result<bool, GrabError> {
let title = metadata.title.clone();
let year = metadata.year().map(i64::from);
let original_language =
@@ -191,43 +235,53 @@ impl GrabAction {
// §6.2: the id RSS matching prefers, and the one Torznab movie
// searches take. TMDB does not know one for every title.
let imdb_id = metadata.imdb_id.clone();
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let title_ref = title.as_str();
let original_language_ref = original_language.as_deref();
let digital_release_ref = digital_release.as_deref();
let imdb_id_ref = imdb_id.as_deref();
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let changed = sqlx::query!(
r#"UPDATE movies
SET title = ?, year = ?, original_language = ?, digital_release = ?,
imdb_id = ?,
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ? AND (
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
OR digital_release IS NOT ? OR imdb_id IS NOT ?
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
OR vote_average IS NOT ?
)"#,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
movie.id,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
)
.execute(database.pool())
.await?
.rows_affected()
!= 0;
if changed {
tracing::info!(
movie_id = movie.id,
"metadata changed; reset targeted search backoff"
);
} else {
if !changed {
// Still stamp the refresh even when nothing changed, or the TTL
// gate above never engages and every tick pays for TMDB again.
sqlx::query!(
@@ -238,31 +292,7 @@ impl GrabAction {
.execute(database.pool())
.await?;
}
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
if !released {
tracing::debug!(
movie_id = movie.id,
"digital release has not happened; skipping targeted search"
);
}
Ok((
PendingMovie {
id: movie.id,
tmdb_id: movie.tmdb_id,
title,
year,
original_language,
search_attempts: if changed { 0 } else { movie.search_attempts },
last_searched_at: if changed {
None
} else {
movie.last_searched_at
},
digital_release,
metadata_refreshed_at: None,
},
released,
))
Ok(changed)
}
/// Search every indexer for one title, cache each candidate with its
@@ -2417,6 +2447,131 @@ mod tests {
assert_eq!(targeted_searches(&indexer).await, 3);
}
/// §9.6: the daily refresh also writes the three stored artwork fields,
/// so pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn metadata_refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let artwork_metadata = RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
);
let metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
// Idempotent: an unchanged refresh neither rewrites nor re-reports,
// same as the fields above it.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let unchanged: (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(unchanged, (poster, backdrop, vote));
}
/// TMDB reports `vote_average: 0` for a title nobody has rated — absence,
/// not zero (#156). It must store as NULL, be overwritten when votes
/// arrive, and go back to NULL if they are withdrawn.
#[tokio::test]
async fn metadata_refresh_stores_an_unrated_title_as_null() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let unrated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 0.0,"#,
)
};
let rated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
)
};
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
// Votes arrive: the NULL is overwritten.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&rated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, Some(8.152));
// And withdrawn again.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
}
#[tokio::test]
async fn metadata_changes_reset_a_title_backoff() {
let (_dir, database) = wanted_movie().await;
+437 -14
View File
@@ -13,12 +13,15 @@
//! - `upstream_ended` follows TMDB's status, which `ended` is derived from;
//! - a missing `tvdb_id` is backfilled (#120).
//!
//! Episodes that vanish upstream follow two rules (#122): without a file of
//! their own they are deleted outright, cascading through `episode_releases`;
//! with one they are flagged `vanished` instead, because deleting the row
//! would orphan a real file (`media_files` is polymorphic on its owner). A
//! vanished number that reappears clears its flag again. Renumbering needs no
//! matching of its own — it is just these two rules seen from both ends.
//! Episodes and seasons that vanish upstream follow two rules (#122, #137):
//! without a file of their own they are deleted outright, cascading through
//! `episode_releases`; with one they are flagged `vanished` instead, because
//! deleting the row would orphan a real file (`media_files` is polymorphic on
//! its owner). A vanished number that reappears clears its flag again.
//! Season 0 is exempt — TMDB drops and re-adds it routinely, and #118 already
//! keeps specials out of status, so churning it is noise rather than signal.
//! Renumbering needs no matching of its own — it is just these two rules seen
//! from both ends.
//!
//! Refresh is idempotent: over unchanged TMDB data only the stamp moves.
@@ -71,7 +74,8 @@ impl SeriesRefreshAction {
r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id,
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
)
@@ -125,6 +129,11 @@ impl SeriesRefreshAction {
.map(|season| (season.number, (season.id, season.tracked)))
.collect::<HashMap<_, _>>();
let mut revealed = Vec::new();
let upstream_seasons = metadata
.seasons
.iter()
.map(|summary| i64::from(summary.number))
.collect::<HashSet<_>>();
for summary in &metadata.seasons {
if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) {
changed |= self
@@ -136,6 +145,15 @@ impl SeriesRefreshAction {
summary.number,
)
.await?;
// The season is back upstream — a TMDB reversal, or a
// renumber seen from the other end. The conflict is over.
let restored = sqlx::query!(
"UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
season_id
)
.execute(&mut *transaction)
.await?;
changed |= restored.rows_affected() > 0;
} else {
let detail = self.tmdb.season(tmdb_id, summary.number).await?;
revealed.push(RefreshedSeason {
@@ -150,13 +168,23 @@ impl SeriesRefreshAction {
});
}
}
apply_auto_track(&core_series(stale), &mut revealed);
// #160: the fact of a previous refresh rides in from the row, so the
// seeding refresh never tracks the back catalogue it reveals.
apply_auto_track(
&core_series(stale),
stale.metadata_refreshed_at.is_some(),
&mut revealed,
);
for refreshed in revealed {
self.insert_revealed_season(&mut transaction, stale.id, refreshed)
.await?;
changed = true;
}
changed |= self
.reconcile_vanished_seasons(&mut transaction, &existing, &upstream_seasons)
.await?;
transaction.commit().await?;
stamp_refreshed(database, stale.id).await?;
Ok(changed.then(|| {
@@ -201,6 +229,29 @@ impl SeriesRefreshAction {
.await?;
changed = true;
}
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let artwork_moved =
stale.poster_path != poster_path || stale.backdrop_path != backdrop_path;
if artwork_moved || stale.vote_average != vote_average {
sqlx::query!(
"UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
poster_path_ref,
backdrop_path_ref,
vote_average,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
Ok(changed)
}
@@ -391,6 +442,52 @@ impl SeriesRefreshAction {
}
Ok(changed)
}
/// #137, one level up from `reconcile_vanished`. Seasons the library
/// knows that TMDB no longer lists have vanished upstream. One with no
/// file on any of its episodes is deleted — the foreign key cascades
/// through its episodes and their `episode_releases` — and one with a
/// file anywhere under it is flagged instead: dropping the rows would
/// orphan a real file, the same trap #122 documents. Season 0 is exempt:
/// TMDB drops and re-adds specials routinely, and §4.2 already keeps them
/// out of derived status, so churning the flag is noise rather than
/// signal.
async fn reconcile_vanished_seasons(
&self,
executor: &mut sqlx::SqliteConnection,
known: &HashMap<i64, (i64, bool)>,
upstream: &HashSet<i64>,
) -> Result<bool, RefreshError> {
let mut changed = false;
for (&number, &(season_id, _)) in known {
if number == 0 || upstream.contains(&number) {
continue;
}
let has_file = sqlx::query_scalar!(
r#"SELECT EXISTS(
SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id
WHERE f.owner_kind = 'episode' AND e.season_id = ?
) AS "exists!: bool""#,
season_id
)
.fetch_one(&mut *executor)
.await?;
if has_file {
sqlx::query!(
"UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
season_id
)
.execute(&mut *executor)
.await?;
} else {
sqlx::query!("DELETE FROM seasons WHERE id = ?", season_id)
.execute(&mut *executor)
.await?;
}
changed = true;
}
Ok(changed)
}
}
impl Action for SeriesRefreshAction {
@@ -414,8 +511,11 @@ struct DueSeries {
auto_track: bool,
upstream_ended: bool,
metadata_refreshed_at: Option<String>,
/// §9.6 artwork columns, compared against TMDB's on each refresh.
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
}
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would
/// never be grabbed anyway.
@@ -502,6 +602,7 @@ mod tests {
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use arr_meta::UNTITLED_EPISODE;
fn series_body(status: &str) -> serde_json::Value {
json!({
@@ -538,6 +639,48 @@ mod tests {
})
}
/// The series detail as `mount` serves it, restricted to the given
/// season numbers — a dropped number is how a vanished season presents.
fn series_body_with(status: &str, numbers: &[u32]) -> serde_json::Value {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": status,
"seasons": numbers
.iter()
.map(|number| json!({"season_number": number, "episode_count": 0}))
.collect::<Vec<_>>(),
"external_ids": {"tvdb_id": 361_391}
})
}
/// Mounts only the season detail endpoints listed; a request for any
/// other season would fail the test loudly.
async fn mount_with(server: &MockServer, status: &str, numbers: &[u32]) {
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(
ResponseTemplate::new(200).set_body_json(series_body_with(status, numbers)),
)
.mount(server)
.await;
for (number, body) in [
(1u32, season_one_body(&two_episodes())),
(2, season_two_body()),
] {
if !numbers.contains(&number) {
continue;
}
Mock::given(method("GET"))
.and(path(format!("/tv/82728/season/{number}")))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
}
/// A TMDB serving one series with two seasons. `reset` between phases of
/// a test and remount, counting requests by delta around each phase.
async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer {
@@ -606,8 +749,47 @@ mod tests {
.unwrap();
}
/// §9.6: the daily refresh writes the three stored artwork fields, so
/// pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
async fn refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = seeded_series(true).await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"seasons": []
})))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM series WHERE tmdb_id = 82728",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.
#[tokio::test]
async fn the_seeding_refresh_reveals_but_tracks_nothing() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
@@ -619,14 +801,14 @@ mod tests {
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, true), (2, true)]);
assert_eq!(rows, vec![(1, false), (2, false)]);
let wanted: i64 = sqlx::query_scalar(
"SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(wanted, 3);
assert_eq!(wanted, 0);
// #120 backfill: the series was added before it had an id on file.
let tvdb_id: Option<i64> =
sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728")
@@ -643,6 +825,50 @@ mod tests {
assert_eq!(stamped, 1);
}
/// #160. Once the series has been refreshed before, a season TMDB reveals
/// that the library has never seen genuinely appeared after add time, so
/// it is tracked and its episodes arrive wanted.
#[tokio::test]
async fn a_later_refresh_tracks_a_genuinely_new_season() {
let (_dir, database) = seeded_series(true).await;
let server = MockServer::start().await;
mount_with(&server, "Returning Series", &[1]).await;
action(&server).tick(&database).await.unwrap();
let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(tracked, 0, "the seeding refresh tracks nothing");
expire_refresh(&database).await;
server.reset().await;
mount(
&server,
"Returning Series",
season_one_body(&two_episodes()),
)
.await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
let rows: Vec<(i64, bool)> =
sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, false), (2, true)]);
let wanted: Vec<i64> = sqlx::query_scalar(
"SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id
WHERE s.number = 2 AND e.wanted = 1",
)
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(wanted, vec![1]);
}
/// `auto_track` is a rule about reveals, not intent: without it nothing
/// arrives wanted, even though everything is revealed.
#[tokio::test]
@@ -704,13 +930,13 @@ mod tests {
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(rows, vec![(1, true), (2, true)]);
assert_eq!(rows, vec![(1, false), (2, false)]);
let counts: (i64, i64) =
sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(counts, (3, 3));
assert_eq!(counts, (3, 0));
}
/// Episodes revealed into an existing season follow that season's rule:
@@ -976,4 +1202,201 @@ mod tests {
.unwrap();
assert!(!vanished, "the conflict is over once TMDB lists it again");
}
/// #153. TMDB has not named an unaired episode yet, so the refresh stores
/// the placeholder instead of an empty string — and #121's guarded update
/// swaps it for the real title once TMDB fills it in.
#[tokio::test]
async fn an_unnamed_episode_stores_the_placeholder_until_tmdb_names_it() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb(
"Returning Series",
season_one_body(&json!([
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "", "air_date": null}
])),
)
.await;
action(&server).tick(&database).await.unwrap();
let unnamed = season_one_episode(&database, 2).await;
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
.bind(unnamed)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(title, UNTITLED_EPISODE);
expire_refresh(&database).await;
server.reset().await;
mount(
&server,
"Returning Series",
season_one_body(&json!([
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}
])),
)
.await;
action(&server).tick(&database).await.unwrap();
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
.bind(unnamed)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(title, "Hospital");
}
async fn season_two_episode(database: &Db) -> i64 {
sqlx::query_scalar(
"SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id
WHERE s.number = 2 AND e.number = 1",
)
.fetch_one(database.pool())
.await
.unwrap()
}
/// #137. TMDB dropped season 2 entirely: with no file under any of its
/// episodes the season is deleted, and its episodes and their stored
/// releases cascade with it.
#[tokio::test]
async fn a_vanished_season_without_files_is_deleted() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
action(&server).tick(&database).await.unwrap();
let episode = season_two_episode(&database).await;
attach_release(&database, episode).await;
expire_refresh(&database).await;
server.reset().await;
mount_with(&server, "Returning Series", &[1]).await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert_eq!(
outcomes.len(),
1,
"a season removal is work worth reporting"
);
let seasons: Vec<i64> = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(seasons, vec![1]);
let episodes: i64 = sqlx::query_scalar(
"SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(episodes, 0, "the vanished season's episodes cascade");
let releases: i64 =
sqlx::query_scalar("SELECT count(*) FROM episode_releases WHERE episode_id = ?")
.bind(episode)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(releases, 0);
}
/// #137. With a file anywhere under it the vanished season is never
/// deleted: dropping the rows would orphan a real file. The season is
/// flagged — the same conflict marker an episode gets (#122) — and its
/// episodes and file stay put.
#[tokio::test]
async fn a_vanished_season_with_a_file_is_flagged_not_deleted() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
action(&server).tick(&database).await.unwrap();
let episode = season_two_episode(&database).await;
attach_file(&database, episode).await;
expire_refresh(&database).await;
server.reset().await;
mount_with(&server, "Returning Series", &[1]).await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
let (vanished, episodes, files): (i64, i64, i64) = sqlx::query_as(
"SELECT s.vanished,
(SELECT count(*) FROM episodes e WHERE e.season_id = s.id),
(SELECT count(*) FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = ?)
FROM seasons s WHERE s.number = 2",
)
.bind(episode)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(
(vanished, episodes, files),
(1, 1, 1),
"flagged as a conflict, episodes and file intact"
);
}
/// Idempotence at season level too: a season TMDB restores clears the
/// flag again, the way a restored episode number does (#122).
#[tokio::test]
async fn a_restored_season_clears_the_vanished_flag() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
action(&server).tick(&database).await.unwrap();
let episode = season_two_episode(&database).await;
attach_file(&database, episode).await;
expire_refresh(&database).await;
server.reset().await;
mount_with(&server, "Returning Series", &[1]).await;
action(&server).tick(&database).await.unwrap();
expire_refresh(&database).await;
// TMDB puts the season back where it was.
server.reset().await;
mount(
&server,
"Returning Series",
season_one_body(&two_episodes()),
)
.await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
let vanished: bool = sqlx::query_scalar("SELECT vanished FROM seasons WHERE number = 2")
.fetch_one(database.pool())
.await
.unwrap();
assert!(!vanished, "the conflict is over once TMDB lists it again");
}
/// §4.2 keeps specials out of derived status, so nothing downstream can
/// notice their absence — and TMDB drops and re-adds season 0 routinely.
/// A vanished season 0 is therefore left alone either way.
#[tokio::test]
async fn a_vanished_season_zero_is_left_alone() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb("Returning Series", season_one_body(&two_episodes())).await;
action(&server).tick(&database).await.unwrap();
sqlx::query("INSERT INTO seasons (series_id, number) SELECT id, 0 FROM series")
.execute(database.pool())
.await
.unwrap();
expire_refresh(&database).await;
server.reset().await;
mount_with(&server, "Returning Series", &[1, 2]).await;
let outcomes = action(&server).tick(&database).await.unwrap();
assert!(
outcomes.is_empty(),
"churning specials is not work to report"
);
let seasons: Vec<i64> = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(seasons, vec![0, 1, 2]);
}
}
@@ -0,0 +1,6 @@
-- #137. TMDB deletes whole seasons as well as episodes (#122). A season that
-- vanished upstream with a file anywhere under it cannot be deleted — the
-- file row would orphan — so it is flagged instead, the same conflict marker
-- episodes carry. Seasons without any file cascade away outright.
ALTER TABLE seasons ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
CHECK (vanished IN (0, 1));
@@ -0,0 +1,11 @@
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
-- search haystack, §7.4 filenames and the compat shim as if they were real
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
-- boundary now substitutes — #121's guarded update replaces it once TMDB
-- fills the title in.
--
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
-- a transaction, where that pragma is a no-op); enforcement lives in code.
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
@@ -0,0 +1,11 @@
-- §9.6: the three fields rich detail keeps out of the database are the ones
-- pure-SQL views need — the in-library half of unified search (§9.2) and the
-- library grid would otherwise cost one TMDB call per row. Nullable, no
-- default: a title added before its first metadata refresh has no artwork
-- yet, and TMDB itself has entries with no poster.
ALTER TABLE movies ADD COLUMN poster_path TEXT;
ALTER TABLE movies ADD COLUMN backdrop_path TEXT;
ALTER TABLE movies ADD COLUMN vote_average REAL;
ALTER TABLE series ADD COLUMN poster_path TEXT;
ALTER TABLE series ADD COLUMN backdrop_path TEXT;
ALTER TABLE series ADD COLUMN vote_average REAL;
+32
View File
@@ -228,6 +228,38 @@ mod tests {
}
}
/// §9.6: the stored artwork columns are nullable, and `REAL` is a valid
/// STRICT type — a rating must not be squeezed into an integer.
#[tokio::test]
async fn title_artwork_columns_are_nullable_and_take_reals() {
let (_dir, db) = fresh().await;
sqlx::query(
"INSERT INTO movies (tmdb_id, title, root_id)
SELECT 1, 'No Artwork Yet', id FROM roots WHERE kind = 'movie' LIMIT 1",
)
.execute(db.pool())
.await
.expect("row without artwork");
sqlx::query(
"UPDATE movies SET poster_path = '/dune.jpg', backdrop_path = '/dune-wide.jpg',
vote_average = 8.152
WHERE tmdb_id = 1",
)
.execute(db.pool())
.await
.expect("artwork write");
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(db.pool())
.await
.expect("movie row");
assert_eq!(poster.as_deref(), Some("/dune.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-wide.jpg"));
assert_eq!(vote, Some(8.152));
}
#[tokio::test]
async fn foreign_keys_are_enforced() {
let (_dir, db) = fresh().await;
+63 -2
View File
@@ -9,8 +9,10 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
SeriesSearchResult, Video,
};
/// TMDB's v3 API root.
@@ -163,6 +165,65 @@ impl TmdbClient {
Ok(raw.into())
}
/// Rich detail for one movie's §9.6 page.
///
/// One HTTP call: credits, videos and external ids come back appended to
/// the same response. Served through the same cache as [`Self::movie`];
/// nothing here is persisted (§9.6).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
let path = format!("movie/{tmdb_id}");
let params = [(
"append_to_response",
"credits,videos,external_ids".to_owned(),
)];
let raw: RawMovieDetail = self.get_json(&path, &params).await?;
Ok(raw.into())
}
/// Rich detail for one series' §9.6 page.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_detail(&self, tmdb_id: u32) -> Result<SeriesDetail> {
let path = format!("tv/{tmdb_id}");
let params = [(
"append_to_response",
"credits,videos,external_ids".to_owned(),
)];
let raw: RawSeriesDetail = self.get_json(&path, &params).await?;
Ok(raw.into())
}
/// The chosen trailer for a movie, fetching only the videos list.
///
/// #144's search-row chip resolves through this, where a full detail
/// response would be waste (§9.6).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn movie_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
let path = format!("movie/{tmdb_id}/videos");
let raw: RawVideoList = self.get_json(&path, &[]).await?;
Ok(select_trailer(&raw.into_videos()))
}
/// The chosen trailer for a series, fetching only the videos list.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
let path = format!("tv/{tmdb_id}/videos");
let raw: RawVideoList = self.get_json(&path, &[]).await?;
Ok(select_trailer(&raw.into_videos()))
}
/// External ids for one TV series, of which the TVDB id is the one this
/// project needs (§6.1).
///
+2 -1
View File
@@ -24,5 +24,6 @@ mod model;
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
pub use error::{Error, Result};
pub use model::{
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
};
+397 -6
View File
@@ -12,7 +12,9 @@ use serde::{Deserialize, Serialize};
const RELEASE_TYPE_DIGITAL: u8 = 4;
/// One row of a TMDB search.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
///
/// `vote_average` is the one float, so this is `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MovieSearchResult {
/// TMDB's own id, the key everything else hangs off.
pub tmdb_id: u32,
@@ -30,6 +32,11 @@ pub struct MovieSearchResult {
pub overview: Option<String>,
/// Path fragment, not a URL. TMDB's image base is a separate concern.
pub poster_path: Option<String>,
/// TMDB's rating, out of 10, when TMDB has votes for it; zero normalised
/// away like [`Movie::vote_average`].
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
}
impl MovieSearchResult {
@@ -41,7 +48,9 @@ impl MovieSearchResult {
}
/// A movie as TMDB describes it, reduced to what this project uses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
///
/// `vote_average` is the one float, so this is `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Movie {
/// TMDB's own id.
pub tmdb_id: u32,
@@ -77,6 +86,15 @@ pub struct Movie {
pub overview: Option<String>,
/// Path fragment, not a URL.
pub poster_path: Option<String>,
/// Path fragment, not a URL. §9.6 stores this on the row alongside
/// [`Movie::poster_path`] and `vote_average`.
pub backdrop_path: Option<String>,
/// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
/// artwork (§9.6). TMDB sends `0` where "no rating yet" is meant, so a
/// zero rating is normalised away here rather than stored as one.
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
}
impl Movie {
@@ -97,7 +115,7 @@ impl Movie {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeriesSearchResult {
pub tmdb_id: u32,
pub title: String,
@@ -105,6 +123,10 @@ pub struct SeriesSearchResult {
pub first_air_date: Option<NaiveDate>,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// Zero normalised away, like [`Movie::vote_average`].
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
}
impl SeriesSearchResult {
@@ -114,7 +136,9 @@ impl SeriesSearchResult {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// TMDB's slice for series. `vote_average` is the one float, so this is
/// `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Series {
pub tmdb_id: u32,
pub tvdb_id: Option<u32>,
@@ -124,6 +148,14 @@ pub struct Series {
pub status: String,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// §9.6 stores this on the row alongside `poster_path` and
/// [`Series::vote_average`].
pub backdrop_path: Option<String>,
/// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
/// artwork (§9.6); zero is normalised away like [`Movie::vote_average`].
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
pub seasons: Vec<SeasonSummary>,
}
@@ -140,6 +172,10 @@ pub struct SeasonSummary {
pub episode_count: u32,
}
/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces
/// it once TMDB fills the title in.
pub const UNTITLED_EPISODE: &str = "TBA";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Season {
pub number: u32,
@@ -153,6 +189,113 @@ pub struct Episode {
pub air_date: Option<NaiveDate>,
}
/// Cast is truncated to the top 10 billed, in the crate, so no caller has to
/// remember to (§9.6).
const CAST_LIMIT: usize = 10;
/// A genre as a detail response carries it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Genre {
pub id: u32,
pub name: String,
}
/// One of the top-billed cast members on a detail page. `profile_path` is a
/// path fragment — §9.6 hotlinks images and the browser composes the URL.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CastMember {
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
pub tmdb_id: u32,
pub name: String,
pub character: String,
pub profile_path: Option<String>,
pub order: u32,
}
/// One entry of a title's video list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Video {
pub key: String,
/// `YouTube`, `Vimeo`, … Only `YouTube` ever becomes a trailer (§9.6).
pub site: String,
/// TMDB calls this field `type`: `Trailer`, `Teaser`, `Clip`, …
pub kind: String,
pub name: String,
pub official: bool,
}
/// Rich movie detail for the §9.6 page. One upstream request via
/// `append_to_response`, served through the same cache as everything else;
/// nothing here is persisted.
///
/// No float fields are involved in equality except `vote_average`, so this is
/// `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MovieDetail {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<Genre>,
pub backdrop_path: Option<String>,
pub poster_path: Option<String>,
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
/// where "no votes yet" is meant, and a detail page must not show it as
/// a rating.
pub vote_average: Option<f64>,
pub vote_count: u32,
pub homepage: Option<String>,
pub status: String,
pub runtime: Option<u32>,
/// §9.6 links out to `IMDb` for movies.
pub imdb_id: Option<String>,
/// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order.
pub cast: Vec<CastMember>,
/// The one trailer worth showing, chosen by the §9.6 rule.
pub trailer: Option<Video>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeriesDetail {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<Genre>,
pub backdrop_path: Option<String>,
pub poster_path: Option<String>,
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
/// where "no votes yet" is meant, and a detail page must not show it as
/// a rating.
pub vote_average: Option<f64>,
pub vote_count: u32,
pub homepage: Option<String>,
pub status: String,
/// Episode length in minutes; TMDB sends a list per episode, this takes
/// the first.
pub episode_runtime: Option<u32>,
/// §9.6 links out to TVDB for series.
pub tvdb_id: Option<u32>,
pub cast: Vec<CastMember>,
pub trailer: Option<Video>,
}
/// The trailer rule from §9.6, shared by the detail calls and the videos-only
/// calls #144 reads. Preference order: an official `YouTube` trailer, then any
/// `YouTube` trailer, then any `YouTube` teaser, then nothing. Within a tier
/// the first match wins, which keeps the result deterministic for a given
/// response.
#[must_use]
pub(crate) fn select_trailer(videos: &[Video]) -> Option<Video> {
let pick = |want: &dyn Fn(&Video) -> bool| {
videos
.iter()
.find(|video| video.site == "YouTube" && want(video))
.cloned()
};
pick(&|video| video.kind == "Trailer" && video.official)
.or_else(|| pick(&|video| video.kind == "Trailer"))
.or_else(|| pick(&|video| video.kind == "Teaser"))
}
// --- TMDB wire types -------------------------------------------------------
//
// Private on purpose. TMDB's field names stop here.
@@ -182,6 +325,10 @@ pub(crate) struct RawSeriesSearchResult {
overview: Option<String>,
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
}
impl From<RawSeriesSearchResult> for SeriesSearchResult {
@@ -193,6 +340,8 @@ impl From<RawSeriesSearchResult> for SeriesSearchResult {
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
}
}
}
@@ -213,6 +362,12 @@ pub(crate) struct RawSeries {
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
#[serde(default)]
seasons: Vec<RawSeasonSummary>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
@@ -248,6 +403,9 @@ impl From<RawSeries> for Series {
status: raw.status,
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
seasons: raw
.seasons
.into_iter()
@@ -285,7 +443,13 @@ impl From<RawSeason> for Season {
.into_iter()
.map(|episode| Episode {
number: episode.episode_number,
title: episode.name,
// TMDB leaves an unaired episode's name empty; a placeholder
// keeps "" out of search, filenames and the compat shim.
title: if episode.name.is_empty() {
UNTITLED_EPISODE.to_owned()
} else {
episode.name
},
air_date: episode.air_date.as_deref().and_then(parse_date),
})
.collect(),
@@ -296,7 +460,7 @@ impl From<RawSeason> for Season {
/// What one `IMDb` id resolved to. An id names one title, so at most one of
/// the two lists is non-empty — but TMDB answers both kinds in the same
/// response, and both are surfaced rather than filtered here.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FindResults {
pub movies: Vec<MovieSearchResult>,
pub series: Vec<SeriesSearchResult>,
@@ -325,6 +489,10 @@ pub(crate) struct RawSearchResult {
overview: Option<String>,
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
}
impl From<RawSearchResult> for MovieSearchResult {
@@ -337,6 +505,8 @@ impl From<RawSearchResult> for MovieSearchResult {
release_date: raw.release_date.as_deref().and_then(parse_date),
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
}
}
}
@@ -367,6 +537,12 @@ pub(crate) struct RawMovie {
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
#[serde(default)]
release_dates: Option<RawReleaseDates>,
}
@@ -426,6 +602,9 @@ impl From<RawMovie> for Movie {
status: raw.status,
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
}
}
}
@@ -458,3 +637,215 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|text| !text.is_empty())
}
/// TMDB uses `0` where "no votes yet" is meant, so an unrated title never
/// carries a rating rather than carrying zero.
fn rating(value: f64) -> Option<f64> {
(value != 0.0).then_some(value)
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawMovieDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
runtime: Option<u32>,
#[serde(default)]
imdb_id: Option<String>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeriesDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
struct RawGenre {
id: u32,
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCredits {
#[serde(default)]
cast: Vec<RawCastMember>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCastMember {
id: u32,
#[serde(default)]
name: String,
#[serde(default)]
character: String,
#[serde(default)]
profile_path: Option<String>,
order: u32,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawVideoList {
#[serde(default)]
results: Vec<RawVideo>,
}
impl RawVideoList {
pub(crate) fn into_videos(self) -> Vec<Video> {
self.results.into_iter().map(Into::into).collect()
}
}
#[derive(Debug, Deserialize)]
struct RawVideo {
#[serde(default)]
key: String,
#[serde(default)]
site: String,
#[serde(rename = "type", default)]
kind: String,
#[serde(default)]
name: String,
official: bool,
}
impl From<RawVideo> for Video {
fn from(raw: RawVideo) -> Self {
Self {
key: raw.key,
site: raw.site,
kind: raw.kind,
name: raw.name,
official: raw.official,
}
}
}
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
let mut cast: Vec<CastMember> = raw
.map(|credits| {
credits
.cast
.into_iter()
.map(|member| CastMember {
tmdb_id: member.id,
name: member.name,
character: member.character,
profile_path: non_empty(member.profile_path),
order: member.order,
})
.collect()
})
.unwrap_or_default();
// TMDB's own ordering is by `order`; sorting makes the truncation hold
// even if a fixture or future API revision sends them shuffled.
cast.sort_by_key(|member| member.order);
cast.truncate(CAST_LIMIT);
cast
}
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
let videos: Vec<Video> = raw
.map(|list| list.results.into_iter().map(Into::into).collect())
.unwrap_or_default();
select_trailer(&videos)
}
impl From<RawMovieDetail> for MovieDetail {
fn from(raw: RawMovieDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
runtime: raw.runtime,
imdb_id: non_empty(raw.imdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
impl From<RawSeriesDetail> for SeriesDetail {
fn from(raw: RawSeriesDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
episode_runtime: raw.episode_run_time.into_iter().next(),
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"id": 693134,
"imdb_id": "tt15239678",
"title": "Dune: Part Two",
"original_title": "Dune: Part Two",
"tagline": "Long live the fighters.",
"overview": "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.",
"status": "Released",
"runtime": 167,
"homepage": "https://www.dunemovie.com",
"vote_average": 8.152,
"vote_count": 6249,
"poster_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"genres": [
{ "id": 878, "name": "Science Fiction" },
{ "id": 12, "name": "Adventure" }
],
"credits": {
"cast": [
{ "id": 5530, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/x3UkVAsKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 0 },
{ "id": 3559977, "name": "Zendaya", "character": "Chani", "profile_path": "/xaWu0DjKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 1 },
{ "id": 37614, "name": "Rebecca Ferguson", "character": "Lady Jessica", "profile_path": "/fPM5vM7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 2 },
{ "id": 1110844, "name": "Austin Butler", "character": "Feyd-Rautha Harkonnen", "profile_path": null, "order": 3 },
{ "id": 22451, "name": "Josh Brolin", "character": "Gurney Halleck", "profile_path": "/qR11vJKKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 4 },
{ "id": 593015, "name": "Florence Pugh", "character": "Princess Irulan", "profile_path": "/mP55vB7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 5 },
{ "id": 1244949, "name": "Dave Bautista", "character": "Glossu Rabban Harkonnen", "profile_path": null, "order": 6 },
{ "id": 11220, "name": "Christopher Walken", "character": "Emperor Shaddam IV", "profile_path": "/wP55vC7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 7 },
{ "id": 38673, "name": "Léa Seydoux", "character": "Lady Margot Fenring", "profile_path": null, "order": 8 },
{ "id": 2880644, "name": "Souheila Yacoub", "character": "Shishakli", "profile_path": "/nP55vD7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 9 },
{ "id": 10952, "name": "Stellan Skarsgård", "character": "Baron Vladimir Harkonnen", "profile_path": null, "order": 10 },
{ "id": 33940, "name": "Charlotte Rampling", "character": "Reverend Mother Gaius Helen Mohiam", "profile_path": null, "order": 11 }
]
},
"videos": {
"results": [
{ "key": "n9xhJrPXop4", "site": "YouTube", "type": "Teaser", "name": "Official Teaser", "official": true },
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Dune Part Two Fan Trailer", "official": false },
{ "key": "clip_vimeo_id", "site": "Vimeo", "type": "Trailer", "name": "Trailer (Vimeo)", "official": true },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
},
"external_ids": {
"imdb_id": "tt15239678"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": 693134,
"results": [
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"tagline": "",
"overview": "The slice-of-life adventures of an Australian cattle dog called Bluey and her family.",
"status": "Returning Series",
"homepage": "https://www.blueytv.com",
"vote_average": 8.417,
"vote_count": 118,
"poster_path": "/58Pm1HTKHefFBCPVAVXOI0cDdIg.jpg",
"backdrop_path": "/9K4mLtNKhEfFBCPVAVXOI0cDdIg.jpg",
"first_air_date": "2018-10-01",
"episode_run_time": [7],
"genres": [
{ "id": 16, "name": "Animation" },
{ "id": 10751, "name": "Family" },
{ "id": 10759, "name": "Action & Adventure" }
],
"credits": {
"cast": [
{ "id": 2134777, "name": "Melanie Zanetti", "character": "Chilli Heeler (voice)", "profile_path": "/zAnetti.jpg", "order": 1 },
{ "id": 1760828, "name": "David McCormack", "character": "Bandit Heeler (voice)", "profile_path": "/dMcCormack.jpg", "order": 0 }
]
},
"videos": {
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
},
"external_ids": {
"imdb_id": "tt7614372",
"tvdb_id": 361391
}
}
@@ -0,0 +1,7 @@
{
"id": 82728,
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
}
+281 -1
View File
@@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
use std::time::Duration;
use arr_meta::{Error, TmdbClient};
use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE};
use chrono::NaiveDate;
use wiremock::matchers::{header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -20,6 +20,10 @@ const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json");
const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json");
const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json");
const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json");
fn client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("test-key")
@@ -213,6 +217,9 @@ async fn unreleased_movie_has_no_dates_and_no_imdb_id() {
assert_eq!(movie.imdb_id, None);
assert_eq!(movie.runtime, None);
assert!(movie.origin_countries.is_empty());
// TMDB reports vote_average: 0 for an unrated title — absence, not zero
// (#156), so it is normalised away like any other zero-valued null.
assert_eq!(movie.vote_average, None);
assert!(!movie.is_digitally_released(date(2026, 8, 22)));
}
@@ -464,6 +471,33 @@ async fn debug_output_does_not_leak_the_api_key() {
assert!(rendered.contains("redacted"), "{rendered}");
}
/// TMDB leaves an unaired episode's name as `""`. It must not reach the
/// library as an empty string — search haystacks, §7.4 filenames and the
/// compat shim all treat it as real text (#153) — so it becomes a placeholder
/// that a later refresh replaces once TMDB names it.
#[tokio::test]
async fn season_episode_without_a_name_gets_the_placeholder_title() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/season/1"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"season_number": 1, "episodes": [
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "", "air_date": null}
]}"#,
))
.mount(&server)
.await;
let season = client(&server)
.season(82_728, 1)
.await
.expect("lookup succeeds");
assert_eq!(season.episodes[0].title, "Magic Xylophone");
assert_eq!(season.episodes[1].title, UNTITLED_EPISODE);
}
/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
/// where TMDB keeps the mapping.
#[tokio::test]
@@ -529,3 +563,249 @@ async fn series_without_a_tvdb_id_maps_to_none() {
assert_eq!(ids.tvdb_id, None);
}
// --- detail (#143, §9.6) ----------------------------------------------------
/// A whole detail page costs one upstream request: credits, videos and
/// external ids ride along in the same response.
#[tokio::test]
async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
}
#[tokio::test]
async fn movie_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.tmdb_id, 693_134);
assert_eq!(
movie.overview.as_deref(),
Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.")
);
assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters."));
assert_eq!(movie.genres.len(), 2);
assert_eq!(movie.genres[0].id, 878);
assert_eq!(movie.genres[0].name, "Science Fiction");
assert!(movie.poster_path.is_some());
assert!(movie.backdrop_path.is_some());
assert!((movie.vote_average.expect("rated") - 8.152).abs() < f64::EPSILON);
assert_eq!(movie.vote_count, 6_249);
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
assert_eq!(movie.status, "Released");
assert_eq!(movie.runtime, Some(167));
assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678"));
}
/// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to
/// remember to. The fixture carries 12 entries; only the first 10 by `order`
/// survive.
#[tokio::test]
async fn movie_detail_cast_is_truncated_to_ten_by_order() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.cast.len(), 10);
assert_eq!(movie.cast[0].tmdb_id, 5_530);
assert_eq!(movie.cast[0].name, "Timothée Chalamet");
assert_eq!(movie.cast[0].character, "Paul Atreides");
assert_eq!(movie.cast[0].order, 0);
// Truncation keeps the lowest `order` values, not the first rows sent.
assert_eq!(movie.cast[9].name, "Souheila Yacoub");
assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952));
}
/// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a
/// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before
/// the official one; neither wins.
#[tokio::test]
async fn movie_detail_picks_the_official_youtube_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
let trailer = movie.trailer.expect("a trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
assert_eq!(trailer.site, "YouTube");
assert_eq!(trailer.kind, "Trailer");
assert!(trailer.official);
}
#[tokio::test]
async fn series_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let series = client(&server)
.series_detail(82_728)
.await
.expect("lookup succeeds");
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.status, "Returning Series");
assert_eq!(series.episode_runtime, Some(7));
assert_eq!(series.tvdb_id, Some(361_391));
assert!(series.tagline.is_none());
assert_eq!(series.genres[0].name, "Animation");
// Cast comes out in TMDB's `order`, not in the order the rows arrived.
assert_eq!(series.cast[0].name, "David McCormack");
// No trailer exists for this series, so the teaser tier is what fires.
let trailer = series.trailer.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Detail goes through the same cache as everything else — two calls, one
/// request.
#[tokio::test]
async fn movie_and_series_detail_are_served_from_the_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let tmdb = client(&server);
let _ = tmdb.movie_detail(693_134).await.expect("succeeds");
let _ = tmdb.movie_detail(693_134).await.expect("cached");
let _ = tmdb.series_detail(82_728).await.expect("succeeds");
let _ = tmdb.series_detail(82_728).await.expect("cached");
}
/// #144's search-row chip resolves through the videos-only call: one request,
/// no full detail response.
#[tokio::test]
async fn movie_videos_fetches_only_the_videos_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(693_134)
.await
.expect("lookup succeeds")
.expect("an official trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
}
#[tokio::test]
async fn series_videos_applies_the_same_selection_rule() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.series_videos(82_728)
.await
.expect("lookup succeeds")
.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none.
#[tokio::test]
async fn vimeo_only_video_lists_yield_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"id": 1, "results": [
{"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true}
]}"#,
),
)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}
#[tokio::test]
async fn empty_video_list_yields_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#))
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}
+165 -20
View File
@@ -129,6 +129,10 @@
<header class="deck-head library-bar">
<h2 class="deck-label">library</h2>
<span class="deck-count readout" id="library-summary"></span>
<div class="view-toggle">
<button type="button" class="view-btn" id="library-view-grid" aria-pressed="true">grid</button>
<button type="button" class="view-btn" id="library-view-list" aria-pressed="false">list</button>
</div>
<button
type="button"
class="bucket-toggle readout"
@@ -171,7 +175,8 @@
</header>
<p class="queue-note readout dim">
kids titles with no qualifying pt release — parked here, sometimes for months, by
design. allow english writes this title's override and the normal search proceeds.
design. allow english writes a movie's override and its search proceeds; series wait
for the detail view.
</p>
<ul class="deck-rows" id="rows-no-pt"></ul>
</section>
@@ -182,32 +187,110 @@
<span class="deck-count readout" id="count-decision"></span>
</header>
<p class="queue-note readout dim">
hard-failed twice on different releases — open the releases and decide by hand.
hard-failed twice on different releases — open the movie's page and decide by hand;
series entries are display-only for now.
</p>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
</main>
<main class="deck releases" id="releases" hidden>
<!--
MOVIE PAGE (§9.6, issue 149): a library row opens /movies/{id} — one
machined module carries what the film IS: backdrop hotlinked from TMDB
under a scrim that sinks it into the console charcoal, poster framed at
left, Rajdhani title with year, TMDB rating and vote count, runtime and
genres, the tagline quoted, overview below. TRAILER resolves from the
metadata response's own key (no second call); TMDB, IMDb and Rotten
Tomatoes sit beside it — RT is a search URL built here, never a resolved
page. LIBRARY controls follow on their own row: wanted, blocked, root,
then search indexers and remove. CAST is the top 10 billed, each photo
linking out to that person's TMDB page. ON DISK lists imported files
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
§9.3 deck closes the page unchanged — buckets, chips, score heat and
rejection rules untouched; /movies/{id}/releases lands here with the
deck section in view.
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="releases-title"></h2>
<span class="releases-year readout dim" id="releases-year"></span>
</div>
<button type="button" class="control" id="releases-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
id="releases-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove
</button>
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<div class="remove-panel" id="remove-panel" hidden></div>
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="movie-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="movie-title"></h2>
<span class="movie-year readout dim" id="movie-year"></span>
</div>
<span class="row-chips movie-chips" id="movie-chips"></span>
<p class="movie-rating readout" id="movie-rating" hidden></p>
<p class="movie-meta readout dim" id="movie-meta" hidden></p>
<p class="movie-tagline" id="movie-tagline" hidden></p>
<p class="movie-overview" id="movie-overview" hidden></p>
<div class="movie-actions" id="movie-actions"></div>
</div>
</div>
</section>
<section class="deck-group" id="movie-controls" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<button type="button" class="control control-quiet" id="movie-wanted" aria-pressed="false">
wanted
</button>
<button
type="button"
class="control control-quiet"
id="movie-blocked"
aria-pressed="false"
>
blocked
</button>
<label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span>
<select class="form-input movie-root" id="movie-root" aria-labelledby="movie-root-label">
</select>
</label>
<span class="movie-controls-space"></span>
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove
</button>
</div>
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-cast"></ul>
</section>
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
<header class="deck-head">
<h3 class="deck-label" id="label-disk">on disk</h3>
<span class="deck-count readout" id="count-disk"></span>
</header>
<ul class="deck-rows" id="rows-disk"></ul>
</section>
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
<header class="deck-head">
<h3 class="deck-label" id="label-releases">releases</h3>
</header>
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
<header class="deck-head">
@@ -275,6 +358,7 @@
<ul class="deck-rows" id="rows-rejected"></ul>
</div>
</section>
</section>
</main>
<main class="deck" id="settings" hidden aria-label="settings">
@@ -306,6 +390,67 @@
</section>
</main>
<!--
SERIES DETAIL (§4.1 + §4.2 + §9.3, issue 129): a series row anywhere
opens /series/{id} — the title line carries the audience chip, the
derived status and the wanted/on-disk count (season 0 excluded, per
§4.2). Seasons are collapsible groups: engraved name, the tracked
toggle (the one way an operator wants a season — no bulk backfill,
§4.1), counts, search and deck. Episodes read as tag, title, air date,
state, then the imported file's probed attribute chips — ffprobe truth,
not name claims — with want/search/deck only where they can do
something: an unaired episode offers no search button at all.
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="series-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="series-title"></h2>
<span class="movie-year readout dim" id="series-year"></span>
</div>
<!-- audience, episode counts and §4.2 status: operational state,
outranking the artwork, folded into this one header (issue 150) -->
<span class="row-chips movie-chips" id="series-chips"></span>
<p class="movie-rating readout" id="series-rating" hidden></p>
<p class="movie-meta readout dim" id="series-meta" hidden></p>
<p class="movie-tagline" id="series-tagline" hidden></p>
<p class="movie-overview" id="series-overview" hidden></p>
<div class="movie-actions" id="series-actions"></div>
</div>
</div>
</section>
<section class="deck-group" id="series-cast" hidden aria-labelledby="label-series-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-series-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-series-cast"></ul>
</section>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
<main class="deck releases" id="tv-releases" hidden aria-label="season and episode releases">
<header class="releases-head">
<button type="button" class="control" id="tv-releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="tv-releases-title"></h2>
<span class="releases-year readout dim" id="tv-releases-sub"></span>
</div>
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<div id="tv-buckets"></div>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
+7 -1
View File
@@ -99,7 +99,13 @@ function checkCss(source, rel) {
}
function checkPlain(source, rel) {
source.split("\n").forEach((rawLine, index) => {
// Comments are prose, not styling — `checkCss` already ignores them, and
// without the same treatment here an issue reference like `#140` reads as a
// three-digit hex colour and fails the gate.
const stripped = source
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "))
.replace(/\/\/.*$/gm, "");
stripped.split("\n").forEach((rawLine, index) => {
const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token(");
if (COLOR_LITERAL.test(neutral)) {
errors.push(`${rel}:${index + 1}: literal colour outside the token block`);
+4
View File
@@ -17,6 +17,10 @@ export interface LibrarySeries {
auto_track: boolean;
upstream_ended: boolean;
blocked: boolean;
/** Stored artwork (§9.6) — a grid tile renders without a TMDB call. */
poster_path: string | null;
/** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */
vote_average: number | null;
status: SeriesStatus;
wanted_episodes: number;
available_episodes: number;
+2172 -273
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
// Hand-written mirror of arr-api's /api/movies/{id}/metadata and PATCH
// /api/movies/{id} schemas — same reasoning as search.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { type ActionOutcome, errorDetail } from "./releases";
/** One of the top-billed cast members (§9.6). */
export interface MetadataCastMember {
tmdb_id: number;
name: string;
character: string;
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
profile_path: string | null;
}
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
export interface MetadataTrailer {
youtube_key: string;
name: string;
}
/** Rich detail for one library movie's §9.6 page (#146). */
export interface MovieMetadata {
tmdb_id: number;
overview: string | null;
tagline: string | null;
genres: string[];
/** Minutes. */
runtime: number | null;
status: string;
poster_path: string | null;
backdrop_path: string | null;
/** `null` when TMDB has no votes for the title (#156). */
vote_average: number | null;
vote_count: number;
homepage: string | null;
imdb_id: string | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
export type MetadataOutcome =
| { kind: "metadata"; metadata: MovieMetadata }
| { kind: "error"; detail: string };
/** The click-resolved trailer behind a search row's chip (#148). */
export type TrailerOutcome =
| { kind: "trailer"; youtubeKey: string }
| { kind: "none" }
| { kind: "error"; detail: string };
/**
* `GET /api/trailer` (#144), fired from a chip click only (§9.6) — a page
* of results costs zero extra calls. A 404 is TMDB simply having no video:
* an ordinary outcome, never an error.
*/
export async function resolveTrailer(
kind: "movie" | "tv",
tmdbId: number,
): Promise<TrailerOutcome> {
try {
const response = await fetch(`/api/trailer?kind=${kind}&tmdb_id=${tmdbId}`);
if (response.status === 404) {
return { kind: "none" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
const body = (await response.json()) as MetadataTrailer;
return { kind: "trailer", youtubeKey: body.youtube_key };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One request for everything above the release deck (§9.6). */
export async function movieMetadata(movieId: number): Promise<MetadataOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/metadata`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "metadata", metadata: (await response.json()) as MovieMetadata };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The per-title library controls (#149): wanted, blocked and root all ride
* PATCH /api/movies/{id}. Partial bodies only — overrides stay untouched.
*/
export async function updateMovie(
movieId: number,
patch: { wanted?: boolean; blocked?: boolean; root_id?: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- §9.6 link composition -------------------------------------------- */
/** TMDB image URL from a path fragment — the browser picks the size. */
export function tmdbImage(path: string | null, size: string): string | null {
return path === null ? null : `https://image.tmdb.org/t/p/${size}${path}`;
}
export function tmdbMovieLink(tmdbId: number): string {
return `https://www.themoviedb.org/movie/${tmdbId}`;
}
export function tmdbSeriesLink(tmdbId: number): string {
return `https://www.themoviedb.org/tv/${tmdbId}`;
}
/** A series has no imdb_id in this app; TVDB is its second id (§9.6). */
export function tvdbLink(tvdbId: number): string {
return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
}
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
export function imdbLink(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
/**
* The Rotten Tomatoes link is a *search* URL built in the browser (§9.6) —
* never a resolved title page, which this service has no id for.
*/
export function rottenTomatoesSearch(title: string, year: number | null): string {
const query = year === null ? title : `${title} ${year}`;
return `https://www.rottentomatoes.com/search?search=${encodeURIComponent(query)}`;
}
export function youtubeLink(key: string): string {
return `https://www.youtube.com/watch?v=${key}`;
}
/** `8.2` with one decimal, as the readout spells a TMDB rating. */
export function formatRating(voteAverage: number): string {
return voteAverage.toFixed(1);
}
/** `4,821` — grouped votes, matching the issue's own mockup. */
export function formatVoteCount(voteCount: number): string {
return voteCount.toLocaleString("en-US");
}
/** `166 min · Science Fiction, Adventure`, skipping what detail left out. */
export function formatMetaLine(runtime: number | null, genres: string[]): string {
const parts: string[] = [];
if (runtime !== null && runtime > 0) {
parts.push(`${runtime} min`);
}
if (genres.length > 0) {
parts.push(genres.join(", "));
}
return parts.join(" · ");
}
/**
* The file's name inside its §7.4 folder — the audit surface the layout
* section promises (`ls` shows what the file is). The folder itself stays
* with the removal confirmation.
*/
export function fileName(path: string): string {
return path.slice(path.lastIndexOf("/") + 1);
}
+22 -8
View File
@@ -11,14 +11,27 @@ export interface AttentionMovie extends LibraryMovie {
last_searched_at: string | null;
}
/** An episode that put its series here, named for SxxEyy rendering. */
export interface QueuedEpisode {
id: number;
season_number: number;
episode_number: number;
}
/** A season whose failed pack put its series here. */
export interface QueuedSeason {
id: number;
number: number;
}
/** A series in a TV attention queue, with what put it there (§9.5). */
export interface SeriesAttention {
series_id: number;
tmdb_id: number;
title: string;
year: number | null;
episodes: number[];
seasons: number[];
episodes: QueuedEpisode[];
seasons: QueuedSeason[];
}
export interface AttentionQueues {
@@ -78,13 +91,14 @@ export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOu
return { kind: "done", searchQueued: true };
}
/** Entries across both queues — the rail badge's number.
*
* Deliberately movies only for now: the TV lanes are new and the QUEUES
* deck does not render them yet (the backend landed in issue 126).
*/
/** Entries across all four lanes — the rail badge's number. */
export function attentionTotal(queues: AttentionQueues): number {
return queues.no_pt_source.length + queues.needs_decision.length;
return (
queues.no_pt_source.length +
queues.needs_decision.length +
queues.tv_no_pt_source.length +
queues.tv_needs_decision.length
);
}
/** `searched 3×`, or null before the first attempt ever lands here. */
+34 -1
View File
@@ -257,6 +257,39 @@ export function bucketOf(release: MovieRelease): Bucket {
return "rejected";
}
/**
* The probed attributes a file row shows as chips (§5.6, §7.4) — ffprobe
* truth, not name claims. Shared by movie and episode file rows; the probe
* JSON arrives untyped from the API, so the shape is narrowed here.
*/
export function probedAttributeTags(probed: unknown): string[] {
if (probed === null || typeof probed !== "object") {
return [];
}
const probe = probed as {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
};
const tags: string[] = [];
if (probe.resolution) {
tags.push(probe.resolution);
}
if (probe.source) {
tags.push(probe.source);
}
if (probe.hdr && probe.hdr !== "SDR") {
tags.push(probe.hdr);
}
for (const track of probe.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
}
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
const SOURCE_LABEL: Record<string, string> = {
@@ -361,7 +394,7 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
async function errorDetail(response: Response): Promise<string> {
export async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
+48 -1
View File
@@ -8,7 +8,11 @@ export type Route =
| { kind: "queues" }
| { kind: "settings" }
| { kind: "search"; query: string }
| { kind: "releases"; movieId: number };
| { kind: "movie"; movieId: number }
| { kind: "releases"; movieId: number }
| { kind: "series"; seriesId: number }
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
| { kind: "episodeReleases"; episodeId: number };
export function parseRoute(url: URL): Route {
const segments = url.pathname.split("/").filter(Boolean);
@@ -25,12 +29,47 @@ export function parseRoute(url: URL): Route {
const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query };
}
if (segments.length === 2 && segments[0] === "movies") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
return { kind: "movie", movieId };
}
}
if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
return { kind: "releases", movieId };
}
}
if (segments.length === 2 && segments[0] === "series") {
const seriesId = Number(segments[1]);
if (Number.isInteger(seriesId) && seriesId > 0) {
return { kind: "series", seriesId };
}
}
if (
segments.length === 5 &&
segments[0] === "series" &&
segments[2] === "seasons" &&
segments[4] === "releases"
) {
const seriesId = Number(segments[1]);
const seasonNumber = Number(segments[3]);
if (
Number.isInteger(seriesId) &&
seriesId > 0 &&
Number.isInteger(seasonNumber) &&
seasonNumber >= 0
) {
return { kind: "seasonReleases", seriesId, seasonNumber };
}
}
if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") {
const episodeId = Number(segments[1]);
if (Number.isInteger(episodeId) && episodeId > 0) {
return { kind: "episodeReleases", episodeId };
}
}
return { kind: "board" };
}
@@ -46,8 +85,16 @@ export function routePath(route: Route): string {
return "/settings";
case "search":
return `/search?q=${encodeURIComponent(route.query)}`;
case "movie":
return `/movies/${route.movieId}`;
case "releases":
return `/movies/${route.movieId}/releases`;
case "series":
return `/series/${route.seriesId}`;
case "seasonReleases":
return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`;
case "episodeReleases":
return `/episodes/${route.episodeId}/releases`;
}
}
+20
View File
@@ -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;
+324
View File
@@ -0,0 +1,324 @@
// Hand-written mirror of arr-api's series detail, season, episode and
// release schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { MetadataCastMember, MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
export interface ApiSeries {
id: number;
tmdb_id: number;
title: string;
year: number | null;
root_id: number;
blocked: boolean;
status: SeriesStatus;
wanted_episodes: number;
available_episodes: number;
}
export interface ApiEpisode {
id: number;
series_id: number;
season_id: number;
season_number: number;
number: number;
title: string;
air_date: string | null;
wanted: boolean;
state: string;
/** issue 122: gone upstream while a file of its own remained. */
vanished: boolean;
}
export interface ApiSeason {
id: number;
number: number;
tracked: boolean;
/** Gone upstream while a file under it remained — a conflict, not a state. */
vanished: boolean;
episodes: ApiEpisode[];
}
export type SeasonsOutcome =
| { kind: "seasons"; seasons: ApiSeason[] }
| { kind: "error"; detail: string };
export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
try {
const response = await fetch(`/api/series/${seriesId}`);
return response.ok ? ((await response.json()) as ApiSeries) : null;
} catch {
return null;
}
}
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
try {
const response = await fetch(`/api/episodes/${episodeId}`);
return response.ok ? ((await response.json()) as ApiEpisode) : null;
} catch {
return null;
}
}
export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "seasons", seasons: (await response.json()) as ApiSeason[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
export interface EpisodeFile {
episode_id: number;
path: string;
size: number;
probed: {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
} | null;
waiver: string | null;
}
export type SeriesFilesOutcome =
| { kind: "files"; files: EpisodeFile[] }
| { kind: "error"; detail: string };
export async function fetchSeriesFiles(seriesId: number): Promise<SeriesFilesOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/files`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "files", files: (await response.json()) as EpisodeFile[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The season's tracked toggle (§4.1): turning it on marks every revealed
* episode wanted; turning it off withdraws nothing.
*/
export async function setSeasonTracked(
seriesId: number,
seasonNumber: number,
tracked: boolean,
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tracked }),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export async function setEpisodeWanted(episodeId: number, wanted: boolean): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/episodes/${episodeId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wanted }),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- manual triggers and decks ---------------------------------------- */
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
export interface TvTarget {
releases: () => Promise<ReleasesOutcome>;
grab: (releaseId: number) => Promise<ActionOutcome>;
search: () => Promise<ActionOutcome>;
}
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
const base = `/api/series/${seriesId}/seasons/${seasonNumber}`;
return {
releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`),
};
}
export function episodeTarget(episodeId: number): TvTarget {
const base = `/api/episodes/${episodeId}`;
return {
releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`),
};
}
async function fetchJson(url: string): Promise<ReleasesOutcome> {
try {
const response = await fetch(url);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "results", releases: (await response.json()) as MovieRelease[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
async function post(url: string): Promise<ActionOutcome> {
try {
const response = await fetch(url, { method: "POST" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The one-click override on a waived TV row (§5.1, §9.3). Overrides sit on
* the series — `episode_policy` and `season_policy` both read them there —
* so the relaxed rule is written once, then the grab proceeds.
*/
export async function waiveAndGrabTv(
seriesId: number,
target: TvTarget,
releaseId: number,
rule: string | null,
): Promise<WaiveOutcome> {
const override = waiverOverride(rule);
let overrideWritten = false;
if (override) {
try {
const current = await fetch(`/api/series/${seriesId}`);
if (!current.ok) {
return { kind: "error", detail: await errorDetail(current), overrideWritten };
}
const series = (await current.json()) as { overrides: Record<string, unknown> };
const patch = await fetch(`/api/series/${seriesId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ overrides: { ...series.overrides, ...override } }),
});
if (!patch.ok) {
return { kind: "error", detail: await errorDetail(patch), overrideWritten };
}
overrideWritten = true;
} catch {
return { kind: "error", detail: "daemon unreachable", overrideWritten };
}
}
const grabbed = await target.grab(releaseId);
if (grabbed.kind === "error") {
return { kind: "error", detail: grabbed.detail, overrideWritten };
}
return { kind: "done", overrideWritten };
}
/* ---- §9.6 rich detail --------------------------------------------------- */
/** Rich detail for one library series' §9.6 page (#146). */
export interface SeriesMetadata {
tmdb_id: number;
overview: string | null;
tagline: string | null;
genres: string[];
/** Minutes per episode. */
runtime: number | null;
status: string;
poster_path: string | null;
backdrop_path: string | null;
/** `null` when TMDB has no votes for the title (#156). */
vote_average: number | null;
vote_count: number;
homepage: string | null;
/** §9.6 links out to TVDB for series; absent when the id is unknown. */
tvdb_id: number | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
export type SeriesMetadataOutcome =
| { kind: "metadata"; metadata: SeriesMetadata }
| { kind: "error"; detail: string };
/** One request for everything above the season tree (§9.6). */
export async function seriesMetadata(seriesId: number): Promise<SeriesMetadataOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/metadata`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "metadata", metadata: (await response.json()) as SeriesMetadata };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- display helpers --------------------------------------------------- */
/** Wanted / on-disk counts for one season, whatever its number. */
export function seasonCounts(season: ApiSeason): { wanted: number; available: number } {
let wanted = 0;
let available = 0;
for (const episode of season.episodes) {
if (episode.wanted) {
wanted += 1;
if (episode.state === "available") {
available += 1;
}
}
}
return { wanted, available };
}
/**
* §6.2: do not search before the thing exists. No air date yet counts as
* not aired — TMDB has not dated it, so neither will we.
*/
export function isUnaired(airDate: string | null, now = new Date()): boolean {
if (airDate === null) {
return true;
}
const parsed = Date.parse(airDate);
if (Number.isNaN(parsed)) {
return true;
}
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return parsed > today.getTime() + 86_399_999;
}
/** `2026-08-10` as the readout spells it; unknown stays a dash. */
export function formatAirDate(airDate: string | null): string {
return airDate ?? "—";
}
/** The probed attributes an episode file row shows as chips (§5.6, §7.4). */
export function fileAttributeTags(file: EpisodeFile): string[] {
return probedAttributeTags(file.probed);
}
+670 -4
View File
@@ -699,12 +699,13 @@ body {
transition: background 150ms var(--ease-out);
}
.row-tmdb:hover:not(:disabled),
.row-tmdb:focus-visible {
.row-tmdb:hover:not(:disabled):not([aria-disabled="true"]),
.row-tmdb:focus-visible:not([aria-disabled="true"]) {
background: var(--panel);
}
.row-tmdb:disabled {
.row-tmdb:disabled,
.row-tmdb[aria-disabled="true"] {
cursor: default;
}
@@ -730,6 +731,68 @@ body {
color: var(--ink-muted);
}
/* ---- rich search rows (#148): poster, rating chip, trailer chip -------- */
.row-rich {
align-items: flex-start;
}
.row-main {
display: flex;
flex: 1;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
min-width: 0;
}
/* w92 is the right order of magnitude for a row; the blank is the same
box, so the column edge holds while results stream in */
.row-poster,
.row-poster-blank {
width: 2.75rem;
aspect-ratio: 2 / 3;
flex-shrink: 0;
align-self: center;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.row-poster {
display: block;
background: var(--panel-raised);
object-fit: cover;
}
.chip[role="button"] {
cursor: pointer;
transition:
color 150ms var(--ease-out),
border-color 150ms var(--ease-out);
}
.chip[role="button"]:hover,
.chip[role="button"]:focus-visible {
color: var(--accent);
border-color: oklch(from var(--accent) l c h / 45%);
}
.row-trailer[data-state="pending"] {
color: var(--ink-faint);
}
/* no video on TMDB is an ordinary outcome: quiet and dashed, not fault red */
.row-trailer[data-state="none"] {
border-style: dashed;
color: var(--ink-faint);
cursor: default;
}
.row-trailer[data-state="blocked"] {
color: var(--signal-warn);
cursor: default;
}
/* ---- add flow (inline, root and policy pre-filled) -------------------- */
.add-panel {
@@ -1096,9 +1159,281 @@ body {
}
/* the toggle's own padding would inset its text past the chip column's
right rag; drop it so SATISFIED sits flush with the chips below */
right rag; with the shape toggle right of it, SATISFIED sits flush
against its neighbour instead of the deck edge */
.library-bar .bucket-toggle {
padding: 0;
margin-left: var(--space-2);
}
/* ---- library poster grid (§9.6, issue 151) ------------------------------ */
/* the shape toggle: a joined pair, engaged side lit like any view control */
.view-toggle {
margin-left: auto;
display: inline-flex;
}
.view-btn {
min-height: 1.75rem;
padding: 0 var(--space-2);
font-family: var(--font-readout);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-faint);
background: none;
border: 1px solid var(--line);
cursor: pointer;
transition:
border-color 150ms var(--ease-out),
color 150ms var(--ease-out);
}
.view-btn + .view-btn {
border-left-width: 0;
}
.view-btn:hover {
color: var(--accent-bright);
}
.view-btn[aria-pressed="true"] {
color: var(--accent-bright);
border-color: var(--accent);
}
/* auto-fill keeps every tile the same width down to a phone, where three
narrow-but-legible columns beat two oversized ones */
.deck-rows.grid-rows {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
padding: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
text-align: left;
cursor: pointer;
transition:
border-color 150ms var(--ease-out),
transform 150ms var(--ease-out);
}
.card:hover {
border-color: var(--accent);
}
.card:active {
transform: translateY(1px);
}
/* the poster is framed evidence, like every other image on a panel */
.card-art {
display: block;
aspect-ratio: 2 / 3;
}
.card-poster {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--panel-raised);
}
/* same ratio as a poster, carrying the title: the grid never has a hole */
.card-blank {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--space-3);
background: var(--panel-raised);
box-shadow: inset 0 1px var(--highlight);
}
.card-blank-title {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 600;
letter-spacing: 0.02em;
text-align: center;
color: var(--ink-faint);
}
.card-body {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
}
.card-id {
display: flex;
align-items: baseline;
gap: var(--space-1);
min-width: 0;
}
.card-name {
flex: 1;
/* two clamped lines: a long title stays readable where a single
ellipsised line at tile width would not be */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
}
.card-year {
flex: none;
font-size: var(--text-2xs);
}
.card-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */
/* one season: a collapsible group with its rule, counts and actions */
.season {
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.season-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-1);
}
/* the disclosure is a drawn chevron — no glyph standing in for an icon */
.season-disclose {
flex: none;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 150ms var(--ease-out);
}
.season-disclose:hover,
.season-disclose:focus-visible {
border-color: var(--accent);
}
.season-disclose::before {
content: "";
width: 0.4rem;
height: 0.4rem;
border-right: 2px solid var(--ink-muted);
border-bottom: 2px solid var(--ink-muted);
transform: rotate(-45deg) translate(-5%, -5%);
transition: transform 200ms var(--ease-out);
}
.season-disclose[aria-expanded="true"]::before {
transform: rotate(45deg) translate(-5%, -5%);
}
.season-name {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink);
}
/* the tracked toggle reads as the rule it is; pressed = on */
.season-line .control,
.ep-actions .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
.season-space {
flex: 1;
}
.episodes {
margin: 0;
padding: 0 0 var(--space-2) var(--space-8);
list-style: none;
}
.episode {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.ep-tag {
flex: none;
width: 2.75rem;
color: var(--ink-faint);
}
.ep-title {
min-width: 0;
font-size: var(--text-sm);
color: var(--ink);
}
.ep-air {
flex: none;
width: 6.5rem;
color: var(--ink-faint);
}
.ep-chips {
margin-left: auto;
}
.ep-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: var(--space-3);
}
.ep-unaired {
font-size: var(--text-xs);
}
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
.chip[data-flag="vanished"] {
color: var(--signal-warn);
border-style: dashed;
border-color: oklch(from var(--signal-warn) l c h / 55%);
}
/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */
@@ -1155,6 +1490,281 @@ body {
overflow-wrap: anywhere;
}
/* ---- title detail pages (§9.6, issues #149 and #150) ------------------- */
/* The hero is one machined module carrying what the title IS — shared by
the movie and series pages, which is why its classes read `movie-*`.
The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the
console charcoal under a scrim, so the readouts keep their contrast — a
photo never sits behind text bare. */
.movie-hero {
position: relative;
overflow: hidden;
margin: 0 0 var(--space-8);
}
/* the backdrop layer: painted only when detail supplied one */
.movie-hero::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--backdrop);
background-size: cover;
background-position: center 20%;
opacity: 0;
transition: opacity 400ms var(--ease-out);
}
.movie-hero.has-backdrop::before {
opacity: 1;
}
/* the scrim: ground at the bottom, heavy everywhere the text sits */
.movie-hero::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(
to top,
oklch(from var(--ground) l c h / 92%) 0%,
oklch(from var(--ground) l c h / 72%) 45%,
oklch(from var(--ground) l c h / 40%) 100%
),
oklch(from var(--ground) l c h / 35%);
}
.movie-body {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-6);
align-items: flex-start;
}
/* the poster is framed evidence, like every other image on a panel */
.movie-poster {
flex: none;
width: 10.5rem;
aspect-ratio: 2 / 3;
object-fit: cover;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 2px 12px oklch(from var(--lowlight) l c h / 75%);
}
.movie-info {
min-width: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.movie-idline {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
}
/* the page's voice: bigger than a deck title, same display face */
.movie-title {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.05;
}
.movie-year {
font-size: var(--text-sm);
}
.movie-chips {
margin-left: 0;
}
/* rating and meta are readouts on one line each; the star is drawn */
.movie-rating {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin: 0;
color: var(--ink);
}
.star {
width: 0.75rem;
height: 0.75rem;
fill: var(--ink-muted);
}
/* a row chip is smaller than the detail page's rating readout */
.row-rating .star {
width: 0.625rem;
height: 0.625rem;
}
.movie-meta {
margin: 0;
}
.movie-tagline {
margin: var(--space-1) 0 0;
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-muted);
}
.movie-overview {
max-width: 65ch;
margin: var(--space-1) 0 0;
color: var(--ink);
}
.movie-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.movie-actions .add-note[data-tone="fault"] {
margin: 0;
color: var(--signal-fault);
}
/* external links wear the control skin; anchors must drop the underline */
.movie-actions a.control {
text-decoration: none;
}
/* library controls follow the hero on their own row: state left, errands right */
.movie-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-1);
}
.movie-controls .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright);
border-color: var(--accent);
}
.movie-root-field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.movie-root {
width: auto;
min-width: 11rem;
}
.movie-controls-space {
flex: 1;
}
/* cast: top billed, each linking out to TMDB (§9.6) */
.cast-grid {
margin: 0;
padding: var(--space-3) var(--space-1) 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4) var(--space-2);
}
.cast-link {
display: grid;
gap: var(--space-1);
justify-items: start;
color: inherit;
text-decoration: none;
min-width: 0;
}
.cast-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cast-photo {
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raised);
transition: border-color 150ms var(--ease-out);
}
.cast-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* no profile photo: dead air, not a broken frame */
.cast-photo-none::before {
content: "";
display: block;
width: 40%;
aspect-ratio: 1;
margin: 30% auto 0;
border-radius: 50%;
border: 1px solid var(--line);
}
.cast-link:hover .cast-photo,
.cast-link:focus-visible .cast-photo {
border-color: var(--accent);
}
.cast-name {
font-size: var(--text-xs);
color: var(--ink);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
.cast-character {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
/* on-disk rows share the deck's rel grammar: name leads, chips trail */
.disk-name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--ink);
}
.rel[data-tone="fault"] {
color: var(--signal-fault);
}
/* ---- small screens --------------------------------------------------- */
@media (max-width: 46rem) {
@@ -1163,6 +1773,19 @@ body {
gap: var(--space-3) var(--space-4);
}
/* the library bar wraps to two lines at phone widths: identity and count
on the first, shape and satisfied toggles on the second, each label
whole instead of squeezed into a mid-word break */
.library-bar {
flex-wrap: wrap;
row-gap: var(--space-2);
}
.library-bar .bucket-toggle,
.view-btn {
white-space: nowrap;
}
.rail-verdict {
flex-basis: 100%;
order: 3;
@@ -1194,6 +1817,20 @@ body {
flex-basis: 100%;
}
/* an episode row wraps: identity first, chips full-width, actions right */
.ep-chips {
margin-left: 0;
flex-basis: 100%;
}
.ep-actions {
margin-left: auto;
}
.episodes {
padding-left: var(--space-4);
}
.releases-id {
order: -1;
flex-basis: 100%;
@@ -1214,6 +1851,35 @@ body {
margin-left: auto;
}
/* the hero stacks: poster above the info block, both full width */
.movie-body {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
}
.movie-poster {
width: 8.5rem;
}
.movie-info {
padding-bottom: var(--space-4);
}
/* state controls wrap to their own line, errands stay right-aligned */
.movie-controls-space {
display: none;
}
.movie-root {
min-width: 0;
flex: 1;
}
.cast-grid {
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
}
.board {
padding: var(--space-6) var(--space-4);
}