feat(db): add series, season and episode model (#71)
This commit was merged in pull request #71.
This commit is contained in:
@@ -3,8 +3,8 @@ use std::time::UNIX_EPOCH;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{
|
||||
DolbyVisionProfile, HdrRules, Language, MovieOverrides, Policy, PolicyId, RequiredAudio,
|
||||
Resolution, Rule, ScoreWeights, SizeBand, Source, Verdict,
|
||||
DolbyVisionProfile, HdrRules, Language, Policy, PolicyId, RequiredAudio, Resolution, Rule,
|
||||
ScoreWeights, SizeBand, Source, TitleOverrides, Verdict,
|
||||
};
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
use axum::extract::{Query, State};
|
||||
@@ -293,7 +293,7 @@ pub async fn releases(
|
||||
})?;
|
||||
let overrides: OverridesJson = serde_json::from_value(movie.overrides)
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
only_4k: overrides.only_4k,
|
||||
allow_english_audio: overrides.allow_english_audio,
|
||||
};
|
||||
@@ -460,7 +460,7 @@ fn policy_from_row(row: PolicyRow) -> Result<Policy, ApiError> {
|
||||
fn classify(
|
||||
release: SearchRelease,
|
||||
policy: &Policy,
|
||||
overrides: &MovieOverrides,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
|
||||
@@ -21,6 +21,9 @@ macro_rules! id_type {
|
||||
id_type!(RootId);
|
||||
id_type!(PolicyId);
|
||||
id_type!(MovieId);
|
||||
id_type!(SeriesId);
|
||||
id_type!(SeasonId);
|
||||
id_type!(EpisodeId);
|
||||
id_type!(MediaFileId);
|
||||
id_type!(ReleaseId);
|
||||
id_type!(GrabId);
|
||||
@@ -155,13 +158,13 @@ pub struct Policy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MovieOverrides {
|
||||
pub struct TitleOverrides {
|
||||
pub only_4k: bool,
|
||||
pub allow_english_audio: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MovieState {
|
||||
pub enum MediaState {
|
||||
Missing,
|
||||
Downloading,
|
||||
Available,
|
||||
@@ -176,13 +179,48 @@ pub struct Movie {
|
||||
pub original_language: Language,
|
||||
pub root_id: RootId,
|
||||
pub wanted: bool,
|
||||
pub overrides: MovieOverrides,
|
||||
pub state: MovieState,
|
||||
pub overrides: TitleOverrides,
|
||||
pub state: MediaState,
|
||||
pub blocked: bool,
|
||||
pub search_attempts: u32,
|
||||
pub last_searched_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Series {
|
||||
pub id: SeriesId,
|
||||
pub tmdb_id: u64,
|
||||
pub title: String,
|
||||
pub year: u16,
|
||||
pub original_language: Language,
|
||||
pub root_id: RootId,
|
||||
pub auto_track: bool,
|
||||
pub overrides: TitleOverrides,
|
||||
pub upstream_ended: bool,
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Season {
|
||||
pub id: SeasonId,
|
||||
pub series_id: SeriesId,
|
||||
pub number: u16,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Episode {
|
||||
pub id: EpisodeId,
|
||||
pub season_id: SeasonId,
|
||||
pub number: u16,
|
||||
pub title: String,
|
||||
pub air_date: Option<SystemTime>,
|
||||
pub wanted: bool,
|
||||
pub state: MediaState,
|
||||
pub search_attempts: u32,
|
||||
pub last_searched_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AudioTrack {
|
||||
pub language: Language,
|
||||
|
||||
@@ -2,8 +2,8 @@ use arr_parse::{LanguageMarker, NameClaims};
|
||||
|
||||
use crate::{
|
||||
lang::{is_resolved_portuguese, language_of_marker},
|
||||
HdrFormat, Language, MovieOverrides, Policy, ProbedMedia, RequiredAudio, Resolution, Rule,
|
||||
Source, Verdict,
|
||||
HdrFormat, Language, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source,
|
||||
TitleOverrides, Verdict,
|
||||
};
|
||||
|
||||
/// The evidence available while evaluating a candidate.
|
||||
@@ -55,7 +55,7 @@ pub enum EvaluationPhase {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EvaluationContext<'a> {
|
||||
pub policy: &'a Policy,
|
||||
pub overrides: &'a MovieOverrides,
|
||||
pub overrides: &'a TitleOverrides,
|
||||
/// The title's original language from TMDB (`DESIGN.md` §5.2) — a
|
||||
/// distinct input from any track's language.
|
||||
pub original_language: &'a Language,
|
||||
@@ -103,7 +103,7 @@ pub struct Evaluation {
|
||||
#[must_use]
|
||||
pub fn evaluate(
|
||||
policy: &Policy,
|
||||
overrides: &MovieOverrides,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
@@ -514,7 +514,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None).verdict
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
) -> Verdict {
|
||||
evaluate(
|
||||
policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
original_language,
|
||||
candidate,
|
||||
None,
|
||||
@@ -539,7 +539,7 @@ mod tests {
|
||||
let claims = claims(None, None);
|
||||
let report = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
@@ -562,7 +562,7 @@ mod tests {
|
||||
#[test]
|
||||
fn preferred_resolutions_pass_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
|
||||
for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] {
|
||||
let claims = claims(Some(claimed), Some(ClaimedSource::WebDl));
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolution_outside_root_policy_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedResolution::P480, Resolution::Other(480)),
|
||||
(ClaimedResolution::P576, Resolution::Other(576)),
|
||||
@@ -607,9 +607,9 @@ mod tests {
|
||||
#[test]
|
||||
fn only_4k_tightens_the_root_policy_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
only_4k: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
let media = probed(Resolution::R1080p, Some(Source::WebDl));
|
||||
@@ -634,9 +634,9 @@ mod tests {
|
||||
fn only_4k_never_expands_the_root_policy() {
|
||||
let mut policy = policy();
|
||||
policy.resolution_preference = vec![Resolution::R1080p];
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
only_4k: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl));
|
||||
|
||||
@@ -649,7 +649,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_unsafe_source_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedSource::Cam, Source::Cam),
|
||||
(ClaimedSource::Telesync, Source::Telesync),
|
||||
@@ -679,7 +679,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
Candidate::PreGrab(&claims),
|
||||
),
|
||||
Verdict::Rejected(Rule::Source(Source::Telesync))
|
||||
@@ -689,7 +689,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_safe_source_passes_in_both_phases() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let cases = [
|
||||
(ClaimedSource::Dvd, Source::Dvd),
|
||||
(ClaimedSource::Telecine, Source::Telecine),
|
||||
@@ -721,7 +721,7 @@ mod tests {
|
||||
let media = probed(Resolution::R2160p, source);
|
||||
let report = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -777,7 +777,7 @@ mod tests {
|
||||
|
||||
let report = evaluate(
|
||||
policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -796,7 +796,7 @@ mod tests {
|
||||
let claims = arr_parse::parse("Movie.2024.2160p.WEB-DL.DV-GROUP");
|
||||
let report = evaluate(
|
||||
&policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
@@ -823,7 +823,7 @@ mod tests {
|
||||
#[test]
|
||||
fn hard_failure_wins_over_an_earlier_soft_failure() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let original = en();
|
||||
let claims = claims(None, None);
|
||||
let context = EvaluationContext {
|
||||
@@ -851,7 +851,7 @@ mod tests {
|
||||
#[test]
|
||||
fn first_failure_of_the_deciding_kind_is_stable() {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let original = en();
|
||||
let claims = claims(None, None);
|
||||
let context = EvaluationContext {
|
||||
@@ -984,9 +984,9 @@ mod tests {
|
||||
#[test]
|
||||
fn kids_allow_english_audio_grabs_and_imports_with_a_waiver() {
|
||||
let policy = kids_policy();
|
||||
let overrides = MovieOverrides {
|
||||
let overrides = TitleOverrides {
|
||||
allow_english_audio: true,
|
||||
..MovieOverrides::default()
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = name_claims(&[LanguageMarker::English]);
|
||||
assert_eq!(
|
||||
@@ -1078,7 +1078,7 @@ mod tests {
|
||||
// kids: could be the required pt-PT — must not reject as a dub.
|
||||
let report = evaluate(
|
||||
&kids_policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -1089,7 +1089,7 @@ mod tests {
|
||||
// as verified either.
|
||||
let report = evaluate(
|
||||
&policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&Language::PortugueseBrazil,
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
@@ -1102,7 +1102,7 @@ mod tests {
|
||||
let claims = name_claims(&[LanguageMarker::Portuguese]);
|
||||
let report = evaluate(
|
||||
&kids_policy(),
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
|
||||
@@ -156,7 +156,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
policy::{evaluate, EvaluationContext, PolicyRule, RuleEvaluation, SizeRule},
|
||||
HdrRules, Language, MovieOverrides, PolicyId, RequiredAudio, Rule, Verdict,
|
||||
HdrRules, Language, PolicyId, RequiredAudio, Rule, TitleOverrides, Verdict,
|
||||
};
|
||||
|
||||
const GIB: u64 = 1 << 30;
|
||||
@@ -221,7 +221,7 @@ mod tests {
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = MovieOverrides::default();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
SizeRule.evaluate(&EvaluationContext {
|
||||
@@ -256,7 +256,7 @@ mod tests {
|
||||
let claims = claims(ClaimedSource::Remux);
|
||||
let evaluation = evaluate(
|
||||
&policy,
|
||||
&MovieOverrides::default(),
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".to_owned()),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- TV side of the separate aggregates in DESIGN.md §4. This migration is
|
||||
-- additive: the movie tables stay concrete and unchanged.
|
||||
CREATE TABLE series (
|
||||
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),
|
||||
auto_track INTEGER NOT NULL DEFAULT 0 CHECK (auto_track IN (0, 1)),
|
||||
overrides TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(overrides)),
|
||||
-- §4.2 needs upstream completion to derive `ended` rather than store it.
|
||||
upstream_ended INTEGER NOT NULL DEFAULT 0 CHECK (upstream_ended IN (0, 1)),
|
||||
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
|
||||
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;
|
||||
|
||||
CREATE INDEX series_root ON series (root_id);
|
||||
|
||||
-- Before TV roots existed, the roots foreign key was sufficient to keep a
|
||||
-- movie on a movie root. Preserve that invariant now that both kinds exist.
|
||||
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;
|
||||
|
||||
CREATE TRIGGER series_require_tv_root
|
||||
BEFORE INSERT ON series
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'series require a TV root');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER series_require_tv_root_on_update
|
||||
BEFORE UPDATE OF root_id ON series
|
||||
WHEN EXISTS (SELECT 1 FROM roots WHERE id = NEW.root_id AND kind != 'tv')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'series require a TV root');
|
||||
END;
|
||||
|
||||
CREATE TABLE seasons (
|
||||
id INTEGER PRIMARY KEY,
|
||||
series_id INTEGER NOT NULL REFERENCES series (id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL CHECK (number >= 0),
|
||||
tracked INTEGER NOT NULL DEFAULT 0 CHECK (tracked IN (0, 1)),
|
||||
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 (series_id, number)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE episodes (
|
||||
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)),
|
||||
-- Mirrors arr-core's canonical MediaState vocabulary. The older movies
|
||||
-- table uses legacy names; its additive correction is tracked in #73.
|
||||
state TEXT NOT NULL DEFAULT 'missing'
|
||||
CHECK (state IN ('missing', 'downloading', 'available')),
|
||||
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;
|
||||
|
||||
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 policies (
|
||||
name, required_audio, dub_blacklist, hdr_rules, size_bands,
|
||||
resolution_pref, source_weights, score_weights
|
||||
) VALUES (
|
||||
'TV — main',
|
||||
json('{"require":"original_language","allow_english_fallback":false}'),
|
||||
json('["pt-BR"]'),
|
||||
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
|
||||
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
|
||||
json('["2160p","1080p"]'),
|
||||
-- §5.5 deliberately keeps source tier as Remux > BluRay > WEB-DL.
|
||||
json('{"Remux":4,"BluRay":3,"WEB-DL":2,"WEBRip":1,"HDTV":0}'),
|
||||
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
|
||||
);
|
||||
|
||||
INSERT INTO policies (
|
||||
name, required_audio, dub_blacklist, hdr_rules, size_bands,
|
||||
resolution_pref, source_weights, score_weights
|
||||
) VALUES (
|
||||
'TV — kids',
|
||||
json('{"require":"any_of","langs":["pt-PT"],"or_original_when_portuguese":true,"allow_english_fallback":false}'),
|
||||
json('["pt-BR"]'),
|
||||
json('{"dv_profile_allow":["8.1"],"dv_profile_reject":["5","7"],"allow_hdr10":true,"allow_hdr10plus":true,"allow_sdr":true}'),
|
||||
json('{"2160p":{"floor_gib":8,"target_gib":22,"penalty_points_per_gib_over":60},"1080p":{"floor_gib":3,"target_gib":8,"penalty_points_per_gib_over":60}}'),
|
||||
json('["2160p","1080p"]'),
|
||||
-- §5.2: pt-PT dubs are concentrated in streaming releases.
|
||||
json('{"WEB-DL":4,"WEBRip":2,"BluRay":1,"Remux":1,"HDTV":0}'),
|
||||
json('{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}')
|
||||
);
|
||||
|
||||
INSERT INTO roots (kind, audience, path, policy_id)
|
||||
SELECT 'tv', 'main', '/mnt/media/tv/main', id FROM policies WHERE name = 'TV — main';
|
||||
|
||||
INSERT INTO roots (kind, audience, path, policy_id)
|
||||
SELECT 'tv', 'kids', '/mnt/media/tv/kids', id FROM policies WHERE name = 'TV — kids';
|
||||
+128
-8
@@ -156,13 +156,13 @@ mod tests {
|
||||
|
||||
let roots = db.list_roots().await.expect("list roots");
|
||||
|
||||
assert_eq!(roots.len(), 2, "§5.1: two movie roots, TV comes with #34");
|
||||
assert!(roots.iter().all(|r| r.kind == "movie"));
|
||||
assert_eq!(roots[0].audience, "kids");
|
||||
assert_eq!(roots[0].path, "/mnt/media/movies/kids");
|
||||
assert_eq!(roots[1].audience, "main");
|
||||
assert_eq!(roots[1].path, "/mnt/media/movies/main");
|
||||
assert_ne!(roots[0].policy_id, roots[1].policy_id);
|
||||
let movie_roots: Vec<_> = roots.iter().filter(|root| root.kind == "movie").collect();
|
||||
assert_eq!(movie_roots.len(), 2);
|
||||
assert_eq!(movie_roots[0].audience, "kids");
|
||||
assert_eq!(movie_roots[0].path, "/mnt/media/movies/kids");
|
||||
assert_eq!(movie_roots[1].audience, "main");
|
||||
assert_eq!(movie_roots[1].path, "/mnt/media/movies/main");
|
||||
assert_ne!(movie_roots[0].policy_id, movie_roots[1].policy_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -200,7 +200,7 @@ mod tests {
|
||||
.await
|
||||
.expect("policies");
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows.len(), 4);
|
||||
for row in rows {
|
||||
let size_bands: String = row.get(0);
|
||||
let score_weights: String = row.get(1);
|
||||
@@ -247,4 +247,124 @@ mod tests {
|
||||
.await
|
||||
.expect_err("state is a closed set");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let roots = db.list_roots().await.expect("list roots");
|
||||
let tv_roots: Vec<_> = roots.iter().filter(|root| root.kind == "tv").collect();
|
||||
|
||||
assert_eq!(tv_roots.len(), 2);
|
||||
assert_eq!(tv_roots[0].audience, "kids");
|
||||
assert_eq!(tv_roots[0].path, "/mnt/media/tv/kids");
|
||||
assert_eq!(tv_roots[1].audience, "main");
|
||||
assert_eq!(tv_roots[1].path, "/mnt/media/tv/main");
|
||||
assert_ne!(tv_roots[0].policy_id, tv_roots[1].policy_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_wanted_is_independent_of_tracking_rules() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id = sqlx::query("INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (82728, 'Bluey', ?, 1)")
|
||||
.bind(root_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("series")
|
||||
.last_insert_rowid();
|
||||
let season_id =
|
||||
sqlx::query("INSERT INTO seasons (series_id, number, tracked) VALUES (?, 1, 1)")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("season")
|
||||
.last_insert_rowid();
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("episode");
|
||||
|
||||
let wanted: bool = sqlx::query_scalar("SELECT wanted FROM episodes")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("wanted");
|
||||
assert!(
|
||||
!wanted,
|
||||
"auto_track and tracked are rules, not episode intent"
|
||||
);
|
||||
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' AND audience = 'main'")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("existing movie schema remains writable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregates_require_roots_of_their_own_kind() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let movie_root: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("movie root");
|
||||
let tv_root: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' LIMIT 1")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, root_id) VALUES (1, 'x', ?)")
|
||||
.bind(tv_root)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("movie cannot use TV root");
|
||||
sqlx::query("INSERT INTO series (tmdb_id, title, root_id) VALUES (2, 'x', ?)")
|
||||
.bind(movie_root)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("series cannot use movie root");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_state_and_upstream_completion_are_constrained() {
|
||||
let (_dir, db) = fresh().await;
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, root_id, upstream_ended)
|
||||
VALUES (82728, 'Bluey', ?, 1)",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect("ended 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();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, state)
|
||||
VALUES (?, 1, 'Magic Xylophone', 'grabbed')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("database mirrors MediaState");
|
||||
sqlx::query("UPDATE series SET upstream_ended = 2 WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.expect_err("upstream completion is boolean");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user