Compare commits

...

87 Commits

Author SHA1 Message Date
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 8ec1506098 refactor: split long functions in tv attention work 2026-08-23 16:51:29 +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 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 a1971ed82c feat(daemon): tv series in the needs-a-decision queue 2026-08-23 16:39:06 +01:00
104 changed files with 10103 additions and 546 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"
}
@@ -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, (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": [
{
@@ -88,5 +88,5 @@
true
]
},
"hash": "540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226"
"hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6"
}
@@ -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,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,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,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"
}
@@ -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"
}
@@ -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,32 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false
]
},
"hash": "3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.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,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\", 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 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,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"
}
@@ -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, (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": [
{
@@ -88,5 +88,5 @@
true
]
},
"hash": "f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b"
"hash": "7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7"
}
@@ -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"
}
@@ -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,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?",
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\"\n FROM seasons WHERE series_id = ?",
"describe": {
"columns": [
{
@@ -9,12 +9,12 @@
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"name": "number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "number!: i64",
"name": "tracked!: bool",
"ordinal": 2,
"type_info": "Integer"
}
@@ -28,5 +28,5 @@
false
]
},
"hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10"
"hash": "9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
"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"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863"
}
@@ -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"
}
@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "tag!: String",
"ordinal": 3,
"type_info": "Null"
},
{
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
null,
false
]
},
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM seasons WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM seasons WHERE series_id = ? AND number = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id",
"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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7"
}
@@ -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"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ?",
"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"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63"
}
@@ -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,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 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"
}
@@ -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"
}
@@ -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,74 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"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"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true
]
},
"hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87"
}
@@ -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`.
+45
View File
@@ -485,6 +485,11 @@ which skips to the manual-grab flow.
There is never a moment where the user has to know whether they are searching or
adding.
Result rows are enriched: a poster thumbnail and a rating, under the same rules
as title detail (§9.6) — images hotlinked from path fragments, the rating being
TMDB's `vote_average` with its `vote_count`. A trailer chip renders per row and
resolves only when clicked (§9.6).
### 9.3 Manual search results
Radarr's manual search is unusable because the raw release name is the dominant
@@ -540,6 +545,46 @@ 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.
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
`vote_average` with its `vote_count`. 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.
**Cast** is the top 10 billed — profile photo, actor name, character name —
linking out to that person's TMDB page. This does not contradict §2: nothing
about a person is stored, tracked or searched on. The non-goal is following
people, not naming them.
**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`.
+24 -1
View File
@@ -14,6 +14,7 @@ mod roots;
mod search;
mod series;
mod state;
mod trailer;
use axum::routing::get;
use axum::{Json, Router};
@@ -36,7 +37,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.
@@ -87,12 +91,17 @@ 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!(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 +316,20 @@ mod tests {
("/api/movies/{movie_id}/search", "post"),
("/api/movies/{movie_id}/releases", "get"),
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
(
"/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 +348,7 @@ mod tests {
"UpdateMovie",
"Release",
"AttentionQueues",
"SeriesAttention",
"Series",
] {
assert!(
+367 -1
View File
@@ -93,6 +93,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 +149,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 +172,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 => (
@@ -459,7 +498,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()?;
@@ -627,12 +666,190 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
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"#)
.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 +941,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");
@@ -1183,4 +1401,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)
}
}
+365 -35
View File
@@ -33,11 +33,45 @@ 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,
}
#[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,
}
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SearchInputKind {
@@ -59,6 +93,23 @@ pub struct TmdbMovie {
pub poster_path: Option<String>,
}
#[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>,
}
/// One release's score, kept as its terms so the UI can explain a ranking
/// (`DESIGN.md` §5.5) rather than showing a bare number.
#[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)]
@@ -122,31 +173,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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
.fetch_all(database.pool()).await?
.into_iter()
.map(LibraryResult::Movie)
.collect()
} else {
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
.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" 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" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
.fetch_all(database.pool()).await?
};
library.extend(series_rows.into_iter().map(LibraryResult::Series));
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so the haystack
// is both joined and every token must land somewhere in it.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
.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, (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" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
.fetch_optional(database.pool()).await?
{
library.push(LibraryResult::Series(found));
}
}
}
}
}
@@ -159,11 +253,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 +279,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 +291,75 @@ async fn search_tmdb(
year,
overview: movie.overview,
poster_path: movie.poster_path,
}]
})]
}
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)
@@ -420,6 +562,34 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
}
}
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,
}
}
}
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,
}
}
}
fn input_kind(input: &str) -> SearchInputKind {
let lower = input.to_ascii_lowercase();
if lower.starts_with("magnet:?") {
@@ -441,6 +611,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 +652,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,
@@ -638,6 +821,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 (438631, 'Dune', 2021, 'en', 2)")
.execute(state.database().expect("database").pool()).await.expect("movie");
@@ -649,10 +839,92 @@ 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.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]
async fn numeric_titles_survive_a_missing_tmdb_id() {
let tmdb = MockServer::start().await;
@@ -662,6 +934,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 +948,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 +969,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;
+670 -80
View File
@@ -32,7 +32,7 @@ use utoipa::{IntoParams, ToSchema};
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
use crate::owners::Owner;
use crate::search::tmdb_client;
use crate::state::{AppState, EpisodeCommand};
use crate::state::{AppState, EpisodeCommand, SeasonCommand};
/// A series with the status derived from its episodes (§4.2).
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -97,19 +97,31 @@ pub struct Season {
pub number: i64,
/// §4.1. Whether new episodes of this season arrive wanted.
pub tracked: bool,
/// #141. The season vanished upstream while files remained under it,
/// which is why the row still exists. A conflict for the operator to
/// resolve; nothing was deleted from disk.
pub vanished: bool,
pub episodes: Vec<Episode>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Episode {
pub id: i64,
/// The owning series — the episode deck route needs it without walking
/// seasons first.
pub series_id: i64,
pub season_id: i64,
pub season_number: i64,
pub number: i64,
pub title: String,
pub air_date: Option<String>,
/// §4.1. The only intent in the TV aggregate.
pub wanted: bool,
pub state: String,
/// #122. The episode vanished upstream while a file of its own remained,
/// which is why the row still exists. A conflict for the operator to
/// resolve; nothing was deleted from disk.
pub vanished: bool,
pub search_attempts: i64,
pub last_searched_at: Option<String>,
}
@@ -192,11 +204,13 @@ struct EpisodeRow {
series_id: i64,
id: i64,
season_id: i64,
season_number: i64,
number: i64,
title: String,
air_date: Option<String>,
wanted: bool,
state: String,
vanished: bool,
search_attempts: i64,
last_searched_at: Option<String>,
}
@@ -252,6 +266,7 @@ fn core_episode(row: &EpisodeRow) -> arr_core::Episode {
arr_core::Episode {
id: EpisodeId(row.id),
season_id: SeasonId(row.season_id),
season_number: u16::try_from(row.season_number).unwrap_or_default(),
number: u16::try_from(row.number).unwrap_or_default(),
title: row.title.clone(),
air_date: air_date(row.air_date.as_deref()),
@@ -272,31 +287,14 @@ fn status_name(status: SeriesStatus) -> &'static str {
}
}
fn core_season(id: i64, series_id: i64, number: i64) -> arr_core::Season {
arr_core::Season {
id: SeasonId(id),
series_id: SeriesId(series_id),
number: u16::try_from(number).unwrap_or_default(),
tracked: false,
}
}
fn with_status(
row: &SeriesRow,
seasons: &[arr_core::Season],
episodes: &[arr_core::Episode],
now: SystemTime,
) -> Series {
fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series {
// §4.2: season 0 is invisible to status, so it stays out of the counters
// too. A series reading `complete` next to `42/52 eps` is the confusion
// this avoids. `UNIQUE (series_id, number)` means there is at most one.
let specials = seasons
.iter()
.find(|season| season.number == 0)
.map(|season| season.id);
// this avoids. The season number rides on each episode (#131), so no
// parallel slice can be forgotten.
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
.filter(|episode| episode.wanted && episode.season_number != 0);
let available = wanted
.clone()
.filter(|episode| episode.state == MediaState::Available);
@@ -312,53 +310,26 @@ fn with_status(
overrides: row.overrides.clone(),
upstream_ended: row.upstream_ended,
blocked: row.blocked,
status: status_name(derive_series_status(
&core_series(row),
seasons,
episodes,
now,
))
.to_owned(),
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
}
}
/// Every season and episode in the library, keyed by the series they belong to.
/// Every episode in the library, keyed by the series it belongs to.
///
/// One query each rather than one per series: the whole table is a few thousand
/// One query rather than one per series: the whole table is a few thousand
/// rows for a single household (§10), and the status of every listed series
/// needs all of them anyway.
async fn tv_by_series(
state: &AppState,
) -> Result<
(
HashMap<i64, Vec<arr_core::Season>>,
HashMap<i64, Vec<arr_core::Episode>>,
),
ApiError,
> {
let season_rows = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons"#
)
.fetch_all(pool(state)?)
.await?;
async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Episode>>, ApiError> {
let rows = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id"#
)
.fetch_all(pool(state)?)
.await?;
let mut seasons: HashMap<i64, Vec<arr_core::Season>> = HashMap::new();
for row in &season_rows {
seasons.entry(row.series_id).or_default().push(core_season(
row.id,
row.series_id,
row.number,
));
}
let mut episodes: HashMap<i64, Vec<arr_core::Episode>> = HashMap::new();
for row in &rows {
episodes
@@ -366,7 +337,7 @@ async fn tv_by_series(
.or_default()
.push(core_episode(row));
}
Ok((seasons, episodes))
Ok(episodes)
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
@@ -378,26 +349,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiErro
async fn load_series(state: &AppState, id: i64) -> Result<Series, ApiError> {
let row = load_series_row(state, id).await?;
let season_rows = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons WHERE series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let seasons: Vec<_> = season_rows
.iter()
.map(|row| core_season(row.id, row.series_id, row.number))
.collect();
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let episodes: Vec<_> = episodes.iter().map(core_episode).collect();
Ok(with_status(&row, &seasons, &episodes, SystemTime::now()))
Ok(with_status(&row, &episodes, SystemTime::now()))
}
async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
@@ -443,20 +404,12 @@ pub async fn list(
.await?
};
let (seasons, episodes) = tv_by_series(&state).await?;
let episodes = tv_by_series(&state).await?;
let now = SystemTime::now();
let no_seasons: Vec<arr_core::Season> = Vec::new();
let no_episodes: Vec<arr_core::Episode> = Vec::new();
Ok(Json(
rows.iter()
.map(|row| {
with_status(
row,
seasons.get(&row.id).unwrap_or(&no_seasons),
episodes.get(&row.id).unwrap_or(&no_episodes),
now,
)
})
.map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&no_episodes), now))
.collect(),
))
}
@@ -584,6 +537,28 @@ pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
// The row is loaded first so a missing series is 404 before anything
// touches the disk.
load_series_row(&state, id).await?;
remove_library_files(&state, id).await?;
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
// cascades from the seasons and episodes rows (which the series row's
// delete does): leaving the rows behind would block re-importing the
// same paths after a re-add. Owner tags go with the title they tagged.
sqlx::query!(
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?)",
id
)
.execute(pool(&state)?)
.await?;
sqlx::query!(
"DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
id
)
.execute(pool(&state)?)
.await?;
let result = sqlx::query!("DELETE FROM series WHERE id = ?", id)
.execute(pool(&state)?)
.await?;
@@ -593,16 +568,83 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT)
}
/// Unlink everything this series put under its root. Mirrors the movie
/// handler in `movies.rs`.
///
/// The service knows only what it wrote (§2), so the targets come from
/// `media_files`, never from a scan and never from re-deriving the §7.4 name
/// — a series renamed after import would derive a folder that does not exist
/// while the real one stayed. Each episode file resolves to its title folder,
/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles
/// and artwork go with it.
///
/// The torrent is untouched (§7.3). It keeps seeding under its own rule and
/// the reaper deletes it; a hardlinked file loses only its library name.
///
/// Failure leaves the database alone, so the operator sees the series still
/// there and can retry rather than losing the record of what is on disk.
async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> {
let root = sqlx::query_scalar!(
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
id
)
.fetch_one(pool(state)?)
.await?;
let paths = sqlx::query_scalar!(
r#"SELECT mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?"#,
id
)
.fetch_all(pool(state)?)
.await?;
let mut targets: Vec<std::path::PathBuf> = Vec::new();
for path in &paths {
let Some(target) = crate::movies::title_target(&root, path) else {
// Outside its own root: not ours to delete. The row still goes,
// so the operator sees the series leave and the file stay.
tracing::warn!(%path, %root, "media file is outside its root, not deleted");
continue;
};
if !targets.contains(&target) {
targets.push(target);
}
}
for target in targets {
let metadata = match tokio::fs::symlink_metadata(&target).await {
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
} else {
tokio::fs::remove_file(&target).await
};
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
Ok(())
}
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
let seasons = sqlx::query!(
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#,
series_id
)
.fetch_all(pool(state)?)
.await?;
let episodes = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#,
series_id
)
@@ -616,17 +658,21 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
series_id: season.series_id,
number: season.number,
tracked: season.tracked,
vanished: season.vanished,
episodes: episodes
.iter()
.filter(|episode| episode.season_id == season.id)
.map(|episode| Episode {
id: episode.id,
series_id,
season_id: episode.season_id,
season_number: episode.season_number,
number: episode.number,
title: episode.title.clone(),
air_date: episode.air_date.clone(),
wanted: episode.wanted,
state: episode.state.clone(),
vanished: episode.vanished,
search_attempts: episode.search_attempts,
last_searched_at: episode.last_searched_at.clone(),
})
@@ -681,6 +727,15 @@ pub async fn create_season(
"episode number cannot be negative".into(),
));
}
// The column rejects the empty string (#153); say so before the database
// has to.
if input
.episodes
.iter()
.any(|episode| episode.title.trim().is_empty())
{
return Err(ApiError::Invalid("episode title cannot be empty".into()));
}
let mut numbers: Vec<i64> = input
.episodes
.iter()
@@ -708,6 +763,7 @@ pub async fn create_season(
.map(|episode| arr_core::Episode {
id: EpisodeId(0),
season_id: SeasonId(0),
season_number: u16::try_from(input.number).unwrap_or_default(),
number: u16::try_from(episode.number).unwrap_or_default(),
title: episode.title.clone(),
air_date: air_date(episode.air_date.as_deref()),
@@ -831,7 +887,7 @@ pub async fn update_season(
async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
let row = sqlx::query_as!(
EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#,
id
)
@@ -840,12 +896,15 @@ async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
.ok_or(ApiError::EpisodeNotFound)?;
Ok(Episode {
id: row.id,
series_id: row.series_id,
season_id: row.season_id,
season_number: row.season_number,
number: row.number,
title: row.title,
air_date: row.air_date,
wanted: row.wanted,
state: row.state,
vanished: row.vanished,
search_attempts: row.search_attempts,
last_searched_at: row.last_searched_at,
})
@@ -989,6 +1048,153 @@ pub async fn grab_episode(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
/// Resolves a season by its number within one series, so the deck is
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
series_id,
number
)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeasonNotFound)
}
#[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/search", tag = "series",
params(
("series_id" = i64, Path, description = "Series row id"),
("season_number" = i64, Path, description = "Season number, not its row id")
),
responses(
(status = 202, body = Accepted),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn search_season(
State(state): State<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
// §6.3. `blocked` stops targeted search for the whole series.
let blocked = sqlx::query_scalar!(
r#"SELECT blocked AS "blocked!: bool" FROM series WHERE id = ?"#,
series_id
)
.fetch_one(pool(&state)?)
.await?;
if blocked {
return Err(ApiError::Conflict("series is blocked".into()));
}
state
.send_season_command(SeasonCommand::Search { season_id })
.map_err(|_| ApiError::Unavailable)?;
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/seasons/{season_number}/releases", tag = "series",
params(
("series_id" = i64, Path, description = "Series row id"),
("season_number" = i64, Path, description = "Season number, not its row id")
),
responses(
(status = 200, body = [Release]),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn season_releases(
State(state): State<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<Json<Vec<Release>>, ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let mut releases = sqlx::query_as!(Release, 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.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"#, season_id)
.fetch_all(pool(&state)?)
.await?;
let policy = state
.database()
.ok_or(ApiError::Unavailable)?
.season_policy(season_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::SeasonNotFound)?
.policy;
rescore(&mut releases, &policy)?;
Ok(Json(releases))
}
#[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series",
params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
responses(
(status = 202, body = Accepted),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn grab_season_release(
State(state): State<AppState>,
Path((series_id, number, release_id)): Path<(i64, i64, i64)>,
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'", season_id, release_id)
.fetch_one(pool(&state)?)
.await?;
if !exists {
return Err(ApiError::SeasonNotFound);
}
state
.send_season_command(SeasonCommand::Grab {
season_id,
release_id,
})
.map_err(|_| ApiError::Unavailable)?;
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
/// One imported episode file, keyed to its episode so the detail view can
/// attach the file's probed §7.4 attributes to the episode row.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EpisodeFile {
pub episode_id: i64,
pub path: String,
pub size: i64,
pub probed: Option<serde_json::Value>,
/// The §5.7 rule relaxed to allow this import, when one was.
pub waiver: Option<String>,
}
#[utoipa::path(
get, path = "/api/series/{series_id}/files", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
responses(
(status = 200, body = [EpisodeFile]),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn files(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<EpisodeFile>>, ApiError> {
load_series_row(&state, id).await?;
let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id)
.fetch_all(pool(&state)?)
.await?;
Ok(Json(files))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/owners", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
@@ -1205,6 +1411,28 @@ mod tests {
assert_eq!(season["episodes"][0]["wanted"], false);
}
/// #153. An empty episode title is rejected up front — the column and the
/// TMDB boundary both refuse it, so the API must too.
#[tokio::test]
async fn an_empty_episode_title_is_rejected() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
for title in ["", " "] {
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 1, "episodes": [
{"number": 1, "title": title}
]}))
.send()
.await
.expect("create season");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[tokio::test]
async fn a_rejected_season_leaves_nothing_behind_to_retry_over() {
let (_dir, state, base) = application().await;
@@ -1239,6 +1467,50 @@ mod tests {
assert_eq!(season["number"], 1);
}
#[tokio::test]
async fn a_vanished_season_reaches_the_seasons_endpoint() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let clean = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
assert_eq!(clean["vanished"], false);
add_season(
&base,
series_id,
2,
serde_json::json!([{"number": 1, "title": "One"}]),
)
.await;
sqlx::query("UPDATE seasons SET vanished = 1 WHERE series_id = ? AND number = 2")
.bind(series_id)
.execute(state.database().expect("database").pool())
.await
.expect("flag season vanished");
let seasons: serde_json::Value = reqwest::Client::new()
.get(format!("{base}/api/series/{series_id}/seasons"))
.send()
.await
.expect("list seasons")
.json()
.await
.expect("seasons json");
let seasons = seasons.as_array().expect("seasons array");
assert_eq!(seasons[0]["vanished"], false);
assert_eq!(
seasons[1]["vanished"], true,
"#141: the conflict flag is not stopped at the database"
);
}
#[tokio::test]
async fn intent_is_set_on_seasons_and_on_single_episodes() {
let (_dir, state, base) = application().await;
@@ -1496,6 +1768,136 @@ mod tests {
assert_eq!(response.status(), StatusCode::CONFLICT);
}
/// Issue #125: seasons get the same §9.3 surface as movies and episodes —
/// a targeted search, a deck scoped to one season and rescored against
/// the current policy, and a grab by release id.
#[tokio::test]
async fn manual_season_actions_are_scoped_and_rescore_the_deck() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]),
)
.await;
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons/1/search"))
.send()
.await
.expect("search");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let season_id = season["id"].as_i64().expect("season id");
assert_eq!(
state.next_season_command().await.expect("command"),
SeasonCommand::Search { season_id }
);
// A stale stored score proves the deck is rescored at read time,
// like the movie deck (#114).
let pool = state.database().expect("database").pool();
let name = "Bluey S01 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, -12345, 'eligible') RETURNING id",
)
.bind(name)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/1/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "eligible");
assert_ne!(
releases[0]["score"].as_f64(),
Some(-12345.0),
"the deck reflects the current policy, not the stored score"
);
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/1/releases/{release_id}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
state.next_season_command().await.expect("command"),
SeasonCommand::Grab {
season_id,
release_id
}
);
// A release belonging to another season is not grabbable through
// this one, and an unknown season number is not another season.
let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id")
.fetch_one(pool)
.await
.expect("other release");
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/1/releases/{other}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = reqwest::get(format!("{base}/api/series/{series_id}/seasons/2/releases"))
.await
.expect("unknown season");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_blocked_series_refuses_a_season_search() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot"}]),
)
.await;
reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"blocked": true}))
.send()
.await
.expect("block the series");
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons/1/search"))
.send()
.await
.expect("blocked search");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn owner_tags_filter_the_series_list() {
let (_dir, state, base) = application().await;
@@ -1587,6 +1989,194 @@ mod tests {
assert_eq!(episodes, 0);
}
/// A series on disk for one test: the §7.4 title folder with a season
/// subfolder holding one episode file and one sidecar subtitle.
async fn library_on_disk(
state: &AppState,
episode_id: i64,
root: &std::path::Path,
) -> std::path::PathBuf {
let folder = root.join("Bluey (2018) [tmdbid-82728]");
let season = folder.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create title folder");
let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv");
tokio::fs::write(&feature, b"episode").await.expect("write");
tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs")
.await
.expect("write sidecar");
let pool = state.database().expect("database").pool();
let root_path = root.to_str().expect("utf-8 root");
sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'")
.bind(root_path)
.execute(pool)
.await
.expect("point the root at the tempdir");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
)
.bind(episode_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
folder
}
/// The §7.4 title folder is the unit of deletion, so the season
/// subfolder and sidecars go with it — and the root is never touched.
#[tokio::test]
async fn deleting_a_series_removes_the_whole_title_folder() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]),
)
.await;
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
let root = tempfile::tempdir().expect("root");
let folder = library_on_disk(&state, episode_id, root.path()).await;
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/{series_id}"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(
!folder.exists(),
"the title folder, its seasons and its sidecars are gone"
);
assert!(root.path().exists(), "the root survives its titles");
let pool = state.database().expect("database").pool();
let orphans: i64 =
sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'")
.fetch_one(pool)
.await
.expect("count files");
assert_eq!(orphans, 0, "the file rows go with the files");
}
/// A missing series is 404 before anything touches the disk.
#[tokio::test]
async fn deleting_a_missing_series_is_a_404() {
let (_dir, _state, base) = application().await;
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/999"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
/// The detail view reads its episode files through the series, keyed by
/// episode id, so one request carries every §7.4 attribute tag it shows.
#[tokio::test]
async fn series_files_are_keyed_by_episode() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "kids").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([
{"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"},
{"number": 2, "title": "Hospital", "air_date": "2018-10-02"}
]),
)
.await;
let episodes = season["episodes"].as_array().expect("episodes");
let first = episodes[0]["id"].as_i64().expect("episode id");
let pool = state.database().expect("database").pool();
sqlx::query(
r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
VALUES ('episode', ?, ?, 7, ?, ?)",
)
.bind(first)
.bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv")
.bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#)
.bind(r#"{"rule":"required_audio"}"#)
.execute(pool)
.await
.expect("media file");
let response = reqwest::get(format!("{base}/api/series/{series_id}/files"))
.await
.expect("fetch files");
assert_eq!(response.status(), StatusCode::OK);
let files: serde_json::Value = response.json().await.expect("files json");
let rows = files.as_array().expect("array");
assert_eq!(rows.len(), 1, "only imported episodes carry a file");
assert_eq!(rows[0]["episode_id"], first);
assert_eq!(rows[0]["probed"]["resolution"], "1080p");
assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT");
assert_eq!(rows[0]["waiver"], "required_audio");
let missing = reqwest::get(format!("{base}/api/series/999/files"))
.await
.expect("fetch files");
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
/// The guard that keeps a delete inside the library: a path that is not
/// under the series' root is left alone, whatever the row says.
#[tokio::test]
async fn a_series_file_outside_its_root_is_never_unlinked() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, true).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot"}]),
)
.await;
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
let root = tempfile::tempdir().expect("root");
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(root.path().to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
let elsewhere = tempfile::tempdir().expect("elsewhere");
let stray = elsewhere.path().join("not-ours.mkv");
tokio::fs::write(&stray, b"stray").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)",
)
.bind(episode_id)
.bind(stray.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
let response = reqwest::Client::new()
.delete(format!("{base}/api/series/{series_id}"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert!(
stray.exists(),
"a path outside the root is not ours to delete"
);
}
#[test]
fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!(
+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
);
}
}
+25 -45
View File
@@ -1,6 +1,6 @@
use std::time::{Duration, SystemTime};
use crate::{Episode, MediaState, Season, Series};
use crate::{Episode, MediaState, Series};
const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24);
@@ -16,26 +16,19 @@ pub enum SeriesStatus {
/// Derives a series' status at `now` without persisting lifecycle intent.
///
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a
/// season 0 listed here are ignored, so a manually wanted special cannot pin
/// the series at `incomplete` or hold back `ended`. The seasons must be the
/// series' own; an episode whose season is not listed counts like any other.
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with
/// `season_number` 0 are ignored, so a manually wanted special cannot pin
/// the series at `incomplete` or hold back `ended`. The season number rides
/// on each episode, so there is no second slice a caller can forget (#131).
#[must_use]
pub fn derive_series_status(
series: &Series,
seasons: &[Season],
episodes: &[Episode],
now: SystemTime,
) -> SeriesStatus {
// A series has at most one season 0, so this is a comparison rather than
// a set: no allocation on a function the list view calls per series.
let specials = seasons
.iter()
.find(|season| season.number == 0)
.map(|season| season.id);
let wanted = episodes
.iter()
.filter(|episode| episode.wanted && specials != Some(episode.season_id));
.filter(|episode| episode.wanted && episode.season_number != 0);
let complete = wanted
.clone()
.all(|episode| episode.state == MediaState::Available);
@@ -107,6 +100,7 @@ mod tests {
Episode {
id: EpisodeId(i64::from(number)),
season_id: SeasonId(1),
season_number: 1,
number,
title: format!("Episode {number}"),
air_date,
@@ -117,23 +111,13 @@ mod tests {
}
}
/// The season every default-test episode sits in.
fn seasons() -> [Season; 1] {
[Season {
id: SeasonId(1),
series_id: SeriesId(1),
number: 1,
tracked: true,
}]
}
#[test]
fn airing_when_a_wanted_episode_is_in_the_window() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
for air_date in [now - 14 * DAY, now + 14 * DAY] {
let episodes = [episode(1, Some(air_date), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Airing
);
}
@@ -145,7 +129,7 @@ mod tests {
MediaState::Missing,
)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -156,7 +140,7 @@ mod tests {
let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Incomplete
);
}
@@ -167,7 +151,7 @@ mod tests {
let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
}
@@ -178,7 +162,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete
);
}
@@ -189,7 +173,7 @@ mod tests {
let episodes = [episode(1, Some(now), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, true), &seasons(), &episodes, now),
derive_series_status(&series(true, true), &episodes, now),
SeriesStatus::Ended
);
}
@@ -199,14 +183,14 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Incomplete
);
episodes[0].state = MediaState::Available;
assert_eq!(
derive_series_status(&series(false, false), &seasons(), &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete
);
}
@@ -216,14 +200,14 @@ mod tests {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing));
assert_eq!(
derive_series_status(&series(true, false), &seasons(), &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Airing
);
}
@@ -231,34 +215,30 @@ mod tests {
#[test]
fn missing_specials_do_not_hold_back_complete_or_ended() {
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
let specials = [Season {
id: SeasonId(9),
series_id: SeriesId(1),
number: 0,
tracked: false,
}];
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
special.season_id = SeasonId(9);
special.season_number = 0;
assert_eq!(
derive_series_status(&series(true, false), &specials, &episodes, now),
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting
);
episodes.push(special);
assert_eq!(
derive_series_status(&series(true, false), &[], &episodes, now),
SeriesStatus::Incomplete,
"without knowing the seasons, the special pins the series"
derive_series_status(&series(true, false), &episodes, now),
SeriesStatus::Waiting,
"#131: the season number rides on the episode, so the special is \
invisible however the caller builds the slice"
);
assert_eq!(
derive_series_status(&series(false, false), &specials, &episodes, now),
derive_series_status(&series(false, false), &episodes, now),
SeriesStatus::Complete,
"§4.2: the only gap is a special"
);
assert_eq!(
derive_series_status(&series(false, true), &specials, &episodes, now),
derive_series_status(&series(false, true), &episodes, now),
SeriesStatus::Ended
);
}
+1
View File
@@ -72,6 +72,7 @@ mod tests {
Episode {
id: EpisodeId(season * 100 + i64::from(number)),
season_id: SeasonId(season),
season_number: u16::try_from(season).unwrap_or_default(),
number,
title: format!("Episode {number}"),
air_date: Some(SystemTime::UNIX_EPOCH),
+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);
}
}
+7 -5
View File
@@ -908,7 +908,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 +1121,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 +1179,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 +1190,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 +1210,7 @@ pub(crate) async fn store_episode_release(
.execute(database.pool())
.await?;
}
Ok(eligible)
Ok((release_id, eligible))
}
async fn classify_and_store(
+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 = '';
+69
View File
@@ -275,6 +275,75 @@ impl Db {
root_path: row.root_path,
}))
}
/// The policy attached to a season's series root, with that series'
/// overrides. Same rule as [`Db::episode_policy`]: overrides sit on the
/// series (§5.1), never on a season.
///
/// `None` when the season does not exist.
///
/// # Errors
///
/// If the query fails, or a policy column does not hold the JSON its
/// migration promises.
pub async fn season_policy(&self, season_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
let row = sqlx::query!(
r#"
SELECT s.overrides AS "overrides!: String",
r.id AS "root_id!: i64",
r.kind AS "root_kind!: String",
r.audience AS "root_audience!: String",
r.path AS "root_path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String",
p.required_audio AS "required_audio!: String",
p.dub_blacklist AS "dub_blacklist!: String",
p.hdr_rules AS "hdr_rules!: String",
p.size_bands AS "size_bands!: String",
p.resolution_pref AS "resolution_pref!: String",
p.source_weights AS "source_weights!: String",
p.score_weights AS "score_weights!: String"
FROM seasons se
JOIN series s ON s.id = se.series_id
JOIN roots r ON r.id = s.root_id
JOIN policies p ON p.id = r.policy_id
WHERE se.id = ?
"#,
season_id
)
.fetch_optional(self.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let overrides: OverridesJson = json("overrides", &row.overrides)?;
let policy = PolicyColumns {
id: row.policy_id,
name: row.policy_name,
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}
.to_policy()?;
Ok(Some(TitlePolicy {
policy,
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
},
root_id: row.root_id,
root_kind: row.root_kind,
root_audience: row.root_audience,
root_path: row.root_path,
}))
}
}
/// A BCP-47 tag as the policy columns write it.
+71 -4
View File
@@ -9,8 +9,10 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{
ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage,
RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
SeriesSearchResult, Video,
};
/// TMDB's v3 API root.
@@ -94,14 +96,20 @@ impl TmdbClient {
/// Resolve an `IMDb` title id through TMDB's external-id index.
///
/// TMDB answers with both kinds in one response, so one call surfaces
/// whichever the id names — the way a raw TMDB id resolves (§9.2).
///
/// # Errors
///
/// Any of [`Error`]; see its variants for what callers should distinguish.
pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result<Vec<MovieSearchResult>> {
pub async fn find_by_imdb(&self, imdb_id: &str) -> Result<FindResults> {
let path = format!("find/{}", imdb_id.trim());
let params = [("external_source", "imdb_id".to_owned())];
let page: RawFindPage = self.get_json(&path, &params).await?;
Ok(page.movie_results.into_iter().map(Into::into).collect())
Ok(FindResults {
movies: page.movie_results.into_iter().map(Into::into).collect(),
series: page.tv_results.into_iter().map(Into::into).collect(),
})
}
/// Resolve a TVDB series id through TMDB's external-id index.
@@ -157,6 +165,65 @@ impl TmdbClient {
Ok(raw.into())
}
/// Rich detail for one movie's §9.6 page.
///
/// One HTTP call: credits, videos and external ids come back appended to
/// the same response. Served through the same cache as [`Self::movie`];
/// nothing here is persisted (§9.6).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
let path = format!("movie/{tmdb_id}");
let params = [(
"append_to_response",
"credits,videos,external_ids".to_owned(),
)];
let raw: RawMovieDetail = self.get_json(&path, &params).await?;
Ok(raw.into())
}
/// Rich detail for one series' §9.6 page.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_detail(&self, tmdb_id: u32) -> Result<SeriesDetail> {
let path = format!("tv/{tmdb_id}");
let params = [(
"append_to_response",
"credits,videos,external_ids".to_owned(),
)];
let raw: RawSeriesDetail = self.get_json(&path, &params).await?;
Ok(raw.into())
}
/// The chosen trailer for a movie, fetching only the videos list.
///
/// #144's search-row chip resolves through this, where a full detail
/// response would be waste (§9.6).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn movie_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
let path = format!("movie/{tmdb_id}/videos");
let raw: RawVideoList = self.get_json(&path, &[]).await?;
Ok(select_trailer(&raw.into_videos()))
}
/// The chosen trailer for a series, fetching only the videos list.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
let path = format!("tv/{tmdb_id}/videos");
let raw: RawVideoList = self.get_json(&path, &[]).await?;
Ok(select_trailer(&raw.into_videos()))
}
/// External ids for one TV series, of which the TVDB id is the one this
/// project needs (§6.1).
///
+2 -1
View File
@@ -24,5 +24,6 @@ mod model;
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
pub use error::{Error, Result};
pub use model::{
Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
};
+327 -1
View File
@@ -140,6 +140,10 @@ pub struct SeasonSummary {
pub episode_count: u32,
}
/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces
/// it once TMDB fills the title in.
pub const UNTITLED_EPISODE: &str = "TBA";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Season {
pub number: u32,
@@ -153,6 +157,107 @@ pub struct Episode {
pub air_date: Option<NaiveDate>,
}
/// Cast is truncated to the top 10 billed, in the crate, so no caller has to
/// remember to (§9.6).
const CAST_LIMIT: usize = 10;
/// A genre as a detail response carries it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Genre {
pub id: u32,
pub name: String,
}
/// One of the top-billed cast members on a detail page. `profile_path` is a
/// path fragment — §9.6 hotlinks images and the browser composes the URL.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CastMember {
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
pub tmdb_id: u32,
pub name: String,
pub character: String,
pub profile_path: Option<String>,
pub order: u32,
}
/// One entry of a title's video list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Video {
pub key: String,
/// `YouTube`, `Vimeo`, … Only `YouTube` ever becomes a trailer (§9.6).
pub site: String,
/// TMDB calls this field `type`: `Trailer`, `Teaser`, `Clip`, …
pub kind: String,
pub name: String,
pub official: bool,
}
/// Rich movie detail for the §9.6 page. One upstream request via
/// `append_to_response`, served through the same cache as everything else;
/// nothing here is persisted.
///
/// No float fields are involved in equality except `vote_average`, so this is
/// `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MovieDetail {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<Genre>,
pub backdrop_path: Option<String>,
pub poster_path: Option<String>,
pub vote_average: f64,
pub vote_count: u32,
pub homepage: Option<String>,
pub status: String,
pub runtime: Option<u32>,
/// §9.6 links out to `IMDb` for movies.
pub imdb_id: Option<String>,
/// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order.
pub cast: Vec<CastMember>,
/// The one trailer worth showing, chosen by the §9.6 rule.
pub trailer: Option<Video>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeriesDetail {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<Genre>,
pub backdrop_path: Option<String>,
pub poster_path: Option<String>,
pub vote_average: f64,
pub vote_count: u32,
pub homepage: Option<String>,
pub status: String,
/// Episode length in minutes; TMDB sends a list per episode, this takes
/// the first.
pub episode_runtime: Option<u32>,
/// §9.6 links out to TVDB for series.
pub tvdb_id: Option<u32>,
pub cast: Vec<CastMember>,
pub trailer: Option<Video>,
}
/// The trailer rule from §9.6, shared by the detail calls and the videos-only
/// calls #144 reads. Preference order: an official `YouTube` trailer, then any
/// `YouTube` trailer, then any `YouTube` teaser, then nothing. Within a tier
/// the first match wins, which keeps the result deterministic for a given
/// response.
#[must_use]
pub(crate) fn select_trailer(videos: &[Video]) -> Option<Video> {
let pick = |want: &dyn Fn(&Video) -> bool| {
videos
.iter()
.find(|video| video.site == "YouTube" && want(video))
.cloned()
};
pick(&|video| video.kind == "Trailer" && video.official)
.or_else(|| pick(&|video| video.kind == "Trailer"))
.or_else(|| pick(&|video| video.kind == "Teaser"))
}
// --- TMDB wire types -------------------------------------------------------
//
// Private on purpose. TMDB's field names stop here.
@@ -285,7 +390,13 @@ impl From<RawSeason> for Season {
.into_iter()
.map(|episode| Episode {
number: episode.episode_number,
title: episode.name,
// TMDB leaves an unaired episode's name empty; a placeholder
// keeps "" out of search, filenames and the compat shim.
title: if episode.name.is_empty() {
UNTITLED_EPISODE.to_owned()
} else {
episode.name
},
air_date: episode.air_date.as_deref().and_then(parse_date),
})
.collect(),
@@ -293,6 +404,15 @@ impl From<RawSeason> for Season {
}
}
/// What one `IMDb` id resolved to. An id names one title, so at most one of
/// the two lists is non-empty — but TMDB answers both kinds in the same
/// response, and both are surfaced rather than filtered here.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FindResults {
pub movies: Vec<MovieSearchResult>,
pub series: Vec<SeriesSearchResult>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawFindPage {
#[serde(default)]
@@ -449,3 +569,209 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|text| !text.is_empty())
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawMovieDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
runtime: Option<u32>,
#[serde(default)]
imdb_id: Option<String>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeriesDetail {
id: u32,
#[serde(default)]
tagline: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
genres: Vec<RawGenre>,
#[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
poster_path: Option<String>,
vote_average: f64,
vote_count: u32,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
#[serde(default)]
credits: Option<RawCredits>,
#[serde(default)]
videos: Option<RawVideoList>,
}
#[derive(Debug, Deserialize)]
struct RawGenre {
id: u32,
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCredits {
#[serde(default)]
cast: Vec<RawCastMember>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawCastMember {
id: u32,
#[serde(default)]
name: String,
#[serde(default)]
character: String,
#[serde(default)]
profile_path: Option<String>,
order: u32,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawVideoList {
#[serde(default)]
results: Vec<RawVideo>,
}
impl RawVideoList {
pub(crate) fn into_videos(self) -> Vec<Video> {
self.results.into_iter().map(Into::into).collect()
}
}
#[derive(Debug, Deserialize)]
struct RawVideo {
#[serde(default)]
key: String,
#[serde(default)]
site: String,
#[serde(rename = "type", default)]
kind: String,
#[serde(default)]
name: String,
official: bool,
}
impl From<RawVideo> for Video {
fn from(raw: RawVideo) -> Self {
Self {
key: raw.key,
site: raw.site,
kind: raw.kind,
name: raw.name,
official: raw.official,
}
}
}
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
let mut cast: Vec<CastMember> = raw
.map(|credits| {
credits
.cast
.into_iter()
.map(|member| CastMember {
tmdb_id: member.id,
name: member.name,
character: member.character,
profile_path: non_empty(member.profile_path),
order: member.order,
})
.collect()
})
.unwrap_or_default();
// TMDB's own ordering is by `order`; sorting makes the truncation hold
// even if a fixture or future API revision sends them shuffled.
cast.sort_by_key(|member| member.order);
cast.truncate(CAST_LIMIT);
cast
}
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
let videos: Vec<Video> = raw
.map(|list| list.results.into_iter().map(Into::into).collect())
.unwrap_or_default();
select_trailer(&videos)
}
impl From<RawMovieDetail> for MovieDetail {
fn from(raw: RawMovieDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: raw.vote_average,
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
runtime: raw.runtime,
imdb_id: non_empty(raw.imdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
impl From<RawSeriesDetail> for SeriesDetail {
fn from(raw: RawSeriesDetail) -> Self {
Self {
tmdb_id: raw.id,
overview: non_empty(raw.overview),
tagline: non_empty(raw.tagline),
genres: raw
.genres
.into_iter()
.map(|genre| Genre {
id: genre.id,
name: genre.name,
})
.collect(),
backdrop_path: non_empty(raw.backdrop_path),
poster_path: non_empty(raw.poster_path),
vote_average: raw.vote_average,
vote_count: raw.vote_count,
homepage: non_empty(raw.homepage),
status: raw.status,
episode_runtime: raw.episode_run_time.into_iter().next(),
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
cast: cast_from(raw.credits),
trailer: trailer_from(raw.videos),
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"movie_results": [],
"tv_results": [
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"overview": "The slice-of-life adventures of an Australian cattle dog.",
"poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg"
}
]
}
+46
View File
@@ -0,0 +1,46 @@
{
"id": 693134,
"imdb_id": "tt15239678",
"title": "Dune: Part Two",
"original_title": "Dune: Part Two",
"tagline": "Long live the fighters.",
"overview": "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.",
"status": "Released",
"runtime": 167,
"homepage": "https://www.dunemovie.com",
"vote_average": 8.152,
"vote_count": 6249,
"poster_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"genres": [
{ "id": 878, "name": "Science Fiction" },
{ "id": 12, "name": "Adventure" }
],
"credits": {
"cast": [
{ "id": 5530, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/x3UkVAsKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 0 },
{ "id": 3559977, "name": "Zendaya", "character": "Chani", "profile_path": "/xaWu0DjKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 1 },
{ "id": 37614, "name": "Rebecca Ferguson", "character": "Lady Jessica", "profile_path": "/fPM5vM7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 2 },
{ "id": 1110844, "name": "Austin Butler", "character": "Feyd-Rautha Harkonnen", "profile_path": null, "order": 3 },
{ "id": 22451, "name": "Josh Brolin", "character": "Gurney Halleck", "profile_path": "/qR11vJKKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 4 },
{ "id": 593015, "name": "Florence Pugh", "character": "Princess Irulan", "profile_path": "/mP55vB7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 5 },
{ "id": 1244949, "name": "Dave Bautista", "character": "Glossu Rabban Harkonnen", "profile_path": null, "order": 6 },
{ "id": 11220, "name": "Christopher Walken", "character": "Emperor Shaddam IV", "profile_path": "/wP55vC7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 7 },
{ "id": 38673, "name": "Léa Seydoux", "character": "Lady Margot Fenring", "profile_path": null, "order": 8 },
{ "id": 2880644, "name": "Souheila Yacoub", "character": "Shishakli", "profile_path": "/nP55vD7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 9 },
{ "id": 10952, "name": "Stellan Skarsgård", "character": "Baron Vladimir Harkonnen", "profile_path": null, "order": 10 },
{ "id": 33940, "name": "Charlotte Rampling", "character": "Reverend Mother Gaius Helen Mohiam", "profile_path": null, "order": 11 }
]
},
"videos": {
"results": [
{ "key": "n9xhJrPXop4", "site": "YouTube", "type": "Teaser", "name": "Official Teaser", "official": true },
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Dune Part Two Fan Trailer", "official": false },
{ "key": "clip_vimeo_id", "site": "Vimeo", "type": "Trailer", "name": "Trailer (Vimeo)", "official": true },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
},
"external_ids": {
"imdb_id": "tt15239678"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": 693134,
"results": [
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"tagline": "",
"overview": "The slice-of-life adventures of an Australian cattle dog called Bluey and her family.",
"status": "Returning Series",
"homepage": "https://www.blueytv.com",
"vote_average": 8.417,
"vote_count": 118,
"poster_path": "/58Pm1HTKHefFBCPVAVXOI0cDdIg.jpg",
"backdrop_path": "/9K4mLtNKhEfFBCPVAVXOI0cDdIg.jpg",
"first_air_date": "2018-10-01",
"episode_run_time": [7],
"genres": [
{ "id": 16, "name": "Animation" },
{ "id": 10751, "name": "Family" },
{ "id": 10759, "name": "Action & Adventure" }
],
"credits": {
"cast": [
{ "id": 2134777, "name": "Melanie Zanetti", "character": "Chilli Heeler (voice)", "profile_path": "/zAnetti.jpg", "order": 1 },
{ "id": 1760828, "name": "David McCormack", "character": "Bandit Heeler (voice)", "profile_path": "/dMcCormack.jpg", "order": 0 }
]
},
"videos": {
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
},
"external_ids": {
"imdb_id": "tt7614372",
"tvdb_id": 361391
}
}
@@ -0,0 +1,7 @@
{
"id": 82728,
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
}
+306 -1
View File
@@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
use std::time::Duration;
use arr_meta::{Error, TmdbClient};
use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE};
use chrono::NaiveDate;
use wiremock::matchers::{header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -19,6 +19,11 @@ const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json");
const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json");
const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json");
const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json");
fn client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("test-key")
@@ -463,6 +468,33 @@ async fn debug_output_does_not_leak_the_api_key() {
assert!(rendered.contains("redacted"), "{rendered}");
}
/// TMDB leaves an unaired episode's name as `""`. It must not reach the
/// library as an empty string — search haystacks, §7.4 filenames and the
/// compat shim all treat it as real text (#153) — so it becomes a placeholder
/// that a later refresh replaces once TMDB names it.
#[tokio::test]
async fn season_episode_without_a_name_gets_the_placeholder_title() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/season/1"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"season_number": 1, "episodes": [
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "", "air_date": null}
]}"#,
))
.mount(&server)
.await;
let season = client(&server)
.season(82_728, 1)
.await
.expect("lookup succeeds");
assert_eq!(season.episodes[0].title, "Magic Xylophone");
assert_eq!(season.episodes[1].title, UNTITLED_EPISODE);
}
/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
/// where TMDB keeps the mapping.
#[tokio::test]
@@ -483,6 +515,33 @@ async fn series_external_ids_carries_the_tvdb_id() {
assert_eq!(ids.tvdb_id, Some(361_391));
}
/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a
/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results`
/// half is what this reads.
#[tokio::test]
async fn find_by_imdb_resolves_series() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/find/tt7614372"))
.and(query_param("external_source", "imdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES))
.expect(1)
.mount(&server)
.await;
let results = client(&server)
.find_by_imdb("tt7614372")
.await
.expect("lookup succeeds");
assert!(results.movies.is_empty());
assert_eq!(results.series.len(), 1);
let series = &results.series[0];
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.title, "Bluey");
assert_eq!(series.year(), Some(2018));
}
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
/// can tell "unknown" from a real id and fall back to the text query.
#[tokio::test]
@@ -501,3 +560,249 @@ async fn series_without_a_tvdb_id_maps_to_none() {
assert_eq!(ids.tvdb_id, None);
}
// --- detail (#143, §9.6) ----------------------------------------------------
/// A whole detail page costs one upstream request: credits, videos and
/// external ids ride along in the same response.
#[tokio::test]
async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
}
#[tokio::test]
async fn movie_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.tmdb_id, 693_134);
assert_eq!(
movie.overview.as_deref(),
Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.")
);
assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters."));
assert_eq!(movie.genres.len(), 2);
assert_eq!(movie.genres[0].id, 878);
assert_eq!(movie.genres[0].name, "Science Fiction");
assert!(movie.poster_path.is_some());
assert!(movie.backdrop_path.is_some());
assert!((movie.vote_average - 8.152).abs() < f64::EPSILON);
assert_eq!(movie.vote_count, 6_249);
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
assert_eq!(movie.status, "Released");
assert_eq!(movie.runtime, Some(167));
assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678"));
}
/// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to
/// remember to. The fixture carries 12 entries; only the first 10 by `order`
/// survive.
#[tokio::test]
async fn movie_detail_cast_is_truncated_to_ten_by_order() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.cast.len(), 10);
assert_eq!(movie.cast[0].tmdb_id, 5_530);
assert_eq!(movie.cast[0].name, "Timothée Chalamet");
assert_eq!(movie.cast[0].character, "Paul Atreides");
assert_eq!(movie.cast[0].order, 0);
// Truncation keeps the lowest `order` values, not the first rows sent.
assert_eq!(movie.cast[9].name, "Souheila Yacoub");
assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952));
}
/// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a
/// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before
/// the official one; neither wins.
#[tokio::test]
async fn movie_detail_picks_the_official_youtube_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
let trailer = movie.trailer.expect("a trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
assert_eq!(trailer.site, "YouTube");
assert_eq!(trailer.kind, "Trailer");
assert!(trailer.official);
}
#[tokio::test]
async fn series_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let series = client(&server)
.series_detail(82_728)
.await
.expect("lookup succeeds");
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.status, "Returning Series");
assert_eq!(series.episode_runtime, Some(7));
assert_eq!(series.tvdb_id, Some(361_391));
assert!(series.tagline.is_none());
assert_eq!(series.genres[0].name, "Animation");
// Cast comes out in TMDB's `order`, not in the order the rows arrived.
assert_eq!(series.cast[0].name, "David McCormack");
// No trailer exists for this series, so the teaser tier is what fires.
let trailer = series.trailer.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Detail goes through the same cache as everything else — two calls, one
/// request.
#[tokio::test]
async fn movie_and_series_detail_are_served_from_the_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let tmdb = client(&server);
let _ = tmdb.movie_detail(693_134).await.expect("succeeds");
let _ = tmdb.movie_detail(693_134).await.expect("cached");
let _ = tmdb.series_detail(82_728).await.expect("succeeds");
let _ = tmdb.series_detail(82_728).await.expect("cached");
}
/// #144's search-row chip resolves through the videos-only call: one request,
/// no full detail response.
#[tokio::test]
async fn movie_videos_fetches_only_the_videos_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(693_134)
.await
.expect("lookup succeeds")
.expect("an official trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
}
#[tokio::test]
async fn series_videos_applies_the_same_selection_rule() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.series_videos(82_728)
.await
.expect("lookup succeeds")
.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none.
#[tokio::test]
async fn vimeo_only_video_lists_yield_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"id": 1, "results": [
{"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true}
]}"#,
),
)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}
#[tokio::test]
async fn empty_video_list_yields_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#))
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}
+4 -3
View File
@@ -36,7 +36,7 @@ pub(crate) fn tag_of(token: &str) -> Option<EpisodeClaim> {
.or_else(|| date_tag(token))
}
/// `S01`, `S01-S03`, `S01E02`, `S01E02E03`, `S01E02-E04`.
/// `S01`, `S01-S03`, `S01-S01`, `S01E02`, `S01E02E03`, `S01E02-E04`.
fn season_tag(token: &str) -> Option<EpisodeClaim> {
let rest = token.strip_prefix('s')?;
let (season, mut rest) = digits(rest);
@@ -55,7 +55,8 @@ fn season_tag(token: &str) -> Option<EpisodeClaim> {
return None;
}
let last: u32 = last.parse().ok()?;
return (last > season).then_some(EpisodeClaim::Seasons {
// A degenerate range (`S01-S01`) is one season's pack, not nothing.
return (last >= season).then_some(EpisodeClaim::Seasons {
first: season,
last,
});
@@ -148,7 +149,7 @@ fn season_words(token: &str) -> Option<EpisodeClaim> {
return None;
}
let last: u32 = last.parse().ok()?;
(last > first).then_some(EpisodeClaim::Seasons { first, last })
(last >= first).then_some(EpisodeClaim::Seasons { first, last })
}
/// Splits a leading run of ASCII digits off a token.
+11 -3
View File
@@ -55,11 +55,19 @@ pub(crate) fn classify_pair(a: &str, b: &str) -> Option<(Marker, Strength)> {
("season", n) if !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()) => {
(Marker::Junk, Strength::Strong)
}
("seasons", n) if is_season_count(n) => (Marker::Junk, Strength::Strong),
_ => return None,
};
Some(m)
}
/// `1`, `1-3`: the count after a spelled-out `Seasons`. Each part is 1-2
/// digits so a trailing year (`Four Seasons 2024`) never reads as a count.
fn is_season_count(n: &str) -> bool {
n.split('-')
.all(|part| (1..=2).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit()))
}
#[allow(clippy::too_many_lines)]
fn classify_exact(t: &str) -> Option<(Marker, Strength)> {
use Strength::{Strong, Weak};
@@ -135,9 +143,9 @@ fn classify_exact(t: &str) -> Option<(Marker, Strength)> {
// "Legendado" claims pt-BR *subtitles* over original audio — it must
// NOT become a PtBr audio claim or main-root originals get rejected.
"proper" | "repack" | "internal" | "hybrid" | "10bit" | "8bit" | "hi10p" | "amzn"
| "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" => (Marker::Junk, Strong),
"nf" | "hulu" | "max" | "itunes" | "cr" | "complete" | "sample" | "subbed" | "subs"
| "sub" | "mkv" | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => {
| "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" | "complete" => (Marker::Junk, Strong),
"nf" | "hulu" | "max" | "itunes" | "cr" | "sample" | "subbed" | "subs" | "sub" | "mkv"
| "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => {
(Marker::Junk, Weak)
}
_ => return None,
+72
View File
@@ -307,6 +307,78 @@ fn cases() -> Vec<Case> {
..NameClaims::default()
},
},
Case {
// `COMPLETE` before the tag closes the title like any strong
// marker, wherever the group puts it.
name: "Bluey.COMPLETE.S01.1080p.WEB-DL",
want: NameClaims {
title: s("Bluey"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// The trailing placement already worked; it keeps working.
name: "Bluey.S01.COMPLETE.1080p.WEB-DL",
want: NameClaims {
title: s("Bluey"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// A degenerate range is one season's pack.
name: "Fallout.S01-S01.1080p.BluRay.x264-GROUP",
want: NameClaims {
title: s("Fallout"),
resolution: Some(Resolution::P1080),
source: Some(Source::BluRay),
codec: Some(Codec::X264),
group: s("GROUP"),
episode: Some(EpisodeClaim::Seasons { first: 1, last: 1 }),
..NameClaims::default()
},
},
Case {
// The spelled-out plural range closes the title too.
name: "Fallout.Seasons.1-3.1080p.BluRay-GROUP",
want: NameClaims {
title: s("Fallout"),
resolution: Some(Resolution::P1080),
source: Some(Source::BluRay),
group: s("GROUP"),
episode: Some(EpisodeClaim::Seasons { first: 1, last: 3 }),
..NameClaims::default()
},
},
Case {
// A bare number after the plural is a single season, like
// the singular form.
name: "Show.Name.Seasons.1.1080p.WEB-DL-GROUP",
want: NameClaims {
title: s("Show Name"),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
group: s("GROUP"),
episode: Some(EpisodeClaim::Season { season: 1 }),
..NameClaims::default()
},
},
Case {
// A year-sized number after `Seasons` is a year, not a count.
name: "The.Four.Seasons.2024.1080p.WEB-DL",
want: NameClaims {
title: s("The Four Seasons"),
year: Some(2024),
resolution: Some(Resolution::P1080),
source: Some(Source::WebDl),
..NameClaims::default()
},
},
Case {
name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM",
want: NameClaims {
+42 -2
View File
@@ -171,7 +171,8 @@
</header>
<p class="queue-note readout dim">
kids titles with no qualifying pt release — parked here, sometimes for months, by
design. allow english writes this title's override and the normal search proceeds.
design. allow english writes a movie's override and its search proceeds; series wait
for the detail view.
</p>
<ul class="deck-rows" id="rows-no-pt"></ul>
</section>
@@ -182,7 +183,8 @@
<span class="deck-count readout" id="count-decision"></span>
</header>
<p class="queue-note readout dim">
hard-failed twice on different releases — open the releases and decide by hand.
hard-failed twice on different releases — open a movie's releases and decide by hand;
series entries are display-only for now.
</p>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
@@ -306,6 +308,44 @@
</section>
</main>
<!--
SERIES DETAIL (§4.1 + §4.2 + §9.3, issue 129): a series row anywhere
opens /series/{id} — the title line carries the audience chip, the
derived status and the wanted/on-disk count (season 0 excluded, per
§4.2). Seasons are collapsible groups: engraved name, the tracked
toggle (the one way an operator wants a season — no bulk backfill,
§4.1), counts, search and deck. Episodes read as tag, title, air date,
state, then the imported file's probed attribute chips — ffprobe truth,
not name claims — with want/search/deck only where they can do
something: an unaired episode offers no search button at all.
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="series-title"></h2>
<span class="releases-year readout dim" id="series-year"></span>
<span class="row-chips series-chips" id="series-chips"></span>
</div>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
<main class="deck releases" id="tv-releases" hidden aria-label="season and episode releases">
<header class="releases-head">
<button type="button" class="control" id="tv-releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="tv-releases-title"></h2>
<span class="releases-year readout dim" id="tv-releases-sub"></span>
</div>
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<div id="tv-buckets"></div>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
+7 -1
View File
@@ -99,7 +99,13 @@ function checkCss(source, rel) {
}
function checkPlain(source, rel) {
source.split("\n").forEach((rawLine, index) => {
// Comments are prose, not styling — `checkCss` already ignores them, and
// without the same treatment here an issue reference like `#140` reads as a
// three-digit hex colour and fails the gate.
const stripped = source
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "))
.replace(/\/\/.*$/gm, "");
stripped.split("\n").forEach((rawLine, index) => {
const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token(");
if (COLOR_LITERAL.test(neutral)) {
errors.push(`${rel}:${index + 1}: literal colour outside the token block`);
+1221 -125
View File
File diff suppressed because it is too large Load Diff
+32 -2
View File
@@ -11,9 +11,34 @@ export interface AttentionMovie extends LibraryMovie {
last_searched_at: string | null;
}
/** An episode that put its series here, named for SxxEyy rendering. */
export interface QueuedEpisode {
id: number;
season_number: number;
episode_number: number;
}
/** A season whose failed pack put its series here. */
export interface QueuedSeason {
id: number;
number: number;
}
/** A series in a TV attention queue, with what put it there (§9.5). */
export interface SeriesAttention {
series_id: number;
tmdb_id: number;
title: string;
year: number | null;
episodes: QueuedEpisode[];
seasons: QueuedSeason[];
}
export interface AttentionQueues {
no_pt_source: AttentionMovie[];
needs_decision: AttentionMovie[];
tv_no_pt_source: SeriesAttention[];
tv_needs_decision: SeriesAttention[];
}
export type AttentionOutcome =
@@ -66,9 +91,14 @@ export async function allowEnglishAudio(movieId: number): Promise<AllowEnglishOu
return { kind: "done", searchQueued: true };
}
/** Entries across both queues — the rail badge's number. */
/** Entries across all four lanes — the rail badge's number. */
export function attentionTotal(queues: AttentionQueues): number {
return queues.no_pt_source.length + queues.needs_decision.length;
return (
queues.no_pt_source.length +
queues.needs_decision.length +
queues.tv_no_pt_source.length +
queues.tv_needs_decision.length
);
}
/** `searched 3×`, or null before the first attempt ever lands here. */
+1 -1
View File
@@ -361,7 +361,7 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
async function errorDetail(response: Response): Promise<string> {
export async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;

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