fix: reopen the gap for a grab whose torrent vanished (#98)
This commit was merged in pull request #98.
This commit is contained in:
-20
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)\n VALUES (?, ?, ?, ?, 'sent')\n ON CONFLICT (infohash) DO NOTHING\n RETURNING id AS \"id!: i64\"",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "id!: i64",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Integer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 4
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "12f31ea2737b9a80b7d23b4c4029bbfebb0291b73f6eb5ec6084b05c38ae753f"
|
|
||||||
}
|
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)\n VALUES (?, ?, ?, ?, 'sent')\n ON CONFLICT (infohash) DO UPDATE SET\n release_id = excluded.release_id,\n target_kind = excluded.target_kind,\n target_id = excluded.target_id,\n state = 'sent',\n grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n imported_at = NULL\n WHERE grabs.state = 'vanished'\n RETURNING id AS \"id!: i64\"",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 4
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2d0b92ba9aa4bc257286a67f0d37e2182c4478153c9096c1522c5ab23ad472e9"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE movies SET state = 'missing',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "33db11897ccc36001f465428246c34dce837a77cabe6a745b267c382146f99c8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE grabs SET state = 'vanished' WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "345555a01e2c48f0a7a9ba3593a09127eb6ebc773f4629afe6eb354a53473640"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE episodes SET state = 'missing',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3c2672f1140e57c80d8148d53e0ae014152343acd33bef369c8c2c05579d9381"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE episodes\n SET state = 'missing',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE season_id = ? AND state = 'downloading'\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = episodes.id\n )",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "724e97893a234595fcc9feb2be9e17211025067128991b5914c73c3ccb88d1b1"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "UPDATE grabs\n SET state = 'imported',\n imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "8fac200bd76490e737c10bbaaa968325ea83c6ee558b2291abc5c1fa943334f0"
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "UPDATE grabs\n SET state = 'imported',\n imported_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "9318221b7be54520b2ce3b9df1af806f02bc531356b2939e8179ab3be4d1a998"
|
|
||||||
}
|
|
||||||
@@ -475,9 +475,10 @@ impl Grabber {
|
|||||||
let mut outcomes = Vec::new();
|
let mut outcomes = Vec::new();
|
||||||
for grab in sent {
|
for grab in sent {
|
||||||
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
let Some(progress) = torrents.get(&grab.infohash.to_ascii_lowercase()) else {
|
||||||
// Gone from Transmission. Deciding whether that is a failure
|
outcomes.push(
|
||||||
// or a manual removal is issue #86's; leaving the row alone
|
self.vanish(database, grab.id, &grab.target_kind, grab.target_id)
|
||||||
// keeps this tick from re-grabbing behind the operator.
|
.await?,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if *progress < 1.0 {
|
if *progress < 1.0 {
|
||||||
@@ -503,6 +504,34 @@ impl Grabber {
|
|||||||
Ok(outcomes)
|
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(
|
async fn record_attempt(
|
||||||
&self,
|
&self,
|
||||||
database: &Db,
|
database: &Db,
|
||||||
@@ -561,14 +590,23 @@ impl Grabber {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A duplicate here is the restart case: the torrent was added before
|
// `infohash` is unique, so re-grabbing the same release conflicts
|
||||||
// the process died. `DO NOTHING` keeps the original row.
|
// 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_kind = target.scope.target_kind();
|
||||||
let target_id = target.scope.target_id();
|
let target_id = target.scope.target_id();
|
||||||
let inserted = sqlx::query!(
|
let inserted = sqlx::query!(
|
||||||
r#"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
r#"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||||
VALUES (?, ?, ?, ?, 'sent')
|
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""#,
|
RETURNING id AS "id!: i64""#,
|
||||||
winner.id,
|
winner.id,
|
||||||
target_kind,
|
target_kind,
|
||||||
@@ -1014,6 +1052,56 @@ pub(crate) async fn record_episode_search(
|
|||||||
Ok(())
|
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
|
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
|
||||||
/// both stacks can run against one Transmission.
|
/// both stacks can run against one Transmission.
|
||||||
fn label(loaded: &MoviePolicy) -> String {
|
fn label(loaded: &MoviePolicy) -> String {
|
||||||
@@ -1521,6 +1609,62 @@ mod tests {
|
|||||||
assert_eq!(grabs(&database).await[0].2, "downloaded");
|
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
|
/// §5.2: the language rules are expressed against the title's original
|
||||||
/// language, and guessing it is worse than waiting for it.
|
/// language, and guessing it is worse than waiting for it.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ impl ImportAction {
|
|||||||
.torrent_paths(pending.grab_id, &pending.infohash)
|
.torrent_paths(pending.grab_id, &pending.infohash)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return self.vanish(database, pending).await.map(Some);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No expected runtime yet: the movies table carries no TMDB runtime,
|
// No expected runtime yet: the movies table carries no TMDB runtime,
|
||||||
@@ -398,8 +398,8 @@ impl ImportAction {
|
|||||||
infohash: &str,
|
infohash: &str,
|
||||||
) -> Result<Option<Vec<PathBuf>>, ImportError> {
|
) -> Result<Option<Vec<PathBuf>>, ImportError> {
|
||||||
let Some(content) = self.transmission.torrent_content(infohash).await? else {
|
let Some(content) = self.transmission.torrent_content(infohash).await? else {
|
||||||
// Gone from Transmission. Whether that is a failure or a manual
|
// Gone from Transmission — the caller marks the grab vanished
|
||||||
// removal is issue #86's call; leave the grab alone.
|
// and reopens the gap (§86).
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
grab_id,
|
grab_id,
|
||||||
infohash,
|
infohash,
|
||||||
@@ -461,7 +461,7 @@ impl ImportAction {
|
|||||||
.torrent_paths(pending.grab_id, &pending.infohash)
|
.torrent_paths(pending.grab_id, &pending.infohash)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return self.vanish_tv(database, pending).await.map(Some);
|
||||||
};
|
};
|
||||||
let candidates = self.probe_all(&paths).await?;
|
let candidates = self.probe_all(&paths).await?;
|
||||||
let assignments = assign_files(pending, &episodes, candidates);
|
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
|
/// §7.5: the filesystem watcher misses the just-hardlinked file. A
|
||||||
/// failure to reach Jellyfin must not fail the import, which has already
|
/// failure to reach Jellyfin must not fail the import, which has already
|
||||||
/// succeeded.
|
/// succeeded.
|
||||||
@@ -1627,6 +1691,79 @@ mod tests {
|
|||||||
assert!(outcomes.is_empty());
|
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
|
/// Torrent-declared names are untrusted: absolute and `..`-carrying
|
||||||
/// entries are skipped, and the import proceeds from what remains.
|
/// entries are skipped, and the import proceeds from what remains.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- #86: a grab whose torrent Transmission no longer reports (manual removal,
|
||||||
|
-- not a policy failure) gets its own state. Distinct from 'failed' so it
|
||||||
|
-- does not feed the needs_decision attention queue (attention.rs), which
|
||||||
|
-- counts 'failed' as a hard-fail signal. SQLite cannot alter a CHECK, so the
|
||||||
|
-- table is rebuilt (see 0010).
|
||||||
|
CREATE TABLE grabs_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
release_id INTEGER NOT NULL REFERENCES releases (id),
|
||||||
|
target_kind TEXT NOT NULL CHECK (target_kind IN ('movie', 'episode', 'season')),
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
infohash TEXT NOT NULL UNIQUE,
|
||||||
|
state TEXT NOT NULL DEFAULT 'sent'
|
||||||
|
CHECK (state IN ('sent', 'downloaded', 'imported', 'failed', 'vanished')),
|
||||||
|
grabbed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
imported_at TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
INSERT INTO grabs_new (id, release_id, target_kind, target_id, infohash, state, grabbed_at, imported_at)
|
||||||
|
SELECT id, release_id, target_kind, target_id, infohash, state, grabbed_at, imported_at FROM grabs;
|
||||||
|
|
||||||
|
DROP TABLE grabs;
|
||||||
|
ALTER TABLE grabs_new RENAME TO grabs;
|
||||||
|
|
||||||
|
CREATE INDEX grabs_state ON grabs (state);
|
||||||
|
CREATE INDEX grabs_target ON grabs (target_kind, target_id);
|
||||||
Reference in New Issue
Block a user