fix(daemon): park vanished grabs instead of re-grabbing
Overrides #86: removing a torrent by hand in Transmission is human intent, not a gap to refill. Clear `wanted` and mark the target `parked` (a new movies/episodes state) rather than reopening it as `missing`, so neither targeted search nor RSS matching pick it back up. Blacklist stays untouched, since the release never failed policy. Closes #108
This commit is contained in:
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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 SET wanted = 0, state = 'parked',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3f945137b92734bd4963ff2de58fc6d1381a46d3cfadc13f6bd223cf913b0e05"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE movies SET wanted = 0, state = 'parked',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8954e2110d8bc591b38bb568ed0dc56502cf567fe015cb21360b14b571867af8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE episodes\n SET wanted = 0, state = 'parked',\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": "9bb48bc6c67150cbbfeaf8542c4060a9bc45698f0fe482a0c2136263cf12a4d3"
|
||||||
|
}
|
||||||
@@ -238,6 +238,9 @@ pub enum MediaState {
|
|||||||
Missing,
|
Missing,
|
||||||
Downloading,
|
Downloading,
|
||||||
Available,
|
Available,
|
||||||
|
/// #108: `wanted` was cleared after the grab vanished from Transmission.
|
||||||
|
/// Distinct from `Missing` so it does not read as an open gap.
|
||||||
|
Parked,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
|||||||
@@ -576,11 +576,12 @@ impl Grabber {
|
|||||||
Ok(outcomes)
|
Ok(outcomes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §86: a `sent` grab whose infohash Transmission no longer reports —
|
/// §86/#108: a `sent` grab whose infohash Transmission no longer reports
|
||||||
/// removed by hand, not a policy failure. Marked `vanished` rather than
|
/// — removed by hand, not a policy failure. Marked `vanished` rather than
|
||||||
/// `failed` so it does not feed the `needs_decision` queue (attention.rs),
|
/// `failed` so it does not feed the `needs_decision` queue (attention.rs).
|
||||||
/// and the gap reopens with nothing blacklisted, since the release itself
|
/// Nothing is blacklisted, since the release itself never failed policy,
|
||||||
/// never failed policy.
|
/// and the target is parked rather than reopened, since removing a
|
||||||
|
/// torrent by hand is human intent, not a gap to refill.
|
||||||
async fn vanish(
|
async fn vanish(
|
||||||
&self,
|
&self,
|
||||||
database: &Db,
|
database: &Db,
|
||||||
@@ -591,16 +592,16 @@ impl Grabber {
|
|||||||
sqlx::query!("UPDATE grabs SET state = 'vanished' WHERE id = ?", grab_id)
|
sqlx::query!("UPDATE grabs SET state = 'vanished' WHERE id = ?", grab_id)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
reopen_target(database, target_kind, target_id).await?;
|
park_target(database, target_kind, target_id).await?;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
grab_id,
|
grab_id,
|
||||||
target_kind,
|
target_kind,
|
||||||
target_id,
|
target_id,
|
||||||
"torrent vanished from Transmission; gap reopened"
|
"torrent vanished from Transmission; target parked"
|
||||||
);
|
);
|
||||||
Ok(Outcome::new(
|
Ok(Outcome::new(
|
||||||
format!("grab {grab_id} sent, torrent vanished from Transmission"),
|
format!("grab {grab_id} sent, torrent vanished from Transmission"),
|
||||||
format!("reopened {target_kind} {target_id}"),
|
format!("parked {target_kind} {target_id}"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,11 +1241,13 @@ pub(crate) async fn record_episode_search(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §86: reopen the gap behind a grab that no longer counts as in flight —
|
/// #108, overriding #86: a torrent removed by hand is human intent, not a
|
||||||
/// a movie or episode goes back to `missing`; a season pack reopens only the
|
/// gap to refill. Clear `wanted` (the leaf intent, DESIGN.md §4.1) and mark
|
||||||
/// episodes it was still covering (mirrors `import::hard_fail_tv`'s season
|
/// the target `parked` instead of reopening it, so neither targeted search
|
||||||
/// case), leaving ones already imported from a partial pack alone.
|
/// nor RSS matching (§6.2) ever pick it back up. A season pack parks only
|
||||||
pub(crate) async fn reopen_target(
|
/// 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 park_target(
|
||||||
database: &Db,
|
database: &Db,
|
||||||
target_kind: &str,
|
target_kind: &str,
|
||||||
target_id: i64,
|
target_id: i64,
|
||||||
@@ -1252,7 +1255,7 @@ pub(crate) async fn reopen_target(
|
|||||||
match target_kind {
|
match target_kind {
|
||||||
"movie" => {
|
"movie" => {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE movies SET state = 'missing',
|
"UPDATE movies SET wanted = 0, state = 'parked',
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
target_id
|
target_id
|
||||||
@@ -1262,7 +1265,7 @@ pub(crate) async fn reopen_target(
|
|||||||
}
|
}
|
||||||
"episode" => {
|
"episode" => {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE episodes SET state = 'missing',
|
"UPDATE episodes SET wanted = 0, state = 'parked',
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
target_id
|
target_id
|
||||||
@@ -1273,7 +1276,7 @@ pub(crate) async fn reopen_target(
|
|||||||
"season" => {
|
"season" => {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE episodes
|
"UPDATE episodes
|
||||||
SET state = 'missing',
|
SET wanted = 0, state = 'parked',
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
WHERE season_id = ? AND state = 'downloading'
|
WHERE season_id = ? AND state = 'downloading'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -1857,41 +1860,37 @@ 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
|
/// #108, overriding §86: a torrent removed by hand — gone from
|
||||||
/// finished — reopens the gap instead of leaving the grab stuck forever,
|
/// Transmission before it finished — parks the movie instead of
|
||||||
/// and is marked `vanished` rather than `failed` so it never counts
|
/// reopening the gap, and is marked `vanished` rather than `failed` so
|
||||||
/// toward the `needs_decision` queue (attention.rs). A search-empty
|
/// it never counts toward the `needs_decision` queue (attention.rs).
|
||||||
/// indexer on the second tick isolates the reopen from the re-grab that
|
|
||||||
/// would otherwise follow it in the same tick.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_torrent_removed_by_hand_reopens_the_gap() {
|
async fn a_torrent_removed_by_hand_parks_the_movie() {
|
||||||
let (_dir, database) = wanted_movie().await;
|
let (_dir, database) = wanted_movie().await;
|
||||||
let indexer = prowlarr().await;
|
let indexer = prowlarr().await;
|
||||||
let (downloader, fake) = transmission().await;
|
let (downloader, fake) = transmission().await;
|
||||||
|
|
||||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||||
fake.torrents.lock().unwrap().clear();
|
fake.torrents.lock().unwrap().clear();
|
||||||
let empty_indexer = empty_prowlarr().await;
|
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||||
let outcomes = action(&empty_indexer, &downloader)
|
|
||||||
.tick(&database)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(outcomes.len(), 1);
|
assert_eq!(outcomes.len(), 1);
|
||||||
assert_eq!(grabs(&database).await[0].2, "vanished");
|
assert_eq!(grabs(&database).await[0].2, "vanished");
|
||||||
let state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
let (state, wanted): (String, bool) =
|
||||||
.fetch_one(database.pool())
|
sqlx::query_as("SELECT state, wanted FROM movies WHERE id = 1")
|
||||||
.await
|
.fetch_one(database.pool())
|
||||||
.unwrap();
|
.await
|
||||||
assert_eq!(state, "missing");
|
.unwrap();
|
||||||
|
assert_eq!(state, "parked");
|
||||||
|
assert!(!wanted, "the leaf intent is cleared, DESIGN.md §4.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §86 continued: nothing blacklists the vanished release, so the exact
|
/// #108: a parked movie is not re-grabbed by the next tick, even with a
|
||||||
/// same infohash can be grabbed again — reclaiming its own dead row
|
/// matching release still available — `wanted = 0` removes it from the
|
||||||
/// rather than silently losing the grab to the `infohash` uniqueness
|
/// work list (§8), which is the whole point of parking rather than
|
||||||
/// constraint.
|
/// reopening.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_reopened_gap_can_regrab_the_same_infohash() {
|
async fn a_parked_movie_is_not_regrabbed() {
|
||||||
let (_dir, database) = wanted_movie().await;
|
let (_dir, database) = wanted_movie().await;
|
||||||
let indexer = prowlarr().await;
|
let indexer = prowlarr().await;
|
||||||
let (downloader, fake) = transmission().await;
|
let (downloader, fake) = transmission().await;
|
||||||
@@ -1899,9 +1898,38 @@ mod tests {
|
|||||||
|
|
||||||
action.tick(&database).await.unwrap();
|
action.tick(&database).await.unwrap();
|
||||||
fake.torrents.lock().unwrap().clear();
|
fake.torrents.lock().unwrap().clear();
|
||||||
|
action.tick(&database).await.unwrap();
|
||||||
let outcomes = action.tick(&database).await.unwrap();
|
let outcomes = action.tick(&database).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(outcomes.len(), 2, "vanish, then a fresh grab");
|
assert!(outcomes.is_empty(), "parked, nothing left to do");
|
||||||
|
assert_eq!(fake.torrents().len(), 0, "no fresh torrent sent");
|
||||||
|
let grabs = grabs(&database).await;
|
||||||
|
assert_eq!(grabs.len(), 1);
|
||||||
|
assert_eq!(grabs[0].2, "vanished");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #108: nothing blacklists the vanished release — a one-click re-want
|
||||||
|
/// (the manual trigger) can still grab it again, reclaiming the dead
|
||||||
|
/// row rather than losing the grab to the `infohash` uniqueness
|
||||||
|
/// constraint.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_rewanted_movie_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();
|
||||||
|
action.tick(&database).await.unwrap();
|
||||||
|
|
||||||
|
sqlx::query("UPDATE movies SET wanted = 1 WHERE id = 1")
|
||||||
|
.execute(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let outcomes = action.tick(&database).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(outcomes.len(), 1, "a fresh grab");
|
||||||
assert_eq!(fake.torrents().len(), 1, "same magnet, same infohash");
|
assert_eq!(fake.torrents().len(), 1, "same magnet, same infohash");
|
||||||
let grabs = grabs(&database).await;
|
let grabs = grabs(&database).await;
|
||||||
assert_eq!(grabs.len(), 1, "the dead row is reclaimed, not duplicated");
|
assert_eq!(grabs.len(), 1, "the dead row is reclaimed, not duplicated");
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ impl ImportAction {
|
|||||||
) -> 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 — the caller marks the grab vanished
|
// Gone from Transmission — the caller marks the grab vanished
|
||||||
// and reopens the gap (§86).
|
// and parks the target (#108).
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
grab_id,
|
grab_id,
|
||||||
infohash,
|
infohash,
|
||||||
@@ -660,11 +660,13 @@ impl ImportAction {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §86: a `downloaded` grab whose torrent Transmission no longer reports
|
/// §86/#108: a `downloaded` grab whose torrent Transmission no longer
|
||||||
/// — removed by hand, not a policy failure. Marked `vanished` rather
|
/// reports — removed by hand, not a policy failure. Marked `vanished`
|
||||||
/// than `failed` so it does not feed the `needs_decision` queue
|
/// rather than `failed` so it does not feed the `needs_decision` queue
|
||||||
/// (attention.rs), and nothing is blacklisted, since the release itself
|
/// (attention.rs). Nothing is blacklisted, since the release itself
|
||||||
/// never failed policy.
|
/// never failed policy, and the movie is parked rather than reopened,
|
||||||
|
/// since removing a torrent by hand is human intent, not a gap to
|
||||||
|
/// refill.
|
||||||
async fn vanish(&self, database: &Db, pending: &PendingImport) -> Result<Outcome, ImportError> {
|
async fn vanish(&self, database: &Db, pending: &PendingImport) -> Result<Outcome, ImportError> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE grabs SET state = 'vanished' WHERE id = ?",
|
"UPDATE grabs SET state = 'vanished' WHERE id = ?",
|
||||||
@@ -672,25 +674,25 @@ impl ImportAction {
|
|||||||
)
|
)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
crate::grab::reopen_target(database, "movie", pending.movie_id).await?;
|
crate::grab::park_target(database, "movie", pending.movie_id).await?;
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
grab_id = pending.grab_id,
|
grab_id = pending.grab_id,
|
||||||
movie_id = pending.movie_id,
|
movie_id = pending.movie_id,
|
||||||
title = pending.title,
|
title = pending.title,
|
||||||
release = pending.release_name,
|
release = pending.release_name,
|
||||||
"torrent vanished from Transmission; gap reopened"
|
"torrent vanished from Transmission; movie parked"
|
||||||
);
|
);
|
||||||
Ok(Outcome::new(
|
Ok(Outcome::new(
|
||||||
format!(
|
format!(
|
||||||
"grab {} downloaded, torrent vanished from Transmission",
|
"grab {} downloaded, torrent vanished from Transmission",
|
||||||
pending.grab_id
|
pending.grab_id
|
||||||
),
|
),
|
||||||
format!("reopened movie {}", pending.movie_id),
|
format!("parked movie {}", pending.movie_id),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TV counterpart of [`Self::vanish`]: reopens the episode, or the
|
/// TV counterpart of [`Self::vanish`]: parks the episode, or the
|
||||||
/// still-downloading episodes of a season pack.
|
/// still-downloading episodes of a season pack.
|
||||||
async fn vanish_tv(
|
async fn vanish_tv(
|
||||||
&self,
|
&self,
|
||||||
@@ -707,20 +709,20 @@ impl ImportAction {
|
|||||||
Some(episode_id) => ("episode", episode_id),
|
Some(episode_id) => ("episode", episode_id),
|
||||||
None => ("season", pending.season_id),
|
None => ("season", pending.season_id),
|
||||||
};
|
};
|
||||||
crate::grab::reopen_target(database, target_kind, target_id).await?;
|
crate::grab::park_target(database, target_kind, target_id).await?;
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
grab_id = pending.grab_id,
|
grab_id = pending.grab_id,
|
||||||
series = pending.series_title,
|
series = pending.series_title,
|
||||||
release = pending.release_name,
|
release = pending.release_name,
|
||||||
"torrent vanished from Transmission; gap reopened"
|
"torrent vanished from Transmission; target parked"
|
||||||
);
|
);
|
||||||
Ok(Outcome::new(
|
Ok(Outcome::new(
|
||||||
format!(
|
format!(
|
||||||
"grab {} downloaded, torrent vanished from Transmission",
|
"grab {} downloaded, torrent vanished from Transmission",
|
||||||
pending.grab_id
|
pending.grab_id
|
||||||
),
|
),
|
||||||
format!("reopened {target_kind} {target_id}"),
|
format!("parked {target_kind} {target_id}"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1691,11 +1693,12 @@ mod tests {
|
|||||||
assert!(outcomes.is_empty());
|
assert!(outcomes.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §86: a `downloaded` grab whose torrent Transmission no longer reports
|
/// #108, overriding §86: a `downloaded` grab whose torrent Transmission
|
||||||
/// — removed by hand, not a policy failure — is marked `vanished` and
|
/// no longer reports — removed by hand, not a policy failure — is marked
|
||||||
/// reopens the movie as a gap, without touching the blacklist.
|
/// `vanished` and parks the movie (`wanted` cleared) instead of
|
||||||
|
/// reopening it as a gap, without touching the blacklist.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_vanished_downloaded_grab_reopens_the_gap() {
|
async fn a_vanished_downloaded_grab_parks_the_movie() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
|
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
|
||||||
database.migrate().await.unwrap();
|
database.migrate().await.unwrap();
|
||||||
@@ -1752,11 +1755,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(grab_state, "vanished");
|
assert_eq!(grab_state, "vanished");
|
||||||
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
let (movie_state, wanted): (String, bool) =
|
||||||
.fetch_one(database.pool())
|
sqlx::query_as("SELECT state, wanted FROM movies WHERE id = 1")
|
||||||
.await
|
.fetch_one(database.pool())
|
||||||
.unwrap();
|
.await
|
||||||
assert_eq!(movie_state, "missing");
|
.unwrap();
|
||||||
|
assert_eq!(movie_state, "parked");
|
||||||
|
assert!(!wanted, "the leaf intent is cleared, DESIGN.md §4.1");
|
||||||
let blacklisted: i64 = sqlx::query_scalar("SELECT count(*) FROM blacklist")
|
let blacklisted: i64 = sqlx::query_scalar("SELECT count(*) FROM blacklist")
|
||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- #108: a vanished torrent must not re-grab. Overrides #86, which reopened
|
||||||
|
-- the gap (state = 'missing', wanted untouched) so the next tick re-grabbed
|
||||||
|
-- the same release. Adds 'parked' so the daemon can clear `wanted` and mark
|
||||||
|
-- the title honestly instead — distinct from 'missing' (an open gap) and
|
||||||
|
-- 'available' (satisfied). SQLite cannot alter a CHECK, so both tables are
|
||||||
|
-- rebuilt (see 0007).
|
||||||
|
|
||||||
|
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
|
||||||
|
|
||||||
|
CREATE TABLE movies_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tmdb_id INTEGER NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year INTEGER,
|
||||||
|
original_language TEXT,
|
||||||
|
root_id INTEGER NOT NULL REFERENCES roots (id),
|
||||||
|
wanted INTEGER NOT NULL DEFAULT 1 CHECK (wanted IN (0, 1)),
|
||||||
|
overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)),
|
||||||
|
state TEXT NOT NULL DEFAULT 'missing'
|
||||||
|
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
|
||||||
|
search_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_searched_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
digital_release TEXT,
|
||||||
|
metadata_refreshed_at TEXT,
|
||||||
|
imdb_id TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
INSERT INTO movies_new (
|
||||||
|
id, tmdb_id, title, year, original_language, root_id, wanted, overrides,
|
||||||
|
state, blocked, search_attempts, last_searched_at, created_at, updated_at,
|
||||||
|
digital_release, metadata_refreshed_at, imdb_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, tmdb_id, title, year, original_language, root_id, wanted, overrides,
|
||||||
|
state, blocked, search_attempts, last_searched_at, created_at, updated_at,
|
||||||
|
digital_release, metadata_refreshed_at, imdb_id
|
||||||
|
FROM movies;
|
||||||
|
|
||||||
|
DROP TABLE movies;
|
||||||
|
ALTER TABLE movies_new RENAME TO movies;
|
||||||
|
|
||||||
|
CREATE INDEX movies_pending_search
|
||||||
|
ON movies (last_searched_at)
|
||||||
|
WHERE wanted = 1 AND blocked = 0;
|
||||||
|
|
||||||
|
CREATE INDEX movies_state ON movies (state);
|
||||||
|
CREATE INDEX movies_root ON movies (root_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER movies_require_movie_root
|
||||||
|
BEFORE INSERT ON movies
|
||||||
|
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER movies_require_movie_root_on_update
|
||||||
|
BEFORE UPDATE OF root_id ON movies
|
||||||
|
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'movie')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'movies require a movie root');
|
||||||
|
END;
|
||||||
|
|
||||||
|
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
|
||||||
|
DROP TABLE movie_releases_backup;
|
||||||
|
|
||||||
|
CREATE TABLE episode_releases_backup AS SELECT * FROM episode_releases;
|
||||||
|
|
||||||
|
CREATE TABLE episodes_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
|
||||||
|
number INTEGER NOT NULL CHECK (number >= 0),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
air_date TEXT,
|
||||||
|
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
|
||||||
|
state TEXT NOT NULL DEFAULT 'missing'
|
||||||
|
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
|
||||||
|
search_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_searched_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE (season_id, number)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
INSERT INTO episodes_new (
|
||||||
|
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||||
|
last_searched_at, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, season_id, number, title, air_date, wanted, state, search_attempts,
|
||||||
|
last_searched_at, created_at, updated_at
|
||||||
|
FROM episodes;
|
||||||
|
|
||||||
|
DROP TABLE episodes;
|
||||||
|
ALTER TABLE episodes_new RENAME TO episodes;
|
||||||
|
|
||||||
|
CREATE INDEX episodes_pending_search
|
||||||
|
ON episodes (last_searched_at)
|
||||||
|
WHERE wanted = 1 AND state = 'missing';
|
||||||
|
|
||||||
|
CREATE INDEX episodes_state ON episodes (state);
|
||||||
|
|
||||||
|
INSERT INTO episode_releases SELECT * FROM episode_releases_backup;
|
||||||
|
DROP TABLE episode_releases_backup;
|
||||||
Reference in New Issue
Block a user