From 54b3ea8a4d81fe0a4c1fdf9b90ef4f00ee68c03e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 09:13:30 +0100 Subject: [PATCH] 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 --- ...c34dce837a77cabe6a745b267c382146f99c8.json | 12 -- ...ae014152343acd33bef369c8c2c05579d9381.json | 12 -- ...fc6d1381a46d3cfadc13f6bd223cf913b0e05.json | 12 ++ ...e17211025067128991b5914c73c3ccb88d1b1.json | 12 -- ...dc56502cf567fe015cb21360b14b571867af8.json | 12 ++ ...060a9bc45698f0fe482a0c2136263cf12a4d3.json | 12 ++ crates/arr-core/src/lib.rs | 3 + crates/arr-daemon/src/grab.rs | 106 +++++++++++------- crates/arr-daemon/src/import.rs | 49 ++++---- .../arr-db/migrations/0014_parked_state.sql | 106 ++++++++++++++++++ 10 files changed, 239 insertions(+), 97 deletions(-) delete mode 100644 .sqlx/query-33db11897ccc36001f465428246c34dce837a77cabe6a745b267c382146f99c8.json delete mode 100644 .sqlx/query-3c2672f1140e57c80d8148d53e0ae014152343acd33bef369c8c2c05579d9381.json create mode 100644 .sqlx/query-3f945137b92734bd4963ff2de58fc6d1381a46d3cfadc13f6bd223cf913b0e05.json delete mode 100644 .sqlx/query-724e97893a234595fcc9feb2be9e17211025067128991b5914c73c3ccb88d1b1.json create mode 100644 .sqlx/query-8954e2110d8bc591b38bb568ed0dc56502cf567fe015cb21360b14b571867af8.json create mode 100644 .sqlx/query-9bb48bc6c67150cbbfeaf8542c4060a9bc45698f0fe482a0c2136263cf12a4d3.json create mode 100644 crates/arr-db/migrations/0014_parked_state.sql diff --git a/.sqlx/query-33db11897ccc36001f465428246c34dce837a77cabe6a745b267c382146f99c8.json b/.sqlx/query-33db11897ccc36001f465428246c34dce837a77cabe6a745b267c382146f99c8.json deleted file mode 100644 index 12b2784..0000000 --- a/.sqlx/query-33db11897ccc36001f465428246c34dce837a77cabe6a745b267c382146f99c8.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-3c2672f1140e57c80d8148d53e0ae014152343acd33bef369c8c2c05579d9381.json b/.sqlx/query-3c2672f1140e57c80d8148d53e0ae014152343acd33bef369c8c2c05579d9381.json deleted file mode 100644 index 47ba519..0000000 --- a/.sqlx/query-3c2672f1140e57c80d8148d53e0ae014152343acd33bef369c8c2c05579d9381.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-3f945137b92734bd4963ff2de58fc6d1381a46d3cfadc13f6bd223cf913b0e05.json b/.sqlx/query-3f945137b92734bd4963ff2de58fc6d1381a46d3cfadc13f6bd223cf913b0e05.json new file mode 100644 index 0000000..d201bb1 --- /dev/null +++ b/.sqlx/query-3f945137b92734bd4963ff2de58fc6d1381a46d3cfadc13f6bd223cf913b0e05.json @@ -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" +} diff --git a/.sqlx/query-724e97893a234595fcc9feb2be9e17211025067128991b5914c73c3ccb88d1b1.json b/.sqlx/query-724e97893a234595fcc9feb2be9e17211025067128991b5914c73c3ccb88d1b1.json deleted file mode 100644 index 3257181..0000000 --- a/.sqlx/query-724e97893a234595fcc9feb2be9e17211025067128991b5914c73c3ccb88d1b1.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-8954e2110d8bc591b38bb568ed0dc56502cf567fe015cb21360b14b571867af8.json b/.sqlx/query-8954e2110d8bc591b38bb568ed0dc56502cf567fe015cb21360b14b571867af8.json new file mode 100644 index 0000000..0542ad0 --- /dev/null +++ b/.sqlx/query-8954e2110d8bc591b38bb568ed0dc56502cf567fe015cb21360b14b571867af8.json @@ -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" +} diff --git a/.sqlx/query-9bb48bc6c67150cbbfeaf8542c4060a9bc45698f0fe482a0c2136263cf12a4d3.json b/.sqlx/query-9bb48bc6c67150cbbfeaf8542c4060a9bc45698f0fe482a0c2136263cf12a4d3.json new file mode 100644 index 0000000..e6a74cb --- /dev/null +++ b/.sqlx/query-9bb48bc6c67150cbbfeaf8542c4060a9bc45698f0fe482a0c2136263cf12a4d3.json @@ -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" +} diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 25694d6..0a1a3dd 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -238,6 +238,9 @@ pub enum MediaState { Missing, Downloading, 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)] diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index eecc8f8..cc71267 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -576,11 +576,12 @@ 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. + /// §86/#108: 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). + /// Nothing is blacklisted, since the release itself 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( &self, database: &Db, @@ -591,16 +592,16 @@ impl Grabber { sqlx::query!("UPDATE grabs SET state = 'vanished' WHERE id = ?", grab_id) .execute(database.pool()) .await?; - reopen_target(database, target_kind, target_id).await?; + park_target(database, target_kind, target_id).await?; tracing::warn!( grab_id, target_kind, target_id, - "torrent vanished from Transmission; gap reopened" + "torrent vanished from Transmission; target parked" ); Ok(Outcome::new( 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(()) } -/// §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( +/// #108, overriding #86: a torrent removed by hand is human intent, not a +/// gap to refill. Clear `wanted` (the leaf intent, DESIGN.md §4.1) and mark +/// the target `parked` instead of reopening it, so neither targeted search +/// nor RSS matching (§6.2) ever pick it back up. A season pack parks 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 park_target( database: &Db, target_kind: &str, target_id: i64, @@ -1252,7 +1255,7 @@ pub(crate) async fn reopen_target( match target_kind { "movie" => { sqlx::query!( - "UPDATE movies SET state = 'missing', + "UPDATE movies SET wanted = 0, state = 'parked', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", target_id @@ -1262,7 +1265,7 @@ pub(crate) async fn reopen_target( } "episode" => { sqlx::query!( - "UPDATE episodes SET state = 'missing', + "UPDATE episodes SET wanted = 0, state = 'parked', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", target_id @@ -1273,7 +1276,7 @@ pub(crate) async fn reopen_target( "season" => { sqlx::query!( "UPDATE episodes - SET state = 'missing', + SET wanted = 0, state = 'parked', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ? AND state = 'downloading' AND NOT EXISTS ( @@ -1857,41 +1860,37 @@ 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. + /// #108, overriding §86: a torrent removed by hand — gone from + /// Transmission before it finished — parks the movie instead of + /// reopening the gap, and is marked `vanished` rather than `failed` so + /// it never counts toward the `needs_decision` queue (attention.rs). #[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 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(); + let outcomes = action(&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"); + let (state, wanted): (String, bool) = + sqlx::query_as("SELECT state, wanted FROM movies WHERE id = 1") + .fetch_one(database.pool()) + .await + .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 - /// same infohash can be grabbed again — reclaiming its own dead row - /// rather than silently losing the grab to the `infohash` uniqueness - /// constraint. + /// #108: a parked movie is not re-grabbed by the next tick, even with a + /// matching release still available — `wanted = 0` removes it from the + /// work list (§8), which is the whole point of parking rather than + /// reopening. #[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 indexer = prowlarr().await; let (downloader, fake) = transmission().await; @@ -1899,9 +1898,38 @@ mod tests { action.tick(&database).await.unwrap(); fake.torrents.lock().unwrap().clear(); + 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"); let grabs = grabs(&database).await; assert_eq!(grabs.len(), 1, "the dead row is reclaimed, not duplicated"); diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index 9843dc0..c55ef8d 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -399,7 +399,7 @@ impl ImportAction { ) -> Result>, ImportError> { let Some(content) = self.transmission.torrent_content(infohash).await? else { // Gone from Transmission — the caller marks the grab vanished - // and reopens the gap (§86). + // and parks the target (#108). tracing::warn!( grab_id, infohash, @@ -660,11 +660,13 @@ 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. + /// §86/#108: 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). Nothing is blacklisted, since the release itself + /// 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 { sqlx::query!( "UPDATE grabs SET state = 'vanished' WHERE id = ?", @@ -672,25 +674,25 @@ impl ImportAction { ) .execute(database.pool()) .await?; - crate::grab::reopen_target(database, "movie", pending.movie_id).await?; + crate::grab::park_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" + "torrent vanished from Transmission; movie parked" ); Ok(Outcome::new( format!( "grab {} downloaded, torrent vanished from Transmission", 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. async fn vanish_tv( &self, @@ -707,20 +709,20 @@ impl ImportAction { Some(episode_id) => ("episode", episode_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!( grab_id = pending.grab_id, series = pending.series_title, release = pending.release_name, - "torrent vanished from Transmission; gap reopened" + "torrent vanished from Transmission; target parked" ); Ok(Outcome::new( format!( "grab {} downloaded, torrent vanished from Transmission", 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()); } - /// §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. + /// #108, overriding §86: a `downloaded` grab whose torrent Transmission + /// no longer reports — removed by hand, not a policy failure — is marked + /// `vanished` and parks the movie (`wanted` cleared) instead of + /// reopening it as a gap, without touching the blacklist. #[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 database = Db::connect(dir.path().join("arr.db")).await.unwrap(); database.migrate().await.unwrap(); @@ -1752,11 +1755,13 @@ mod tests { .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 (movie_state, wanted): (String, bool) = + sqlx::query_as("SELECT state, wanted FROM movies WHERE id = 1") + .fetch_one(database.pool()) + .await + .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") .fetch_one(database.pool()) .await diff --git a/crates/arr-db/migrations/0014_parked_state.sql b/crates/arr-db/migrations/0014_parked_state.sql new file mode 100644 index 0000000..82bf914 --- /dev/null +++ b/crates/arr-db/migrations/0014_parked_state.sql @@ -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;