diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 36cf87f..8745d56 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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 { fn classify( release: SearchRelease, policy: &Policy, - overrides: &MovieOverrides, + overrides: &TitleOverrides, original_language: &Language, ) -> Result { let parsed = arr_parse::parse(&release.name); diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index c6b335f..3449d2d 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -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, } +#[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, + pub wanted: bool, + pub state: MediaState, + pub search_attempts: u32, + pub last_searched_at: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct AudioTrack { pub language: Language, diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 800947d..cd3e2e0 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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, @@ -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, diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index e1cb689..2e0e088 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -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)), diff --git a/crates/arr-db/migrations/0005_series.sql b/crates/arr-db/migrations/0005_series.sql new file mode 100644 index 0000000..5490879 --- /dev/null +++ b/crates/arr-db/migrations/0005_series.sql @@ -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'; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 877beed..665fc2d 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -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"); + } }