From 62aba6315c67dbd03ae10cfd9da0ffab6f26af81 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 23:21:09 +0100 Subject: [PATCH] feat(db): align movie state with canonical MediaState (#85) --- ...6ccfcb06d257fd4435952c93b6287ebdd78a5.json | 12 --- ...5187a1f48546af2c05cd618ba1c1b9f7ac791.json | 12 +++ crates/arr-daemon/src/grab.rs | 4 +- .../migrations/0007_movie_state_canonical.sql | 73 +++++++++++++++++++ crates/arr-db/src/lib.rs | 56 ++++++++++++++ 5 files changed, 143 insertions(+), 14 deletions(-) delete mode 100644 .sqlx/query-0744ebfb2d85bbcdba28a21f88e6ccfcb06d257fd4435952c93b6287ebdd78a5.json create mode 100644 .sqlx/query-aa1739e8bc9d2dd48fcfc3238d25187a1f48546af2c05cd618ba1c1b9f7ac791.json create mode 100644 crates/arr-db/migrations/0007_movie_state_canonical.sql diff --git a/.sqlx/query-0744ebfb2d85bbcdba28a21f88e6ccfcb06d257fd4435952c93b6287ebdd78a5.json b/.sqlx/query-0744ebfb2d85bbcdba28a21f88e6ccfcb06d257fd4435952c93b6287ebdd78a5.json deleted file mode 100644 index 7c58ae5..0000000 --- a/.sqlx/query-0744ebfb2d85bbcdba28a21f88e6ccfcb06d257fd4435952c93b6287ebdd78a5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE movies SET state = 'grabbed',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "0744ebfb2d85bbcdba28a21f88e6ccfcb06d257fd4435952c93b6287ebdd78a5" -} diff --git a/.sqlx/query-aa1739e8bc9d2dd48fcfc3238d25187a1f48546af2c05cd618ba1c1b9f7ac791.json b/.sqlx/query-aa1739e8bc9d2dd48fcfc3238d25187a1f48546af2c05cd618ba1c1b9f7ac791.json new file mode 100644 index 0000000..5dc3a8e --- /dev/null +++ b/.sqlx/query-aa1739e8bc9d2dd48fcfc3238d25187a1f48546af2c05cd618ba1c1b9f7ac791.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE movies SET state = 'downloading',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "aa1739e8bc9d2dd48fcfc3238d25187a1f48546af2c05cd618ba1c1b9f7ac791" +} diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 63928aa..c654f13 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -352,7 +352,7 @@ impl GrabAction { .fetch_optional(database.pool()) .await?; sqlx::query!( - "UPDATE movies SET state = 'grabbed', + "UPDATE movies SET state = 'downloading', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", movie.id @@ -852,7 +852,7 @@ mod tests { .fetch_one(database.pool()) .await .unwrap(); - assert_eq!(state, "grabbed"); + assert_eq!(state, "downloading"); } /// The failure mode the issue names: killed after the torrent is sent and diff --git a/crates/arr-db/migrations/0007_movie_state_canonical.sql b/crates/arr-db/migrations/0007_movie_state_canonical.sql new file mode 100644 index 0000000..bfd5f2f --- /dev/null +++ b/crates/arr-db/migrations/0007_movie_state_canonical.sql @@ -0,0 +1,73 @@ +-- #73. movies.state used missing|grabbed|imported, predating arr-core's +-- canonical MediaState vocabulary (missing|downloading|available) that +-- series/episodes (0005) already follow. +-- +-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table -- which +-- means dropping the old copy. `movie_releases` references it with +-- ON DELETE CASCADE, so the drop would take its rows down too; stash them +-- first and restore them once the new `movies` exists with the same ids. + +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')), + 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')) +) 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 +) +SELECT + id, tmdb_id, title, year, original_language, root_id, wanted, overrides, + CASE state + WHEN 'grabbed' THEN 'downloading' + WHEN 'imported' THEN 'available' + ELSE state + END, + blocked, search_attempts, last_searched_at, created_at, updated_at +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); + +-- Dropping `movies` drops the triggers defined on it too (0005); recreate +-- the movie-root invariant they enforced. +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; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 97c8cda..173ef81 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -252,6 +252,62 @@ mod tests { .expect_err("state is a closed set"); } + /// #73: migration 0007 renames the pre-canonical `grabbed`/`imported` + /// values in place rather than dropping the rows that held them. + #[tokio::test] + async fn movie_state_migration_renames_existing_rows_in_place() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = Db::connect(dir.path().join("arr.db")) + .await + .expect("connect"); + + let pre_0007 = sqlx::migrate::Migrator { + migrations: std::borrow::Cow::Owned( + MIGRATOR.iter().filter(|m| m.version < 7).cloned().collect(), + ), + ignore_missing: MIGRATOR.ignore_missing, + locking: MIGRATOR.locking, + no_tx: MIGRATOR.no_tx, + }; + pre_0007 + .run(db.pool()) + .await + .expect("migrations before #73"); + + sqlx::query( + "INSERT INTO movies (tmdb_id, title, root_id, state) + SELECT 1, 'Old Vocabulary', id, 'grabbed' FROM roots WHERE kind = 'movie' LIMIT 1", + ) + .execute(db.pool()) + .await + .expect("legacy 'grabbed' row, valid under the pre-#73 constraint"); + sqlx::query( + "INSERT INTO movies (tmdb_id, title, root_id, state) + SELECT 2, 'Also Old', id, 'imported' FROM roots WHERE kind = 'movie' LIMIT 1", + ) + .execute(db.pool()) + .await + .expect("legacy 'imported' row, valid under the pre-#73 constraint"); + + db.migrate() + .await + .expect("remaining migrations, including #73"); + + let renamed_from_grabbed: String = + sqlx::query_scalar("SELECT state FROM movies WHERE tmdb_id = 1") + .fetch_one(db.pool()) + .await + .expect("row survives the migration"); + let renamed_from_imported: String = + sqlx::query_scalar("SELECT state FROM movies WHERE tmdb_id = 2") + .fetch_one(db.pool()) + .await + .expect("row survives the migration"); + + assert_eq!(renamed_from_grabbed, "downloading"); + assert_eq!(renamed_from_imported, "available"); + } + #[tokio::test] async fn seeds_two_tv_roots_with_distinct_policies() { let (_dir, db) = fresh().await;