feat(daemon): manual episode/season triggers

This commit is contained in:
Miguel Palhas
2026-08-23 18:24:50 +01:00
parent e4c49b75de
commit 39e6bce2a0
11 changed files with 1031 additions and 5 deletions
@@ -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,20 @@
{
"db_name": "SQLite",
"query": "SELECT s.title AS \"title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "title!: String",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "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"
}
@@ -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,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,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 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"
}
+6 -4
View File
@@ -908,7 +908,7 @@ struct PendingMovie {
/// The eligible view of a stored release, ranked for selection. /// The eligible view of a stored release, ranked for selection.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Eligible { pub(crate) struct Eligible {
id: i64, pub(crate) id: i64,
pub(crate) indexer_id: i64, pub(crate) indexer_id: i64,
pub(crate) guid: String, pub(crate) guid: String,
pub(crate) name: 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 /// 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( pub(crate) async fn store_episode_release(
database: &Db, database: &Db,
episode_ids: &[i64], episode_ids: &[i64],
@@ -1188,7 +1190,7 @@ pub(crate) async fn store_episode_release(
overrides: &TitleOverrides, overrides: &TitleOverrides,
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> { ) -> Result<(i64, Option<Eligible>), GrabError> {
let (release_id, eligible) = classify_and_store( let (release_id, eligible) = classify_and_store(
database, database,
release, release,
@@ -1208,7 +1210,7 @@ pub(crate) async fn store_episode_release(
.execute(database.pool()) .execute(database.pool())
.await?; .await?;
} }
Ok(eligible) Ok((release_id, eligible))
} }
async fn classify_and_store( async fn classify_and_store(
+719 -1
View File
@@ -415,7 +415,7 @@ impl TvGrabAction {
if covered.is_empty() { if covered.is_empty() {
continue; continue;
} }
let stored = store_episode_release( let (stored_id, stored) = store_episode_release(
database, database,
&covered, &covered,
&release, &release,
@@ -425,6 +425,22 @@ impl TvGrabAction {
blacklist, blacklist,
) )
.await?; .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 { candidates.push(TvCandidate {
claim, claim,
eligible: stored, eligible: stored,
@@ -432,6 +448,229 @@ impl TvGrabAction {
} }
Ok(candidates) 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 { impl Action for TvGrabAction {
@@ -593,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result<Vec<SeasonEpis
.collect()) .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 /// Whether a season-pack grab for this season already hard-failed — the
/// fall-back-to-per-episode signal. /// fall-back-to-per-episode signal.
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> { async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
@@ -1099,4 +1612,209 @@ mod tests {
.count(); .count();
assert_eq!(searches_after_first, searches_after_second); 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");
}
} }