Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch was finishing. Brings them in ahead of the merge back. # Conflicts: # crates/arr-api/src/movies.rs # crates/arr-api/src/state.rs # crates/arr-daemon/src/main.rs # web/src/main.ts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
|
||||
-- reference runtime, scaled by the series' minutes per episode. NULL is a
|
||||
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
|
||||
-- the bands apply unscaled.
|
||||
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
|
||||
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- #239: §5.7's 30-day attention window runs from the failure, but only
|
||||
-- grabbed_at existed, so a torrent that stalled past the window and then
|
||||
-- hard-failed at import was born outside it. failed_at records the moment
|
||||
-- the grab entered 'failed'. Existing failed rows get grabbed_at as the
|
||||
-- best available approximation.
|
||||
ALTER TABLE grabs ADD COLUMN failed_at TEXT;
|
||||
|
||||
UPDATE grabs SET failed_at = grabbed_at WHERE state = 'failed';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Issue #244. A root's path could be stored with a trailing separator until
|
||||
-- #243 normalised the incoming value. `roots::update` normalises the payload
|
||||
-- and compares it against the stored value, so such a row never compares
|
||||
-- equal: every edit, a policy change included, takes the relocation branch,
|
||||
-- and there every planned destination is its own source. Normalising the
|
||||
-- payload alone fixed the half that cannot bite; this is the other half.
|
||||
--
|
||||
-- `rtrim` strips every trailing separator at once, so '/mnt/x//' normalises
|
||||
-- in one pass. A bare '/' rtrims to the empty string and is put back, which
|
||||
-- is what `normalize_path` in arr-api does.
|
||||
--
|
||||
-- Guarded, because `roots.path` is UNIQUE and a migration that cannot apply
|
||||
-- stops the daemon booting -- worse than the bug it fixes. A row is
|
||||
-- normalised only when no other row shares its normalised path: neither a
|
||||
-- row already holding the stripped value, nor another trailing-separator row
|
||||
-- that would strip to the same thing. Every row in such a group is left
|
||||
-- exactly as it is. That leaves two roots naming one directory, which is a
|
||||
-- settings mistake for the operator to resolve by hand, not a reason to
|
||||
-- refuse to boot.
|
||||
--
|
||||
-- This cannot introduce a collision either. An updated row's new value is a
|
||||
-- normalised path no other row normalises to, and a row left alone whose raw
|
||||
-- path equalled that value would have had the same normalised path, which is
|
||||
-- the case the guard excludes.
|
||||
UPDATE roots
|
||||
SET path = CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
|
||||
WHERE path <> CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM roots AS other
|
||||
WHERE other.id <> roots.id
|
||||
AND CASE WHEN rtrim(other.path, '/') = '' THEN '/' ELSE rtrim(other.path, '/') END
|
||||
= CASE WHEN rtrim(roots.path, '/') = '' THEN '/' ELSE rtrim(roots.path, '/') END
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
-- no-transaction
|
||||
-- #211: a waived release could not record which rule it relaxed. The old
|
||||
-- constraint made the rule name an exact synonym for rejection:
|
||||
--
|
||||
-- CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))
|
||||
--
|
||||
-- so §9.3's deck showed a bare `waived` beside rejected rows that each named
|
||||
-- their own rule, and §5.7's "watchable but not what was asked" lost the half
|
||||
-- that says what was not asked for. The relaxed form still demands a rule on
|
||||
-- a rejection and stops demanding its absence elsewhere.
|
||||
--
|
||||
-- SQLite cannot alter a CHECK, so the table is rebuilt (see 0014). Unlike the
|
||||
-- rebuilds there, `releases` is a parent: `grabs`, `movie_releases`,
|
||||
-- `episode_releases` and `season_releases` all point at it, three of them
|
||||
-- ON DELETE CASCADE. Dropping the old table with foreign keys enforced would
|
||||
-- delete those children (or, for `grabs`, refuse outright), so this follows
|
||||
-- SQLite's own procedure — foreign keys off, the rebuild in one transaction,
|
||||
-- foreign keys back on. `PRAGMA foreign_keys` is a no-op inside a
|
||||
-- transaction, which is why the file opens `-- no-transaction` and manages
|
||||
-- its own; the migration is still all-or-nothing.
|
||||
--
|
||||
-- Rows are copied verbatim. Every existing `waived` row keeps its NULL and
|
||||
-- goes on reading as it does today; only rows written after this migration
|
||||
-- carry a waived rule.
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE releases_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Prowlarr's indexer id. Not a foreign key: indexers live in Prowlarr.
|
||||
indexer_id INTEGER NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
seeders INTEGER,
|
||||
publish_date TEXT,
|
||||
download_url TEXT NOT NULL,
|
||||
-- §5.6. What the release name claims, before anything is downloaded.
|
||||
parsed TEXT NOT NULL CHECK (json_valid(parsed)),
|
||||
score REAL,
|
||||
-- §9.3. Three buckets. `rejected_rule` names the rule that killed it so
|
||||
-- an over-strict filter is visible without reading release names, and
|
||||
-- §5.7's waiver names the rule it relaxed for the same reason.
|
||||
verdict TEXT CHECK (verdict IN ('eligible', 'waived', 'rejected')),
|
||||
rejected_rule TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (indexer_id, guid),
|
||||
CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO releases_new (
|
||||
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
|
||||
parsed, score, verdict, rejected_rule, created_at
|
||||
)
|
||||
SELECT
|
||||
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
|
||||
parsed, score, verdict, rejected_rule, created_at
|
||||
FROM releases;
|
||||
|
||||
DROP TABLE releases;
|
||||
ALTER TABLE releases_new RENAME TO releases;
|
||||
|
||||
CREATE INDEX releases_verdict ON releases (verdict, score);
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -12,7 +12,7 @@
|
||||
//! infohash, and
|
||||
//! - **infohash**, because the same torrent is re-listed under a new name.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
@@ -21,15 +21,21 @@ use sqlx::SqlitePool;
|
||||
/// rejected row names the rule that killed it" reads the same everywhere.
|
||||
pub const RULE: &str = "blacklisted";
|
||||
|
||||
/// Every blacklist key, loaded once per tick or request.
|
||||
/// Every blacklist key and the reason it was written under, loaded once per
|
||||
/// tick or request.
|
||||
///
|
||||
/// Household scale: a handful of rows. Loading it whole and matching in
|
||||
/// memory keeps the check identical for a release that has a database row and
|
||||
/// one that has only just arrived from an indexer.
|
||||
///
|
||||
/// The reason travels with the key because §9.3's deck has to say what a row
|
||||
/// was blacklisted for (#227): a release the policy rejected on size is one
|
||||
/// the operator can relax and try again, and a corrupt or mismatched one is
|
||||
/// not. A bare `blacklisted` makes those two read the same.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Blacklist {
|
||||
names: HashSet<String>,
|
||||
infohashes: HashSet<String>,
|
||||
names: HashMap<String, String>,
|
||||
infohashes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Blacklist {
|
||||
@@ -39,17 +45,30 @@ impl Blacklist {
|
||||
///
|
||||
/// If the query fails.
|
||||
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
|
||||
// Oldest first, so a key that hard-failed twice under different
|
||||
// reasons keeps the first one — the same rule [`add`] applies when it
|
||||
// refuses to write the second row.
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"#
|
||||
r#"SELECT normalised_name AS "normalised_name!: String",
|
||||
infohash,
|
||||
reason AS "reason!: String"
|
||||
FROM blacklist
|
||||
ORDER BY id"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut blacklist = Self::default();
|
||||
for row in rows {
|
||||
blacklist.names.insert(row.normalised_name);
|
||||
blacklist
|
||||
.names
|
||||
.entry(row.normalised_name)
|
||||
.or_insert_with(|| row.reason.clone());
|
||||
if let Some(infohash) = row.infohash {
|
||||
blacklist.infohashes.insert(infohash.to_ascii_lowercase());
|
||||
blacklist
|
||||
.infohashes
|
||||
.entry(infohash.to_ascii_lowercase())
|
||||
.or_insert(row.reason);
|
||||
}
|
||||
}
|
||||
Ok(blacklist)
|
||||
@@ -58,14 +77,44 @@ impl Blacklist {
|
||||
/// Whether this release name has been blacklisted, under any spelling.
|
||||
#[must_use]
|
||||
pub fn blocks_name(&self, release_name: &str) -> bool {
|
||||
self.names.contains(&arr_parse::normalise(release_name))
|
||||
self.names.contains_key(&arr_parse::normalise(release_name))
|
||||
}
|
||||
|
||||
/// Whether this infohash has been blacklisted. Case-insensitive:
|
||||
/// Transmission and Torznab disagree on the hex casing.
|
||||
#[must_use]
|
||||
pub fn blocks_infohash(&self, infohash: &str) -> bool {
|
||||
self.infohashes.contains(&infohash.to_ascii_lowercase())
|
||||
self.infohashes.contains_key(&infohash.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// What this release name was blacklisted for, or `None` if it was not.
|
||||
#[must_use]
|
||||
pub fn reason_for_name(&self, release_name: &str) -> Option<&str> {
|
||||
self.names
|
||||
.get(&arr_parse::normalise(release_name))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// What this infohash was blacklisted for, or `None` if it was not.
|
||||
#[must_use]
|
||||
pub fn reason_for_infohash(&self, infohash: &str) -> Option<&str> {
|
||||
self.infohashes
|
||||
.get(&infohash.to_ascii_lowercase())
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// What a candidate was blacklisted for, under either key (#227).
|
||||
///
|
||||
/// The name is asked first: it is the key every candidate has, and a
|
||||
/// `.torrent` URL hides its infohash until the download client fetches
|
||||
/// it, exactly as [`Blacklist::blocks_candidate`] describes.
|
||||
#[must_use]
|
||||
pub fn reason_for_candidate(&self, release_name: &str, download_url: &str) -> Option<&str> {
|
||||
self.reason_for_name(release_name).or_else(|| {
|
||||
magnet_infohash(download_url)
|
||||
.and_then(|hash| self.infohashes.get(&hash))
|
||||
.map(String::as_str)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a candidate is blacklisted before anything is sent to the
|
||||
@@ -175,6 +224,54 @@ mod tests {
|
||||
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_key_carries_the_reason_it_was_blacklisted_for() {
|
||||
let (database, _dir) = database().await;
|
||||
add(
|
||||
database.pool(),
|
||||
Some(HASH),
|
||||
"Rick.And.Morty.S08.1080p",
|
||||
"size",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
add(
|
||||
database.pool(),
|
||||
None,
|
||||
"Some.Other.Pack.S01",
|
||||
"no file matches a wanted episode",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let blacklist = Blacklist::load(database.pool()).await.unwrap();
|
||||
// #227: the deck has to tell a policy rejection from a bad release,
|
||||
// and the reason is the only thing that says which.
|
||||
assert_eq!(
|
||||
blacklist.reason_for_name("Rick And Morty S08 1080p"),
|
||||
Some("size")
|
||||
);
|
||||
assert_eq!(
|
||||
blacklist.reason_for_infohash(&HASH.to_ascii_uppercase()),
|
||||
Some("size")
|
||||
);
|
||||
assert_eq!(
|
||||
blacklist.reason_for_name("Some.Other.Pack.S01"),
|
||||
Some("no file matches a wanted episode")
|
||||
);
|
||||
assert_eq!(blacklist.reason_for_name("Never.Failed.S01"), None);
|
||||
|
||||
let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=Renamed.Pack");
|
||||
assert_eq!(
|
||||
blacklist.reason_for_candidate("Renamed.Pack", &magnet),
|
||||
Some("size")
|
||||
);
|
||||
assert_eq!(
|
||||
blacklist.reason_for_candidate("Renamed.Pack", "https://tracker/x.torrent"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
|
||||
let (database, _dir) = database().await;
|
||||
|
||||
@@ -24,6 +24,17 @@ use sqlx::{migrate::MigrateError, SqlitePool};
|
||||
/// The migrations embedded in the binary, so a deploy is one file.
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
|
||||
/// queue, as a SQLite time modifier. Measured from `failed_at` — the moment
|
||||
/// the grab hard-failed, not the moment it was grabbed — so a torrent that
|
||||
/// stalls past the window and then fails still counts (#239).
|
||||
///
|
||||
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
|
||||
/// and the one season that wants attention sits behind the ones that do not.
|
||||
/// Callers pair it with the `failed_at` format:
|
||||
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
|
||||
pub const ATTENTION_WINDOW: &str = "-30 days";
|
||||
|
||||
/// How long a writer waits for the write lock before giving up.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
@@ -343,6 +354,93 @@ mod tests {
|
||||
assert_eq!(renamed_from_imported, "available");
|
||||
}
|
||||
|
||||
/// The four seeded roots, up to but not including migration 0031, with
|
||||
/// the given legacy paths written straight into the rows.
|
||||
async fn roots_before_normalisation(paths: &[(i64, &str)]) -> (tempfile::TempDir, Db) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect");
|
||||
MIGRATOR
|
||||
.run_to(30, db.pool())
|
||||
.await
|
||||
.expect("migrations before #244");
|
||||
for (id, path) in paths {
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(path)
|
||||
.bind(id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("legacy path, valid under the unique index");
|
||||
}
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
async fn root_path(db: &Db, id: i64) -> String {
|
||||
sqlx::query_scalar("SELECT path FROM roots WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("root path")
|
||||
}
|
||||
|
||||
/// #244: a root's path could be stored with a trailing separator until
|
||||
/// #243 normalised the incoming value, and `roots::update` compares a
|
||||
/// normalised payload against the stored value — so such a row could
|
||||
/// never be edited again. Migration 0031 strips the separator, and skips
|
||||
/// a row whose stripped form another row already holds rather than
|
||||
/// tripping the unique index and refusing to apply.
|
||||
#[tokio::test]
|
||||
async fn root_paths_are_normalised_but_never_onto_a_path_in_use() {
|
||||
let (_dir, db) = roots_before_normalisation(&[
|
||||
(1, "/mnt/media/movies/main/"),
|
||||
(2, "/mnt/collide"),
|
||||
(3, "/mnt/collide/"),
|
||||
(4, "/mnt/media/tv/kids///"),
|
||||
])
|
||||
.await;
|
||||
|
||||
db.migrate()
|
||||
.await
|
||||
.expect("0031 applies with a collision present");
|
||||
|
||||
assert_eq!(root_path(&db, 1).await, "/mnt/media/movies/main");
|
||||
assert_eq!(
|
||||
root_path(&db, 4).await,
|
||||
"/mnt/media/tv/kids",
|
||||
"every trailing separator goes in one pass"
|
||||
);
|
||||
assert_eq!(root_path(&db, 2).await, "/mnt/collide");
|
||||
assert_eq!(
|
||||
root_path(&db, 3).await,
|
||||
"/mnt/collide/",
|
||||
"left exactly as it is: normalising it would collide with root 2"
|
||||
);
|
||||
}
|
||||
|
||||
/// #244: the harder half of the same guard. Two rows that strip to the
|
||||
/// same path, *neither* of which already holds the stripped value, still
|
||||
/// have to be left alone — normalising them would collide with each
|
||||
/// other, and a migration that cannot apply stops the daemon booting.
|
||||
#[tokio::test]
|
||||
async fn two_rows_that_would_collide_with_each_other_stop_nothing() {
|
||||
let (_dir, db) =
|
||||
roots_before_normalisation(&[(1, "/mnt/one/"), (2, "/mnt/dup/"), (3, "/mnt/dup//")])
|
||||
.await;
|
||||
|
||||
db.migrate()
|
||||
.await
|
||||
.expect("0031 applies with a mutually colliding pair present");
|
||||
|
||||
assert_eq!(root_path(&db, 2).await, "/mnt/dup/");
|
||||
assert_eq!(root_path(&db, 3).await, "/mnt/dup//");
|
||||
assert_eq!(
|
||||
root_path(&db, 1).await,
|
||||
"/mnt/one",
|
||||
"the rest of the table is still normalised"
|
||||
);
|
||||
}
|
||||
|
||||
/// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a
|
||||
/// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old
|
||||
/// tables does not cascade-delete `movie_releases`/`episode_releases`.
|
||||
@@ -426,6 +524,177 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #211: 0032 rebuilds `releases`, which is a parent — `grabs`,
|
||||
/// `movie_releases`, `episode_releases` and `season_releases` all point
|
||||
/// at it, three of them ON DELETE CASCADE. The rebuild runs with foreign
|
||||
/// keys off so dropping the old table neither cascades those children
|
||||
/// away nor is refused by `grabs`.
|
||||
#[tokio::test]
|
||||
async fn the_releases_rebuild_preserves_children_and_rows() {
|
||||
let (_dir, db) = a_deck_with_every_child_row().await;
|
||||
|
||||
db.migrate().await.expect("remaining migrations");
|
||||
|
||||
for (label, query, expected) in [
|
||||
("releases", "SELECT count(*) FROM releases", 3),
|
||||
("movie_releases", "SELECT count(*) FROM movie_releases", 1),
|
||||
(
|
||||
"episode_releases",
|
||||
"SELECT count(*) FROM episode_releases",
|
||||
1,
|
||||
),
|
||||
("season_releases", "SELECT count(*) FROM season_releases", 1),
|
||||
("grabs", "SELECT count(*) FROM grabs", 1),
|
||||
] {
|
||||
let rows: i64 = sqlx::query_scalar(query)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(rows, expected, "{label} survives the releases rebuild");
|
||||
}
|
||||
|
||||
// An existing waived row keeps its NULL and reads as it did before.
|
||||
let waived: Option<String> =
|
||||
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'waived-guid'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("waived row");
|
||||
assert_eq!(waived, None);
|
||||
let rejected: Option<String> =
|
||||
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'rejected-guid'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("rejected row");
|
||||
assert_eq!(rejected.as_deref(), Some("size"));
|
||||
|
||||
// Foreign keys are back on for the connection the migration used.
|
||||
let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("foreign_keys");
|
||||
assert_eq!(foreign_keys, 1);
|
||||
}
|
||||
|
||||
/// A database migrated to just before 0032, holding one release per
|
||||
/// verdict and one row in every table that points at `releases`.
|
||||
async fn a_deck_with_every_child_row() -> (tempfile::TempDir, Db) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
MIGRATOR
|
||||
.run_to(31, db.pool())
|
||||
.await
|
||||
.expect("migrations before the releases rebuild");
|
||||
|
||||
let movie_id = sqlx::query(
|
||||
"INSERT INTO movies (tmdb_id, title, root_id)
|
||||
SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' LIMIT 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("movie")
|
||||
.last_insert_rowid();
|
||||
let series_id = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, root_id)
|
||||
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("series")
|
||||
.last_insert_rowid();
|
||||
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season")
|
||||
.last_insert_rowid();
|
||||
let episode_id =
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, 'x')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("episode")
|
||||
.last_insert_rowid();
|
||||
// One row per verdict, including the waived row this issue is about,
|
||||
// which under the old constraint could only hold NULL.
|
||||
for (guid, verdict, rule) in [
|
||||
("eligible-guid", "eligible", None),
|
||||
("waived-guid", "waived", None),
|
||||
("rejected-guid", "rejected", Some("size")),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
|
||||
VALUES (1, ?, ?, 1024, 'http://x', '{}', ?, ?)",
|
||||
)
|
||||
.bind(guid)
|
||||
.bind(guid)
|
||||
.bind(verdict)
|
||||
.bind(rule)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("release");
|
||||
}
|
||||
let release_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM releases WHERE guid = 'eligible-guid'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("release id");
|
||||
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(release_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("movie link");
|
||||
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
|
||||
.bind(episode_id)
|
||||
.bind(release_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("episode link");
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season link");
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash)
|
||||
VALUES (?, 'movie', ?, 'infohash-1')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(movie_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("grab");
|
||||
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
/// The relaxed constraint (#211) admits a rule on a waiver and still
|
||||
/// refuses a rejection without one.
|
||||
#[tokio::test]
|
||||
async fn a_waiver_may_name_the_rule_it_relaxed() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
|
||||
VALUES (1, 'waived', 'Some.Release', 1024, 'http://x', '{}', 'waived', 'size')",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("a waiver names its rule");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (1, 'rejected', 'Some.Release', 1024, 'http://x', '{}', 'rejected')",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("a rejection still has to name its rule");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
@@ -193,6 +193,7 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -268,6 +269,75 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
root_audience: row.root_audience,
|
||||
root_path: row.root_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The policy attached to one series' root, with the series' own
|
||||
/// overrides (§5.1).
|
||||
///
|
||||
/// `None` when the series does not exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the query fails, or a policy column does not hold the JSON its
|
||||
/// migration promises.
|
||||
pub async fn series_policy(&self, series_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.overrides AS "overrides!: String",
|
||||
r.id AS "root_id!: i64",
|
||||
r.kind AS "root_kind!: String",
|
||||
r.audience AS "root_audience!: String",
|
||||
r.path AS "root_path!: String",
|
||||
p.id AS "policy_id!: i64",
|
||||
p.name AS "policy_name!: String",
|
||||
p.required_audio AS "required_audio!: String",
|
||||
p.dub_blacklist AS "dub_blacklist!: String",
|
||||
p.hdr_rules AS "hdr_rules!: String",
|
||||
p.size_bands AS "size_bands!: String",
|
||||
p.resolution_pref AS "resolution_pref!: String",
|
||||
p.source_weights AS "source_weights!: String",
|
||||
p.score_weights AS "score_weights!: String"
|
||||
FROM series s
|
||||
JOIN roots r ON r.id = s.root_id
|
||||
JOIN policies p ON p.id = r.policy_id
|
||||
WHERE s.id = ?
|
||||
"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let overrides: OverridesJson = json("overrides", &row.overrides)?;
|
||||
let policy = PolicyColumns {
|
||||
id: row.policy_id,
|
||||
name: row.policy_name,
|
||||
required_audio: row.required_audio,
|
||||
dub_blacklist: row.dub_blacklist,
|
||||
hdr_rules: row.hdr_rules,
|
||||
size_bands: row.size_bands,
|
||||
resolution_pref: row.resolution_pref,
|
||||
source_weights: row.source_weights,
|
||||
score_weights: row.score_weights,
|
||||
}
|
||||
.to_policy()?;
|
||||
|
||||
Ok(Some(TitlePolicy {
|
||||
policy,
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -337,6 +407,7 @@ impl Db {
|
||||
overrides: TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
allow_below_floor: overrides.allow_below_floor,
|
||||
},
|
||||
root_id: row.root_id,
|
||||
root_kind: row.root_kind,
|
||||
@@ -428,6 +499,8 @@ struct OverridesJson {
|
||||
only_4k: bool,
|
||||
#[serde(default)]
|
||||
allow_english_audio: bool,
|
||||
#[serde(default)]
|
||||
allow_below_floor: bool,
|
||||
}
|
||||
|
||||
fn gib(value: u64) -> u64 {
|
||||
@@ -623,7 +696,8 @@ mod tests {
|
||||
&loaded.policy,
|
||||
Resolution::R1080p,
|
||||
one_and_a_half_gib,
|
||||
1
|
||||
1,
|
||||
0
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user