fix: reopen the gap for a grab whose torrent vanished (#98)
This commit was merged in pull request #98.
This commit is contained in:
@@ -475,9 +475,10 @@ impl Grabber {
|
||||
let mut outcomes = Vec::new();
|
||||
for grab in sent {
|
||||
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
||||
// Gone from Transmission. Deciding whether that is a failure
|
||||
// or a manual removal is issue #86's; leaving the row alone
|
||||
// keeps this tick from re-grabbing behind the operator.
|
||||
outcomes.push(
|
||||
self.vanish(database, grab.id, &grab.target_kind, grab.target_id)
|
||||
.await?,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if *progress < 1.0 {
|
||||
@@ -503,6 +504,34 @@ impl Grabber {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// §86: a `sent` grab whose infohash Transmission no longer reports —
|
||||
/// removed by hand, not a policy failure. Marked `vanished` rather than
|
||||
/// `failed` so it does not feed the `needs_decision` queue (attention.rs),
|
||||
/// and the gap reopens with nothing blacklisted, since the release itself
|
||||
/// never failed policy.
|
||||
async fn vanish(
|
||||
&self,
|
||||
database: &Db,
|
||||
grab_id: i64,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
) -> Result<Outcome, GrabError> {
|
||||
sqlx::query!("UPDATE grabs SET state = 'vanished' WHERE id = ?", grab_id)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
reopen_target(database, target_kind, target_id).await?;
|
||||
tracing::warn!(
|
||||
grab_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
"torrent vanished from Transmission; gap reopened"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!("grab {grab_id} sent, torrent vanished from Transmission"),
|
||||
format!("reopened {target_kind} {target_id}"),
|
||||
))
|
||||
}
|
||||
|
||||
async fn record_attempt(
|
||||
&self,
|
||||
database: &Db,
|
||||
@@ -561,14 +590,23 @@ impl Grabber {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// A duplicate here is the restart case: the torrent was added before
|
||||
// the process died. `DO NOTHING` keeps the original row.
|
||||
// `infohash` is unique, so re-grabbing the same release conflicts
|
||||
// with its own earlier row. A `sent`/`downloaded` row is the restart
|
||||
// case and is left alone; a `vanished` one (§86) is reclaimed, since
|
||||
// nothing blacklisted the release.
|
||||
let target_kind = target.scope.target_kind();
|
||||
let target_id = target.scope.target_id();
|
||||
let inserted = sqlx::query!(
|
||||
r#"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, ?, ?, ?, 'sent')
|
||||
ON CONFLICT (infohash) DO NOTHING
|
||||
ON CONFLICT (infohash) DO UPDATE SET
|
||||
release_id = excluded.release_id,
|
||||
target_kind = excluded.target_kind,
|
||||
target_id = excluded.target_id,
|
||||
state = 'sent',
|
||||
grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
imported_at = NULL
|
||||
WHERE grabs.state = 'vanished'
|
||||
RETURNING id AS "id!: i64""#,
|
||||
winner.id,
|
||||
target_kind,
|
||||
@@ -1014,6 +1052,56 @@ pub(crate) async fn record_episode_search(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §86: reopen the gap behind a grab that no longer counts as in flight —
|
||||
/// a movie or episode goes back to `missing`; a season pack reopens only the
|
||||
/// episodes it was still covering (mirrors `import::hard_fail_tv`'s season
|
||||
/// case), leaving ones already imported from a partial pack alone.
|
||||
pub(crate) async fn reopen_target(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match target_kind {
|
||||
"movie" => {
|
||||
sqlx::query!(
|
||||
"UPDATE movies SET state = 'missing',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
target_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
"episode" => {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET state = 'missing',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
target_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
"season" => {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes
|
||||
SET state = 'missing',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE season_id = ? AND state = 'downloading'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = episodes.id
|
||||
)",
|
||||
target_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
other => unreachable!("grabs.target_kind CHECK constraint excludes {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
|
||||
/// both stacks can run against one Transmission.
|
||||
fn label(loaded: &MoviePolicy) -> String {
|
||||
@@ -1521,6 +1609,62 @@ mod tests {
|
||||
assert_eq!(grabs(&database).await[0].2, "downloaded");
|
||||
}
|
||||
|
||||
/// §86: a torrent removed by hand — gone from Transmission before it
|
||||
/// finished — reopens the gap instead of leaving the grab stuck forever,
|
||||
/// and is marked `vanished` rather than `failed` so it never counts
|
||||
/// toward the `needs_decision` queue (attention.rs). A search-empty
|
||||
/// indexer on the second tick isolates the reopen from the re-grab that
|
||||
/// would otherwise follow it in the same tick.
|
||||
#[tokio::test]
|
||||
async fn a_torrent_removed_by_hand_reopens_the_gap() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
fake.torrents.lock().unwrap().clear();
|
||||
let empty_indexer = empty_prowlarr().await;
|
||||
let outcomes = action(&empty_indexer, &downloader)
|
||||
.tick(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(grabs(&database).await[0].2, "vanished");
|
||||
let state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state, "missing");
|
||||
}
|
||||
|
||||
/// §86 continued: nothing blacklists the vanished release, so the exact
|
||||
/// same infohash can be grabbed again — reclaiming its own dead row
|
||||
/// rather than silently losing the grab to the `infohash` uniqueness
|
||||
/// constraint.
|
||||
#[tokio::test]
|
||||
async fn a_reopened_gap_can_regrab_the_same_infohash() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
fake.torrents.lock().unwrap().clear();
|
||||
let outcomes = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 2, "vanish, then a fresh grab");
|
||||
assert_eq!(fake.torrents().len(), 1, "same magnet, same infohash");
|
||||
let grabs = grabs(&database).await;
|
||||
assert_eq!(grabs.len(), 1, "the dead row is reclaimed, not duplicated");
|
||||
assert_eq!(grabs[0].2, "sent");
|
||||
let state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state, "downloading");
|
||||
}
|
||||
|
||||
/// §5.2: the language rules are expressed against the title's original
|
||||
/// language, and guessing it is worse than waiting for it.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user