From 39e6bce2a0aa0dd5ca0a3adcfaee21d86fd896d5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:24:50 +0100 Subject: [PATCH 1/2] feat(daemon): manual episode/season triggers --- ...8a8efb0c7af09fa6cff4b1f31fb963da58bd5.json | 20 + ...8036f72347fbbbf8836e1ad495bfa66e6ebfc.json | 12 + ...586dc4f7682ab4edbd483d36fedbc985a27f4.json | 20 + ...78a2b2705c65da7c037e1c5af062bc53e0345.json | 12 + ...32db6568360e8c3fc7e31e968508d88c1bc67.json | 50 ++ ...a7271c33b1da4ec9f6389b8d7212be80c97ea.json | 80 ++ ...b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json | 50 ++ ...007d7cb65f063274a322702ffcfef133ea454.json | 50 ++ ...48356c724775e9df246fc91f3edf899732a90.json | 12 + crates/arr-daemon/src/grab.rs | 10 +- crates/arr-daemon/src/tv_grab.rs | 720 +++++++++++++++++- 11 files changed, 1031 insertions(+), 5 deletions(-) create mode 100644 .sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json create mode 100644 .sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json create mode 100644 .sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json create mode 100644 .sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json create mode 100644 .sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json create mode 100644 .sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json create mode 100644 .sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json create mode 100644 .sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json create mode 100644 .sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json diff --git a/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json b/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json new file mode 100644 index 0000000..70d7aae --- /dev/null +++ b/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json @@ -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" +} diff --git a/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json b/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json new file mode 100644 index 0000000..ed911e6 --- /dev/null +++ b/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json @@ -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" +} diff --git a/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json b/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json new file mode 100644 index 0000000..07fecc6 --- /dev/null +++ b/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json @@ -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" +} diff --git a/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json b/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json new file mode 100644 index 0000000..efda8ff --- /dev/null +++ b/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json @@ -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" +} diff --git a/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json b/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json new file mode 100644 index 0000000..327079e --- /dev/null +++ b/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json @@ -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" +} diff --git a/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json b/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json new file mode 100644 index 0000000..eae3f95 --- /dev/null +++ b/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json @@ -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" +} diff --git a/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json b/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json new file mode 100644 index 0000000..8e2eb8c --- /dev/null +++ b/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json @@ -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" +} diff --git a/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json b/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json new file mode 100644 index 0000000..0a53f27 --- /dev/null +++ b/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json @@ -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" +} diff --git a/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json b/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json new file mode 100644 index 0000000..0e5a838 --- /dev/null +++ b/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json @@ -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" +} diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 06bbe52..3f60459 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -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, @@ -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, GrabError> { +) -> Result<(i64, Option), 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( diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index b215153..92ae166 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -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, 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 = 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, 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 = 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, + 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, 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, 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, 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, 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, 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, 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 { @@ -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 = 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 = 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 = + 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"); + } } From 8a77049fdbe3c3bee537f71a2ab7961a57e7ad45 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:24:50 +0100 Subject: [PATCH 2/2] feat(daemon): drain all manual command channels --- crates/arr-daemon/src/main.rs | 75 +++++++++++++------- crates/arr-daemon/src/manual.rs | 122 ++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 46 deletions(-) diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 6d06d21..d243b0e 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -123,7 +123,7 @@ async fn run() -> Result<(), Error> { None }; let notifier = Notifier::new(config.ntfy_url.clone())?; - let (reconcile, manual_grab) = + let (reconcile, manual_grab, manual_tv) = reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; // Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and @@ -157,10 +157,17 @@ async fn run() -> Result<(), Error> { let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone())); - // Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so - // the manual-search and manual-grab endpoints were a no-op — the command - // sat in the 64-slot buffer forever. - let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx)); + // Issue #107: nothing else drains `AppState`'s command channels, so the + // manual-search and manual-grab endpoints were a no-op — the command sat + // in the 64-slot buffer forever. Issue #132: the episode and season + // channels are drained by the same lane. + let mut manual_task = tokio::spawn(manual::run( + state, + database, + manual_grab, + manual_tv, + shutdown_rx, + )); let signal_tx = shutdown_tx.clone(); let server = async move { axum::serve(listener, app) @@ -198,17 +205,18 @@ async fn run() -> Result<(), Error> { /// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not /// configured stays unregistered rather than failing every tick. /// -/// Also returns a second, independent `GrabAction` for `manual::run` (issue -/// #107) — the manual trigger needs the same search-and-grab path on demand -/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register` -/// takes ownership of the one it ticks. +/// Also returns independent action instances for `manual::run` (issues #107 +/// and #132) — the manual trigger needs the same search-and-grab paths on +/// demand rather than on the reconcile tick's schedule, and +/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie +/// one needs TMDB too; TV grabbing does not (§6.2). fn reconcile_loop( database: &Db, config: &Config, transmission: &arr_dl::TransmissionClient, tmdb: Option<&Arc>, notifier: &Notifier, -) -> Result<(ReconcileLoop, Option), Error> { +) -> Result<(ReconcileLoop, Option, Option), Error> { let reconcile = ReconcileLoop::new(database.clone()); let seeding = SeedingRules::new( SeedingLimits { @@ -235,7 +243,7 @@ fn reconcile_loop( .map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key)) .transpose()?; - let (mut reconcile, manual_grab) = register_movie_grab( + let (reconcile, manual_grab) = register_movie_grab( reconcile, prowlarr.as_ref(), transmission, @@ -243,20 +251,8 @@ fn reconcile_loop( &seeding, tmdb, ); - // TV grabbing needs no TMDB at grab time: air dates are already on the - // episode rows, which is the same gate the digital release date is for - // movies (§6.2). - if let Some(prowlarr) = prowlarr.as_ref() { - reconcile = reconcile.register( - Tick::Reconcile, - TvGrabAction::new( - prowlarr.clone(), - transmission.clone(), - config.download_dir.clone(), - seeding.clone(), - ), - ); - } + let (mut reconcile, manual_tv) = + register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding); // RSS needs no TMDB: it matches what the feeds already carry against the // wanted list (§6.2). if let Some(prowlarr) = prowlarr { @@ -322,7 +318,34 @@ fn reconcile_loop( } let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone())); - Ok((reconcile, manual_grab)) + Ok((reconcile, manual_grab, manual_tv)) +} + +/// Register the TV grab lane on `reconcile` and hand back a second, +/// independent instance for `manual::run` (issue #132). `None` when Prowlarr +/// is not configured. TV grabbing needs no TMDB at grab time: air dates are +/// already on the episode rows, which is the same gate the digital release +/// date is for movies (§6.2). +fn register_tv_grab( + mut reconcile: ReconcileLoop, + prowlarr: Option<&arr_indexer::ProwlarrClient>, + transmission: &arr_dl::TransmissionClient, + config: &Config, + seeding: &SeedingRules, +) -> (ReconcileLoop, Option) { + 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, diff --git a/crates/arr-daemon/src/manual.rs b/crates/arr-daemon/src/manual.rs index a9bd2b1..af3eff3 100644 --- a/crates/arr-daemon/src/manual.rs +++ b/crates/arr-daemon/src/manual.rs @@ -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, + movies: Option, + tv: Option, mut shutdown: watch::Receiver, ) { 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 {