feat: queue only targets still waiting for a file
The needs-a-decision queue had no liveness condition on the season lane and none at all in the API reader, so a season pack that hard-failed twice, fell back to per-episode grabbing exactly as §6.2 intends, and was then fully acquired kept notifying for 30 days, and `GET /api/queues/attention` listed titles the daemon never notified on. DESIGN.md §5.7 now states the third face of the same rule alongside the count and the window: a movie or an episode is queued while `wanted` and not `available`; a season, holding no intent of its own (§4.1), while at least one of its episodes is. Both readers apply it on all three lanes. `just ci` passed through the gate.
This commit is contained in:
@@ -3,8 +3,13 @@
|
||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||
//!
|
||||
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
|
||||
//! releases, both inside `arr_db::ATTENTION_WINDOW`. One bad torrent is not a
|
||||
//! decision, and a failure already dealt with ages out (#226).
|
||||
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
|
||||
//! waiting for a file. One bad torrent is not a decision, a failure already
|
||||
//! dealt with ages out (#226), and a target that has since been acquired
|
||||
//! leaves at once (#238). The season lane reads that last condition off its
|
||||
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
|
||||
//! filters identically, or the two channels tell the operator different
|
||||
//! stories.
|
||||
//!
|
||||
//! Edge-triggered per title: it notifies once when the title enters either
|
||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||
@@ -218,6 +223,11 @@ fn tv_entry(
|
||||
/// different releases hard-failed post-probe, and seasons two different packs
|
||||
/// hard-failed on. One entry per series, so the notification can be one per
|
||||
/// series however long the broken season is.
|
||||
///
|
||||
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
|
||||
/// only while `wanted` and not `available`, and a season only while at least
|
||||
/// one of its episodes is. A season pack that failed twice and then fell back
|
||||
/// to per-episode grabbing (§6.2) drops out as those episodes land.
|
||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||
let mut tv = HashMap::new();
|
||||
|
||||
@@ -280,6 +290,11 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
)
|
||||
GROUP BY s.id, s.title, s.year, se.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
@@ -393,6 +408,21 @@ mod tests {
|
||||
series_id
|
||||
}
|
||||
|
||||
/// A wanted, missing episode: the least that keeps its season live for
|
||||
/// §5.7's liveness condition.
|
||||
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(number)
|
||||
.bind(format!("Episode {number}"))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// stamped `age_days` in the past, so §5.7's window can be exercised
|
||||
/// without waiting a month.
|
||||
@@ -687,6 +717,7 @@ mod tests {
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
@@ -707,6 +738,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
|
||||
/// once every episode has landed the system worked, so the season leaves
|
||||
/// the queue at once rather than notifying for 30 days (#238).
|
||||
#[tokio::test]
|
||||
async fn a_fully_acquired_season_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_wanted_episode(&database, season_id, 2).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"two packs failed and the season still has episodes missing"
|
||||
);
|
||||
|
||||
// Per-episode grabbing got the first one. Still a gap, still queued.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"already notified, and still queued"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"every episode acquired: nothing left to decide"
|
||||
);
|
||||
|
||||
// Proof it actually left rather than merely staying quiet: a season
|
||||
// still queued would not notify again on re-entry.
|
||||
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"broken again: re-enters the queue and notifies"
|
||||
);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
|
||||
/// queue instead of sitting in it forever (#226).
|
||||
#[tokio::test]
|
||||
@@ -717,6 +812,7 @@ mod tests {
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user