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.
|
/// 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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user