fix: reopen the gap for a grab whose torrent vanished (#98)
This commit was merged in pull request #98.
This commit is contained in:
@@ -283,7 +283,7 @@ impl ImportAction {
|
||||
.torrent_paths(pending.grab_id, &pending.infohash)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
return self.vanish(database, pending).await.map(Some);
|
||||
};
|
||||
|
||||
// No expected runtime yet: the movies table carries no TMDB runtime,
|
||||
@@ -398,8 +398,8 @@ impl ImportAction {
|
||||
infohash: &str,
|
||||
) -> Result<Option<Vec<PathBuf>>, ImportError> {
|
||||
let Some(content) = self.transmission.torrent_content(infohash).await? else {
|
||||
// Gone from Transmission. Whether that is a failure or a manual
|
||||
// removal is issue #86's call; leave the grab alone.
|
||||
// Gone from Transmission — the caller marks the grab vanished
|
||||
// and reopens the gap (§86).
|
||||
tracing::warn!(
|
||||
grab_id,
|
||||
infohash,
|
||||
@@ -461,7 +461,7 @@ impl ImportAction {
|
||||
.torrent_paths(pending.grab_id, &pending.infohash)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
return self.vanish_tv(database, pending).await.map(Some);
|
||||
};
|
||||
let candidates = self.probe_all(&paths).await?;
|
||||
let assignments = assign_files(pending, &episodes, candidates);
|
||||
@@ -660,6 +660,70 @@ impl ImportAction {
|
||||
))
|
||||
}
|
||||
|
||||
/// §86: a `downloaded` grab whose torrent 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 nothing is blacklisted, since the release itself
|
||||
/// never failed policy.
|
||||
async fn vanish(&self, database: &Db, pending: &PendingImport) -> Result<Outcome, ImportError> {
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'vanished' WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
crate::grab::reopen_target(database, "movie", pending.movie_id).await?;
|
||||
|
||||
tracing::warn!(
|
||||
grab_id = pending.grab_id,
|
||||
movie_id = pending.movie_id,
|
||||
title = pending.title,
|
||||
release = pending.release_name,
|
||||
"torrent vanished from Transmission; gap reopened"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!(
|
||||
"grab {} downloaded, torrent vanished from Transmission",
|
||||
pending.grab_id
|
||||
),
|
||||
format!("reopened movie {}", pending.movie_id),
|
||||
))
|
||||
}
|
||||
|
||||
/// TV counterpart of [`Self::vanish`]: reopens the episode, or the
|
||||
/// still-downloading episodes of a season pack.
|
||||
async fn vanish_tv(
|
||||
&self,
|
||||
database: &Db,
|
||||
pending: &PendingTvImport,
|
||||
) -> Result<Outcome, ImportError> {
|
||||
sqlx::query!(
|
||||
"UPDATE grabs SET state = 'vanished' WHERE id = ?",
|
||||
pending.grab_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
let (target_kind, target_id) = match pending.episode_id {
|
||||
Some(episode_id) => ("episode", episode_id),
|
||||
None => ("season", pending.season_id),
|
||||
};
|
||||
crate::grab::reopen_target(database, target_kind, target_id).await?;
|
||||
|
||||
tracing::warn!(
|
||||
grab_id = pending.grab_id,
|
||||
series = pending.series_title,
|
||||
release = pending.release_name,
|
||||
"torrent vanished from Transmission; gap reopened"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!(
|
||||
"grab {} downloaded, torrent vanished from Transmission",
|
||||
pending.grab_id
|
||||
),
|
||||
format!("reopened {target_kind} {target_id}"),
|
||||
))
|
||||
}
|
||||
|
||||
/// §7.5: the filesystem watcher misses the just-hardlinked file. A
|
||||
/// failure to reach Jellyfin must not fail the import, which has already
|
||||
/// succeeded.
|
||||
@@ -1627,6 +1691,79 @@ mod tests {
|
||||
assert!(outcomes.is_empty());
|
||||
}
|
||||
|
||||
/// §86: a `downloaded` grab whose torrent Transmission no longer reports
|
||||
/// — removed by hand, not a policy failure — is marked `vanished` and
|
||||
/// reopens the movie as a gap, without touching the blacklist.
|
||||
#[tokio::test]
|
||||
async fn a_vanished_downloaded_grab_reopens_the_gap() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
|
||||
database.migrate().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, state)
|
||||
SELECT 693134, 'Dune: Part Two', 2024, 'en', id, 'downloading'
|
||||
FROM roots WHERE kind = 'movie' AND audience = 'main'",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let release_id = sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'good', ?, 23622320128, 'magnet:x', '{}', 'eligible')",
|
||||
)
|
||||
.bind(RELEASE_NAME)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.last_insert_rowid();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, 'movie', 1, ?, 'downloaded')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(INFOHASH)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success",
|
||||
"arguments": {"torrents": []}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
Prober::new().with_binary(fake_ffprobe(dir.path(), HDR10_PROBE)),
|
||||
JellyfinClient::new(jellyfin_server.uri(), None).unwrap(),
|
||||
Notifier::new(ntfy_server.uri()).unwrap(),
|
||||
Some("operator-topic".to_string()),
|
||||
);
|
||||
|
||||
let outcomes = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(grab_state, "vanished");
|
||||
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(movie_state, "missing");
|
||||
let blacklisted: i64 = sqlx::query_scalar("SELECT count(*) FROM blacklist")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blacklisted, 0, "a vanished torrent is not a policy failure");
|
||||
}
|
||||
|
||||
/// Torrent-declared names are untrusted: absolute and `..`-carrying
|
||||
/// entries are skipped, and the import proceeds from what remains.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user