feat(daemon): manual episode/season triggers
This commit is contained in:
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+80
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -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<Option<Eligible>, GrabError> {
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1208,7 +1210,7 @@ pub(crate) async fn store_episode_release(
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(eligible)
|
||||
Ok((release_id, eligible))
|
||||
}
|
||||
|
||||
async fn classify_and_store(
|
||||
|
||||
@@ -415,7 +415,7 @@ impl TvGrabAction {
|
||||
if covered.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let stored = store_episode_release(
|
||||
let (stored_id, stored) = store_episode_release(
|
||||
database,
|
||||
&covered,
|
||||
&release,
|
||||
@@ -425,6 +425,22 @@ impl TvGrabAction {
|
||||
blacklist,
|
||||
)
|
||||
.await?;
|
||||
// A pack for exactly this season belongs on the season deck too
|
||||
// (§9.3, issue #125) — including when it is rejected, which the
|
||||
// deck shows greyed out.
|
||||
if claim
|
||||
.as_ref()
|
||||
.is_some_and(|claim| is_this_seasons_pack(claim, season.season_number_u32()))
|
||||
{
|
||||
sqlx::query!(
|
||||
"INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)
|
||||
ON CONFLICT DO NOTHING",
|
||||
season.season_id,
|
||||
stored_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
candidates.push(TvCandidate {
|
||||
claim,
|
||||
eligible: stored,
|
||||
@@ -432,6 +448,229 @@ impl TvGrabAction {
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// The manual episode trigger (§6.2, §9.3, issue #132): reset the
|
||||
/// episode's backoff so selection cannot skip it, then sweep the
|
||||
/// indexers scoped to this one episode instead of the tick's
|
||||
/// `EPISODE_SEARCHES_PER_TICK` batch.
|
||||
///
|
||||
/// Same shape as the movie trigger: a gap gets search and grab; an
|
||||
/// already-satisfied episode gets its deck refreshed and no grab; a
|
||||
/// blocked series is refused outright (§6.3). An unaired episode is not
|
||||
/// a gap yet — nothing to close until it airs (§6.2).
|
||||
pub(crate) async fn search_episode_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<(), GrabError> {
|
||||
let Some(episode) = manual_episode(database, episode_id).await? else {
|
||||
tracing::info!(
|
||||
episode_id,
|
||||
"manual episode search refused: unknown or blocked episode"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(original_language) = episode.season.original_language.as_deref() else {
|
||||
tracing::warn!(
|
||||
series = episode.season.series_title,
|
||||
"no original language yet; not searching"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
let Some(loaded) = database.episode_policy(episode_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
reset_episode_backoff(database, episode_id).await?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let episodes = season_episodes(database, episode.season.season_id).await?;
|
||||
let selector = TvSelector::Episode {
|
||||
season: episode.season.season_number_u32(),
|
||||
episode: u32::try_from(episode.number).unwrap_or_default(),
|
||||
};
|
||||
|
||||
if !episode.is_gap() {
|
||||
let candidates = self
|
||||
.search(
|
||||
database,
|
||||
&episode.season,
|
||||
&episodes,
|
||||
selector,
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
record_episode_search(database, &[episode_id]).await?;
|
||||
tracing::info!(
|
||||
episode_id,
|
||||
eligible = candidates.len(),
|
||||
"episode release deck refreshed; not grabbing a satisfied episode"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !episode.aired() {
|
||||
tracing::info!(episode_id, "episode has not aired; nothing to search for");
|
||||
return Ok(());
|
||||
}
|
||||
let Some(this_one) = episodes.iter().find(|row| row.id == episode_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.grab_episodes(
|
||||
database,
|
||||
&episode.season,
|
||||
&episodes,
|
||||
std::slice::from_ref(&this_one),
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
usize::MAX,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manual one-click episode grab (§9.3, issue #132): the release is
|
||||
/// already chosen off the episode deck, so this skips search and scoring
|
||||
/// and sends it straight to Transmission. A manual grab may take a
|
||||
/// `waived` release (§9.3), never a `rejected` one.
|
||||
pub(crate) async fn grab_episode_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let Some(loaded) = database.episode_policy(episode_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(title) = episode_series_title(database, episode_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(release) = load_episode_release(database, episode_id, release_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
self.grabber
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
scope: GrabScope::Episode { episode_id },
|
||||
title: &title,
|
||||
counts_as_attempt: false,
|
||||
},
|
||||
&loaded,
|
||||
&blacklist,
|
||||
release,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
||||
/// reset every open episode's backoff and run the same pack-or-fall-back
|
||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
||||
///
|
||||
/// A satisfied season — nothing open, or a grab already in flight —
|
||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||
pub(crate) async fn search_season_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
) -> Result<(), GrabError> {
|
||||
let Some(season) = manual_season(database, season_id).await? else {
|
||||
tracing::info!(
|
||||
season_id,
|
||||
"manual season search refused: unknown or blocked season"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let Some(original_language) = season.original_language.as_deref() else {
|
||||
tracing::warn!(
|
||||
series = season.series_title,
|
||||
"no original language yet; not searching"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
let Some(loaded) = database.season_policy(season_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
reset_season_backoff(database, season_id).await?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
|
||||
if !season.is_gap {
|
||||
let episodes = season_episodes(database, season_id).await?;
|
||||
let open: Vec<i64> = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
|
||||
.map(|episode| episode.id)
|
||||
.collect();
|
||||
let candidates = self
|
||||
.search(
|
||||
database,
|
||||
&season,
|
||||
&episodes,
|
||||
TvSelector::Season {
|
||||
season: season.season_number_u32(),
|
||||
},
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
record_episode_search(database, &open).await?;
|
||||
tracing::info!(
|
||||
season_id,
|
||||
eligible = candidates.len(),
|
||||
"season release deck refreshed; not grabbing a satisfied season"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manual one-click season grab (§9.3, issue #125): a chosen pack
|
||||
/// goes straight to Transmission against the season's still-open gaps.
|
||||
pub(crate) async fn grab_season_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let Some(loaded) = database.season_policy(season_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(title) = season_series_title(database, season_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(release) = load_season_release(database, season_id, release_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let episode_ids: Vec<i64> = season_episodes(database, season_id)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
|
||||
.map(|episode| episode.id)
|
||||
.collect();
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
self.grabber
|
||||
.send_winner(
|
||||
database,
|
||||
&GrabTarget {
|
||||
scope: GrabScope::Season {
|
||||
season_id,
|
||||
episode_ids,
|
||||
},
|
||||
title: &title,
|
||||
counts_as_attempt: false,
|
||||
},
|
||||
&loaded,
|
||||
&blacklist,
|
||||
release,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for TvGrabAction {
|
||||
@@ -593,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result<Vec<SeasonEpis
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One episode named by a manual command (#132), with the series context a
|
||||
/// targeted search needs. Same gap facts as [`season_episodes`] plus the
|
||||
/// lane decision. Blocked refuses upstream of here.
|
||||
#[derive(Debug)]
|
||||
struct ManualEpisode {
|
||||
number: i64,
|
||||
air_date: Option<String>,
|
||||
wanted: bool,
|
||||
has_file: bool,
|
||||
in_flight: bool,
|
||||
season: PendingSeason,
|
||||
}
|
||||
|
||||
impl ManualEpisode {
|
||||
/// Wanted with neither a file nor a grab in flight — a gap the manual
|
||||
/// trigger may close. Anything else only gets a deck refresh.
|
||||
fn is_gap(&self) -> bool {
|
||||
self.wanted && !self.has_file && !self.in_flight
|
||||
}
|
||||
|
||||
/// Unknown is unaired (§6.2): nothing to close until it airs.
|
||||
fn aired(&self) -> bool {
|
||||
air_date_time(self.air_date.as_deref())
|
||||
.is_some_and(|date| date <= std::time::SystemTime::now())
|
||||
}
|
||||
}
|
||||
|
||||
/// Same columns as [`pending_seasons`], scoped to one id, without the tick's
|
||||
/// batch limit and without the gap filter — the manual trigger names the
|
||||
/// season, so what remains decides the lane here instead of hiding it.
|
||||
async fn manual_episode(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
) -> Result<Option<ManualEpisode>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT e.id AS "id!: i64",
|
||||
e.number AS "number!: i64",
|
||||
e.air_date,
|
||||
e.wanted AS "wanted!: bool",
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
) AS "has_file!: bool",
|
||||
EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'episode' AND g.target_id = e.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
) AS "in_flight!: bool",
|
||||
se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.tvdb_id AS series_tvdb_id,
|
||||
s.original_language
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE e.id = ?
|
||||
AND s.blocked = 0
|
||||
"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| ManualEpisode {
|
||||
number: row.number,
|
||||
air_date: row.air_date,
|
||||
wanted: row.wanted,
|
||||
has_file: row.has_file,
|
||||
in_flight: row.in_flight,
|
||||
season: PendingSeason {
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_title: row.series_title,
|
||||
series_tvdb_id: row.series_tvdb_id,
|
||||
original_language: row.original_language,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// A season addressed by a manual command, with whether it is still a gap.
|
||||
async fn manual_season(
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
) -> Result<Option<PendingSeasonGap>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT se.id AS "season_id!: i64",
|
||||
se.number AS "season_number!: i64",
|
||||
s.title AS "series_title!: String",
|
||||
s.tvdb_id AS series_tvdb_id,
|
||||
s.original_language,
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND e.wanted = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'episode' AND g.target_id = e.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'season' AND g.target_id = se.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
) AS "is_gap!: bool"
|
||||
FROM seasons se
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE se.id = ?
|
||||
AND s.blocked = 0
|
||||
"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| PendingSeasonGap {
|
||||
season: PendingSeason {
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
series_title: row.series_title,
|
||||
series_tvdb_id: row.series_tvdb_id,
|
||||
original_language: row.original_language,
|
||||
},
|
||||
is_gap: row.is_gap,
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`PendingSeason`] plus the lane decision for the manual trigger.
|
||||
struct PendingSeasonGap {
|
||||
season: PendingSeason,
|
||||
is_gap: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for PendingSeasonGap {
|
||||
type Target = PendingSeason;
|
||||
|
||||
fn deref(&self) -> &PendingSeason {
|
||||
&self.season
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditional, unlike the tick's backoff gate: the manual trigger's whole
|
||||
/// point is to ignore the exponential backoff (§6.2).
|
||||
async fn reset_episode_backoff(database: &Db, episode_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A manual season search is one attempt against every open episode at
|
||||
/// once, so they all start from zero together.
|
||||
async fn reset_season_backoff(database: &Db, season_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE season_id = ?",
|
||||
season_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn episode_series_title(database: &Db, episode_id: i64) -> Result<Option<String>, GrabError> {
|
||||
let title = sqlx::query_scalar!(
|
||||
r#"SELECT s.title AS "title!: String"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE e.id = ?"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
async fn season_series_title(database: &Db, season_id: i64) -> Result<Option<String>, GrabError> {
|
||||
let title = sqlx::query_scalar!(
|
||||
r#"SELECT s.title AS "title!: String"
|
||||
FROM seasons se
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE se.id = ?"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
/// The chosen release, still associated with the episode and not
|
||||
/// hard-failed — a manual grab may take a `waived` release (§9.3).
|
||||
async fn load_episode_release(
|
||||
database: &Db,
|
||||
episode_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.indexer_id AS "indexer_id!: i64",
|
||||
r.guid AS "guid!: String",
|
||||
r.name AS "name!: String",
|
||||
r.download_url AS "download_url!: String",
|
||||
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
|
||||
FROM releases r
|
||||
JOIN episode_releases er ON er.release_id = r.id
|
||||
WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
|
||||
"#,
|
||||
episode_id,
|
||||
release_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| Eligible {
|
||||
id: row.id,
|
||||
indexer_id: row.indexer_id,
|
||||
guid: row.guid,
|
||||
name: row.name,
|
||||
download_url: row.download_url,
|
||||
score: row.score,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The season counterpart: chosen off the season deck (issue #125).
|
||||
async fn load_season_release(
|
||||
database: &Db,
|
||||
season_id: i64,
|
||||
release_id: i64,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.indexer_id AS "indexer_id!: i64",
|
||||
r.guid AS "guid!: String",
|
||||
r.name AS "name!: String",
|
||||
r.download_url AS "download_url!: String",
|
||||
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
|
||||
FROM releases r
|
||||
JOIN season_releases sr ON sr.release_id = r.id
|
||||
WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
|
||||
"#,
|
||||
season_id,
|
||||
release_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| Eligible {
|
||||
id: row.id,
|
||||
indexer_id: row.indexer_id,
|
||||
guid: row.guid,
|
||||
name: row.name,
|
||||
download_url: row.download_url,
|
||||
score: row.score,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether a season-pack grab for this season already hard-failed — the
|
||||
/// fall-back-to-per-episode signal.
|
||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
@@ -1099,4 +1612,209 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(searches_after_first, searches_after_second);
|
||||
}
|
||||
|
||||
/// Issue #132: an `EpisodeCommand::Search` must do the work, not merely
|
||||
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
|
||||
/// backoff, nowhere near elapsed) so a tick would skip this episode;
|
||||
/// ending at 1 rather than 6 proves the reset happened, and a torrent in
|
||||
/// Transmission proves the grab did.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_search_searches_and_grabs_now() {
|
||||
let (_dir, database, _season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET search_attempts = 5,
|
||||
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')
|
||||
WHERE number = 1",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_episode_now(&database, episode_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sources: Vec<String> = fake
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.map(|torrent| torrent.source)
|
||||
.collect();
|
||||
assert_eq!(sources.len(), 1, "{sources:?}");
|
||||
assert!(sources[0].ends_with("e01.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
|
||||
);
|
||||
let attempts: i64 = sqlx::query_scalar("SELECT search_attempts FROM episodes WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts, 0,
|
||||
"reset from 5; a completed grab records no further attempt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
|
||||
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
|
||||
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
|
||||
?, '{}', 900, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(format!("{}/dl/e01.torrent", indexer.uri()))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
|
||||
.bind(episode_id)
|
||||
.bind(release_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.grab_episode_release_now(&database, episode_id, release_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
|
||||
);
|
||||
let searched = indexer
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|request| {
|
||||
request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "t" && value == "search")
|
||||
});
|
||||
assert!(!searched, "a chosen release must not trigger a search");
|
||||
}
|
||||
|
||||
/// Issue #132: a `SeasonCommand::Search` runs the pack lane now, against
|
||||
/// the season deck's own table — the backoff reset is what proves it was
|
||||
/// really drained rather than dropped on the floor.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_search_takes_the_pack_now() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
sqlx::query(
|
||||
"UPDATE episodes SET search_attempts = 5,
|
||||
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
let packs: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT r.name FROM season_releases sr JOIN releases r ON r.id = sr.release_id",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(packs.contains(&"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos".to_owned()));
|
||||
let attempts: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT search_attempts FROM episodes ORDER BY number")
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attempts,
|
||||
vec![0, 0, 0],
|
||||
"reset from 5; a completed grab records no further attempt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
?, '{}', 900, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(format!("{}/dl/pack.torrent", indexer.uri()))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.grab_season_release_now(&database, season_id, release_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(
|
||||
grabs(&database).await,
|
||||
vec![("season".to_owned(), season_id, "sent".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
episode_states(&database).await,
|
||||
vec!["downloading", "downloading", "downloading"]
|
||||
);
|
||||
let searched = indexer
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|request| {
|
||||
request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "t" && value == "search")
|
||||
});
|
||||
assert!(!searched, "a chosen release must not trigger a search");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user