Compare commits

...

120 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 34ccbeeb5a feat(api): expose vanished flag on episodes 2026-08-23 18:01:41 +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
Miguel Palhas 8b9a6d5a16 feat(daemon): reconcile episodes that vanish upstream 2026-08-23 17:59:45 +01:00
Miguel Palhas 3bcc612f9a feat(db): flag episodes that vanish upstream 2026-08-23 17:59:41 +01:00
Miguel Palhas 384e260d72 Merge #121: daily series metadata refresh
Closes #121
2026-08-23 17:47:46 +01:00
Miguel Palhas 0c58aae707 feat(daemon): daily series metadata refresh lane 2026-08-23 17:45:09 +01:00
Miguel Palhas 808145d73b feat(db): stamp series metadata refresh time 2026-08-23 17:45:09 +01:00
Miguel Palhas 360a4bf43c Merge #127: unified search covers series and episodes
Closes #127
2026-08-23 17:41:22 +01:00
Miguel Palhas 58f2f8361a feat(web): mirror tv results in search client 2026-08-23 17:39:49 +01:00
Miguel Palhas 9f8d58fd58 feat(api): unified search covers series and episodes 2026-08-23 17:35:40 +01:00
Miguel Palhas 237696417b Merge #123: episode and season-pack matching in arr-core
Closes #123
2026-08-23 17:10:05 +01:00
Miguel Palhas e6d3ea4964 feat(core): episode and season-pack matching 2026-08-23 17:06:04 +01:00
Miguel Palhas 8b8e1dcec9 Merge #126: TV titles in the needs-a-decision queue
Closes #126
2026-08-23 16:58:08 +01:00
Miguel Palhas bb75750548 fix(web): reword comment tripping the token gate 2026-08-23 16:56:19 +01:00
Miguel Palhas 24f5295e87 chore(db): refresh offline query data 2026-08-23 16:55:58 +01:00
Miguel Palhas 28e7dd9a20 Merge #119: seasons.tracked governs episode intent
Closes #119
2026-08-23 16:54:47 +01:00
Miguel Palhas e3d41c24e1 refactor(daemon): extract tv queue roll-up 2026-08-23 16:53:52 +01:00
Miguel Palhas 8c7c785c74 Merge #125: season search, deck and grab
Closes #125
2026-08-23 16:52:45 +01:00
Miguel Palhas 7632140ef2 fix(api): hoist test helper above statements 2026-08-23 16:52:13 +01:00
Miguel Palhas 8ec1506098 refactor: split long functions in tv attention work 2026-08-23 16:51:29 +01:00
Miguel Palhas def9650489 style: cargo fmt 2026-08-23 16:50:53 +01:00
Miguel Palhas fa3b5226e7 feat(api): season tracked toggle marks revealed episodes 2026-08-23 16:50:45 +01:00
Miguel Palhas 75bf6fb14e feat(api): season search, deck and grab endpoints 2026-08-23 16:49:42 +01:00
Miguel Palhas 42a0895975 feat(db): season_releases table and season policy loader 2026-08-23 16:49:42 +01:00
Miguel Palhas e8cea4aec5 refactor(api): extract tv attention lanes 2026-08-23 16:47:38 +01:00
Miguel Palhas 135d4b8247 feat(core): season tracked rule over revealed episodes 2026-08-23 16:46:26 +01:00
Miguel Palhas b801bfbcc3 style: cargo fmt 2026-08-23 16:44:32 +01:00
Miguel Palhas 24f6dab109 feat(api): tv series in the attention queues endpoint 2026-08-23 16:44:16 +01:00
Miguel Palhas 93ccea2de0 fix(api): specials stay out of episode counters 2026-08-23 16:40:38 +01:00
Miguel Palhas a1971ed82c feat(daemon): tv series in the needs-a-decision queue 2026-08-23 16:39:06 +01:00
Miguel Palhas fc93d27f7e Merge #120: series.tvdb_id and tvsearch by ID
Closes #120
2026-08-23 16:35:34 +01:00
Miguel Palhas b911f31b03 Merge #118: exclude season 0 from tracking and status
Closes #118
2026-08-23 16:35:30 +01:00
Miguel Palhas 3bd613ec63 feat(core): derived status ignores season 0 2026-08-23 16:32:13 +01:00
Miguel Palhas 2fca8ec09f feat(core): auto-track skips season 0 2026-08-23 16:32:13 +01:00
Miguel Palhas da42467539 Merge #117: amend DESIGN.md for TV tracking
Closes #117
2026-08-23 16:24:17 +01:00
Miguel Palhas 248870bcd0 docs(design): amend TV tracking rules 2026-08-23 16:23:28 +01:00
125 changed files with 13750 additions and 703 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,20 @@
{
"db_name": "SQLite",
"query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 3
},
"nullable": [
true
]
},
"hash": "0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab"
}
@@ -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"
}
@@ -0,0 +1,38 @@
{
"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 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": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"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"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "tag!: String",
"ordinal": 3,
"type_info": "Null"
},
{
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 7,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
null,
false,
true,
true,
false
]
},
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM episodes WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? AND number = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tracked!: bool",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false
]
},
"hash": "3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
"describe": {
"columns": [
{
@@ -19,38 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 8,
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 9,
"ordinal": 11,
"type_info": "Text"
}
],
@@ -63,12 +73,14 @@
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882"
"hash": "3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
"describe": {
"columns": [
{
@@ -19,38 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 8,
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 9,
"ordinal": 11,
"type_info": "Text"
}
],
@@ -63,12 +73,14 @@
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb"
"hash": "3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652"
}
@@ -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 EXISTS(\n SELECT 1 FROM media_files\n WHERE owner_kind = 'episode' AND owner_id = ?\n ) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d"
}
@@ -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,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n 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.title, s.year, se.id\n ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
}
@@ -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 title LIKE ? 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": "f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b"
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34"
}
@@ -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.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
"describe": {
"columns": [
{
@@ -19,38 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 8,
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 9,
"ordinal": 11,
"type_info": "Text"
}
],
@@ -63,12 +73,14 @@
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26"
"hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467"
}
@@ -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 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": [
{
"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": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
true,
true
]
},
"hash": "6f41ffaa0679bbbe51ea5d54fbdbf757e0f85d1d86ee6322253f223fae49265f"
}
@@ -0,0 +1,92 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "metadata_refreshed_at",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE se.id = ?\n ",
"describe": {
"columns": [
{
"name": "overrides!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "root_kind!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "root_audience!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "root_path!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "policy_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "policy_name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "required_audio!: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "dub_blacklist!: String",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "hdr_rules!: String",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "size_bands!: String",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "resolution_pref!: String",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "source_weights!: String",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "score_weights!: String",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506"
}
@@ -0,0 +1,50 @@
{
"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 \"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": [
{
"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": "season_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"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"
}
@@ -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 ORDER BY title, year, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -64,9 +64,19 @@
"type_info": "Text"
},
{
"name": "waiver?: serde_json::Value",
"name": "poster_path",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +95,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "61e9081eaedc7a69ada44b057c59553511e64634b75386d523f82c2d03d78f1a"
"hash": "8c61a4e4f6b3b8cc02cd4b6289f6889889ab06d9a77f26c4d745659c8e1f4a07"
}
@@ -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"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829"
}
@@ -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"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\"\n FROM seasons WHERE series_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "tracked!: bool",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false
]
},
"hash": "9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -64,9 +64,19 @@
"type_info": "Text"
},
{
"name": "waiver?: serde_json::Value",
"name": "poster_path",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text"
}
],
"parameters": {
@@ -85,8 +95,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "af1b98d2aa84eac69323823e232b61897d4f6bef978e3bf46ba0d999f90c4acd"
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
}
@@ -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"
}
@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date,\n vanished AS \"vanished!: bool\"\n FROM episodes WHERE season_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "air_date",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
false
]
},
"hash": "a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n 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 ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4"
}
@@ -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"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"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": "size!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "seeders",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "publish_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "download_url!: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "score",
"ordinal": 9,
"type_info": "Float"
},
{
"name": "verdict",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "rejected_rule",
"ordinal": 11,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
}
@@ -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 title LIKE ? 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": "540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226"
"hash": "ad1a3b886179c638051ed9bf34ddbc153097fd7626f68b316d810ccc8305222d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM seasons WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8"
}
@@ -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,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET tvdb_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e"
}
@@ -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 s.tmdb_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": [
true,
false,
false,
true,
true,
false,
false,
true,
true
]
},
"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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes SET title = ?, air_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND (title IS NOT ? OR air_date IS NOT ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
"describe": {
"columns": [
{
@@ -19,38 +19,48 @@
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "number!: i64",
"ordinal": 4,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "air_date",
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 8,
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 9,
"ordinal": 11,
"type_info": "Text"
}
],
@@ -63,12 +73,14 @@
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372"
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
}
@@ -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,20 @@
{
"db_name": "SQLite",
"query": "SELECT blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "blocked!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET upstream_ended = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85"
}
+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`.
+85 -6
View File
@@ -129,10 +129,24 @@ happening right now". It is user-maintained, so it drifts and stops meaning
anything.
Here, intent is `Episode.wanted` and `Movie.wanted` only.
`Series.auto_track` is not intent — it is a rule that says "when metadata
reveals a new season, mark its episodes wanted". An untracked series where you
`Series.auto_track` is not intent — it is a rule one level up: when metadata
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
already-revealed episode in the season wanted, and keeps marking episodes
wanted as metadata reveals them. Turning it off withdraws nothing: leaf intent
is never removed implicitly, so anything already marked wanted stays wanted
until cleared explicitly.
Both rules skip season 0. Specials are dozens of undated shorts and recaps
that indexers do not carry, so `auto_track` never marks season 0 tracked. See
§4.2 for how season 0 stays out of derived status.
### 4.2 Status is derived, never stored as intent
Recomputed on metadata refresh and on file change:
@@ -150,6 +164,17 @@ 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
grabbable in the series detail view, but never affect status.
### 4.3 People are tags, not paths
`Owner` is a many-to-many tag on titles. It drives UI filtering, the default
@@ -339,7 +364,12 @@ do ID-based search; some are text-only.
RSS is an empty-query Torznab call whose results are matched against the wanted
list locally. Because its cost does not scale with the wanted list, every wanted
item is matched against every RSS result forever.
item is matched against every RSS result forever — with one guard: the RSS lane
skips a season pack for a season that already has episodes on disk, the same
guard §14 applies to re-grabs. Both lanes agree, so a season does not behave
differently depending on which lane sees a release first. A pack for a season
with nothing on disk stays eligible. When a single episode cannot be found on
its own, the escape hatch is the season release deck, not a lane exception.
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
title's metadata changes. It never gives up entirely, it goes quiet.
@@ -457,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.
@@ -465,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
@@ -520,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`.
@@ -644,5 +721,7 @@ means that pipeline is already proven.
"remuxes are bad" to "remux audio needs a downmix".
- **Size band numbers** in §5.5 are placeholders pending that test.
- **Season-pack re-grab.** When an airing season completes, the episodes are
already present individually. Nothing re-grabs the pack. Whether that is ever
wanted is unresolved and deliberately deferred.
already present individually. Nothing re-grabs the pack, and per §6.2 the
RSS lane obeys the same guard — it skips packs for any season with episodes
on disk. Whether a re-grab is ever wanted remains unresolved and deliberately
deferred.
+30 -1
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,
};
@@ -36,7 +39,10 @@ pub use series::{
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
UpdateSeason, UpdateSeries,
};
pub use state::{AppState, EpisodeCommand, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
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.
@@ -79,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))
@@ -87,12 +94,18 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::search_episode))
.routes(routes!(series::episode_releases))
.routes(routes!(series::grab_episode))
.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))
@@ -307,7 +320,22 @@ 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",
),
(
"/api/series/{series_id}/seasons/{season_number}/releases",
"get",
),
(
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
"post",
),
("/api/queues/attention", "get"),
("/api/trailer", "get"),
("/api/series", "get"),
("/api/policies", "get"),
("/api/policies", "post"),
@@ -326,6 +354,7 @@ mod tests {
"UpdateMovie",
"Release",
"AttentionQueues",
"SeriesAttention",
"Series",
] {
assert!(
+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);
}
}
+465 -8
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>,
@@ -93,6 +98,41 @@ pub struct MovieFile {
pub struct AttentionQueues {
pub no_pt_source: Vec<Movie>,
pub needs_decision: Vec<Movie>,
/// TV side (§9.5): series whose wanted episodes found no Portuguese-audio
/// source.
pub tv_no_pt_source: Vec<SeriesAttention>,
/// TV side: series put here by hard failures on episodes or season packs.
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)]
pub struct SeriesAttention {
pub series_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
/// Episodes that put the series in this queue.
pub episodes: Vec<QueuedEpisode>,
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
pub seasons: Vec<QueuedSeason>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -114,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,
@@ -134,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 => (
@@ -205,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?)
}
@@ -230,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?
};
@@ -271,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?;
@@ -284,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")),
@@ -459,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()?;
@@ -621,18 +684,196 @@ 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?;
Ok(Json(AttentionQueues {
no_pt_source,
needs_decision,
tv_no_pt_source,
tv_needs_decision,
}))
}
/// The TV lanes of the attention queues (§9.5): one entry per series with
/// the episodes and seasons that put it there. The two hard-fail conditions
/// share a lane; a series arriving through both is merged into one entry.
async fn tv_attention(
state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
let database = pool(state)?;
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",
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
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
)
.fetch_all(database)
.await?;
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",
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, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#
)
.fetch_all(database)
.await?;
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", 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, se.number
"#
)
.fetch_all(database)
.await?;
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
for row in no_pt_rows {
merge_episode(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
for row in episode_hard_fails {
merge_episode(
&mut tv_needs_decision,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
for row in season_pack_fails {
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: &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);
return;
}
entries.push(SeriesAttention {
series_id,
tmdb_id,
title: title.to_owned(),
year,
episodes: vec![episode],
seasons: Vec::new(),
});
}
#[utoipa::path(
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie row id")),
@@ -724,6 +965,7 @@ pub async fn untag_owner(
mod tests {
use super::*;
use crate::{router, Upstreams};
use sqlx::Row;
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
@@ -761,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.
@@ -1183,4 +1492,152 @@ mod tests {
assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0));
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
}
/// §9.5 TV: the attention queues report series, with the episodes and
/// seasons that put each one there.
#[tokio::test]
async fn attention_queues_report_tv_series() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id, episode_id) = seed_queued_series(pool).await;
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.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"],
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!([
{ "id": episode_id, "season_number": 1, "episode_number": 3 },
])
);
assert_eq!(
queues["tv_needs_decision"][0]["seasons"],
serde_json::json!([{ "id": season_id, "number": 1 }])
);
// Blocking the series empties its no-PT-source lane.
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
.bind(series_id)
.execute(pool)
.await
.expect("block series");
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("blocked queues")
.json()
.await
.expect("blocked queues json");
assert_eq!(queues["tv_no_pt_source"].as_array().map(Vec::len), Some(0));
assert_eq!(
queues["tv_needs_decision"].as_array().map(Vec::len),
Some(1),
"hard failures queue regardless of blocking"
);
}
/// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a
/// season pack that hard-failed; and an episode two different releases
/// hard-failed on.
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids tv root");
let series_id: i64 = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (1, 'Bluey', 2018, ?)
RETURNING id",
)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series")
.get(0);
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
for number in [1, 2] {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
VALUES (?, ?, ?, 1, 'missing', 1)",
)
.bind(season_id)
.bind(number)
.bind(format!("Episode {number}"))
.execute(pool)
.await
.expect("episode");
}
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 3, 'Episode 3', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("episode");
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 3")
.bind(season_id)
.fetch_one(pool)
.await
.expect("episode id");
for (kind, guid, suffix) in [
("season", "pack", "pack"),
("episode", "first", "a"),
("episode", "second", "b"),
] {
let release_id: i64 = sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
)
.bind(guid)
.fetch_one(pool)
.await
.expect("release")
.get(0);
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
)
.bind(release_id)
.bind(kind)
.bind(if kind == "season" {
season_id
} else {
episode_id
})
.bind(format!("hash-{suffix}"))
.execute(pool)
.await
.expect("failed grab");
}
(series_id, season_id, episode_id)
}
}
+408 -36
View File
@@ -33,11 +33,57 @@ pub struct ReleasesQuery {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse {
pub kind: SearchInputKind,
pub library: Vec<Movie>,
pub tmdb: Vec<TmdbMovie>,
/// In-library hits, grouped first (§9.2): movies and series by title,
/// episodes by episode title with their series and `SxxEyy` for context.
pub library: Vec<LibraryResult>,
pub tmdb: Vec<TmdbResult>,
pub manual: Option<String>,
}
/// One in-library hit. Tagged so a client can branch on what it found
/// without re-deriving it from fields.
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LibraryResult {
Movie(Movie),
Series(LibrarySeries),
Episode(LibraryEpisode),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibrarySeries {
pub id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
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)]
pub struct LibraryEpisode {
pub episode_id: i64,
pub series_id: i64,
pub series_title: String,
/// `SxxEyy`, so the episode title reads in context (§9.2).
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)]
#[serde(rename_all = "snake_case")]
pub enum SearchInputKind {
@@ -57,6 +103,31 @@ 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)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TmdbResult {
Movie(TmdbMovie),
Series(TmdbSeries),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct TmdbSeries {
pub tmdb_id: u32,
pub title: String,
pub original_language: String,
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
@@ -122,31 +193,74 @@ pub async fn search(
}
let database = state.database().ok_or(ApiError::Unavailable)?;
let mut library = if matches!(kind, SearchInputKind::TmdbId) {
let tokens = like_tokens(input);
// §9.2 keeps the two result sets grouped and the library first, so each
// kind lands in its own block: movies, then series, then episodes.
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<i64>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
let pattern = format!("%{}%", escape_like(input));
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 title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern)
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, 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)
.collect()
};
let series_rows = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.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", 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 {
let pattern = format!("%{}%", escape_like(input));
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 title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, pattern)
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
.fetch_all(database.pool()).await?
};
library.extend(series_rows.into_iter().map(LibraryResult::Series));
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so the haystack
// is both joined and every token must land somewhere in it.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", 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));
let tmdb = tmdb_client(&state)?;
let results = search_tmdb(&tmdb, kind, input).await?;
if matches!(kind, SearchInputKind::ImdbId) {
for result in &results {
let tmdb_id = i64::from(result.tmdb_id);
if let Some(movie) = 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(movie);
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));
}
}
}
}
}
@@ -159,11 +273,24 @@ pub async fn search(
}))
}
/// Movie and series TMDB ids live in separate namespaces, so deduplication
/// keys on the kind as well as the id.
fn already_matched(matches: &[TmdbResult], candidate: &TmdbResult) -> bool {
let (is_series, tmdb_id) = match candidate {
TmdbResult::Movie(movie) => (false, movie.tmdb_id),
TmdbResult::Series(series) => (true, series.tmdb_id),
};
matches.iter().any(|existing| match existing {
TmdbResult::Movie(movie) => !is_series && movie.tmdb_id == tmdb_id,
TmdbResult::Series(series) => is_series && series.tmdb_id == tmdb_id,
})
}
async fn search_tmdb(
tmdb: &arr_meta::TmdbClient,
kind: SearchInputKind,
input: &str,
) -> Result<Vec<TmdbMovie>, ApiError> {
) -> Result<Vec<TmdbResult>, ApiError> {
let results = match kind {
SearchInputKind::TmdbId => {
let id = input
@@ -172,10 +299,11 @@ async fn search_tmdb(
.trim()
.parse::<u32>()
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
// §9.2: a raw TMDB id resolves against both movie and TV.
let mut matches = match tmdb.movie(id).await {
Ok(movie) => {
let year = movie.year();
vec![TmdbMovie {
vec![TmdbResult::Movie(TmdbMovie {
tmdb_id: movie.tmdb_id,
title: movie.title,
original_title: movie.original_title,
@@ -183,41 +311,77 @@ 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(),
Err(error) => return Err(upstream_error(&error)),
};
match tmdb.series(id).await {
Ok(series) => matches.push(TmdbResult::Series(series.into())),
Err(arr_meta::Error::NotFound { .. }) => {}
Err(error) => return Err(upstream_error(&error)),
}
if !input.starts_with("tmdb:") {
for movie in tmdb
.search_movies(input, None)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(TmdbMovie::from)
{
if !matches
.iter()
.any(|candidate| candidate.tmdb_id == movie.tmdb_id)
{
matches.push(movie);
let candidate = TmdbResult::Movie(movie.into());
if !already_matched(&matches, &candidate) {
matches.push(candidate);
}
}
for series in tmdb
.search_series(input)
.await
.map_err(|error| upstream_error(&error))?
{
let candidate = TmdbResult::Series(series.into());
if !already_matched(&matches, &candidate) {
matches.push(candidate);
}
}
}
matches
}
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
Ok(movies) => movies.into_iter().map(TmdbMovie::from).collect(),
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
Err(error) => return Err(upstream_error(&error)),
},
SearchInputKind::Text => tmdb
.search_movies(input, None)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(TmdbMovie::from)
.collect(),
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();
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)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(|movie| TmdbResult::Movie(movie.into()))
.collect();
results.extend(
tmdb.search_series(input)
.await
.map_err(|error| upstream_error(&error))?
.into_iter()
.map(|series| TmdbResult::Series(series.into())),
);
results
}
SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(),
};
Ok(results)
@@ -416,6 +580,40 @@ 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,
}
}
}
impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
fn from(series: arr_meta::SeriesSearchResult) -> Self {
let year = series.year();
Self {
tmdb_id: series.tmdb_id,
title: series.title,
original_language: series.original_language,
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
impl From<arr_meta::Series> for TmdbSeries {
fn from(series: arr_meta::Series) -> Self {
let year = series.year();
Self {
tmdb_id: series.tmdb_id,
title: series.title,
original_language: series.original_language,
year,
overview: series.overview,
poster_path: series.poster_path,
vote_average: series.vote_average,
vote_count: series.vote_count,
}
}
}
@@ -441,6 +639,19 @@ fn input_kind(input: &str) -> SearchInputKind {
}
}
/// §9.2's example — `bluey hospital` finding the episode — needs word-wise
/// matching, not one literal phrase. The query's tokens travel as a JSON
/// array the queries walk with `json_each`; LIKE metacharacters are escaped
/// here rather than in SQL.
fn like_tokens(input: &str) -> serde_json::Value {
serde_json::Value::Array(
input
.split_whitespace()
.map(|token| serde_json::Value::String(escape_like(token)))
.collect(),
)
}
fn escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
@@ -469,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,
@@ -633,11 +844,19 @@ 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)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)")
.execute(state.database().expect("database").pool()).await.expect("movie");
@@ -649,8 +868,103 @@ mod tests {
.await
.expect("json");
assert_eq!(response["kind"], "text");
assert_eq!(response["library"][0]["kind"], "movie");
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
/// `bluey hospital` finds the episode with its series and `SxxEyy` along
/// for context — and plain `bluey` finds the series itself.
#[tokio::test]
async fn library_results_cover_series_and_episode_titles() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search/movie"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
"id": 82_728, "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");
let series_id: i64 = sqlx::query_scalar(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id",
)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series");
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
.bind(season_id)
.execute(pool)
.await
.expect("episode");
let response: serde_json::Value =
reqwest::get(format!("{base}/api/search?q=bluey%20hospital"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["library"].as_array().expect("library").len(), 1);
let episode = &response["library"][0];
assert_eq!(episode["kind"], "episode");
assert_eq!(episode["series_title"], "Bluey");
assert_eq!(episode["tag"], "S01E02");
assert_eq!(episode["title"], "Hospital");
// TMDB carries the TV result below the library set.
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["library"][0]["kind"], "series");
assert_eq!(response["library"][0]["title"], "Bluey");
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
}
#[tokio::test]
@@ -662,6 +976,11 @@ mod tests {
.respond_with(ResponseTemplate::new(404))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/tv/1917"))
.respond_with(ResponseTemplate::new(404))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/movie"))
.and(query_param("query", "1917"))
@@ -671,6 +990,13 @@ mod tests {
})))
.mount(&tmdb)
.await;
Mock::given(method("GET"))
.and(path("/search/tv"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)")
.execute(state.database().expect("database").pool()).await.expect("movie");
@@ -685,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;
File diff suppressed because it is too large Load Diff
+32
View File
@@ -72,6 +72,8 @@ pub struct AppState {
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
episode_commands: mpsc::Sender<EpisodeCommand>,
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
season_commands: mpsc::Sender<SeasonCommand>,
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
}
/// Work explicitly requested through the movie API.
@@ -91,6 +93,17 @@ pub enum EpisodeCommand {
Grab { episode_id: i64, release_id: i64 },
}
/// Work explicitly requested through the season deck API (issue #125).
///
/// A season pack is one torrent for the whole season and the grab targets
/// `target_kind = 'season'`, so the manual action names a season — unlike
/// [`EpisodeCommand`], where intent lives at the leaf.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeasonCommand {
Search { season_id: i64 },
Grab { season_id: i64, release_id: i64 },
}
impl AppState {
/// Build the state, including the shared HTTP client.
///
@@ -101,6 +114,7 @@ impl AppState {
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
let (season_commands, pending_season_commands) = mpsc::channel(64);
Ok(Self {
http,
upstreams: Arc::new(upstreams),
@@ -109,6 +123,8 @@ impl AppState {
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
episode_commands,
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
season_commands,
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
})
}
@@ -137,6 +153,15 @@ impl AppState {
self.pending_episode_commands.lock().await.recv().await
}
/// Wait for the next manual season action in the daemon's reconcile loop.
///
/// # Errors
///
/// If every sender has been dropped.
pub async fn next_season_command(&self) -> Option<SeasonCommand> {
self.pending_season_commands.lock().await.recv().await
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.http
}
@@ -162,4 +187,11 @@ impl AppState {
) -> Result<(), mpsc::error::TrySendError<EpisodeCommand>> {
self.episode_commands.try_send(command)
}
pub(crate) fn send_season_command(
&self,
command: SeasonCommand,
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
self.season_commands.try_send(command)
}
}
+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);
}
}
+7 -1
View File
@@ -15,7 +15,10 @@ pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease;
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
pub use matching::{match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
pub use matching::{
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
WantedEpisode, WantedMovie,
};
pub use score::{Score, ScoreWeights};
pub use status::{derive_series_status, SeriesStatus};
@@ -285,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>,
+496 -11
View File
@@ -6,17 +6,22 @@
//! 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
//! year is never matched by a release that does not state one;
//! - anything that matches two wanted titles matches neither.
//!
//! The TV side ([`match_episode`]) answers a narrower question about one
//! release and one wanted episode: is this its file, or a pack for exactly
//! the season holding it?
use crate::MovieId;
use arr_parse::NameClaims;
use crate::{EpisodeId, MovieId};
use arr_parse::{EpisodeClaim, NameClaims};
/// A wanted title, in the shape matching needs.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -71,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
@@ -91,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;
@@ -150,6 +157,113 @@ fn unique(mut matches: impl Iterator<Item = MovieMatch>) -> Option<MovieMatch> {
matches.next().is_none().then_some(first)
}
/// One wanted episode, in the shape matching needs. The ids are the
/// series', not the episode's: trackers attach them to the show.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WantedEpisode {
pub id: EpisodeId,
/// TMDB id of the series, compared against the one the tracker attached
/// to the item.
pub tmdb_id: Option<u32>,
/// `IMDb` id of the series, as TMDB spells it (`tt11301866`). Absent
/// until the metadata refresh has run.
pub imdb_id: Option<String>,
pub title: String,
pub season: u32,
pub episode: u32,
}
/// Which file shape satisfied a wanted episode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MatchShape {
/// A release holding exactly this one episode.
SingleEpisode,
/// A pack for exactly the season this episode belongs to.
SeasonPack,
}
/// What tied a release to a wanted episode, and what it is.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EpisodeMatch {
pub episode: EpisodeId,
pub kind: MatchKind,
pub shape: MatchShape,
}
/// Whether a release satisfies one wanted episode, and as what.
///
/// Identity is decided as [`match_movie`] decides it — a supplied id that
/// matches nothing wanted ends the comparison, a junk id falls through to
/// the title — except the title lane compares the series title alone. TV
/// release names rarely state a year at all, and when they do it is the
/// season's air year rather than the series', so the year rule films live
/// by would starve this lane; exact title comparison is what keeps two
/// series sharing a word apart.
///
/// Only two shapes satisfy anything: a file holding exactly this episode
/// and a pack for exactly its season. Multi-episode files and multi-season
/// packs are left to the operator, who reaches them through the episode and
/// season decks (§6.2).
#[must_use]
pub fn match_episode(
wanted: &WantedEpisode,
ids: &ReleaseIds,
claims: &NameClaims,
) -> Option<EpisodeMatch> {
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
let kind = if ids.tmdb_id.is_some() || imdb_id.is_some() {
if ids.tmdb_id.is_some() && wanted.tmdb_id == ids.tmdb_id {
MatchKind::TmdbId
} else if imdb_id.is_some() && wanted.imdb_id.as_deref().and_then(imdb_key) == imdb_id {
MatchKind::ImdbId
} else {
return None;
}
} else {
let title = match_key(claims.title.as_deref()?);
if title.is_empty() || title != match_key(&wanted.title) {
return None;
}
MatchKind::TitleAndYear
};
// Whatever the name or id says, nothing without a TV tag holds an
// episode, and no daily-show air date is a numbered one.
let claim = claims.episode.as_ref()?;
let shape = if is_this_seasons_pack(claim, wanted.season) {
MatchShape::SeasonPack
} else if is_single_episode(claim, wanted.season, wanted.episode) {
MatchShape::SingleEpisode
} else {
return None;
};
Some(EpisodeMatch {
episode: wanted.id,
kind,
shape,
})
}
/// A pack for exactly this season. Multi-season packs are never selected
/// automatically: they drag other seasons in with them.
fn is_this_seasons_pack(claim: &EpisodeClaim, season: u32) -> bool {
match claim {
EpisodeClaim::Season { season: claimed } => *claimed == season,
EpisodeClaim::Seasons { first, last } => first == last && *first == season,
EpisodeClaim::Episodes { .. } | EpisodeClaim::Daily { .. } => false,
}
}
/// A release holding exactly this one episode. Multi-episode files are left
/// to manual grabs: two episodes automatically picking the same torrent
/// would fight over one `grabs` row.
fn is_single_episode(claim: &EpisodeClaim, season: u32, episode: u32) -> bool {
matches!(
claim,
EpisodeClaim::Episodes { episodes, .. } if episodes.len() == 1
) && claim.covers(season, episode)
}
/// The form two titles are compared in: lower case, accents folded to ASCII,
/// `&` spelled out, and every run of anything else one space.
///
@@ -202,7 +316,10 @@ fn fold(character: char) -> Option<&'static str> {
#[cfg(test)]
mod tests {
use super::{match_key, match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
use super::{
match_episode, match_key, match_movie, EpisodeId, EpisodeMatch, MatchKind, MatchShape,
MovieMatch, ReleaseIds, WantedEpisode, WantedMovie,
};
use crate::MovieId;
fn wanted() -> Vec<WantedMovie> {
@@ -314,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![
@@ -440,4 +586,343 @@ mod tests {
})
);
}
fn wanted_episode() -> WantedEpisode {
WantedEpisode {
id: EpisodeId(7),
tmdb_id: Some(934_644),
imdb_id: Some("tt11301866".to_owned()),
title: "Fallout".to_owned(),
season: 1,
episode: 3,
}
}
fn episode_ids(tmdb_id: Option<u32>, imdb_id: Option<&str>) -> ReleaseIds {
ids(tmdb_id, imdb_id)
}
/// `(release name, expected shape)` for releases that satisfy the
/// wanted `Fallout` S01E03, matched on title alone.
#[test]
fn real_single_episode_releases_match_their_episode() {
let cases = [
(
"Fallout.2024.S01E03.1080p.WEB-DL.DDP5.1.H.264-GROUP",
MatchShape::SingleEpisode,
),
(
"Fallout.S01E03.2160p.WEB-DL.DV.HDR10Plus.HEVC-GROUP",
MatchShape::SingleEpisode,
),
(
"Fallout.S01E03.720p.AMZN.WEBRip.DDP5.1.x264-GROUP",
MatchShape::SingleEpisode,
),
];
for (name, shape) in cases {
assert_eq!(
match_episode(
&wanted_episode(),
&ReleaseIds::default(),
&arr_parse::parse(name),
),
Some(EpisodeMatch {
episode: EpisodeId(7),
kind: MatchKind::TitleAndYear,
shape,
}),
"{name}"
);
}
}
/// The same series, offered as a pack for exactly its season.
#[test]
fn a_pack_for_this_season_matches_every_episode_in_it() {
let cases = [
(
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP",
MatchShape::SeasonPack,
),
(
"Fallout.Season.1.1080p.BluRay.x265-GROUP",
MatchShape::SeasonPack,
),
(
"Fallout.S01.1080p.BluRay.x264-GROUP",
MatchShape::SeasonPack,
),
(
"Fallout.S01-S01.1080p.BluRay.x264-GROUP",
MatchShape::SeasonPack,
),
];
for (name, shape) in cases {
assert_eq!(
match_episode(
&wanted_episode(),
&ReleaseIds::default(),
&arr_parse::parse(name),
),
Some(EpisodeMatch {
episode: EpisodeId(7),
kind: MatchKind::TitleAndYear,
shape,
}),
"{name}"
);
}
}
/// `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]
fn mangled_series_titles_still_match() {
let wanted = WantedEpisode {
id: EpisodeId(8),
tmdb_id: None,
imdb_id: None,
title: "Ms. Marvel".to_owned(),
season: 1,
episode: 3,
};
assert_eq!(
match_episode(
&wanted,
&ReleaseIds::default(),
&arr_parse::parse("Ms.Marvel.S01E03.1080p.DSNY.WEB-DL.DDP5.1.Atmos.H.264-GROUP"),
)
.map(|matched| matched.shape),
Some(MatchShape::SingleEpisode)
);
}
/// Two series that share a word are different series. Both pairs are
/// real shows.
#[test]
fn another_series_sharing_a_word_is_not_a_match() {
let lone_star = WantedEpisode {
id: EpisodeId(9),
tmdb_id: None,
imdb_id: None,
title: "9-1-1: Lone Star".to_owned(),
season: 4,
episode: 1,
};
assert_eq!(
match_episode(
&lone_star,
&ReleaseIds::default(),
&arr_parse::parse("9-1-1.S07E01.1080p.AMZN.WEB-DL.DDP5.1.H.264-GROUP"),
),
None
);
let fallout = wanted_episode();
assert_eq!(
match_episode(
&fallout,
&ReleaseIds::default(),
&arr_parse::parse("The.Fallout.2021.S01E03.1080p.WEB-DL"),
),
None
);
}
/// A tracker-supplied id identifies the series whatever the name says.
#[test]
fn a_supplied_id_matches_whatever_the_name_says_tv() {
for (tmdb_id, imdb_id) in [(Some(934_644), None), (None, Some("tt11301866"))] {
assert_eq!(
match_episode(
&wanted_episode(),
&episode_ids(tmdb_id, imdb_id),
&arr_parse::parse("Totally.Unrelated.Name.S01.1080p.WEB-DL-GROUP"),
),
Some(EpisodeMatch {
episode: EpisodeId(7),
kind: if tmdb_id.is_some() {
MatchKind::TmdbId
} else {
MatchKind::ImdbId
},
shape: MatchShape::SeasonPack,
}),
"{tmdb_id:?} {imdb_id:?}"
);
}
}
/// Torznab definitions spell the same id several ways; all of them are
/// the one series.
#[test]
fn an_imdb_id_matches_however_the_tracker_spells_it_tv() {
for spelling in ["tt11301866", "11301866", "tt011301866"] {
assert_eq!(
match_episode(
&wanted_episode(),
&episode_ids(None, Some(spelling)),
&arr_parse::parse("Unrelated.Name.S01E03.1080p.WEB-DL"),
)
.map(|matched| matched.kind),
Some(MatchKind::ImdbId),
"{spelling}"
);
}
}
/// Same rule as films: the tracker said which series this is and it is
/// not ours, so the title does not get a vote.
#[test]
fn a_supplied_id_that_is_not_wanted_ends_the_comparison_tv() {
assert_eq!(
match_episode(
&wanted_episode(),
&episode_ids(Some(1), None),
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
),
None
);
assert_eq!(
match_episode(
&wanted_episode(),
&episode_ids(None, Some("tt0000001")),
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
),
None
);
}
/// A placeholder or a non-id is no id at all, so the title lane still
/// runs.
#[test]
fn a_junk_imdb_id_falls_through_to_the_title_tv() {
for junk in ["", "tt", "tt0000000", "n/a"] {
assert_eq!(
match_episode(
&wanted_episode(),
&episode_ids(None, Some(junk)),
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
)
.map(|matched| matched.shape),
Some(MatchShape::SingleEpisode),
"{junk}"
);
}
}
/// Everything that is not this exact episode or this exact season's
/// pack satisfies nothing, whoever issued it.
#[test]
fn every_other_shape_satisfies_nothing() {
let refusals = [
// Wrong episode, wrong season.
"Fallout.S01E04.1080p.WEB-DL-GROUP",
"Fallout.S02E03.1080p.WEB-DL-GROUP",
// Multi-episode files fight over one grabs row; manual only.
"Fallout.S01E02E03.1080p.WEB-DL-GROUP",
"Fallout.S01E02-E04.1080p.WEB-DL-GROUP",
// Another season's pack, and a multi-season pack.
"Fallout.S02.2160p.WEB-DL-GROUP",
"Fallout.S01-S03.1080p.BluRay-GROUP",
// A daily-show air date is not a numbered episode.
"Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL",
// No TV tag at all: a film, not an episode of anything.
"A.Complete.Unknown.2024.2160p.WEB-DL-GROUP",
];
for name in refusals {
assert_eq!(
match_episode(
&wanted_episode(),
&ReleaseIds::default(),
&arr_parse::parse(name),
),
None,
"{name}"
);
}
}
/// 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};
use arr_parse::EpisodeClaim::{Daily, Episodes, Season, Seasons};
assert!(is_this_seasons_pack(&Seasons { first: 1, last: 1 }, 1));
assert!(!is_this_seasons_pack(&Seasons { first: 1, last: 3 }, 1));
assert!(!is_this_seasons_pack(
&Episodes {
season: 1,
episodes: vec![3]
},
1
));
assert!(!is_this_seasons_pack(&Season { season: 2 }, 1));
assert!(!is_this_seasons_pack(
&Daily {
year: 2024,
month: 5,
day: 13
},
1
));
// A single episode of another season is still not this one.
assert!(!is_single_episode(
&Episodes {
season: 2,
episodes: vec![3]
},
1,
3
));
}
/// A daily show's own episode, dated but unnumbered, satisfies nothing:
/// there is no numbered wanted episode it can be filed against.
#[test]
fn a_daily_claim_never_satisfies_a_numbered_wanted_episode() {
let wanted = WantedEpisode {
id: EpisodeId(10),
tmdb_id: Some(46_298),
imdb_id: None,
title: "Last Week Tonight with John Oliver".to_owned(),
season: 11,
episode: 17,
};
assert_eq!(
match_episode(
&wanted,
&ReleaseIds::default(),
&arr_parse::parse("Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL"),
),
None
);
}
}
+40 -1
View File
@@ -15,13 +15,20 @@ 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 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,
episodes: &[Episode],
now: SystemTime,
) -> SeriesStatus {
let wanted = episodes.iter().filter(|episode| episode.wanted);
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && episode.season_number != 0);
let complete = wanted
.clone()
.all(|episode| episode.state == MediaState::Available);
@@ -93,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,
@@ -203,4 +211,35 @@ mod tests {
SeriesStatus::Airing
);
}
#[test]
fn missing_specials_do_not_hold_back_complete_or_ended() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
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), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(special);
assert_eq!(
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), &episodes, now),
SeriesStatus::Complete,
"§4.2: the only gap is a special"
);
assert_eq!(
derive_series_status(&series(false, true), &episodes, now),
SeriesStatus::Ended
);
}
}
+90 -6
View File
@@ -11,13 +11,17 @@ pub struct RefreshedSeason {
/// Applies a series' tracking rule to newly revealed seasons.
///
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
/// Season zero is treated like every other newly revealed season.
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
/// still track it by hand.
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
if !series.auto_track {
return;
}
for refreshed in seasons.iter_mut().filter(|season| season.is_new) {
for refreshed in seasons
.iter_mut()
.filter(|season| season.is_new && season.season.number != 0)
{
refreshed.season.tracked = true;
for episode in &mut refreshed.episodes {
episode.wanted = true;
@@ -25,11 +29,25 @@ pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
}
}
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
/// already-revealed episodes.
///
/// Turning tracking on marks every revealed episode wanted; future reveals
/// follow while the season stays tracked. Turning it off withdraws nothing:
/// leaf intent is never removed implicitly.
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
if tracked {
for episode in episodes {
episode.wanted = true;
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::{apply_auto_track, RefreshedSeason};
use super::{apply_auto_track, apply_tracked, RefreshedSeason};
use crate::{
Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId,
TitleOverrides,
@@ -54,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),
@@ -119,12 +138,77 @@ mod tests {
}
#[test]
fn season_zero_follows_the_same_rule() {
fn auto_track_skips_season_zero() {
let mut refresh = [refreshed_season(0, true, &[false])];
apply_auto_track(&series(true), &mut refresh);
assert!(refresh[0].season.tracked);
assert!(refresh[0].episodes[0].wanted);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
}
#[test]
fn tracked_on_marks_revealed_episodes_wanted() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
apply_tracked(true, &mut episodes);
assert!(
episodes.iter().all(|episode| episode.wanted),
"tracked on must mark every revealed episode wanted, input {wanted:?}"
);
}
}
#[test]
fn tracked_off_withdraws_nothing() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
let before = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
apply_tracked(false, &mut episodes);
let after = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
assert_eq!(before, after, "tracked off must not touch intent");
}
}
}
+429 -22
View File
@@ -1,11 +1,14 @@
//! §9.5 *needs a decision* → the operator alone: a movie entered the
//! no-PT-source queue, or hard-failed twice on different releases (the same
//! two queues `GET /api/queues/attention` reports, §9.3).
//! §9.5 *needs a decision* → the operator alone: a movie or series entered
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! Edge-triggered: a movie notifies once when it enters either queue, and is
//! forgotten once it leaves both, so a future re-entry notifies again. The
//! notified set is in-memory and transient — a restart re-notifies whatever
//! is currently queued, same tradeoff `ImportAction`'s probe cache makes.
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
//! twenty-episode season is one message, which is the restraint §9.5 exists
//! for. The notified set is in-memory and transient — a restart re-notifies
//! whatever is currently queued, same tradeoff `ImportAction`'s probe cache
//! makes.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -16,11 +19,67 @@ use tokio::sync::Mutex;
use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome};
/// What is queued. Movies and series have independent id sequences, so the
/// kind travels with the id.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Subject {
Movie(i64),
Series(i64),
}
/// One series' standing in the TV queues, rolled up from its episodes.
#[derive(Debug, Default)]
struct TvEntry {
/// Wanted episodes whose every candidate was rejected for language.
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
failed_season_packs: Vec<i64>,
}
impl TvEntry {
fn reason(&self) -> String {
let mut parts = Vec::new();
if !self.no_pt_source.is_empty() {
parts.push(plural(
self.no_pt_source.len(),
"episode found no Portuguese-audio source",
"episodes found no Portuguese-audio source",
));
}
if !self.hard_failed_episodes.is_empty() {
parts.push(plural(
self.hard_failed_episodes.len(),
"episode hard-failed twice on different releases",
"episodes hard-failed twice on different releases",
));
}
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
));
}
parts.join("; ")
}
}
fn plural(count: usize, one: &str, many: &str) -> String {
if count == 1 {
format!("1 {one}")
} else {
format!("{count} {many}")
}
}
#[derive(Debug)]
pub struct AttentionAction {
notifier: Notifier,
operator_topic: String,
notified: Arc<Mutex<HashSet<i64>>>,
notified: Arc<Mutex<HashSet<Subject>>>,
}
impl AttentionAction {
@@ -34,7 +93,7 @@ impl AttentionAction {
}
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, sqlx::Error> {
let mut queued: HashMap<i64, (String, &'static str)> = HashMap::new();
let mut queued: HashMap<Subject, (String, String)> = HashMap::new();
let no_pt_source = sqlx::query!(
r#"
@@ -59,10 +118,10 @@ impl AttentionAction {
.await?;
for row in no_pt_source {
queued.insert(
row.id,
Subject::Movie(row.id),
(
title_with_year(&row.title, row.year),
"no Portuguese-audio source found",
"no Portuguese-audio source found".to_string(),
),
);
}
@@ -71,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
@@ -80,18 +140,25 @@ impl AttentionAction {
.fetch_all(database.pool())
.await?;
for row in needs_decision {
queued.entry(row.id).or_insert_with(|| {
queued.entry(Subject::Movie(row.id)).or_insert_with(|| {
(
title_with_year(&row.title, row.year),
"hard-failed twice on different releases",
"hard-failed twice on different releases".to_string(),
)
});
}
for (series_id, title, year, entry) in queue_tv(database).await? {
queued.insert(
Subject::Series(series_id),
(title_with_year(&title, year), entry.reason()),
);
}
let mut notified = self.notified.lock().await;
let mut outcomes = Vec::new();
for (&movie_id, (title, reason)) in &queued {
if !notified.insert(movie_id) {
for (subject, (title, reason)) in &queued {
if !notified.insert(subject.clone()) {
continue;
}
match self
@@ -99,16 +166,22 @@ impl AttentionAction {
.send(&self.operator_topic, title, reason)
.await
{
Ok(()) => outcomes.push(Outcome::new(
format!("movie {movie_id} needs a decision"),
format!("notified operator: {reason}"),
)),
Ok(()) => {
let (kind, id) = match subject {
Subject::Movie(id) => ("movie", *id),
Subject::Series(id) => ("series", *id),
};
outcomes.push(Outcome::new(
format!("{kind} {id} needs a decision"),
format!("notified operator: {reason}"),
));
}
Err(error) => {
tracing::warn!(%error, movie_id, "needs-decision notification failed");
tracing::warn!(%error, ?subject, "needs-decision notification failed");
}
}
}
notified.retain(|movie_id| queued.contains_key(movie_id));
notified.retain(|subject| queued.contains_key(subject));
Ok(outcomes)
}
@@ -121,6 +194,100 @@ fn title_with_year(title: &str, year: Option<i64>) -> String {
}
}
/// The series' roll-up entry, created empty on first sight.
fn tv_entry(
tv: &mut HashMap<i64, (String, Option<i64>, TvEntry)>,
series_id: i64,
title: String,
year: Option<i64>,
) -> &mut TvEntry {
&mut tv
.entry(series_id)
.or_insert_with(|| (title, year, TvEntry::default()))
.2
}
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// series however long the broken season is.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
let tv_no_pt_source = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
"#
)
.fetch_all(database.pool())
.await?;
for row in tv_no_pt_source {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.no_pt_source
.push(row.episode_id);
}
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
g.target_id AS "episode_id!: 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'
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
"#
)
.fetch_all(database.pool())
.await?;
for row in episode_hard_fails {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.hard_failed_episodes
.push(row.episode_id);
}
let season_pack_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
g.target_id AS "season_id!: 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.title, s.year, se.id
"#
)
.fetch_all(database.pool())
.await?;
for row in season_pack_fails {
tv_entry(&mut tv, row.series_id, row.title, row.year)
.failed_season_packs
.push(row.season_id);
}
Ok(tv
.into_iter()
.map(|(id, (t, y, e))| (id, t, y, e))
.collect())
}
impl Action for AttentionAction {
fn name(&self) -> &'static str {
"attention"
@@ -168,6 +335,94 @@ mod tests {
.unwrap()
}
/// A series on the kids TV root with one season, and `count` wanted,
/// missing episodes that have been searched. No stored releases: every
/// candidate was rejected for language (§5.2's no-PT-source case).
async fn insert_no_pt_source_series(database: &Db, tmdb_id: i64, episode_count: usize) -> i64 {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)",
)
.bind(tmdb_id)
.bind(root_id)
.execute(database.pool())
.await
.unwrap();
let series_id: i64 = sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?")
.bind(tmdb_id)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE series_id = ?")
.bind(series_id)
.fetch_one(database.pool())
.await
.unwrap();
for number in 0..episode_count {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
VALUES (?, ?, ?, 1, 'missing', 1)",
)
.bind(season_id)
.bind(i64::try_from(number).unwrap_or(0) + 1)
.bind(format!("Episode {number}"))
.execute(database.pool())
.await
.unwrap();
}
series_id
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
RETURNING id",
)
.bind(release_guid)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.execute(database.pool())
.await
.unwrap();
}
async fn action(server: &MockServer) -> AttentionAction {
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(server)
.await;
AttentionAction::new(
Notifier::new(server.uri()).unwrap(),
"operator-topic".to_string(),
)
}
#[tokio::test]
async fn a_movie_entering_the_queue_notifies_the_operator_once() {
let (_dir, database) = seeded_database().await;
@@ -224,4 +479,156 @@ mod tests {
assert_eq!(third.len(), 1, "re-enters and notifies again");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn a_series_entering_the_queue_notifies_once_per_series() {
let (_dir, database) = seeded_database().await;
// Twenty broken episodes are one notification, not twenty (§9.5).
insert_no_pt_source_series(&database, 1, 20).await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "one notification for the whole series");
assert_eq!(second.len(), 0, "does not repeat while still queued");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_series_leaving_and_re_entering_the_queue_notifies_again() {
let (_dir, database) = seeded_database().await;
let series_id = insert_no_pt_source_series(&database, 1, 1).await;
let server = MockServer::start().await;
let action = action(&server).await;
action.tick(&database).await.unwrap();
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
action.tick(&database).await.unwrap();
sqlx::query("UPDATE series SET blocked = 0 WHERE id = ?")
.bind(series_id)
.execute(database.pool())
.await
.unwrap();
let third = action.tick(&database).await.unwrap();
assert_eq!(third.len(), 1, "re-enters and notifies again");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn an_episode_hard_failed_twice_notifies_its_series() {
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;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
let outcomes = action.tick(&database).await.unwrap();
assert_eq!(
outcomes.len(),
1,
"both hard-fail conditions roll up to one series notification"
);
}
#[tokio::test]
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
let movie_id = insert_no_pt_source_movie(&database).await;
insert_failed_grab(&database, "movie", movie_id, "first").await;
insert_failed_grab(&database, "movie", movie_id, "second").await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
.bind(movie_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 1, 'Episode 0', 1, 'missing')",
)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(database.pool())
.await
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
let server = MockServer::start().await;
let action = action(&server).await;
let first = action.tick(&database).await.unwrap();
assert_eq!(first.len(), 1, "queued while unsatisfied");
// Imported from a third release: satisfied, no decision needed.
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
.bind(episode_id)
.execute(database.pool())
.await
.unwrap();
let second = action.tick(&database).await.unwrap();
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
+194 -37
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,
@@ -1121,7 +1151,7 @@ pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&st
last_searched_at.with_timezone(&chrono::Utc) + backoff <= chrono::Utc::now()
}
fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
pub(crate) fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
let Some(refreshed_at) = metadata_refreshed_at else {
return true;
};
@@ -1179,7 +1209,9 @@ pub(crate) async fn store_release(
}
/// The TV counterpart: one release row, associated with every episode the
/// claim covers — a season pack matches the whole season.
/// claim covers — a season pack matches the whole season. Returns the
/// release id alongside the eligible view; the id is what links a season
/// pack into `season_releases`, which lists rejected candidates too.
pub(crate) async fn store_episode_release(
database: &Db,
episode_ids: &[i64],
@@ -1188,7 +1220,7 @@ pub(crate) async fn store_episode_release(
overrides: &TitleOverrides,
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
) -> Result<(i64, Option<Eligible>), GrabError> {
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1208,7 +1240,7 @@ pub(crate) async fn store_episode_release(
.execute(database.pool())
.await?;
}
Ok(eligible)
Ok((release_id, eligible))
}
async fn classify_and_store(
@@ -2415,6 +2447,131 @@ mod tests {
assert_eq!(targeted_searches(&indexer).await, 3);
}
/// §9.6: the daily refresh also writes the three stored artwork fields,
/// so pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn metadata_refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let artwork_metadata = RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
);
let metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
// Idempotent: an unchanged refresh neither rewrites nor re-reports,
// same as the fields above it.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let unchanged: (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(unchanged, (poster, backdrop, vote));
}
/// TMDB reports `vote_average: 0` for a title nobody has rated — absence,
/// not zero (#156). It must store as NULL, be overwritten when votes
/// arrive, and go back to NULL if they are withdrawn.
#[tokio::test]
async fn metadata_refresh_stores_an_unrated_title_as_null() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let unrated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 0.0,"#,
)
};
let rated = || {
RELEASED_METADATA.replace(
r#""original_language": "en","#,
r#""original_language": "en",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152,"#,
)
};
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
// Votes arrive: the NULL is overwritten.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&rated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, Some(8.152));
// And withdrawn again.
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')",
)
.execute(database.pool())
.await
.unwrap();
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
.tick(&database)
.await
.unwrap();
let vote: Option<f64> =
sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(vote, None);
}
#[tokio::test]
async fn metadata_changes_reset_a_title_backoff() {
let (_dir, database) = wanted_movie().await;
+58 -26
View File
@@ -12,6 +12,7 @@ mod notify;
mod reaper;
pub mod reconcile;
mod rss;
mod series_refresh;
mod tv_grab;
mod web;
@@ -31,6 +32,7 @@ use notify::Notifier;
use reaper::ReaperAction;
use reconcile::{ReconcileLoop, Tick};
use rss::RssAction;
use series_refresh::SeriesRefreshAction;
use tower_http::trace::TraceLayer;
use tv_grab::TvGrabAction;
@@ -121,7 +123,7 @@ async fn run() -> Result<(), Error> {
None
};
let notifier = Notifier::new(config.ntfy_url.clone())?;
let (reconcile, manual_grab) =
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
@@ -155,10 +157,17 @@ async fn run() -> Result<(), Error> {
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone()));
// Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so
// the manual-search and manual-grab endpoints were a no-op — the command
// sat in the 64-slot buffer forever.
let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx));
// Issue #107: nothing else drains `AppState`'s command channels, so the
// manual-search and manual-grab endpoints were a no-op — the command sat
// in the 64-slot buffer forever. Issue #132: the episode and season
// channels are drained by the same lane.
let mut manual_task = tokio::spawn(manual::run(
state,
database,
manual_grab,
manual_tv,
shutdown_rx,
));
let signal_tx = shutdown_tx.clone();
let server = async move {
axum::serve(listener, app)
@@ -196,17 +205,18 @@ async fn run() -> Result<(), Error> {
/// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not
/// configured stays unregistered rather than failing every tick.
///
/// Also returns a second, independent `GrabAction` for `manual::run` (issue
/// #107) — the manual trigger needs the same search-and-grab path on demand
/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register`
/// takes ownership of the one it ticks.
/// Also returns independent action instances for `manual::run` (issues #107
/// and #132) — the manual trigger needs the same search-and-grab paths on
/// demand rather than on the reconcile tick's schedule, and
/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie
/// one needs TMDB too; TV grabbing does not (§6.2).
fn reconcile_loop(
database: &Db,
config: &Config,
transmission: &arr_dl::TransmissionClient,
tmdb: Option<&Arc<TmdbClient>>,
notifier: &Notifier,
) -> Result<(ReconcileLoop, Option<GrabAction>), Error> {
) -> Result<(ReconcileLoop, Option<GrabAction>, Option<TvGrabAction>), Error> {
let reconcile = ReconcileLoop::new(database.clone());
let seeding = SeedingRules::new(
SeedingLimits {
@@ -233,7 +243,7 @@ fn reconcile_loop(
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
.transpose()?;
let (mut reconcile, manual_grab) = register_movie_grab(
let (reconcile, manual_grab) = register_movie_grab(
reconcile,
prowlarr.as_ref(),
transmission,
@@ -241,20 +251,8 @@ fn reconcile_loop(
&seeding,
tmdb,
);
// TV grabbing needs no TMDB at grab time: air dates are already on the
// episode rows, which is the same gate the digital release date is for
// movies (§6.2).
if let Some(prowlarr) = prowlarr.as_ref() {
reconcile = reconcile.register(
Tick::Reconcile,
TvGrabAction::new(
prowlarr.clone(),
transmission.clone(),
config.download_dir.clone(),
seeding.clone(),
),
);
}
let (mut reconcile, manual_tv) =
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding);
// RSS needs no TMDB: it matches what the feeds already carry against the
// wanted list (§6.2).
if let Some(prowlarr) = prowlarr {
@@ -268,6 +266,13 @@ fn reconcile_loop(
),
);
}
// §8: metadata refresh is its own daily lane, staggered against the
// other ticks. Without TMDB there is nothing to refresh from.
if let Some(tmdb) = tmdb {
reconcile = reconcile.register(Tick::Metadata, SeriesRefreshAction::new(Arc::clone(tmdb)));
} else {
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
}
let jellyfin = jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
@@ -313,7 +318,34 @@ fn reconcile_loop(
}
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
Ok((reconcile, manual_grab))
Ok((reconcile, manual_grab, manual_tv))
}
/// Register the TV grab lane on `reconcile` and hand back a second,
/// independent instance for `manual::run` (issue #132). `None` when Prowlarr
/// is not configured. TV grabbing needs no TMDB at grab time: air dates are
/// already on the episode rows, which is the same gate the digital release
/// date is for movies (§6.2).
fn register_tv_grab(
mut reconcile: ReconcileLoop,
prowlarr: Option<&arr_indexer::ProwlarrClient>,
transmission: &arr_dl::TransmissionClient,
config: &Config,
seeding: &SeedingRules,
) -> (ReconcileLoop, Option<TvGrabAction>) {
let Some(prowlarr) = prowlarr else {
return (reconcile, None);
};
let tv_grab_action = || {
TvGrabAction::new(
prowlarr.clone(),
transmission.clone(),
config.download_dir.clone(),
seeding.clone(),
)
};
reconcile = reconcile.register(Tick::Reconcile, tv_grab_action());
(reconcile, Some(tv_grab_action()))
}
/// Register the movie grab lane on `reconcile` and hand back a second,
+102 -20
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())]
);
}
}
File diff suppressed because it is too large Load Diff
+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,10 @@
-- The season counterpart of episode_releases (0006). A targeted season search
-- returns packs the operator has not committed to, which is the wrong grain
-- for a table that links a release to one episode (DESIGN.md §9.3, issue 125).
CREATE TABLE season_releases (
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
release_id INTEGER NOT NULL REFERENCES releases (id) ON DELETE CASCADE,
PRIMARY KEY (season_id, release_id)
) STRICT;
CREATE INDEX season_releases_release ON season_releases (release_id);
@@ -0,0 +1,4 @@
-- §8: the daily series metadata refresh is gated on this stamp, exactly as
-- the movie lane gates on `movies.metadata_refreshed_at` (0009). NULL means
-- never refreshed, which makes a newly added series immediately due.
ALTER TABLE series ADD COLUMN metadata_refreshed_at TEXT;
@@ -0,0 +1,7 @@
-- #122. TMDB deletes and renumbers episodes. An episode that vanished
-- upstream and has no file is deleted outright by the refresh; one that has
-- a file is never deleted — `media_files.path` is UNIQUE and its owner is
-- polymorphic, so dropping the row would orphan a real file — and instead
-- carries this flag as the conflict the operator resolves.
ALTER TABLE episodes ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
CHECK (vanished IN (0, 1));
@@ -0,0 +1,6 @@
-- #137. TMDB deletes whole seasons as well as episodes (#122). A season that
-- vanished upstream with a file anywhere under it cannot be deleted — the
-- file row would orphan — so it is flagged instead, the same conflict marker
-- episodes carry. Seasons without any file cascade away outright.
ALTER TABLE seasons ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
CHECK (vanished IN (0, 1));
@@ -0,0 +1,11 @@
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
-- search haystack, §7.4 filenames and the compat shim as if they were real
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
-- boundary now substitutes — #121's guarded update replaces it once TMDB
-- fills the title in.
--
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
-- a transaction, where that pragma is a no-op); enforcement lives in code.
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
@@ -0,0 +1,11 @@
-- §9.6: the three fields rich detail keeps out of the database are the ones
-- pure-SQL views need — the in-library half of unified search (§9.2) and the
-- library grid would otherwise cost one TMDB call per row. Nullable, no
-- default: a title added before its first metadata refresh has no artwork
-- yet, and TMDB itself has entries with no poster.
ALTER TABLE movies ADD COLUMN poster_path TEXT;
ALTER TABLE movies ADD COLUMN backdrop_path TEXT;
ALTER TABLE movies ADD COLUMN vote_average REAL;
ALTER TABLE series ADD COLUMN poster_path TEXT;
ALTER TABLE series ADD COLUMN backdrop_path TEXT;
ALTER TABLE series ADD COLUMN vote_average REAL;

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