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"); + } }