Compare commits

...

85 Commits

Author SHA1 Message Date
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 64e711feec fix(core): guard movie id lane against episode tags 2026-08-23 19:13:49 +01:00
Miguel Palhas a378b22306 Merge #134: satisfied titles leave the attention queue
Closes #134
2026-08-23 19:09:15 +01:00
Miguel Palhas 04b2688330 Merge #124: match RSS results against wanted episodes
Closes #124
2026-08-23 19:09:15 +01:00
Miguel Palhas 91414a043f chore: regenerate sqlx query data 2026-08-23 19:07:29 +01:00
Miguel Palhas a28b455fff fix: gate hard-fail attention on unsatisfied titles 2026-08-23 19:07:29 +01:00
Miguel Palhas 9df120b92a feat(daemon): match RSS results to wanted episodes
One feed pass now serves films and episodes alike. A
single-episode release grabs its open episode directly; a season
pack only grabs when season_grab_mode allows packs for that
season (§14, #117), and when it wins, the singles stand down.
Blocked series keep matching RSS (§6.3) and nothing here touches
the targeted-search backoff (§6.2).
2026-08-23 19:07:00 +01:00
Miguel Palhas 03e7774869 Merge #136: resolve IMDb ids to series as well
Closes #136
2026-08-23 19:01:42 +01:00
Miguel Palhas fea1a04c75 feat(api): surface series for IMDb id searches 2026-08-23 18:59:35 +01:00
Miguel Palhas 3956617d41 feat(meta): resolve IMDb ids against series too 2026-08-23 18:59:35 +01:00
Miguel Palhas d8316ae0c8 feat(api): series files keyed by episode id 2026-08-23 18:41:36 +01:00
Miguel Palhas 127338fd4d Merge #132: drain manual episode and season commands
Closes #132
2026-08-23 18:27:43 +01:00
Miguel Palhas 695f1882be Merge #128: series delete unlinks its files
Closes #128
2026-08-23 18:25:56 +01:00
Miguel Palhas 8a77049fdb feat(daemon): drain all manual command channels 2026-08-23 18:24:50 +01:00
Miguel Palhas 39e6bce2a0 feat(daemon): manual episode/season triggers 2026-08-23 18:24:50 +01:00
Miguel Palhas b0f56f59aa fix(api): delete series files and media_files rows 2026-08-23 18:23:41 +01:00
Miguel Palhas 1eac85c427 Merge #131: derive status from the episode's own season number
Closes #131
2026-08-23 18:21:34 +01:00
Miguel Palhas 50b68e24b6 api: derive status from episode season numbers
with_status drops its seasons slice for the same reason as
derive_series_status; tv_by_series no longer loads seasons at all.
Offline query data regenerated.
2026-08-23 18:18:26 +01:00
Miguel Palhas 69263da4f3 daemon: populate season_number on refreshed episodes 2026-08-23 18:18:26 +01:00
Miguel Palhas b51386e004 core: carry season number on Episode
derive_series_status identified specials by looking the episode's
season up in a parallel seasons slice, so a caller passing an
incomplete slice silently reverted to pre-#118 behaviour. The season
number now rides on each episode and the slice is gone.
2026-08-23 18:18:26 +01:00
Miguel Palhas 17949c0848 Merge #138: close title boundary on plural season ranges
Closes #138
2026-08-23 18:17:19 +01:00
Miguel Palhas 4f848c27f0 fix(parse): close title on spelled-out season ranges 2026-08-23 18:12:47 +01:00
Miguel Palhas e4c49b75de Merge #130: add a series from unified search
Closes #130
2026-08-23 18:05:45 +01:00
Miguel Palhas 00d0ec7f16 Merge #135: fix season-pack name parsing
Closes #135
2026-08-23 18:05:39 +01:00
Miguel Palhas adfc60154d Merge #122: reconcile episodes that vanish upstream
Closes #122
2026-08-23 18:05:39 +01:00
Miguel Palhas 8d79c7c378 fix(parse): pack titles and degenerate ranges
COMPLETE before the season tag was weak junk, so it stayed in
the title and exact-title matching failed silently (#135). It
now closes the boundary wherever it lands.

S01-S01 produced no claim because the dash-range branch required
last > season; a degenerate range is one season's pack, matching
the rule match_episode lifted.
2026-08-23 18:02:39 +01:00
Miguel Palhas 043ebffa1c feat(web): series and episodes in unified search
In-library hits now read as whatever they are: movies open the release
deck as before, series hits render display-only until issue 129's detail
view gives them a link target, and an episode hit reads as its series
plus SxxEyy plus the episode title. TMDB results show TV alongside film,
and the add panel filters roots by kind; a series adds the one §4.1
add-time choice, auto_track, stated plainly: future seasons are tracked
automatically, past seasons are not.
2026-08-23 18:00:54 +01:00
106 changed files with 9903 additions and 989 deletions
+14 -5
View File
@@ -17,8 +17,11 @@ reads the library as derived status, not flags.
anywhere.
- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below,
engraved micro-label headings, machined rows, chips on the right.
- Add flow: inline panel under a TMDB row — root radio cards (audience name,
policy name, path), main pre-selected, one confirm. Never a separate page.
- Add flow: inline panel under a TMDB row of either kind — root radio cards
(audience name, policy name, path), main pre-selected, one confirm. Never a
separate page. A series adds the one §4.1 add-time question: an auto-track
pressed control (default on) with the plain line "future seasons are tracked
automatically; past seasons are not", echoed in the added confirmation.
- Release deck (§9.3, issue #31): a library row opens the buckets view.
ELIGIBLE leads with fixed-width attribute chips in aligned mono columns
(rem-width `.cw-*` classes shared by chips and the column header, so the
@@ -59,7 +62,13 @@ reads the library as derived status, not flags.
- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS,
cyan for interaction only, dead-air rule (no idle animation), no horizontal
scroll at any viewport (§9.3 acceptance criterion).
- Unified search covers both kinds (issue 130): in-library hits read as
whatever they are — a movie row, a series hit (display-only until the
detail view of issue 129 exists) and an episode hit rendered as its series,
an `SxxEyy` chip and the episode title in sans beside it. TMDB rows show TV
alongside film; the "in library" dedup spans movies and series.
- Unresolved: releases land in the persisted store only once the daemon
consumes search commands (#68-adjacent); TMDB search is movie-only until
series land (#39); per-season and per-episode library actions are #39;
attention queues surface is #33.
consumes search commands (#68-adjacent); episode hits have no link target
until issue 129's series detail view exists — they render display-only for
now; per-season and per-episode library actions are #39; attention queues
surface is #33.
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207"
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
}
@@ -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"
}
@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 8,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true
]
},
"hash": "268dc95276f57bf3c457c98d172ef5e6f9acf430ae419ca68dc3800b0390b5b7"
}
@@ -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,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
@@ -27,6 +27,21 @@
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 7,
"type_info": "Integer"
}
],
"parameters": {
@@ -37,8 +52,11 @@
false,
false,
null,
false,
true,
true,
false
]
},
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
"hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT s.title AS \"title!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?",
"describe": {
"columns": [
{
"name": "title!: String",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE season_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
"describe": {
"columns": [
{
@@ -19,43 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
}
],
@@ -68,6 +73,7 @@
false,
false,
false,
false,
true,
false,
false,
@@ -76,5 +82,5 @@
true
]
},
"hash": "633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256"
"hash": "3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5"
}
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false
]
},
"hash": "3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
"describe": {
"columns": [
{
@@ -19,43 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
}
],
@@ -68,6 +73,7 @@
false,
false,
false,
false,
true,
false,
false,
@@ -76,5 +82,5 @@
true
]
},
"hash": "3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a"
"hash": "3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"on_disk!: bool\"\n FROM episodes e\n WHERE e.season_id = ?\n ORDER BY e.number\n ",
"describe": {
"columns": [
{
"name": "air_date",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "on_disk!: bool",
"ordinal": 1,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
null
]
},
"hash": "3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT s.title AS \"title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "title!: String",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language,\n (\n EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ) AS \"is_gap!: bool\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n AND s.blocked = 0\n ",
"describe": {
"columns": [
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "series_tvdb_id",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "is_gap!: bool",
"ordinal": 5,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false
]
},
"hash": "519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67"
}
@@ -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 id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
"describe": {
"columns": [
{
@@ -22,6 +22,11 @@
"name": "tracked!: bool",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "vanished!: bool",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
@@ -31,8 +36,9 @@
true,
false,
false,
false,
false
]
},
"hash": "ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e"
"hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81"
}
@@ -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 se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
"describe": {
"columns": [
{
@@ -19,43 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
}
],
@@ -68,6 +73,7 @@
false,
false,
false,
false,
true,
false,
false,
@@ -76,5 +82,5 @@
true
]
},
"hash": "c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d"
"hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467"
}
@@ -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,80 @@
{
"db_name": "SQLite",
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"number!: i64\",\n e.air_date,\n e.wanted AS \"wanted!: bool\",\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\",\n EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n ) AS \"in_flight!: bool\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?\n AND s.blocked = 0\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "air_date",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "has_file!: bool",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "in_flight!: bool",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "series_tvdb_id",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea"
}
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false
]
},
"hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
}
@@ -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"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"episode!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY e.id\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "episode!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "season!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"describe": {
"columns": [
{
@@ -28,5 +28,5 @@
true
]
},
"hash": "ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42"
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
}
@@ -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,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
@@ -57,6 +57,16 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
@@ -73,8 +83,10 @@
false,
false,
false,
false
false,
true,
true
]
},
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
"hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152"
}
@@ -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,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN episode_releases er ON er.release_id = r.id\n WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "indexer_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "guid!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "download_url!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "score!: i64",
"ordinal": 5,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
false,
false,
null
]
},
"hash": "a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf"
}
@@ -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\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series WHERE id = ?",
"describe": {
"columns": [
{
@@ -57,6 +57,16 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
@@ -73,8 +83,10 @@
false,
false,
false,
false
false,
true,
true
]
},
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
"hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32"
}
@@ -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": "\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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average FROM series ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -57,6 +57,16 @@
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
@@ -73,8 +83,10 @@
false,
false,
false,
false
false,
true,
true
]
},
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
"hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5"
}
@@ -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"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN season_releases sr ON sr.release_id = r.id\n WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "indexer_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "guid!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "download_url!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "score!: i64",
"ordinal": 5,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
false,
false,
null
]
},
"hash": "d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n ",
"describe": {
"columns": [
{
"name": "original_language",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90"
}
@@ -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 se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"describe": {
"columns": [
{
@@ -19,43 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
}
],
@@ -68,6 +73,7 @@
false,
false,
false,
false,
true,
false,
false,
@@ -76,5 +82,5 @@
true
]
},
"hash": "448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8"
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
}
@@ -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"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT r.path AS \"path!: String\" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d"
}
@@ -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"
}
+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);
}
}
+208 -41
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")),
@@ -478,7 +522,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
/// root, or the file itself when it sits in the root with no folder of its
/// own. `None` when the file is not under the root at all, which is the
/// guard that keeps a delete inside the library it belongs to.
fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
pub(crate) fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
let root = std::path::Path::new(root);
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
let first = relative.components().next()?;
@@ -640,10 +684,10 @@ pub async fn grab(
)
)]
pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQueues>, ApiError> {
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -667,7 +711,9 @@ async fn tv_attention(
let no_pt_rows = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, e.id AS "episode_id!: i64"
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
@@ -688,13 +734,15 @@ async fn tv_attention(
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64"
s.title AS "title!: String", s.year,
g.target_id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM grabs g
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#
)
@@ -703,12 +751,13 @@ async fn tv_attention(
let season_pack_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64"
s.title AS "title!: String", s.year,
g.target_id AS "season_id!: i64", se.number AS "season_number!: i64"
FROM grabs g
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
"#
)
.fetch_all(database)
@@ -720,9 +769,11 @@ async fn tv_attention(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
row.title,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
@@ -732,54 +783,93 @@ async fn tv_attention(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
row.title,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
for row in season_pack_fails {
match tv_needs_decision
.iter_mut()
.find(|entry| entry.series_id == row.series_id)
{
Some(entry) => entry.seasons.push(row.season_id),
None => tv_needs_decision.push(SeriesAttention {
series_id: row.series_id,
tmdb_id: row.tmdb_id,
title: row.title,
year: row.year,
episodes: Vec::new(),
seasons: vec![row.season_id],
}),
}
merge_season(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.season_id,
row.season_number,
);
}
Ok((tv_no_pt_source, tv_needs_decision))
}
/// One more qualifying season for its series, creating the series' entry on
/// first sight.
#[allow(clippy::too_many_arguments)]
fn merge_season(
entries: &mut Vec<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
season_id: i64,
season_number: i64,
) {
match entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
{
Some(entry) => entry.seasons.push(QueuedSeason {
id: season_id,
number: season_number,
}),
None => entries.push(SeriesAttention {
series_id,
tmdb_id,
title: title.to_owned(),
year,
episodes: Vec::new(),
seasons: vec![QueuedSeason {
id: season_id,
number: season_number,
}],
}),
}
}
/// One more qualifying episode for its series, creating the series' entry on
/// first sight.
#[allow(clippy::too_many_arguments)]
fn merge_episode(
entries: &mut Vec<SeriesAttention>,
series_id: i64,
tmdb_id: i64,
title: String,
title: &str,
year: Option<i64>,
episode_id: i64,
season_number: i64,
episode_number: i64,
) {
let episode = QueuedEpisode {
id: episode_id,
season_number,
episode_number,
};
if let Some(entry) = entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
{
entry.episodes.push(episode_id);
entry.episodes.push(episode);
return;
}
entries.push(SeriesAttention {
series_id,
tmdb_id,
title,
title: title.to_owned(),
year,
episodes: vec![episode_id],
episodes: vec![episode],
seasons: Vec::new(),
});
}
@@ -913,6 +1003,73 @@ mod tests {
response.json().await.expect("movie json")
}
/// §9.6: the three stored artwork fields come off the detail response at
/// add time, so a title added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_movie_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/movie/693134"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134,
"title": "Dune Part Two",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two",
"original_language": "en", "root_id": 2
}))
.send()
.await
.expect("create movie");
assert_eq!(response.status(), StatusCode::CREATED);
let movie_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("movie id");
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM movies WHERE id = ?",
)
.bind(movie_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
}
/// §5.7: a soft-failed import is imported and waived, and the waiver
/// reaches the API — a file that merely plays must never read as a clean
/// match.
@@ -1350,21 +1507,31 @@ mod tests {
.json()
.await
.expect("queues json");
let episode_ids: Vec<i64> =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number")
.bind(season_id)
.fetch_all(pool)
.await
.expect("episode ids");
assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id);
assert_eq!(
queues["tv_no_pt_source"][0]["episodes"]
.as_array()
.map(Vec::len),
Some(2)
queues["tv_no_pt_source"][0]["episodes"],
serde_json::json!([
{ "id": episode_ids[0], "season_number": 1, "episode_number": 1 },
{ "id": episode_ids[1], "season_number": 1, "episode_number": 2 },
])
);
assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id);
assert_eq!(
queues["tv_needs_decision"][0]["episodes"],
serde_json::json!([episode_id])
serde_json::json!([
{ "id": episode_id, "season_number": 1, "episode_number": 3 },
])
);
assert_eq!(
queues["tv_needs_decision"][0]["seasons"],
serde_json::json!([season_id])
serde_json::json!([{ "id": season_id, "number": 1 }])
);
// Blocking the series empties its no-PT-source lane.
+128 -21
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,10 +224,10 @@ pub async fn search(
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
} else {
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
.fetch_all(database.pool()).await?
};
library.extend(series_rows.into_iter().map(LibraryResult::Series));
@@ -215,7 +235,7 @@ pub async fn search(
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so the haystack
// is both joined and every token must land somewhere in it.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
.fetch_all(database.pool())
.await?;
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
@@ -224,14 +244,23 @@ pub async fn search(
let results = search_tmdb(&tmdb, kind, input).await?;
if matches!(kind, SearchInputKind::ImdbId) {
for result in &results {
let TmdbResult::Movie(movie) = result else {
continue;
};
let tmdb_id = i64::from(movie.tmdb_id);
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Movie(found));
match result {
TmdbResult::Movie(movie) => {
let tmdb_id = i64::from(movie.tmdb_id);
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Movie(found));
}
}
TmdbResult::Series(series) => {
let tmdb_id = i64::from(series.tmdb_id);
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Series(found));
}
}
}
}
}
@@ -282,6 +311,8 @@ async fn search_tmdb(
year,
overview: movie.overview,
poster_path: movie.poster_path,
vote_average: movie.vote_average,
vote_count: movie.vote_count,
})]
}
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
@@ -316,14 +347,24 @@ async fn search_tmdb(
}
matches
}
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
Ok(movies) => movies
SearchInputKind::ImdbId => {
let found = tmdb
.find_by_imdb(input)
.await
.map_err(|error| upstream_error(&error))?;
let mut results: Vec<TmdbResult> = found
.movies
.into_iter()
.map(|movie| TmdbResult::Movie(movie.into()))
.collect(),
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
Err(error) => return Err(upstream_error(&error)),
},
.collect();
results.extend(
found
.series
.into_iter()
.map(|series| TmdbResult::Series(series.into())),
);
results
}
SearchInputKind::Text => {
let mut results: Vec<TmdbResult> = tmdb
.search_movies(input, None)
@@ -539,6 +580,8 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
year,
overview: movie.overview,
poster_path: movie.poster_path,
vote_average: movie.vote_average,
vote_count: movie.vote_count,
}
}
}
@@ -553,6 +596,8 @@ impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
@@ -567,6 +612,8 @@ impl From<arr_meta::Series> for TmdbSeries {
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
@@ -633,7 +680,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
.map_err(|_| ApiError::Unavailable)
}
fn upstream_error(error: &arr_meta::Error) -> ApiError {
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
match error {
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
_ => ApiError::Unavailable,
@@ -797,7 +844,8 @@ mod tests {
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
"id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two",
"original_language": "en", "release_date": "2024-02-27"
"original_language": "en", "release_date": "2024-02-27",
"poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000
}]})),
)
.mount(&tmdb)
@@ -824,6 +872,19 @@ mod tests {
assert_eq!(response["library"][0]["title"], "Dune");
assert_eq!(response["tmdb"][0]["kind"], "movie");
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
// §9.6: both halves carry artwork and a rating, read from stored
// columns and TMDB's search body — no upstream call per row.
assert_eq!(
response["library"][0]["poster_path"],
serde_json::Value::Null
);
assert_eq!(
response["library"][0]["vote_average"],
serde_json::Value::Null
);
assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg");
assert_eq!(response["tmdb"][0]["vote_average"], 8.1);
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
}
/// §9.2: the in-library set matches series titles and episode titles, so
@@ -950,6 +1011,52 @@ mod tests {
assert_eq!(response["tmdb"][0]["title"], "1917");
}
/// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does —
/// a hit on TMDB and, when tracked, in the library set too.
#[tokio::test]
async fn an_imdb_id_resolves_to_a_series() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/find/tt7614372"))
.and(query_param("external_source", "imdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"movie_results": [],
"tv_results": [{
"id": 82_728, "name": "Bluey", "original_name": "Bluey",
"original_language": "en", "first_air_date": "2018-10-01"
}]
})))
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
let pool = state.database().expect("database").pool();
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("TV root");
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)",
)
.bind(root_id)
.execute(pool)
.await
.expect("series");
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["kind"], "imdb_id");
assert_eq!(response["library"][0]["kind"], "series");
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
}
#[tokio::test]
async fn manual_releases_are_classified_and_name_rejection_rules() {
let tmdb = MockServer::start().await;
+522 -93
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,12 +203,18 @@ struct SeriesRow {
overrides: serde_json::Value,
upstream_ended: bool,
blocked: bool,
/// Stored at add time and refreshed with metadata (#145), so pure-SQL
/// views never need TMDB (§9.6).
poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
vote_average: Option<f64>,
}
struct EpisodeRow {
series_id: i64,
id: i64,
season_id: i64,
season_number: i64,
number: i64,
title: String,
air_date: Option<String>,
@@ -257,6 +276,7 @@ fn core_episode(row: &EpisodeRow) -> arr_core::Episode {
arr_core::Episode {
id: EpisodeId(row.id),
season_id: SeasonId(row.season_id),
season_number: u16::try_from(row.season_number).unwrap_or_default(),
number: u16::try_from(row.number).unwrap_or_default(),
title: row.title.clone(),
air_date: air_date(row.air_date.as_deref()),
@@ -277,31 +297,14 @@ fn status_name(status: SeriesStatus) -> &'static str {
}
}
fn core_season(id: i64, series_id: i64, number: i64) -> arr_core::Season {
arr_core::Season {
id: SeasonId(id),
series_id: SeriesId(series_id),
number: u16::try_from(number).unwrap_or_default(),
tracked: false,
}
}
fn with_status(
row: &SeriesRow,
seasons: &[arr_core::Season],
episodes: &[arr_core::Episode],
now: SystemTime,
) -> Series {
fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series {
// §4.2: season 0 is invisible to status, so it stays out of the counters
// too. A series reading `complete` next to `42/52 eps` is the confusion
// this avoids. `UNIQUE (series_id, number)` means there is at most one.
let specials = seasons
.iter()
.find(|season| season.number == 0)
.map(|season| season.id);
// this avoids. The season number rides on each episode (#131), so no
// parallel slice can be forgotten.
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
.filter(|episode| episode.wanted && episode.season_number != 0);
let available = wanted
.clone()
.filter(|episode| episode.state == MediaState::Available);
@@ -317,53 +320,28 @@ fn with_status(
overrides: row.overrides.clone(),
upstream_ended: row.upstream_ended,
blocked: row.blocked,
status: status_name(derive_series_status(
&core_series(row),
seasons,
episodes,
now,
))
.to_owned(),
poster_path: row.poster_path.clone(),
vote_average: row.vote_average,
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
}
}
/// Every season and episode in the library, keyed by the series they belong to.
/// Every episode in the library, keyed by the series it belongs to.
///
/// One query each rather than one per series: the whole table is a few thousand
/// One query rather than one per series: the whole table is a few thousand
/// rows for a single household (§10), and the status of every listed series
/// needs all of them anyway.
async fn tv_by_series(
state: &AppState,
) -> Result<
(
HashMap<i64, Vec<arr_core::Season>>,
HashMap<i64, Vec<arr_core::Episode>>,
),
ApiError,
> {
let season_rows = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons"#
)
.fetch_all(pool(state)?)
.await?;
async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Episode>>, ApiError> {
let rows = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id"#
)
.fetch_all(pool(state)?)
.await?;
let mut seasons: HashMap<i64, Vec<arr_core::Season>> = HashMap::new();
for row in &season_rows {
seasons.entry(row.series_id).or_default().push(core_season(
row.id,
row.series_id,
row.number,
));
}
let mut episodes: HashMap<i64, Vec<arr_core::Episode>> = HashMap::new();
for row in &rows {
episodes
@@ -371,11 +349,11 @@ async fn tv_by_series(
.or_default()
.push(core_episode(row));
}
Ok((seasons, episodes))
Ok(episodes)
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series WHERE id = ?"#, id)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeriesNotFound)
@@ -383,26 +361,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiErro
async fn load_series(state: &AppState, id: i64) -> Result<Series, ApiError> {
let row = load_series_row(state, id).await?;
let season_rows = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons WHERE series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let seasons: Vec<_> = season_rows
.iter()
.map(|row| core_season(row.id, row.series_id, row.number))
.collect();
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let episodes: Vec<_> = episodes.iter().map(core_episode).collect();
Ok(with_status(&row, &seasons, &episodes, SystemTime::now()))
Ok(with_status(&row, &episodes, SystemTime::now()))
}
async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
@@ -439,29 +407,21 @@ pub async fn list(
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<Vec<Series>>, ApiError> {
let rows = if let Some(owner_id) = query.owner_id {
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id)
.fetch_all(pool(&state)?)
.await?
} else {
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#)
sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average FROM series ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
let (seasons, episodes) = tv_by_series(&state).await?;
let episodes = tv_by_series(&state).await?;
let now = SystemTime::now();
let no_seasons: Vec<arr_core::Season> = Vec::new();
let no_episodes: Vec<arr_core::Episode> = Vec::new();
Ok(Json(
rows.iter()
.map(|row| {
with_status(
row,
seasons.get(&row.id).unwrap_or(&no_seasons),
episodes.get(&row.id).unwrap_or(&no_episodes),
now,
)
})
.map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&no_episodes), now))
.collect(),
))
}
@@ -490,12 +450,19 @@ pub async fn create(
let title = input.title.trim();
// §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not
// knowing one must not block adding the series — the search falls back
// to the title text query until a refresh fills it in (#121).
let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await;
// to the title text query until a refresh fills it in (#121). The same
// response carries §9.6's stored artwork fields, so a series added today
// has a poster before tomorrow's refresh. Best effort either way.
let tmdb_series = lookup_tmdb_series(&state, input.tmdb_id).await;
let tvdb_id = tmdb_series.as_ref().and_then(|s| s.tvdb_id).map(i64::from);
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
let result = sqlx::query!(
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides
input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
)
.execute(pool(&state)?)
.await?;
@@ -505,14 +472,10 @@ pub async fn create(
))
}
/// Best effort: `None` when TMDB has no id or cannot be reached.
async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option<i64> {
/// Best effort: `None` when TMDB has no such id or cannot be reached.
async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option<arr_meta::Series> {
let client = tmdb_client(state).ok()?;
let ids = client
.series_external_ids(u32::try_from(tmdb_id).ok()?)
.await
.ok()?;
ids.tvdb_id.map(i64::from)
client.series(u32::try_from(tmdb_id).ok()?).await.ok()
}
#[utoipa::path(
@@ -589,6 +552,28 @@ pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
// The row is loaded first so a missing series is 404 before anything
// touches the disk.
load_series_row(&state, id).await?;
remove_library_files(&state, id).await?;
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
// cascades from the seasons and episodes rows (which the series row's
// delete does): leaving the rows behind would block re-importing the
// same paths after a re-add. Owner tags go with the title they tagged.
sqlx::query!(
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?)",
id
)
.execute(pool(&state)?)
.await?;
sqlx::query!(
"DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
id
)
.execute(pool(&state)?)
.await?;
let result = sqlx::query!("DELETE FROM series WHERE id = ?", id)
.execute(pool(&state)?)
.await?;
@@ -598,16 +583,83 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT)
}
/// Unlink everything this series put under its root. Mirrors the movie
/// handler in `movies.rs`.
///
/// The service knows only what it wrote (§2), so the targets come from
/// `media_files`, never from a scan and never from re-deriving the §7.4 name
/// — a series renamed after import would derive a folder that does not exist
/// while the real one stayed. Each episode file resolves to its title folder,
/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles
/// and artwork go with it.
///
/// The torrent is untouched (§7.3). It keeps seeding under its own rule and
/// the reaper deletes it; a hardlinked file loses only its library name.
///
/// Failure leaves the database alone, so the operator sees the series still
/// there and can retry rather than losing the record of what is on disk.
async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> {
let root = sqlx::query_scalar!(
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
id
)
.fetch_one(pool(state)?)
.await?;
let paths = sqlx::query_scalar!(
r#"SELECT mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let mut targets: Vec<std::path::PathBuf> = Vec::new();
for path in &paths {
let Some(target) = crate::movies::title_target(&root, path) else {
// Outside its own root: not ours to delete. The row still goes,
// so the operator sees the series leave and the file stay.
tracing::warn!(%path, %root, "media file is outside its root, not deleted");
continue;
};
if !targets.contains(&target) {
targets.push(target);
}
}
for target in targets {
let metadata = match tokio::fs::symlink_metadata(&target).await {
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
} else {
tokio::fs::remove_file(&target).await
};
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
Ok(())
}
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
let seasons = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
series_id
)
.fetch_all(pool(state)?)
.await?;
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
series_id
)
@@ -621,12 +673,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(),
@@ -687,6 +742,15 @@ pub async fn create_season(
"episode number cannot be negative".into(),
));
}
// The column rejects the empty string (#153); say so before the database
// has to.
if input
.episodes
.iter()
.any(|episode| episode.title.trim().is_empty())
{
return Err(ApiError::Invalid("episode title cannot be empty".into()));
}
let mut numbers: Vec<i64> = input
.episodes
.iter()
@@ -714,6 +778,7 @@ pub async fn create_season(
.map(|episode| arr_core::Episode {
id: EpisodeId(0),
season_id: SeasonId(0),
season_number: u16::try_from(input.number).unwrap_or_default(),
number: u16::try_from(episode.number).unwrap_or_default(),
title: episode.title.clone(),
air_date: air_date(episode.air_date.as_deref()),
@@ -837,7 +902,7 @@ pub async fn update_season(
async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
let row = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#,
id
)
@@ -846,7 +911,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,
@@ -1110,6 +1177,39 @@ pub async fn grab_season_release(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
/// One imported episode file, keyed to its episode so the detail view can
/// attach the file's probed §7.4 attributes to the episode row.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EpisodeFile {
pub episode_id: i64,
pub path: String,
pub size: i64,
pub probed: Option<serde_json::Value>,
/// The §5.7 rule relaxed to allow this import, when one was.
pub waiver: Option<String>,
}
#[utoipa::path(
get, path = "/api/series/{series_id}/files", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
responses(
(status = 200, body = [EpisodeFile]),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn files(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<EpisodeFile>>, ApiError> {
load_series_row(&state, id).await?;
let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id)
.fetch_all(pool(&state)?)
.await?;
Ok(Json(files))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/owners", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
@@ -1262,6 +1362,81 @@ mod tests {
response.json().await.expect("season json")
}
/// §9.6: the three stored artwork fields come off the series detail
/// response at add time — the same call that used to fetch only the
/// TVDB id — so a series added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_series_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/tv/82728"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 82_728,
"name": "Bluey",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"external_ids": {"tvdb_id": 361_391}
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey",
"original_language": "en", "root_id": 3
}))
.send()
.await
.expect("create series");
assert_eq!(response.status(), StatusCode::CREATED);
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("series id");
let (tvdb_id, poster, backdrop, vote): (
Option<i64>,
Option<String>,
Option<String>,
Option<f64>,
) = sqlx::query_as(
"SELECT tvdb_id, poster_path, backdrop_path, vote_average FROM series WHERE id = ?",
)
.bind(series_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("series row");
assert_eq!(tvdb_id, Some(361_391));
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
#[tokio::test]
async fn series_must_sit_on_a_tv_root() {
let (_dir, state, base) = application().await;
@@ -1326,6 +1501,28 @@ mod tests {
assert_eq!(season["episodes"][0]["wanted"], false);
}
/// #153. An empty episode title is rejected up front — the column and the
/// TMDB boundary both refuse it, so the API must too.
#[tokio::test]
async fn an_empty_episode_title_is_rejected() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
for title in ["", " "] {
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 1, "episodes": [
{"number": 1, "title": title}
]}))
.send()
.await
.expect("create season");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[tokio::test]
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
let (_dir, state, base) = application().await;
@@ -1360,6 +1557,50 @@ mod tests {
assert_eq!(season["number"], 1);
}
#[tokio::test]
async fn a_vanished_season_reaches_the_seasons_endpoint() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let clean = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
assert_eq!(clean["vanished"], false);
add_season(
&base,
series_id,
2,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
sqlx::query("UPDATE seasons SET vanished = 1 WHERE series_id = ? AND number = 2")
.bind(series_id)
.execute(state.database().expect("database").pool())
.await
.expect("flag season vanished");
let seasons: serde_json::Value = reqwest::Client::new()
.get(format!("{base}/api/series/{series_id}/seasons"))
.send()
.await
.expect("list seasons")
.json()
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
assert_eq!(seasons[0]["vanished"], false);
assert_eq!(
seasons[1]["vanished"], true,
"#141: the conflict flag is not stopped at the database"
);
}
#[tokio::test]
async fn intent_is_set_on_seasons_and_on_single_episodes() {
let (_dir, state, base) = application().await;
@@ -1838,6 +2079,194 @@ mod tests {
assert_eq!(episodes, 0);
}
/// A series on disk for one test: the §7.4 title folder with a season
/// subfolder holding one episode file and one sidecar subtitle.
async fn library_on_disk(
state: &AppState,
episode_id: i64,
root: &std::path::Path,
) -> std::path::PathBuf {
let folder = root.join("Bluey (2018) [tmdbid-82728]");
let season = folder.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create title folder");
let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv");
tokio::fs::write(&feature, b"episode").await.expect("write");
tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs")
.await
.expect("write sidecar");
let pool = state.database().expect("database").pool();
let root_path = root.to_str().expect("utf-8 root");
sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'")
.bind(root_path)
.execute(pool)
.await
.expect("point the root at the tempdir");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
)
.bind(episode_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
folder
}
/// The §7.4 title folder is the unit of deletion, so the season
/// subfolder and sidecars go with it — and the root is never touched.
#[tokio::test]
async fn deleting_a_series_removes_the_whole_title_folder() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]),
)
.await;
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
let root = tempfile::tempdir().expect("root");
let folder = library_on_disk(&state, episode_id, root.path()).await;
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/{series_id}"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(
!folder.exists(),
"the title folder, its seasons and its sidecars are gone"
);
assert!(root.path().exists(), "the root survives its titles");
let pool = state.database().expect("database").pool();
let orphans: i64 =
sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'")
.fetch_one(pool)
.await
.expect("count files");
assert_eq!(orphans, 0, "the file rows go with the files");
}
/// A missing series is 404 before anything touches the disk.
#[tokio::test]
async fn deleting_a_missing_series_is_a_404() {
let (_dir, _state, base) = application().await;
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/999"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
/// The detail view reads its episode files through the series, keyed by
/// episode id, so one request carries every §7.4 attribute tag it shows.
#[tokio::test]
async fn series_files_are_keyed_by_episode() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "kids").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
{"number": 2, "title": "Hospital", "air_date": "2018-10-02"}
]),
)
.await;
let episodes = season["episodes"].as_array().expect("episodes");
let first = episodes[0]["id"].as_i64().expect("episode id");
let pool = state.database().expect("database").pool();
sqlx::query(
r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
VALUES ('episode', ?, ?, 7, ?, ?)",
)
.bind(first)
.bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv")
.bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#)
.bind(r#"{"rule":"required_audio"}"#)
.execute(pool)
.await
.expect("media file");
let response = reqwest::get(format!("{base}/api/series/{series_id}/files"))
.await
.expect("fetch files");
assert_eq!(response.status(), StatusCode::OK);
let files: serde_json::Value = response.json().await.expect("files json");
let rows = files.as_array().expect("array");
assert_eq!(rows.len(), 1, "only imported episodes carry a file");
assert_eq!(rows[0]["episode_id"], first);
assert_eq!(rows[0]["probed"]["resolution"], "1080p");
assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT");
assert_eq!(rows[0]["waiver"], "required_audio");
let missing = reqwest::get(format!("{base}/api/series/999/files"))
.await
.expect("fetch files");
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
/// The guard that keeps a delete inside the library: a path that is not
/// under the series' root is left alone, whatever the row says.
#[tokio::test]
async fn a_series_file_outside_its_root_is_never_unlinked() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot"}]),
)
.await;
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
let root = tempfile::tempdir().expect("root");
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(root.path().to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
let elsewhere = tempfile::tempdir().expect("elsewhere");
let stray = elsewhere.path().join("not-ours.mkv");
tokio::fs::write(&stray, b"stray").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)",
)
.bind(episode_id)
.bind(stray.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/{series_id}"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(
stray.exists(),
"a path outside the root is not ours to delete"
);
}
#[test]
fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!(
+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);
}
}
+3
View File
@@ -288,6 +288,9 @@ pub struct Season {
pub struct Episode {
pub id: EpisodeId,
pub season_id: SeasonId,
/// The owning season's number, carried so status derivation never needs
/// a second slice a caller can forget (#131).
pub season_number: u16,
pub number: u16,
pub title: String,
pub air_date: Option<SystemTime>,
+71 -11
View File
@@ -6,9 +6,10 @@
//! nearly free, because the feed is read again ten minutes later and the
//! targeted search still runs. So every rule here is deliberately strict:
//!
//! - an ID the tracker itself supplied beats anything read off the name, and
//! an ID that matches nothing wanted ends the comparison rather than
//! falling back to the title;
//! - an ID the tracker itself supplied beats anything read off the name,
//! except a season or episode tag in the name, which makes the release a
//! TV one whatever id came with it; and an ID that matches nothing wanted
//! ends the comparison rather than falling back to the title;
//! - two supplied IDs that disagree about which title this is are an
//! ambiguity like any other, and match nothing;
//! - a title match needs the years to agree, and a wanted title with a known
@@ -75,6 +76,13 @@ pub fn match_movie(
ids: &ReleaseIds,
claims: &NameClaims,
) -> Option<MovieMatch> {
// A season or episode tag makes this a TV release, whatever the title
// or the tracker-supplied id says. Movies are the only thing RSS grabs
// today (§13, phase 4), and trackers do mislabel ids.
if claims.episode.is_some() {
return None;
}
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
if ids.tmdb_id.is_some() || imdb_id.is_some() {
// A title matched by both ids is still one title, so each wanted
@@ -95,11 +103,6 @@ pub fn match_movie(
}));
}
// A season or episode tag makes this a TV release, whatever the title
// says. Movies are the only thing RSS grabs today (§13, phase 4).
if claims.episode.is_some() {
return None;
}
let title = match_key(claims.title.as_deref()?);
if title.is_empty() {
return None;
@@ -428,6 +431,35 @@ mod tests {
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
}
/// The same rule on the id lane (#139): trackers mislabel ids, so a
/// season or episode tag in the name vetoes an id that matches too.
#[test]
fn an_episode_tag_beats_a_matching_supplied_id() {
let wanted = vec![WantedMovie {
id: MovieId(1),
tmdb_id: Some(693_134),
imdb_id: Some("tt15239678".to_owned()),
title: "Dune: Part Two".to_owned(),
year: Some(2024),
}];
let cases = [
("Fallout.S01E03.1080p.WEB-DL", &ids(Some(693_134), None)),
("Fallout.S01.1080p.BluRay-GROUP", &ids(Some(693_134), None)),
(
"Fallout.2024.S01E03.1080p.WEB-DL",
&ids(None, Some("tt15239678")),
),
("Fallout.S01E03.1080p.WEB-DL", &ids(None, Some("15239678"))),
];
for (name, release_ids) in cases {
assert_eq!(
match_movie(&wanted, release_ids, &arr_parse::parse(name)),
None,
"{name}"
);
}
}
#[test]
fn two_wanted_titles_that_both_match_are_an_ambiguity() {
let wanted = vec![
@@ -621,6 +653,10 @@ mod tests {
"Fallout.S01.1080p.BluRay.x264-GROUP",
MatchShape::SeasonPack,
),
(
"Fallout.S01-S01.1080p.BluRay.x264-GROUP",
MatchShape::SeasonPack,
),
];
for (name, shape) in cases {
assert_eq!(
@@ -639,6 +675,32 @@ mod tests {
}
}
/// `COMPLETE` before the season tag used to stay in the title and
/// break the exact-title comparison (#135).
#[test]
fn a_pack_marker_before_the_tag_still_matches() {
let wanted = WantedEpisode {
id: EpisodeId(10),
tmdb_id: None,
imdb_id: None,
title: "Bluey".to_owned(),
season: 1,
episode: 3,
};
assert_eq!(
match_episode(
&wanted,
&ReleaseIds::default(),
&arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"),
),
Some(EpisodeMatch {
episode: EpisodeId(10),
kind: MatchKind::TitleAndYear,
shape: MatchShape::SeasonPack,
})
);
}
/// Punctuation, case and accents in the series title do not block the
/// match.
#[test]
@@ -808,9 +870,7 @@ mod tests {
}
}
/// `S01-S01` does not parse as a claim (`arr-parse` takes only a real
/// range), but the lifted rule is what a degenerate range would mean:
/// one season's pack, not a multi-season one.
/// A degenerate range means one season's pack, not a multi-season one.
#[test]
fn a_degenerate_season_range_is_this_seasons_pack() {
use super::{is_single_episode, is_this_seasons_pack};
+25 -45
View File
@@ -1,6 +1,6 @@
use std::time::{Duration, SystemTime};
use crate::{Episode, MediaState, Season, Series};
use crate::{Episode, MediaState, Series};
const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24);
@@ -16,26 +16,19 @@ pub enum SeriesStatus {
/// Derives a series' status at `now` without persisting lifecycle intent.
///
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a
/// season 0 listed here are ignored, so a manually wanted special cannot pin
/// the series at `incomplete` or hold back `ended`. The seasons must be the
/// series' own; an episode whose season is not listed counts like any other.
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with
/// `season_number` 0 are ignored, so a manually wanted special cannot pin
/// the series at `incomplete` or hold back `ended`. The season number rides
/// on each episode, so there is no second slice a caller can forget (#131).
#[must_use]
pub fn derive_series_status(
series: &Series,
seasons: &[Season],
episodes: &[Episode],
now: SystemTime,
) -> SeriesStatus {
// A series has at most one season 0, so this is a comparison rather than
// a set: no allocation on a function the list view calls per series.
let specials = seasons
.iter()
.find(|season| season.number == 0)
.map(|season| season.id);
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
.filter(|episode| episode.wanted && episode.season_number != 0);
let complete = wanted
.clone()
.all(|episode| episode.state == MediaState::Available);
@@ -107,6 +100,7 @@ mod tests {
Episode {
id: EpisodeId(i64::from(number)),
season_id: SeasonId(1),
season_number: 1,
number,
title: format!("Episode {number}"),
air_date,
@@ -117,23 +111,13 @@ mod tests {
}
}
/// The season every default-test episode sits in.
fn seasons() -> [Season; 1] {
[Season {
id: SeasonId(1),
series_id: SeriesId(1),
number: 1,
tracked: true,
}]
}
#[test]
fn airing_when_a_wanted_episode_is_in_the_window() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
for air_date in [now - 14 * DAY, now + 14 * DAY] {
let episodes = [episode(1, Some(air_date), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Airing
);
}
@@ -145,7 +129,7 @@ mod tests {
MediaState::Missing,
)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -156,7 +140,7 @@ mod tests {
let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Incomplete
);
}
@@ -167,7 +151,7 @@ mod tests {
let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -178,7 +162,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete
);
}
@@ -189,7 +173,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, true), &seasons(), &episodes, now),
derive_series_status(&series(true, true), &episodes, now),
SeriesStatus::Ended
);
}
@@ -199,14 +183,14 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Incomplete
);
episodes[0].state = MediaState::Available;
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete
);
}
@@ -216,14 +200,14 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing));
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Airing
);
}
@@ -231,34 +215,30 @@ mod tests {
#[test]
fn missing_specials_do_not_hold_back_complete_or_ended() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let specials = [Season {
id: SeasonId(9),
series_id: SeriesId(1),
number: 0,
tracked: false,
}];
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
special.season_id = SeasonId(9);
special.season_number = 0;
assert_eq!(
derive_series_status(&series(true, false), &specials, &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(special);
assert_eq!(
derive_series_status(&series(true, false), &[], &episodes, now),
SeriesStatus::Incomplete,
"without knowing the seasons, the special pins the series"
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting,
"#131: the season number rides on the episode, so the special is \
invisible however the caller builds the slice"
);
assert_eq!(
derive_series_status(&series(false, false), &specials, &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete,
"§4.2: the only gap is a special"
);
assert_eq!(
derive_series_status(&series(false, true), &specials, &episodes, now),
derive_series_status(&series(false, true), &episodes, now),
SeriesStatus::Ended
);
}
+1
View File
@@ -72,6 +72,7 @@ mod tests {
Episode {
id: EpisodeId(season * 100 + i64::from(number)),
season_id: SeasonId(season),
season_number: u16::try_from(season).unwrap_or_default(),
number,
title: format!("Episode {number}"),
air_date: Some(SystemTime::UNIX_EPOCH),
+71 -1
View File
@@ -130,7 +130,8 @@ impl AttentionAction {
r#"
SELECT id AS "id!: i64", title AS "title!: String", year
FROM movies
WHERE (SELECT count(DISTINCT g.release_id)
WHERE movies.wanted = 1 AND movies.state != 'available'
AND (SELECT count(DISTINCT g.release_id)
FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
AND g.state = 'failed') >= 2
@@ -249,6 +250,7 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
@@ -561,4 +563,72 @@ mod tests {
"both hard-fail conditions roll up to one series notification"
);
}
#[tokio::test]
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
let movie_id = insert_no_pt_source_movie(&database).await;
insert_failed_grab(&database, "movie", movie_id, "first").await;
insert_failed_grab(&database, "movie", movie_id, "second").await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
.bind(movie_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 1, 'Episode 0', 1, 'missing')",
)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(database.pool())
.await
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
.bind(episode_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
+193 -36
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
@@ -908,7 +938,7 @@ struct PendingMovie {
/// The eligible view of a stored release, ranked for selection.
#[derive(Debug, Clone)]
pub(crate) struct Eligible {
id: i64,
pub(crate) id: i64,
pub(crate) indexer_id: i64,
pub(crate) guid: String,
pub(crate) name: String,
@@ -1179,7 +1209,9 @@ pub(crate) async fn store_release(
}
/// The TV counterpart: one release row, associated with every episode the
/// claim covers — a season pack matches the whole season.
/// claim covers — a season pack matches the whole season. Returns the
/// release id alongside the eligible view; the id is what links a season
/// pack into `season_releases`, which lists rejected candidates too.
pub(crate) async fn store_episode_release(
database: &Db,
episode_ids: &[i64],
@@ -1188,7 +1220,7 @@ pub(crate) async fn store_episode_release(
overrides: &TitleOverrides,
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
) -> Result<(i64, Option<Eligible>), GrabError> {
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1208,7 +1240,7 @@ pub(crate) async fn store_episode_release(
.execute(database.pool())
.await?;
}
Ok(eligible)
Ok((release_id, eligible))
}
async fn classify_and_store(
@@ -2415,6 +2447,131 @@ mod tests {
assert_eq!(targeted_searches(&indexer).await, 3);
}
/// §9.6: the daily refresh also writes the three stored artwork fields,
/// so pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn metadata_refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let artwork_metadata = RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
);
let metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
// Idempotent: an unchanged refresh neither rewrites nor re-reports,
// same as the fields above it.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let unchanged: (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(unchanged, (poster, backdrop, vote));
}
/// TMDB reports `vote_average: 0` for a title nobody has rated — absence,
/// not zero (#156). It must store as NULL, be overwritten when votes
/// arrive, and go back to NULL if they are withdrawn.
#[tokio::test]
async fn metadata_refresh_stores_an_unrated_title_as_null() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let unrated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 0.0,"#,
)
};
let rated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
)
};
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
// Votes arrive: the NULL is overwritten.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&rated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, Some(8.152));
// And withdrawn again.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
}
#[tokio::test]
async fn metadata_changes_reset_a_title_backoff() {
let (_dir, database) = wanted_movie().await;
+49 -26
View File
@@ -123,7 +123,7 @@ async fn run() -> Result<(), Error> {
None
};
let notifier = Notifier::new(config.ntfy_url.clone())?;
let (reconcile, manual_grab) =
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
@@ -157,10 +157,17 @@ async fn run() -> Result<(), Error> {
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone()));
// Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so
// the manual-search and manual-grab endpoints were a no-op — the command
// sat in the 64-slot buffer forever.
let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx));
// Issue #107: nothing else drains `AppState`'s command channels, so the
// manual-search and manual-grab endpoints were a no-op — the command sat
// in the 64-slot buffer forever. Issue #132: the episode and season
// channels are drained by the same lane.
let mut manual_task = tokio::spawn(manual::run(
state,
database,
manual_grab,
manual_tv,
shutdown_rx,
));
let signal_tx = shutdown_tx.clone();
let server = async move {
axum::serve(listener, app)
@@ -198,17 +205,18 @@ async fn run() -> Result<(), Error> {
/// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not
/// configured stays unregistered rather than failing every tick.
///
/// Also returns a second, independent `GrabAction` for `manual::run` (issue
/// #107) — the manual trigger needs the same search-and-grab path on demand
/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register`
/// takes ownership of the one it ticks.
/// Also returns independent action instances for `manual::run` (issues #107
/// and #132) — the manual trigger needs the same search-and-grab paths on
/// demand rather than on the reconcile tick's schedule, and
/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie
/// one needs TMDB too; TV grabbing does not (§6.2).
fn reconcile_loop(
database: &Db,
config: &Config,
transmission: &arr_dl::TransmissionClient,
tmdb: Option<&Arc<TmdbClient>>,
notifier: &Notifier,
) -> Result<(ReconcileLoop, Option<GrabAction>), Error> {
) -> Result<(ReconcileLoop, Option<GrabAction>, Option<TvGrabAction>), Error> {
let reconcile = ReconcileLoop::new(database.clone());
let seeding = SeedingRules::new(
SeedingLimits {
@@ -235,7 +243,7 @@ fn reconcile_loop(
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
.transpose()?;
let (mut reconcile, manual_grab) = register_movie_grab(
let (reconcile, manual_grab) = register_movie_grab(
reconcile,
prowlarr.as_ref(),
transmission,
@@ -243,20 +251,8 @@ fn reconcile_loop(
&seeding,
tmdb,
);
// TV grabbing needs no TMDB at grab time: air dates are already on the
// episode rows, which is the same gate the digital release date is for
// movies (§6.2).
if let Some(prowlarr) = prowlarr.as_ref() {
reconcile = reconcile.register(
Tick::Reconcile,
TvGrabAction::new(
prowlarr.clone(),
transmission.clone(),
config.download_dir.clone(),
seeding.clone(),
),
);
}
let (mut reconcile, manual_tv) =
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding);
// RSS needs no TMDB: it matches what the feeds already carry against the
// wanted list (§6.2).
if let Some(prowlarr) = prowlarr {
@@ -322,7 +318,34 @@ fn reconcile_loop(
}
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
Ok((reconcile, manual_grab))
Ok((reconcile, manual_grab, manual_tv))
}
/// Register the TV grab lane on `reconcile` and hand back a second,
/// independent instance for `manual::run` (issue #132). `None` when Prowlarr
/// is not configured. TV grabbing needs no TMDB at grab time: air dates are
/// already on the episode rows, which is the same gate the digital release
/// date is for movies (§6.2).
fn register_tv_grab(
mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient,
config: &Config,
seeding: &SeedingRules,
) -> (ReconcileLoop, Option<TvGrabAction>) {
let Some(prowlarr) = prowlarr else {
return (reconcile, None);
};
let tv_grab_action = || {
TvGrabAction::new(
prowlarr.clone(),
transmission.clone(),
config.download_dir.clone(),
seeding.clone(),
)
};
reconcile = reconcile.register(Tick::Reconcile, tv_grab_action());
(reconcile, Some(tv_grab_action()))
}
/// Register the movie grab lane on `reconcile` and hand back a second,
+102 -20
View File
@@ -1,23 +1,33 @@
//! Drains `AppState`'s `MovieCommand` channel — the daemon-side consumer
//! DESIGN.md §6.2 and §9.3 assume exists (issue #107). `Search` resets the
//! movie's backoff and re-runs the targeted-search + grab lane immediately;
//! Drains `AppState`'s three command channels — the daemon-side consumer
//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search`
//! resets backoff and re-runs the targeted-search + grab lane immediately;
//! `Grab` sends an already-chosen release straight to Transmission, skipping
//! search.
//! search. Movies, episodes and seasons share one lane because they share
//! one operator waiting on a 202.
use arr_api::{AppState, MovieCommand};
use arr_api::{AppState, EpisodeCommand, MovieCommand, SeasonCommand};
use arr_db::Db;
use tokio::sync::watch;
use crate::grab::{GrabAction, GrabError};
use crate::tv_grab::TvGrabAction;
/// Run until every sender is dropped or `shutdown` fires. `grab` is `None`
/// when Prowlarr or TMDB is not configured (`main.rs` warns about this
/// already for the reconcile lane) — commands are still drained so the
/// channel never fills, they just cannot be acted on.
/// Which channel a drained command came off.
enum Command {
Movie(MovieCommand),
Episode(EpisodeCommand),
Season(SeasonCommand),
}
/// Run until every sender is dropped or `shutdown` fires. Either action is
/// `None` when Prowlarr (or TMDB, for movies) is not configured (`main.rs`
/// warns about this already for the reconcile lanes) — commands are still
/// drained so the channels never fill, they just cannot be acted on.
pub async fn run(
state: AppState,
database: Db,
grab: Option<GrabAction>,
movies: Option<GrabAction>,
tv: Option<TvGrabAction>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
@@ -29,22 +39,54 @@ pub async fn run(
}
continue;
}
command = state.next_movie_command() => command,
command = state.next_movie_command() => command.map(Command::Movie),
command = state.next_episode_command() => command.map(Command::Episode),
command = state.next_season_command() => command.map(Command::Season),
};
let Some(command) = command else {
return;
};
let Some(grab) = &grab else {
tracing::warn!("manual movie command received but Prowlarr or TMDB is not configured");
continue;
};
if let Err(error) = handle(grab, &database, command).await {
tracing::error!(%error, "manual movie command failed");
match command {
Command::Movie(command) => {
let Some(movies) = &movies else {
tracing::warn!(
"manual movie command received but Prowlarr or TMDB is not configured"
);
continue;
};
if let Err(error) = handle_movie(movies, &database, command).await {
tracing::error!(%error, "manual movie command failed");
}
}
Command::Episode(command) => {
let Some(tv) = &tv else {
tracing::warn!(
"manual episode command received but Prowlarr is not configured"
);
continue;
};
if let Err(error) = handle_episode(tv, &database, command).await {
tracing::error!(%error, "manual episode command failed");
}
}
Command::Season(command) => {
let Some(tv) = &tv else {
tracing::warn!("manual season command received but Prowlarr is not configured");
continue;
};
if let Err(error) = handle_season(tv, &database, command).await {
tracing::error!(%error, "manual season command failed");
}
}
}
}
}
async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Result<(), GrabError> {
async fn handle_movie(
grab: &GrabAction,
database: &Db,
command: MovieCommand,
) -> Result<(), GrabError> {
match command {
MovieCommand::Search { movie_id } => {
grab.search_now(database, movie_id).await?;
@@ -60,6 +102,46 @@ async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Resu
Ok(())
}
async fn handle_episode(
tv: &TvGrabAction,
database: &Db,
command: EpisodeCommand,
) -> Result<(), GrabError> {
match command {
EpisodeCommand::Search { episode_id } => {
tv.search_episode_now(database, episode_id).await?;
}
EpisodeCommand::Grab {
episode_id,
release_id,
} => {
tv.grab_episode_release_now(database, episode_id, release_id)
.await?;
}
}
Ok(())
}
async fn handle_season(
tv: &TvGrabAction,
database: &Db,
command: SeasonCommand,
) -> Result<(), GrabError> {
match command {
SeasonCommand::Search { season_id } => {
tv.search_season_now(database, season_id).await?;
}
SeasonCommand::Grab {
season_id,
release_id,
} => {
tv.grab_season_release_now(database, season_id, release_id)
.await?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -160,7 +242,7 @@ mod tests {
let transmission = transmission().await;
let grab = grab_action(&indexer, &transmission);
handle(&grab, &database, MovieCommand::Search { movie_id: 1 })
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
.await
.unwrap();
@@ -218,7 +300,7 @@ mod tests {
.await
.unwrap();
handle(
handle_movie(
&grab,
&database,
MovieCommand::Grab {
+693 -56
View File
@@ -9,19 +9,31 @@
//!
//! `blocked` is honoured the other way round from targeted search (§6.3): it
//! stops a title being searched for, and leaves it matching RSS.
//!
//! One pass serves films and episodes alike: each feed item goes through the
//! movie matcher first, then the episode one (`arr_core::matching`). A
//! single-episode release grabs its wanted, missing episode directly; a
//! season pack only grabs when [`season_grab_mode`] allows packs for that
//! season — the same guard §14 gives targeted search, so a season behaves
//! the same however a release is found (§6.2). Like everything else here,
//! none of it backs off or counts attempts.
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use arr_core::matching::{match_movie, MatchKind, ReleaseIds, WantedMovie};
use arr_core::{Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy};
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
use arr_core::matching::{
match_episode, match_movie, MatchKind, MatchShape, ReleaseIds, WantedEpisode, WantedMovie,
};
use arr_core::{EpisodeId, Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
use arr_dl::TransmissionClient;
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use crate::grab::{
store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, SeedingRules,
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
SeedingRules,
};
use crate::indexers::IndexerDirectory;
use crate::reconcile::{Action, ActionFuture, Outcome};
@@ -51,7 +63,8 @@ impl RssAction {
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
let wanted = wanted_movies(database).await?;
if wanted.is_empty() {
let episodes = wanted_episodes(database).await?;
if wanted.is_empty() && episodes.is_empty() {
return Ok(Vec::new());
}
let indexers = self.indexers.searchable().await?;
@@ -62,12 +75,12 @@ impl RssAction {
let releases = self.feeds(&indexers).await;
let blacklist = Blacklist::load(database.pool()).await?;
let winners = self
.match_feeds(database, &wanted, releases, &blacklist)
let (movies, tv) = self
.match_feeds(database, &wanted, &episodes, releases, &blacklist)
.await?;
let mut outcomes = Vec::new();
for (movie, winner) in winners {
for (movie, winner) in movies {
let outcome = self
.grabber
.send_winner(
@@ -86,6 +99,29 @@ impl RssAction {
.await?;
outcomes.extend(outcome);
}
for TvWinner {
grabbable,
scope,
candidate: winner,
} in tv
{
let outcome = self
.grabber
.send_winner(
database,
&GrabTarget {
scope,
title: &grabbable.series_title,
// Same property as the movie lane above.
counts_as_attempt: false,
},
&grabbable.policy,
&blacklist,
winner,
)
.await?;
outcomes.extend(outcome);
}
Ok(outcomes)
}
@@ -116,11 +152,13 @@ impl RssAction {
&self,
database: &Db,
wanted: &[WantedMovie],
episodes: &[WantedEpisodeRow],
releases: Vec<SearchRelease>,
blacklist: &Blacklist,
) -> Result<Vec<(Grabbable, Eligible)>, GrabError> {
) -> Result<(Vec<(Grabbable, Eligible)>, Vec<TvWinner>), GrabError> {
let mut policies: HashMap<i64, Option<Grabbable>> = HashMap::new();
let mut best: HashMap<i64, Eligible> = HashMap::new();
let mut tv = TvCandidates::default();
for release in releases {
let claims = arr_parse::parse(&release.name);
@@ -128,56 +166,239 @@ impl RssAction {
tmdb_id: release.tmdb_id,
imdb_id: release.imdb_id.clone(),
};
let Some(matched) = match_movie(wanted, &ids, &claims) else {
continue;
};
let movie_id = matched.movie.0;
let grabbable = match policies.entry(movie_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_grabbable(database, wanted, movie_id).await?)
if let Some(matched) = match_movie(wanted, &ids, &claims) {
let movie_id = matched.movie.0;
let grabbable = match policies.entry(movie_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_grabbable(database, wanted, movie_id).await?)
}
};
let Some(grabbable) = grabbable.as_ref() else {
continue;
};
tracing::info!(
movie_id,
release = release.name,
by = match matched.kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title and year",
},
"RSS result matched a wanted title"
);
if let Some(candidate) = store_release(
database,
movie_id,
&release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?
{
let incumbent = best.get(&movie_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
best.insert(movie_id, candidate);
}
}
};
let Some(grabbable) = grabbable.as_ref() else {
continue;
};
tracing::info!(
movie_id,
release = release.name,
by = match matched.kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title and year",
},
"RSS result matched a wanted title"
);
let stored = store_release(
database,
movie_id,
&release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?;
let Some(candidate) = stored else {
continue;
};
let incumbent = best.get(&movie_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
best.insert(movie_id, candidate);
}
tv.offer(database, episodes, &release, &ids, &claims, blacklist)
.await?;
}
let mut winners: Vec<(Grabbable, Eligible)> = best
let tv_winners = tv.winners(database, episodes).await?;
let mut movies: Vec<(Grabbable, Eligible)> = best
.into_iter()
.filter_map(|(movie_id, candidate)| {
let grabbable = policies.get(&movie_id).and_then(Clone::clone)?;
Some((grabbable, candidate))
})
.collect();
winners.sort_by_key(|(movie, _)| movie.id);
movies.sort_by_key(|(movie, _)| movie.id);
Ok((movies, tv_winners))
}
}
/// The episode lane's state over one feed pass: the grab context per season
/// and the best eligible candidate per wanted episode and per season's pack.
#[derive(Default)]
struct TvCandidates {
policies: HashMap<i64, Option<TvGrabbable>>,
best_single: HashMap<i64, Eligible>,
/// Per season: the best pack alongside the open episodes it covers.
best_pack: HashMap<i64, (Eligible, Vec<i64>)>,
}
impl TvCandidates {
/// Offer one feed item to every wanted episode. A release holds either
/// exactly one episode or whole seasons, so each row it matches lands in
/// exactly one bucket.
async fn offer(
&mut self,
database: &Db,
episodes: &[WantedEpisodeRow],
release: &SearchRelease,
ids: &ReleaseIds,
claims: &arr_parse::NameClaims,
blacklist: &Blacklist,
) -> Result<(), GrabError> {
let mut packs: HashMap<i64, Vec<i64>> = HashMap::new();
let mut single: Option<(i64, MatchKind)> = None;
for row in episodes {
let Some(matched) = match_episode(&row.wanted, ids, claims) else {
continue;
};
match matched.shape {
MatchShape::SeasonPack => {
packs
.entry(row.season_id)
.or_default()
.push(row.wanted.id.0);
}
MatchShape::SingleEpisode => single = Some((row.wanted.id.0, matched.kind)),
}
}
for (season_id, covered) in packs {
let Some(candidate) = self
.store_candidate(database, episodes, season_id, &covered, release, blacklist)
.await?
else {
continue;
};
let incumbent = self.best_pack.get(&season_id);
if incumbent.is_none_or(|(incumbent, _)| beats(&candidate, incumbent)) {
self.best_pack.insert(season_id, (candidate, covered));
}
}
if let Some((episode_id, kind)) = single {
tracing::info!(
episode_id,
release = release.name,
by = tv_match_kind(kind),
"RSS result matched a wanted episode"
);
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
return Ok(());
};
let Some(candidate) = self
.store_candidate(
database,
episodes,
row.season_id,
&[episode_id],
release,
blacklist,
)
.await?
else {
return Ok(());
};
let incumbent = self.best_single.get(&episode_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
self.best_single.insert(episode_id, candidate);
}
}
Ok(())
}
/// Classify and store a feed item against the open episodes it covers,
/// loading the season's grab context on first use.
async fn store_candidate(
&mut self,
database: &Db,
episodes: &[WantedEpisodeRow],
season_id: i64,
covered: &[i64],
release: &SearchRelease,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
let grabbable = match self.policies.entry(season_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_tv_grabbable(database, episodes, season_id).await?)
}
};
let Some(grabbable) = grabbable.as_ref() else {
return Ok(None);
};
let (_, stored) = store_episode_release(
database,
covered,
release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?;
Ok(stored)
}
/// Turn the per-season and per-episode winners into grabs. A season with
/// an eligible pack only takes it when [`season_grab_mode`] allows packs
/// there — the guard targeted search obeys (§6.2, §14) — and when it
/// does, the season's singles stand down. Otherwise each open episode
/// takes its own winner.
async fn winners(
&self,
database: &Db,
episodes: &[WantedEpisodeRow],
) -> Result<Vec<TvWinner>, GrabError> {
let mut winners = Vec::new();
let mut covered_by_packs: HashSet<i64> = HashSet::new();
let mut seasons: Vec<i64> = self.best_pack.keys().copied().collect();
seasons.sort_unstable();
for season_id in seasons {
let Some((candidate, episode_ids)) = self.best_pack.get(&season_id) else {
continue;
};
let Some(grabbable) = self.policies.get(&season_id).and_then(Clone::clone) else {
continue;
};
if !pack_allowed(database, season_id).await? {
tracing::info!(
season_id,
series = grabbable.series_title,
"RSS skips a season pack the season's grab mode refuses"
);
continue;
}
covered_by_packs.extend(episode_ids.iter().copied());
winners.push(TvWinner {
grabbable,
scope: GrabScope::Season {
season_id,
episode_ids: episode_ids.clone(),
},
candidate: candidate.clone(),
});
}
let mut singles: Vec<i64> = self.best_single.keys().copied().collect();
singles.sort_unstable();
for episode_id in singles {
if covered_by_packs.contains(&episode_id) {
continue;
}
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
continue;
};
let Some(grabbable) = self.policies.get(&row.season_id).and_then(Clone::clone) else {
continue;
};
let Some(candidate) = self.best_single.get(&episode_id) else {
continue;
};
winners.push(TvWinner {
grabbable,
scope: GrabScope::Episode { episode_id },
candidate: candidate.clone(),
});
}
Ok(winners)
}
}
@@ -255,6 +476,196 @@ async fn wanted_movies(database: &Db) -> Result<Vec<WantedMovie>, GrabError> {
.collect())
}
/// A matched series season with everything a grab needs loaded.
#[derive(Clone, Debug)]
struct TvGrabbable {
series_title: String,
policy: TitlePolicy,
language: Language,
}
/// One winner of the episode lane: what to grab, for whom, and which
/// release won.
struct TvWinner {
grabbable: TvGrabbable,
scope: GrabScope,
candidate: Eligible,
}
/// A wanted episode with the season context matching and grabbing need.
struct WantedEpisodeRow {
wanted: WantedEpisode,
season_id: i64,
}
fn tv_match_kind(kind: MatchKind) -> &'static str {
match kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title",
}
}
/// Every wanted episode still open — wanted, no file on disk, no live grab
/// of its own or of its season. Same shape as the movie list: unlimited,
/// unordered by recency, blocked series included on purpose (§6.3).
async fn wanted_episodes(database: &Db) -> Result<Vec<WantedEpisodeRow>, GrabError> {
let rows = sqlx::query!(
r#"
SELECT e.id AS "id!: i64",
e.number AS "episode!: i64",
se.id AS "season_id!: i64",
se.number AS "season!: i64",
s.tmdb_id AS "series_tmdb_id!: i64",
s.title AS "series_title!: String"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE e.wanted = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'season' AND g.target_id = se.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
ORDER BY e.id
"#
)
.fetch_all(database.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| WantedEpisodeRow {
wanted: WantedEpisode {
id: EpisodeId(row.id),
tmdb_id: u32::try_from(row.series_tmdb_id).ok(),
// The series table carries no `IMDb` id yet, so this lane
// matches on TMDB id or title until one is backfilled.
imdb_id: None,
title: row.series_title,
season: u32::try_from(row.season).unwrap_or_default(),
episode: u32::try_from(row.episode).unwrap_or_default(),
},
season_id: row.season_id,
})
.collect())
}
/// The policy and original language a matched season needs, or `None` when
/// it cannot be evaluated yet. Same refusals as [`load_grabbable`].
async fn load_tv_grabbable(
database: &Db,
episodes: &[WantedEpisodeRow],
season_id: i64,
) -> Result<Option<TvGrabbable>, GrabError> {
let Some(series_title) = episodes
.iter()
.find(|row| row.season_id == season_id)
.map(|row| row.wanted.title.clone())
else {
return Ok(None);
};
let Some(policy) = database.season_policy(season_id).await? else {
return Ok(None);
};
// §5.2, same as films.
let language: Option<String> = sqlx::query_scalar!(
r#"
SELECT s.original_language
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?
"#,
season_id
)
.fetch_optional(database.pool())
.await?
.flatten();
let Some(language) = language else {
tracing::warn!(
season_id,
series = series_title,
"no original language yet; not grabbing from RSS"
);
return Ok(None);
};
Ok(Some(TvGrabbable {
series_title,
policy,
language: arr_db::policy::language(&language),
}))
}
/// Whether §6.2's guard lets a season pack be grabbed here: exactly what
/// [`season_grab_mode`] requires of targeted search, computed over every
/// episode the season is known to hold (§14, amended in #117).
async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let episodes = sqlx::query!(
r#"
SELECT e.air_date,
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "on_disk!: bool"
FROM episodes e
WHERE e.season_id = ?
ORDER BY e.number
"#,
season_id
)
.fetch_all(database.pool())
.await?;
let pack_hard_failed = sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
) AS "failed!: bool""#,
season_id
)
.fetch_one(database.pool())
.await?;
Ok(season_grab_mode(&SeasonGrabFacts {
air_dates: &episodes
.iter()
.map(|episode| air_date_time(episode.air_date.as_deref()))
.collect::<Vec<_>>(),
now: std::time::SystemTime::now(),
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
pack_hard_failed,
}) == SeasonGrabMode::SeasonPack)
}
/// An `air_date` as TMDB writes it (`YYYY-MM-DD`), or a full timestamp if
/// one ever arrives that way. Unknown is unaired.
fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
let value = value?;
let timestamp = if let Ok(date) = value.parse::<chrono::NaiveDate>() {
date.and_time(chrono::NaiveTime::MIN).and_utc().timestamp()
} else {
value
.parse::<chrono::DateTime<chrono::Utc>>()
.ok()?
.timestamp()
};
let seconds = u64::try_from(timestamp.abs()).ok()?;
if timestamp < 0 {
std::time::UNIX_EPOCH.checked_sub(std::time::Duration::from_secs(seconds))
} else {
std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds))
}
}
/// The policy and original language a matched title needs, or `None` when
/// the title cannot be evaluated yet.
async fn load_grabbable(
@@ -312,6 +723,48 @@ mod tests {
/// asked for.
const FEED: &str = include_str!("../tests/fixtures/rss.xml");
/// A season pack and its three episodes, all eligible under the seeded
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
const TV_FEED: &str = r#"<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<item>
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP</title>
<guid>tv-pack</guid><link>https://indexer.invalid/download/tv-pack</link>
<enclosure url="https://indexer.invalid/download/tv-pack" length="85899345920"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="120"/>
</item>
<item>
<title>Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e01</guid><link>https://indexer.invalid/download/tv-e01</link>
<enclosure url="https://indexer.invalid/download/tv-e01" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e02</guid><link>https://indexer.invalid/download/tv-e02</link>
<enclosure url="https://indexer.invalid/download/tv-e02" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e03</guid><link>https://indexer.invalid/download/tv-e03</link>
<enclosure url="https://indexer.invalid/download/tv-e03" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>The.Fallout.2021.S01E03.1080p.WEB-DL-GROUP</title>
<guid>tv-near-miss</guid><link>https://indexer.invalid/download/tv-near-miss</link>
<enclosure url="https://indexer.invalid/download/tv-near-miss" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
</channel>
</rss>"#;
const INDEXERS: [i64; 2] = [7, 9];
/// Enough of Transmission to add a torrent and list nothing back.
@@ -350,9 +803,9 @@ mod tests {
/// Two indexers, both advertising a text search, both serving the same
/// feed to an empty query.
async fn prowlarr() -> MockServer {
async fn prowlarr(feed: &str) -> MockServer {
let server = MockServer::start().await;
let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server);
let feed = test_downloads::rewrite(feed, "https://indexer.invalid/download/", &server);
test_downloads::mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
@@ -430,6 +883,59 @@ mod tests {
)
}
/// Fallout S01 with wanted episodes airing at the given dates. Returns
/// the season id and the episode ids in episode order.
async fn wanted_series(database: &Db, air_dates: &[&str]) -> (i64, Vec<i64>) {
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id)
SELECT 106379, 'Fallout', 2024, 'en', id
FROM roots WHERE kind = 'tv' AND audience = 'main'",
)
.execute(database.pool())
.await
.unwrap();
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (1, 1) RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
let mut episodes = Vec::new();
for (index, air_date) in air_dates.iter().enumerate() {
let id: i64 = sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, air_date, wanted)
VALUES (?, ?, ?, ?, 1) RETURNING id",
)
.bind(season_id)
.bind(i64::try_from(index).unwrap() + 1)
.bind(format!("Episode {}", index + 1))
.bind(air_date)
.fetch_one(database.pool())
.await
.unwrap();
episodes.push(id);
}
(season_id, episodes)
}
/// `(target_kind, target_id, state)` for every TV grab that was made.
async fn tv_grabs(database: &Db) -> Vec<(String, i64, String)> {
sqlx::query_as::<_, (String, i64, String)>(
"SELECT target_kind, target_id, state FROM grabs
WHERE target_kind IN ('episode', 'season') ORDER BY target_kind, target_id",
)
.fetch_all(database.pool())
.await
.unwrap()
}
async fn episode_states(database: &Db) -> Vec<String> {
sqlx::query_scalar::<_, String>("SELECT state FROM episodes ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap()
}
/// `(movie title, grabbed release name)` for every grab that was made.
async fn grabbed(database: &Db) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
@@ -462,7 +968,7 @@ mod tests {
(438_631, "Dune", 2021, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -506,7 +1012,7 @@ mod tests {
#[tokio::test]
async fn a_near_miss_is_not_grabbed() {
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -525,7 +1031,7 @@ mod tests {
(9_999_999, "Dune: Part Three", 2024, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -542,7 +1048,7 @@ mod tests {
#[tokio::test]
async fn a_blocked_title_still_matches_rss() {
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -560,7 +1066,7 @@ mod tests {
(438_631, "Dune", 2021, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -582,4 +1088,135 @@ mod tests {
assert_eq!(feeds, 1);
}
}
/// A single-episode release in the feed closes a wanted, missing
/// episode's gap, with no targeted-search attempt spent (§6.2). The
/// season here is airing, so its pack is not eligible ([`season_grab_mode`]).
#[tokio::test]
async fn a_missing_episode_is_grabbed_from_rss() {
let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await;
let indexer = prowlarr(TV_FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 2);
assert_eq!(
tv_grabs(&database).await,
episodes
.iter()
.map(|id| ("episode".to_owned(), *id, "sent".to_owned()))
.collect::<Vec<_>>(),
"each gap takes its own single; the pack and the near miss stay out"
);
assert_eq!(fake.added.lock().unwrap().len(), 2);
let attempts: Vec<(i64, Option<String>)> =
sqlx::query_as("SELECT search_attempts, last_searched_at FROM episodes ORDER BY id")
.fetch_all(database.pool())
.await
.unwrap();
assert!(attempts
.iter()
.all(|(count, at)| *count == 0 && at.is_none()));
}
/// The acceptance case: one feed pass over a fully aired season with
/// nothing on disk takes the pack alone, and the singles stand down.
#[tokio::test]
async fn a_completed_season_takes_one_pack_from_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, _episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
// Only the pack is wanted here: strip the singles so the pass has to
// pick between shapes on coverage, not on availability.
let pack_only = TV_FEED.replace(
"Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E01.2160p.WEB-DL-GROUP</title>",
);
let pack_only = pack_only.replace(
"Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E02.2160p.WEB-DL-GROUP</title>",
);
let pack_only = pack_only.replace(
"Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
);
let indexer = prowlarr(&pack_only).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(fake.added.lock().unwrap().len(), 1);
assert_eq!(
tv_grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading"; 3],
"the pack flips every episode it covers"
);
}
/// §6.2 with #117's guard: an episode already on disk keeps the season
/// per-episode here too — the pack is skipped and the open gaps take
/// their singles.
#[tokio::test]
async fn a_pack_for_a_season_with_a_file_on_disk_is_skipped() {
let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size)
VALUES ('episode', ?, '/tv/fallout/s01e01.mkv', 1)",
)
.bind(episodes[0])
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let grabs = tv_grabs(&database).await;
assert!(
!grabs.iter().any(|(kind, _, _)| kind == "season"),
"no pack over an episode already on disk"
);
// Episode 1 is not wanted any more — it has a file — and the near
// miss never matched, so exactly the two open gaps are filled.
assert_eq!(
grabs,
vec![
("episode".to_owned(), episodes[1], "sent".to_owned()),
("episode".to_owned(), episodes[2], "sent".to_owned()),
]
);
}
/// §6.3: `blocked` stops targeted search for a series and leaves RSS
/// matching on. The one-episode season here is fully aired, so what
/// matches is its pack — the same release targeted search would take.
#[tokio::test]
async fn a_blocked_series_still_matches_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, _episodes) = wanted_series(&database, &["2024-04-11"]).await;
sqlx::query("UPDATE series SET blocked = 1")
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(
tv_grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
}
}
+381 -8
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 {
@@ -157,6 +175,10 @@ impl SeriesRefreshAction {
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 +223,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)
}
@@ -279,6 +324,7 @@ impl SeriesRefreshAction {
let mut fresh = Vec::new();
let mut changed = false;
let season = season_number(number);
for source in &detail.episodes {
let number = i64::from(source.number);
if let Some(&(id, ref title, ref air_date, vanished)) = known.get(&number) {
@@ -311,6 +357,7 @@ impl SeriesRefreshAction {
fresh.push(CoreEpisode {
id: EpisodeId(0),
season_id: SeasonId(season_id),
season_number: season,
number: season_number(source.number),
title: source.title.clone(),
air_date: source.air_date.map(system_time),
@@ -389,6 +436,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 {
@@ -412,8 +505,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.
@@ -449,6 +545,7 @@ fn core_episodes(series_id: i64, detail: &TmdbSeasonDetail) -> Vec<CoreEpisode>
.map(|source| CoreEpisode {
id: EpisodeId(0),
season_id: SeasonId(series_id),
season_number: season_number(detail.number),
number: season_number(source.number),
title: source.title.clone(),
air_date: source.air_date.map(system_time),
@@ -499,6 +596,7 @@ mod tests {
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use arr_meta::UNTITLED_EPISODE;
fn series_body(status: &str) -> serde_json::Value {
json!({
@@ -535,6 +633,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 {
@@ -603,6 +743,42 @@ 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 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));
}
#[tokio::test]
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
let (_dir, database) = seeded_series(true).await;
@@ -973,4 +1149,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]);
}
}
+719 -1
View File
@@ -415,7 +415,7 @@ impl TvGrabAction {
if covered.is_empty() {
continue;
}
let stored = store_episode_release(
let (stored_id, stored) = store_episode_release(
database,
&covered,
&release,
@@ -425,6 +425,22 @@ impl TvGrabAction {
blacklist,
)
.await?;
// A pack for exactly this season belongs on the season deck too
// (§9.3, issue #125) — including when it is rejected, which the
// deck shows greyed out.
if claim
.as_ref()
.is_some_and(|claim| is_this_seasons_pack(claim, season.season_number_u32()))
{
sqlx::query!(
"INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)
ON CONFLICT DO NOTHING",
season.season_id,
stored_id
)
.execute(database.pool())
.await?;
}
candidates.push(TvCandidate {
claim,
eligible: stored,
@@ -432,6 +448,229 @@ impl TvGrabAction {
}
Ok(candidates)
}
/// The manual episode trigger (§6.2, §9.3, issue #132): reset the
/// episode's backoff so selection cannot skip it, then sweep the
/// indexers scoped to this one episode instead of the tick's
/// `EPISODE_SEARCHES_PER_TICK` batch.
///
/// Same shape as the movie trigger: a gap gets search and grab; an
/// already-satisfied episode gets its deck refreshed and no grab; a
/// blocked series is refused outright (§6.3). An unaired episode is not
/// a gap yet — nothing to close until it airs (§6.2).
pub(crate) async fn search_episode_now(
&self,
database: &Db,
episode_id: i64,
) -> Result<(), GrabError> {
let Some(episode) = manual_episode(database, episode_id).await? else {
tracing::info!(
episode_id,
"manual episode search refused: unknown or blocked episode"
);
return Ok(());
};
let Some(original_language) = episode.season.original_language.as_deref() else {
tracing::warn!(
series = episode.season.series_title,
"no original language yet; not searching"
);
return Ok(());
};
let original_language = arr_db::policy::language(original_language);
let Some(loaded) = database.episode_policy(episode_id).await? else {
return Ok(());
};
reset_episode_backoff(database, episode_id).await?;
let blacklist = Blacklist::load(database.pool()).await?;
let episodes = season_episodes(database, episode.season.season_id).await?;
let selector = TvSelector::Episode {
season: episode.season.season_number_u32(),
episode: u32::try_from(episode.number).unwrap_or_default(),
};
if !episode.is_gap() {
let candidates = self
.search(
database,
&episode.season,
&episodes,
selector,
&loaded,
&original_language,
&blacklist,
)
.await?;
record_episode_search(database, &[episode_id]).await?;
tracing::info!(
episode_id,
eligible = candidates.len(),
"episode release deck refreshed; not grabbing a satisfied episode"
);
return Ok(());
}
if !episode.aired() {
tracing::info!(episode_id, "episode has not aired; nothing to search for");
return Ok(());
}
let Some(this_one) = episodes.iter().find(|row| row.id == episode_id) else {
return Ok(());
};
self.grab_episodes(
database,
&episode.season,
&episodes,
std::slice::from_ref(&this_one),
&loaded,
&original_language,
&blacklist,
usize::MAX,
)
.await?;
Ok(())
}
/// The manual one-click episode grab (§9.3, issue #132): the release is
/// already chosen off the episode deck, so this skips search and scoring
/// and sends it straight to Transmission. A manual grab may take a
/// `waived` release (§9.3), never a `rejected` one.
pub(crate) async fn grab_episode_release_now(
&self,
database: &Db,
episode_id: i64,
release_id: i64,
) -> Result<Option<Outcome>, GrabError> {
let Some(loaded) = database.episode_policy(episode_id).await? else {
return Ok(None);
};
let Some(title) = episode_series_title(database, episode_id).await? else {
return Ok(None);
};
let Some(release) = load_episode_release(database, episode_id, release_id).await? else {
return Ok(None);
};
let blacklist = Blacklist::load(database.pool()).await?;
self.grabber
.send_winner(
database,
&GrabTarget {
scope: GrabScope::Episode { episode_id },
title: &title,
counts_as_attempt: false,
},
&loaded,
&blacklist,
release,
)
.await
}
/// The manual season trigger (issue #125's deck, drained per #132):
/// reset every open episode's backoff and run the same pack-or-fall-back
/// lane as the tick, scoped to this one season with no tick budgets.
///
/// A satisfied season — nothing open, or a grab already in flight —
/// gets its deck refreshed and no grab; a blocked series refuses.
pub(crate) async fn search_season_now(
&self,
database: &Db,
season_id: i64,
) -> Result<(), GrabError> {
let Some(season) = manual_season(database, season_id).await? else {
tracing::info!(
season_id,
"manual season search refused: unknown or blocked season"
);
return Ok(());
};
let Some(original_language) = season.original_language.as_deref() else {
tracing::warn!(
series = season.series_title,
"no original language yet; not searching"
);
return Ok(());
};
let original_language = arr_db::policy::language(original_language);
let Some(loaded) = database.season_policy(season_id).await? else {
return Ok(());
};
reset_season_backoff(database, season_id).await?;
let blacklist = Blacklist::load(database.pool()).await?;
if !season.is_gap {
let episodes = season_episodes(database, season_id).await?;
let open: Vec<i64> = episodes
.iter()
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
.map(|episode| episode.id)
.collect();
let candidates = self
.search(
database,
&season,
&episodes,
TvSelector::Season {
season: season.season_number_u32(),
},
&loaded,
&original_language,
&blacklist,
)
.await?;
record_episode_search(database, &open).await?;
tracing::info!(
season_id,
eligible = candidates.len(),
"season release deck refreshed; not grabbing a satisfied season"
);
return Ok(());
}
self.grab_season(database, &season, &blacklist, usize::MAX)
.await?;
Ok(())
}
/// The manual one-click season grab (§9.3, issue #125): a chosen pack
/// goes straight to Transmission against the season's still-open gaps.
pub(crate) async fn grab_season_release_now(
&self,
database: &Db,
season_id: i64,
release_id: i64,
) -> Result<Option<Outcome>, GrabError> {
let Some(loaded) = database.season_policy(season_id).await? else {
return Ok(None);
};
let Some(title) = season_series_title(database, season_id).await? else {
return Ok(None);
};
let Some(release) = load_season_release(database, season_id, release_id).await? else {
return Ok(None);
};
let episode_ids: Vec<i64> = season_episodes(database, season_id)
.await?
.iter()
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
.map(|episode| episode.id)
.collect();
let blacklist = Blacklist::load(database.pool()).await?;
self.grabber
.send_winner(
database,
&GrabTarget {
scope: GrabScope::Season {
season_id,
episode_ids,
},
title: &title,
counts_as_attempt: false,
},
&loaded,
&blacklist,
release,
)
.await
}
}
impl Action for TvGrabAction {
@@ -593,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result<Vec<SeasonEpis
.collect())
}
/// One episode named by a manual command (#132), with the series context a
/// targeted search needs. Same gap facts as [`season_episodes`] plus the
/// lane decision. Blocked refuses upstream of here.
#[derive(Debug)]
struct ManualEpisode {
number: i64,
air_date: Option<String>,
wanted: bool,
has_file: bool,
in_flight: bool,
season: PendingSeason,
}
impl ManualEpisode {
/// Wanted with neither a file nor a grab in flight — a gap the manual
/// trigger may close. Anything else only gets a deck refresh.
fn is_gap(&self) -> bool {
self.wanted && !self.has_file && !self.in_flight
}
/// Unknown is unaired (§6.2): nothing to close until it airs.
fn aired(&self) -> bool {
air_date_time(self.air_date.as_deref())
.is_some_and(|date| date <= std::time::SystemTime::now())
}
}
/// Same columns as [`pending_seasons`], scoped to one id, without the tick's
/// batch limit and without the gap filter — the manual trigger names the
/// season, so what remains decides the lane here instead of hiding it.
async fn manual_episode(
database: &Db,
episode_id: i64,
) -> Result<Option<ManualEpisode>, GrabError> {
let row = sqlx::query!(
r#"
SELECT e.id AS "id!: i64",
e.number AS "number!: i64",
e.air_date,
e.wanted AS "wanted!: bool",
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "has_file!: bool",
EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
) AS "in_flight!: bool",
se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE e.id = ?
AND s.blocked = 0
"#,
episode_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| ManualEpisode {
number: row.number,
air_date: row.air_date,
wanted: row.wanted,
has_file: row.has_file,
in_flight: row.in_flight,
season: PendingSeason {
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
},
}))
}
/// A season addressed by a manual command, with whether it is still a gap.
async fn manual_season(
database: &Db,
season_id: i64,
) -> Result<Option<PendingSeasonGap>, GrabError> {
let row = sqlx::query!(
r#"
SELECT se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language,
(
EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'season' AND g.target_id = se.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
) AS "is_gap!: bool"
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?
AND s.blocked = 0
"#,
season_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| PendingSeasonGap {
season: PendingSeason {
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
},
is_gap: row.is_gap,
}))
}
/// [`PendingSeason`] plus the lane decision for the manual trigger.
struct PendingSeasonGap {
season: PendingSeason,
is_gap: bool,
}
impl std::ops::Deref for PendingSeasonGap {
type Target = PendingSeason;
fn deref(&self) -> &PendingSeason {
&self.season
}
}
/// Unconditional, unlike the tick's backoff gate: the manual trigger's whole
/// point is to ignore the exponential backoff (§6.2).
async fn reset_episode_backoff(database: &Db, episode_id: i64) -> Result<(), GrabError> {
sqlx::query!(
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
episode_id
)
.execute(database.pool())
.await?;
Ok(())
}
/// A manual season search is one attempt against every open episode at
/// once, so they all start from zero together.
async fn reset_season_backoff(database: &Db, season_id: i64) -> Result<(), GrabError> {
sqlx::query!(
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE season_id = ?",
season_id
)
.execute(database.pool())
.await?;
Ok(())
}
async fn episode_series_title(database: &Db, episode_id: i64) -> Result<Option<String>, GrabError> {
let title = sqlx::query_scalar!(
r#"SELECT s.title AS "title!: String"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE e.id = ?"#,
episode_id
)
.fetch_optional(database.pool())
.await?;
Ok(title)
}
async fn season_series_title(database: &Db, season_id: i64) -> Result<Option<String>, GrabError> {
let title = sqlx::query_scalar!(
r#"SELECT s.title AS "title!: String"
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?"#,
season_id
)
.fetch_optional(database.pool())
.await?;
Ok(title)
}
/// The chosen release, still associated with the episode and not
/// hard-failed — a manual grab may take a `waived` release (§9.3).
async fn load_episode_release(
database: &Db,
episode_id: i64,
release_id: i64,
) -> Result<Option<Eligible>, GrabError> {
let row = sqlx::query!(
r#"
SELECT r.id AS "id!: i64",
r.indexer_id AS "indexer_id!: i64",
r.guid AS "guid!: String",
r.name AS "name!: String",
r.download_url AS "download_url!: String",
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
FROM releases r
JOIN episode_releases er ON er.release_id = r.id
WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
"#,
episode_id,
release_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| Eligible {
id: row.id,
indexer_id: row.indexer_id,
guid: row.guid,
name: row.name,
download_url: row.download_url,
score: row.score,
}))
}
/// The season counterpart: chosen off the season deck (issue #125).
async fn load_season_release(
database: &Db,
season_id: i64,
release_id: i64,
) -> Result<Option<Eligible>, GrabError> {
let row = sqlx::query!(
r#"
SELECT r.id AS "id!: i64",
r.indexer_id AS "indexer_id!: i64",
r.guid AS "guid!: String",
r.name AS "name!: String",
r.download_url AS "download_url!: String",
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
FROM releases r
JOIN season_releases sr ON sr.release_id = r.id
WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
"#,
season_id,
release_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| Eligible {
id: row.id,
indexer_id: row.indexer_id,
guid: row.guid,
name: row.name,
download_url: row.download_url,
score: row.score,
}))
}
/// Whether a season-pack grab for this season already hard-failed — the
/// fall-back-to-per-episode signal.
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
@@ -1099,4 +1612,209 @@ mod tests {
.count();
assert_eq!(searches_after_first, searches_after_second);
}
/// Issue #132: an `EpisodeCommand::Search` must do the work, not merely
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
/// backoff, nowhere near elapsed) so a tick would skip this episode;
/// ending at 1 rather than 6 proves the reset happened, and a torrent in
/// Transmission proves the grab did.
#[tokio::test]
async fn a_manual_episode_search_searches_and_grabs_now() {
let (_dir, database, _season_id) =
wanted_season(&["2024-04-11", "2024-04-18", "2024-04-25"]).await;
sqlx::query(
"UPDATE episodes SET search_attempts = 5,
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')
WHERE number = 1",
)
.execute(database.pool())
.await
.unwrap();
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.search_episode_now(&database, episode_id)
.await
.unwrap();
let sources: Vec<String> = fake
.torrents()
.into_iter()
.map(|torrent| torrent.source)
.collect();
assert_eq!(sources.len(), 1, "{sources:?}");
assert!(sources[0].ends_with("e01.torrent"));
assert_eq!(
grabs(&database).await,
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
);
let attempts: i64 = sqlx::query_scalar("SELECT search_attempts FROM episodes WHERE id = ?")
.bind(episode_id)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(
attempts, 0,
"reset from 5; a completed grab records no further attempt"
);
}
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
/// straight to Transmission — no indexer search at all.
#[tokio::test]
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
?, '{}', 900, 'eligible') RETURNING id",
)
.bind(format!("{}/dl/e01.torrent", indexer.uri()))
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
.bind(episode_id)
.bind(release_id)
.execute(database.pool())
.await
.unwrap();
action(&indexer, &downloader)
.grab_episode_release_now(&database, episode_id, release_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert_eq!(
grabs(&database).await,
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
);
let searched = indexer
.received_requests()
.await
.unwrap()
.iter()
.any(|request| {
request
.url
.query_pairs()
.any(|(name, value)| name == "t" && value == "search")
});
assert!(!searched, "a chosen release must not trigger a search");
}
/// Issue #132: a `SeasonCommand::Search` runs the pack lane now, against
/// the season deck's own table — the backoff reset is what proves it was
/// really drained rather than dropped on the floor.
#[tokio::test]
async fn a_manual_season_search_takes_the_pack_now() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
sqlx::query(
"UPDATE episodes SET search_attempts = 5,
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')",
)
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.search_season_now(&database, season_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
assert_eq!(
grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading", "downloading", "downloading"]
);
let packs: Vec<String> = sqlx::query_scalar(
"SELECT r.name FROM season_releases sr JOIN releases r ON r.id = sr.release_id",
)
.fetch_all(database.pool())
.await
.unwrap();
assert!(packs.contains(&"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos".to_owned()));
let attempts: Vec<i64> =
sqlx::query_scalar("SELECT search_attempts FROM episodes ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(
attempts,
vec![0, 0, 0],
"reset from 5; a completed grab records no further attempt"
);
}
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
/// straight to Transmission — no indexer search at all.
#[tokio::test]
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
?, '{}', 900, 'eligible') RETURNING id",
)
.bind(format!("{}/dl/pack.torrent", indexer.uri()))
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(database.pool())
.await
.unwrap();
action(&indexer, &downloader)
.grab_season_release_now(&database, season_id, release_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert_eq!(
grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading", "downloading", "downloading"]
);
let searched = indexer
.received_requests()
.await
.unwrap()
.iter()
.any(|request| {
request
.url
.query_pairs()
.any(|(name, value)| name == "t" && value == "search")
});
assert!(!searched, "a chosen release must not trigger a search");
}
}
@@ -0,0 +1,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;
+71 -4
View File
@@ -9,8 +9,10 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{
ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage,
RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
SeriesSearchResult, Video,
};
/// TMDB's v3 API root.
@@ -94,14 +96,20 @@ impl TmdbClient {
/// Resolve an `IMDb` title id through TMDB's external-id index.
///
/// TMDB answers with both kinds in one response, so one call surfaces
/// whichever the id names — the way a raw TMDB id resolves (§9.2).
///
/// # Errors
///
/// Any of [`Error`]; see its variants for what callers should distinguish.
pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result<Vec<MovieSearchResult>> {
pub async fn find_by_imdb(&self, imdb_id: &str) -> Result<FindResults> {
let path = format!("find/{}", imdb_id.trim());
let params = [("external_source", "imdb_id".to_owned())];
let page: RawFindPage = self.get_json(&path, &params).await?;
Ok(page.movie_results.into_iter().map(Into::into).collect())
Ok(FindResults {
movies: page.movie_results.into_iter().map(Into::into).collect(),
series: page.tv_results.into_iter().map(Into::into).collect(),
})
}
/// Resolve a TVDB series id through TMDB's external-id index.
@@ -157,6 +165,65 @@ impl TmdbClient {
Ok(raw.into())
}
/// Rich detail for one movie's §9.6 page.
///
/// One HTTP call: credits, videos and external ids come back appended to
/// the same response. Served through the same cache as [`Self::movie`];
/// nothing here is persisted (§9.6).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
let path = format!("movie/{tmdb_id}");
let params = [(
"append_to_response",
"credits,videos,external_ids".to_owned(),
)];
let raw: RawMovieDetail = self.get_json(&path, &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, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
};
+405 -5
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(),
@@ -293,6 +457,15 @@ impl From<RawSeason> for Season {
}
}
/// What one `IMDb` id resolved to. An id names one title, so at most one of
/// the two lists is non-empty — but TMDB answers both kinds in the same
/// response, and both are surfaced rather than filtered here.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FindResults {
pub movies: Vec<MovieSearchResult>,
pub series: Vec<SeriesSearchResult>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawFindPage {
#[serde(default)]
@@ -316,6 +489,10 @@ pub(crate) struct RawSearchResult {
overview: Option<String>,
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
}
impl From<RawSearchResult> for MovieSearchResult {
@@ -328,6 +505,8 @@ impl From<RawSearchResult> for MovieSearchResult {
release_date: raw.release_date.as_deref().and_then(parse_date),
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
}
}
}
@@ -358,6 +537,12 @@ pub(crate) struct RawMovie {
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
vote_count: u32,
#[serde(default)]
release_dates: Option<RawReleaseDates>,
}
@@ -417,6 +602,9 @@ impl From<RawMovie> for Movie {
status: raw.status,
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
}
}
}
@@ -449,3 +637,215 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|text| !text.is_empty())
}
/// TMDB uses `0` where "no votes yet" is meant, so an unrated title never
/// carries a rating rather than carrying zero.
fn rating(value: f64) -> Option<f64> {
(value != 0.0).then_some(value)
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawMovieDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
runtime: Option<u32>,
#[serde(default)]
imdb_id: Option<String>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeriesDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
struct RawGenre {
id: u32,
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCredits {
#[serde(default)]
cast: Vec<RawCastMember>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCastMember {
id: u32,
#[serde(default)]
name: String,
#[serde(default)]
character: String,
#[serde(default)]
profile_path: Option<String>,
order: u32,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawVideoList {
#[serde(default)]
results: Vec<RawVideo>,
}
impl RawVideoList {
pub(crate) fn into_videos(self) -> Vec<Video> {
self.results.into_iter().map(Into::into).collect()
}
}
#[derive(Debug, Deserialize)]
struct RawVideo {
#[serde(default)]
key: String,
#[serde(default)]
site: String,
#[serde(rename = "type", default)]
kind: String,
#[serde(default)]
name: String,
official: bool,
}
impl From<RawVideo> for Video {
fn from(raw: RawVideo) -> Self {
Self {
key: raw.key,
site: raw.site,
kind: raw.kind,
name: raw.name,
official: raw.official,
}
}
}
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
let mut cast: Vec<CastMember> = raw
.map(|credits| {
credits
.cast
.into_iter()
.map(|member| CastMember {
tmdb_id: member.id,
name: member.name,
character: member.character,
profile_path: non_empty(member.profile_path),
order: member.order,
})
.collect()
})
.unwrap_or_default();
// TMDB's own ordering is by `order`; sorting makes the truncation hold
// even if a fixture or future API revision sends them shuffled.
cast.sort_by_key(|member| member.order);
cast.truncate(CAST_LIMIT);
cast
}
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
let videos: Vec<Video> = raw
.map(|list| list.results.into_iter().map(Into::into).collect())
.unwrap_or_default();
select_trailer(&videos)
}
impl From<RawMovieDetail> for MovieDetail {
fn from(raw: RawMovieDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
runtime: raw.runtime,
imdb_id: non_empty(raw.imdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
impl From<RawSeriesDetail> for SeriesDetail {
fn from(raw: RawSeriesDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
episode_runtime: raw.episode_run_time.into_iter().next(),
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"movie_results": [],
"tv_results": [
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"overview": "The slice-of-life adventures of an Australian cattle dog.",
"poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg"
}
]
}
+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 }
]
}
+309 -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};
@@ -19,6 +19,11 @@ const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
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")
@@ -212,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)));
}
@@ -463,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]
@@ -483,6 +518,33 @@ async fn series_external_ids_carries_the_tvdb_id() {
assert_eq!(ids.tvdb_id, Some(361_391));
}
/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a
/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results`
/// half is what this reads.
#[tokio::test]
async fn find_by_imdb_resolves_series() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/find/tt7614372"))
.and(query_param("external_source", "imdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES))
.expect(1)
.mount(&server)
.await;
let results = client(&server)
.find_by_imdb("tt7614372")
.await
.expect("lookup succeeds");
assert!(results.movies.is_empty());
assert_eq!(results.series.len(), 1);
let series = &results.series[0];
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.title, "Bluey");
assert_eq!(series.year(), Some(2018));
}
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
/// can tell "unknown" from a real id and fall back to the text query.
#[tokio::test]
@@ -501,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);
}
+4 -3
View File
@@ -36,7 +36,7 @@ pub(crate) fn tag_of(token: &str) -> Option<EpisodeClaim> {
.or_else(|| date_tag(token))
}
/// `S01`, `S01-S03`, `S01E02`, `S01E02E03`, `S01E02-E04`.
/// `S01`, `S01-S03`, `S01-S01`, `S01E02`, `S01E02E03`, `S01E02-E04`.
fn season_tag(token: &str) -> Option<EpisodeClaim> {
let rest = token.strip_prefix('s')?;
let (season, mut rest) = digits(rest);
@@ -55,7 +55,8 @@ fn season_tag(token: &str) -> Option<EpisodeClaim> {
return None;
}
let last: u32 = last.parse().ok()?;
return (last > season).then_some(EpisodeClaim::Seasons {
// A degenerate range (`S01-S01`) is one season's pack, not nothing.
return (last >= season).then_some(EpisodeClaim::Seasons {
first: season,
last,
});
@@ -148,7 +149,7 @@ fn season_words(token: &str) -> Option<EpisodeClaim> {
return None;
}
let last: u32 = last.parse().ok()?;
(last > first).then_some(EpisodeClaim::Seasons { first, last })
(last >= first).then_some(EpisodeClaim::Seasons { first, last })
}
/// Splits a leading run of ASCII digits off a token.
+11 -3
View File
@@ -55,11 +55,19 @@ pub(crate) fn classify_pair(a: &str, b: &str) -> Option<(Marker, Strength)> {
("season", n) if !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()) => {
(Marker::Junk, Strength::Strong)
}
("seasons", n) if is_season_count(n) => (Marker::Junk, Strength::Strong),
_ => return None,
};
Some(m)
}
/// `1`, `1-3`: the count after a spelled-out `Seasons`. Each part is 1-2
/// digits so a trailing year (`Four Seasons 2024`) never reads as a count.
fn is_season_count(n: &str) -> bool {
n.split('-')
.all(|part| (1..=2).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit()))
}
#[allow(clippy::too_many_lines)]
fn classify_exact(t: &str) -> Option<(Marker, Strength)> {
use Strength::{Strong, Weak};
@@ -135,9 +143,9 @@ fn classify_exact(t: &str) -> Option<(Marker, Strength)> {
// "Legendado" claims pt-BR *subtitles* over original audio — it must
// NOT become a PtBr audio claim or main-root originals get rejected.
"proper" | "repack" | "internal" | "hybrid" | "10bit" | "8bit" | "hi10p" | "amzn"
| "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" => (Marker::Junk, Strong),
"nf" | "hulu" | "max" | "itunes" | "cr" | "complete" | "sample" | "subbed" | "subs"
| "sub" | "mkv" | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => {
| "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" | "complete" => (Marker::Junk, Strong),
"nf" | "hulu" | "max" | "itunes" | "cr" | "sample" | "subbed" | "subs" | "sub" | "mkv"
| "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => {
(Marker::Junk, Weak)
}
_ => return None,
+72
View File
@@ -307,6 +307,78 @@ fn cases() -> Vec<Case> {
..NameClaims::default()
},
},
Case {
// `COMPLETE` before the tag closes the title like any strong
// marker, wherever the group puts it.
name: "Bluey.COMPLETE.S01.1080p.WEB-DL",
want: NameClaims {
title: s("Bluey"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// The trailing placement already worked; it keeps working.
name: "Bluey.S01.COMPLETE.1080p.WEB-DL",
want: NameClaims {
title: s("Bluey"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// A degenerate range is one season's pack.
name: "Fallout.S01-S01.1080p.BluRay.x264-GROUP",
want: NameClaims {
title: s("Fallout"),
resolution: Some(Resolution::P1080),
source: Some(Source::BluRay),
codec: Some(Codec::X264),
group: s("GROUP"),
episode: Some(EpisodeClaim::Seasons { first: 1, last: 1 }),
..NameClaims::default()
},
},
Case {
// The spelled-out plural range closes the title too.
name: "Fallout.Seasons.1-3.1080p.BluRay-GROUP",
want: NameClaims {
title: s("Fallout"),
resolution: Some(Resolution::P1080),
source: Some(Source::BluRay),
group: s("GROUP"),
episode: Some(EpisodeClaim::Seasons { first: 1, last: 3 }),
..NameClaims::default()
},
},
Case {
// A bare number after the plural is a single season, like
// the singular form.
name: "Show.Name.Seasons.1.1080p.WEB-DL-GROUP",
want: NameClaims {
title: s("Show Name"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
group: s("GROUP"),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// A year-sized number after `Seasons` is a year, not a count.
name: "The.Four.Seasons.2024.1080p.WEB-DL",
want: NameClaims {
title: s("The Four Seasons"),
year: Some(2024),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
..NameClaims::default()
},
},
Case {
name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM",
want: NameClaims {
+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;
+2280 -274
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);
}

Some files were not shown because too many files have changed in this diff Show More