From b96fa2486e803d130df1f73b1c52bfd98bc432e8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:17:45 +0100 Subject: [PATCH 01/71] chore(arr): add tracker config for blitz --- .claude/tracker.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .claude/tracker.json diff --git a/.claude/tracker.json b/.claude/tracker.json new file mode 100644 index 0000000..b2bcce0 --- /dev/null +++ b/.claude/tracker.json @@ -0,0 +1,11 @@ +{ + "tracker": "gitea", + "defaultBranch": "main", + "commitScope": "arr", + "remoteHost": "gitea", + "remoteBaseUrl": "https://git.naps.pt", + "buildCommand": "just ci", + "contextFiles": ["CLAUDE.md", "DESIGN.md"], + "prReviewers": [], + "labels": [] +} From 248870bcd0a2dc6281ee2769fcc5eebdbd50ec68 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:23:28 +0100 Subject: [PATCH 02/71] docs(design): amend TV tracking rules --- DESIGN.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7443c30..68f28a9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -129,10 +129,20 @@ happening right now". It is user-maintained, so it drifts and stops meaning anything. Here, intent is `Episode.wanted` and `Movie.wanted` only. -`Series.auto_track` is not intent — it is a rule that says "when metadata -reveals a new season, mark its episodes wanted". An untracked series where you +`Series.auto_track` is not intent — it is a rule one level up: when metadata +reveals a new season, that season becomes tracked. An untracked series where you manually marked S02 needs no special case: three wanted episodes, nothing else. +`Season.tracked` is also a rule, not intent. Turning tracking on marks every +already-revealed episode in the season wanted, and keeps marking episodes +wanted as metadata reveals them. Turning it off withdraws nothing: leaf intent +is never removed implicitly, so anything already marked wanted stays wanted +until cleared explicitly. + +Both rules skip season 0. Specials are dozens of undated shorts and recaps +that indexers do not carry, so `auto_track` never marks season 0 tracked. See +§4.2 for how season 0 stays out of derived status. + ### 4.2 Status is derived, never stored as intent Recomputed on metadata refresh and on file change: @@ -150,6 +160,11 @@ behind one toggle. A one-off season grab therefore disappears from the default view by itself once satisfied, and a tracked show reappears by itself when a new season is announced. Nothing to remember to flip. +Season 0 is invisible to all of it: derived status ignores season 0 episodes +entirely, so a manually wanted special cannot pin a series at `incomplete` or +hold back `ended`. The accepted consequence is that specials are visible and +grabbable in the series detail view, but never affect status. + ### 4.3 People are tags, not paths `Owner` is a many-to-many tag on titles. It drives UI filtering, the default @@ -339,7 +354,12 @@ do ID-based search; some are text-only. RSS is an empty-query Torznab call whose results are matched against the wanted list locally. Because its cost does not scale with the wanted list, every wanted -item is matched against every RSS result forever. +item is matched against every RSS result forever — with one guard: the RSS lane +skips a season pack for a season that already has episodes on disk, the same +guard §14 applies to re-grabs. Both lanes agree, so a season does not behave +differently depending on which lane sees a release first. A pack for a season +with nothing on disk stays eligible. When a single episode cannot be found on +its own, the escape hatch is the season release deck, not a lane exception. Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the title's metadata changes. It never gives up entirely, it goes quiet. @@ -644,5 +664,7 @@ means that pipeline is already proven. "remuxes are bad" to "remux audio needs a downmix". - **Size band numbers** in §5.5 are placeholders pending that test. - **Season-pack re-grab.** When an airing season completes, the episodes are - already present individually. Nothing re-grabs the pack. Whether that is ever - wanted is unresolved and deliberately deferred. + already present individually. Nothing re-grabs the pack, and per §6.2 the + RSS lane obeys the same guard — it skips packs for any season with episodes + on disk. Whether a re-grab is ever wanted remains unresolved and deliberately + deferred. From 2fca8ec09ff926bac68b7019458dee516933692e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:13 +0100 Subject: [PATCH 03/71] feat(core): auto-track skips season 0 --- crates/arr-core/src/tracking.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index f821068..b3e356c 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -11,13 +11,17 @@ pub struct RefreshedSeason { /// Applies a series' tracking rule to newly revealed seasons. /// /// Existing seasons keep their tracking rule and leaf-level intent unchanged. -/// Season zero is treated like every other newly revealed season. +/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may +/// still track it by hand. pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) { if !series.auto_track { return; } - for refreshed in seasons.iter_mut().filter(|season| season.is_new) { + for refreshed in seasons + .iter_mut() + .filter(|season| season.is_new && season.season.number != 0) + { refreshed.season.tracked = true; for episode in &mut refreshed.episodes { episode.wanted = true; @@ -119,12 +123,12 @@ mod tests { } #[test] - fn season_zero_follows_the_same_rule() { + fn auto_track_skips_season_zero() { let mut refresh = [refreshed_season(0, true, &[false])]; apply_auto_track(&series(true), &mut refresh); - assert!(refresh[0].season.tracked); - assert!(refresh[0].episodes[0].wanted); + assert!(!refresh[0].season.tracked); + assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted)); } } From 3bd613ec63d20246899b7d0964038f457c3febe1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:13 +0100 Subject: [PATCH 04/71] feat(core): derived status ignores season 0 --- ...ac3cf11d578ee864e36412a6f70477fff8325.json | 32 +++++++ ...cf72c77bb581bc0fdff7b2728cfa13712eb10.json | 32 +++++++ crates/arr-api/src/series.rs | 83 ++++++++++++++++--- crates/arr-core/src/status.rs | 83 ++++++++++++++++--- 4 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 .sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json create mode 100644 .sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json diff --git a/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json b/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json new file mode 100644 index 0000000..fb1a32f --- /dev/null +++ b/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "series_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325" +} diff --git a/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json b/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json new file mode 100644 index 0000000..cbb2e0f --- /dev/null +++ b/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "series_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10" +} diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 2cad2b5..6426990 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -265,7 +265,21 @@ fn status_name(status: SeriesStatus) -> &'static str { } } -fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series { +fn core_season(id: i64, series_id: i64, number: i64) -> arr_core::Season { + arr_core::Season { + id: SeasonId(id), + series_id: SeriesId(series_id), + number: u16::try_from(number).unwrap_or_default(), + tracked: false, + } +} + +fn with_status( + row: &SeriesRow, + seasons: &[arr_core::Season], + episodes: &[arr_core::Episode], + now: SystemTime, +) -> Series { let wanted = episodes.iter().filter(|episode| episode.wanted); let available = wanted .clone() @@ -281,20 +295,37 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) overrides: row.overrides.clone(), upstream_ended: row.upstream_ended, blocked: row.blocked, - status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), + status: status_name(derive_series_status( + &core_series(row), + seasons, + episodes, + now, + )) + .to_owned(), wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX), } } -/// Every episode in the library, keyed by the series it belongs to. +/// Every season and episode in the library, keyed by the series they belong to. /// -/// One query rather than one per series: the whole table is a few thousand +/// One query each rather than one per series: the whole table is a few thousand /// rows for a single household (§10), and the status of every listed series /// needs all of them anyway. -async fn episodes_by_series( +async fn tv_by_series( state: &AppState, -) -> Result>, ApiError> { +) -> Result< + ( + HashMap>, + HashMap>, + ), + ApiError, +> { + let season_rows = sqlx::query!( + r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons"# + ) + .fetch_all(pool(state)?) + .await?; let rows = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at @@ -303,14 +334,22 @@ async fn episodes_by_series( .fetch_all(pool(state)?) .await?; - let mut grouped: HashMap> = HashMap::new(); + let mut seasons: HashMap> = HashMap::new(); + for row in &season_rows { + seasons.entry(row.series_id).or_default().push(core_season( + row.id, + row.series_id, + row.number, + )); + } + let mut episodes: HashMap> = HashMap::new(); for row in &rows { - grouped + episodes .entry(row.series_id) .or_default() .push(core_episode(row)); } - Ok(grouped) + Ok((seasons, episodes)) } async fn load_series_row(state: &AppState, id: i64) -> Result { @@ -322,6 +361,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result Result { let row = load_series_row(state, id).await?; + let season_rows = sqlx::query!( + r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons WHERE series_id = ?"#, + id + ) + .fetch_all(pool(state)?) + .await?; + let seasons: Vec<_> = season_rows + .iter() + .map(|row| core_season(row.id, row.series_id, row.number)) + .collect(); let episodes = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at @@ -331,7 +380,7 @@ async fn load_series(state: &AppState, id: i64) -> Result { .fetch_all(pool(state)?) .await?; let episodes: Vec<_> = episodes.iter().map(core_episode).collect(); - Ok(with_status(&row, &episodes, SystemTime::now())) + Ok(with_status(&row, &seasons, &episodes, SystemTime::now())) } async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> { @@ -377,12 +426,20 @@ pub async fn list( .await? }; - let episodes = episodes_by_series(&state).await?; + let (seasons, episodes) = tv_by_series(&state).await?; let now = SystemTime::now(); - let empty = Vec::new(); + let no_seasons: Vec = Vec::new(); + let no_episodes: Vec = Vec::new(); Ok(Json( rows.iter() - .map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&empty), now)) + .map(|row| { + with_status( + row, + seasons.get(&row.id).unwrap_or(&no_seasons), + episodes.get(&row.id).unwrap_or(&no_episodes), + now, + ) + }) .collect(), )) } diff --git a/crates/arr-core/src/status.rs b/crates/arr-core/src/status.rs index 5e737c6..eb0b87a 100644 --- a/crates/arr-core/src/status.rs +++ b/crates/arr-core/src/status.rs @@ -1,6 +1,7 @@ +use std::collections::HashSet; use std::time::{Duration, SystemTime}; -use crate::{Episode, MediaState, Series}; +use crate::{Episode, MediaState, Season, SeasonId, Series}; const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24); @@ -15,13 +16,26 @@ pub enum SeriesStatus { } /// Derives a series' status at `now` without persisting lifecycle intent. +/// +/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a +/// season 0 listed here are ignored, so a manually wanted special cannot pin +/// the series at `incomplete` or hold back `ended`. The seasons must be the +/// series' own; an episode whose season is not listed counts like any other. #[must_use] pub fn derive_series_status( series: &Series, + seasons: &[Season], episodes: &[Episode], now: SystemTime, ) -> SeriesStatus { - let wanted = episodes.iter().filter(|episode| episode.wanted); + let specials: HashSet = seasons + .iter() + .filter(|season| season.number == 0) + .map(|season| season.id) + .collect(); + let wanted = episodes + .iter() + .filter(|episode| episode.wanted && !specials.contains(&episode.season_id)); let complete = wanted .clone() .all(|episode| episode.state == MediaState::Available); @@ -103,13 +117,23 @@ mod tests { } } + /// The season every default-test episode sits in. + fn seasons() -> [Season; 1] { + [Season { + id: SeasonId(1), + series_id: SeriesId(1), + number: 1, + tracked: true, + }] + } + #[test] fn airing_when_a_wanted_episode_is_in_the_window() { let now = SystemTime::UNIX_EPOCH + 100 * DAY; for air_date in [now - 14 * DAY, now + 14 * DAY] { let episodes = [episode(1, Some(air_date), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Airing ); } @@ -121,7 +145,7 @@ mod tests { MediaState::Missing, )]; assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Waiting ); } @@ -132,7 +156,7 @@ mod tests { let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Incomplete ); } @@ -143,7 +167,7 @@ mod tests { let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Waiting ); } @@ -154,7 +178,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(false, false), &episodes, now), + derive_series_status(&series(false, false), &seasons(), &episodes, now), SeriesStatus::Complete ); } @@ -165,7 +189,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(true, true), &episodes, now), + derive_series_status(&series(true, true), &seasons(), &episodes, now), SeriesStatus::Ended ); } @@ -175,14 +199,14 @@ mod tests { let now = SystemTime::UNIX_EPOCH + 100 * DAY; let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(false, false), &episodes, now), + derive_series_status(&series(false, false), &seasons(), &episodes, now), SeriesStatus::Incomplete ); episodes[0].state = MediaState::Available; assert_eq!( - derive_series_status(&series(false, false), &episodes, now), + derive_series_status(&series(false, false), &seasons(), &episodes, now), SeriesStatus::Complete ); } @@ -192,15 +216,50 @@ mod tests { let now = SystemTime::UNIX_EPOCH + 100 * DAY; let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)]; assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Waiting ); episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing)); assert_eq!( - derive_series_status(&series(true, false), &episodes, now), + derive_series_status(&series(true, false), &seasons(), &episodes, now), SeriesStatus::Airing ); } + + #[test] + fn missing_specials_do_not_hold_back_complete_or_ended() { + let now = SystemTime::UNIX_EPOCH + 100 * DAY; + let specials = [Season { + id: SeasonId(9), + series_id: SeriesId(1), + number: 0, + tracked: false, + }]; + let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)]; + let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing); + special.season_id = SeasonId(9); + + assert_eq!( + derive_series_status(&series(true, false), &specials, &episodes, now), + SeriesStatus::Waiting + ); + + episodes.push(special); + assert_eq!( + derive_series_status(&series(true, false), &[], &episodes, now), + SeriesStatus::Incomplete, + "without knowing the seasons, the special pins the series" + ); + assert_eq!( + derive_series_status(&series(false, false), &specials, &episodes, now), + SeriesStatus::Complete, + "§4.2: the only gap is a special" + ); + assert_eq!( + derive_series_status(&series(false, true), &specials, &episodes, now), + SeriesStatus::Ended + ); + } } From fa3f9cbd911e3a866bb469f76a3a6811fde94e00 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:25 +0100 Subject: [PATCH 05/71] feat(meta): look up series external ids --- crates/arr-meta/src/client.rs | 18 +++++++- crates/arr-meta/src/lib.rs | 4 +- crates/arr-meta/src/model.rs | 11 ++++- .../tests/fixtures/series_external_ids.json | 11 +++++ crates/arr-meta/tests/tmdb.rs | 42 +++++++++++++++++++ 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 crates/arr-meta/tests/fixtures/series_external_ids.json diff --git a/crates/arr-meta/src/client.rs b/crates/arr-meta/src/client.rs index f5ea4b3..c249f97 100644 --- a/crates/arr-meta/src/client.rs +++ b/crates/arr-meta/src/client.rs @@ -9,8 +9,8 @@ use serde::de::DeserializeOwned; use crate::cache::Cache; use crate::error::{Error, Result}; use crate::model::{ - Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage, RawSeason, RawSeries, - RawSeriesSearchPage, Season, Series, SeriesSearchResult, + ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage, + RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult, }; /// TMDB's v3 API root. @@ -157,6 +157,20 @@ impl TmdbClient { Ok(raw.into()) } + /// External ids for one TV series, of which the TVDB id is the one this + /// project needs (§6.1). + /// + /// # Errors + /// + /// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`]. + pub async fn series_external_ids(&self, tmdb_id: u32) -> Result { + let path = format!("tv/{tmdb_id}/external_ids"); + let raw: RawExternalIds = self.get_json(&path, &[]).await?; + Ok(ExternalIds { + tvdb_id: raw.tvdb_id, + }) + } + /// Episodes in one TV season. /// /// # Errors diff --git a/crates/arr-meta/src/lib.rs b/crates/arr-meta/src/lib.rs index 51417e8..adb4fe1 100644 --- a/crates/arr-meta/src/lib.rs +++ b/crates/arr-meta/src/lib.rs @@ -23,4 +23,6 @@ mod model; pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL}; pub use error::{Error, Result}; -pub use model::{Episode, Movie, MovieSearchResult, Season, Series, SeriesSearchResult}; +pub use model::{ + Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult, +}; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index dd3dd93..69adc15 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -218,9 +218,16 @@ pub(crate) struct RawSeries { external_ids: Option, } +/// The slice of `/tv/{id}/external_ids` this project reads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalIds { + /// The Torznab `tvdbid` parameter (§6.1), when TMDB knows one. + pub tvdb_id: Option, +} + #[derive(Debug, Deserialize)] -struct RawExternalIds { - tvdb_id: Option, +pub(crate) struct RawExternalIds { + pub(crate) tvdb_id: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/arr-meta/tests/fixtures/series_external_ids.json b/crates/arr-meta/tests/fixtures/series_external_ids.json new file mode 100644 index 0000000..d9d2dfb --- /dev/null +++ b/crates/arr-meta/tests/fixtures/series_external_ids.json @@ -0,0 +1,11 @@ +{ + "id": 82728, + "imdb_id": "tt7614372", + "freebase_id": "/m/0vpgz0b", + "freebase_mid": "/m/0vpgz0b", + "tvdb_id": 361391, + "tvrage_id": null, + "facebook_id": "OfficialBluey", + "instagram_id": "officialblueytv", + "twitter_id": "officialbluey" +} diff --git a/crates/arr-meta/tests/tmdb.rs b/crates/arr-meta/tests/tmdb.rs index 0a4fb1c..8a239fa 100644 --- a/crates/arr-meta/tests/tmdb.rs +++ b/crates/arr-meta/tests/tmdb.rs @@ -18,6 +18,7 @@ const MOVIE_CIDADE_DE_DEUS: &str = include_str!("fixtures/movie_cidade_de_deus.j const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json"); const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json"); const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json"); +const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json"); fn client(server: &MockServer) -> TmdbClient { TmdbClient::builder("test-key") @@ -461,3 +462,44 @@ async fn debug_output_does_not_leak_the_api_key() { assert!(!rendered.contains("test-key"), "{rendered}"); assert!(rendered.contains("redacted"), "{rendered}"); } + +/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is +/// where TMDB keeps the mapping. +#[tokio::test] +async fn series_external_ids_carries_the_tvdb_id() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/tv/82728/external_ids")) + .respond_with(ResponseTemplate::new(200).set_body_string(SERIES_EXTERNAL_IDS)) + .expect(1) + .mount(&server) + .await; + + let ids = client(&server) + .series_external_ids(82_728) + .await + .expect("lookup succeeds"); + + assert_eq!(ids.tvdb_id, Some(361_391)); +} + +/// A series TMDB has no TVDB id for stays null rather than zero, so callers +/// can tell "unknown" from a real id and fall back to the text query. +#[tokio::test] +async fn series_without_a_tvdb_id_maps_to_none() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/tv/1/external_ids")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#), + ) + .mount(&server) + .await; + + let ids = client(&server) + .series_external_ids(1) + .await + .expect("lookup succeeds"); + + assert_eq!(ids.tvdb_id, None); +} From 46de061e685e2d5cca9b44b774e8c63528f4c76e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:25 +0100 Subject: [PATCH 06/71] feat(db): nullable unique series.tvdb_id --- crates/arr-db/migrations/0016_series_tvdb_id.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 crates/arr-db/migrations/0016_series_tvdb_id.sql diff --git a/crates/arr-db/migrations/0016_series_tvdb_id.sql b/crates/arr-db/migrations/0016_series_tvdb_id.sql new file mode 100644 index 0000000..a893dc3 --- /dev/null +++ b/crates/arr-db/migrations/0016_series_tvdb_id.sql @@ -0,0 +1,6 @@ +-- Torznab `t=tvsearch` is addressed by TVDB id (§6.1); the title text query +-- is only the fallback. Nullable, because TMDB does not know one for every +-- series and #121 backfills it on refresh; unique when present so two rows +-- cannot claim the same show. +ALTER TABLE series ADD COLUMN tvdb_id INTEGER; +CREATE UNIQUE INDEX series_tvdb_id_unique ON series (tvdb_id) WHERE tvdb_id IS NOT NULL; From a5706cf1b4c5ea961e56ddcffcdbb60e0f1c8873 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:28 +0100 Subject: [PATCH 07/71] feat(tv): search indexers by series tvdb id --- crates/arr-api/src/search.rs | 120 +++++++++++++++++++++++++++++-- crates/arr-api/src/series.rs | 30 ++++++-- crates/arr-compat/src/model.rs | 2 +- crates/arr-compat/src/series.rs | 9 ++- crates/arr-daemon/src/tv_grab.rs | 8 ++- 5 files changed, 151 insertions(+), 18 deletions(-) diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index b6c1a0e..52af066 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -331,16 +331,17 @@ async fn movie_releases( /// Classified releases for one episode (`DESIGN.md` §6.1, §9.3). /// -/// The series carries no TVDB ID yet, so `tv_request` falls back to a text -/// search built from the title and the `SxxEyy` tag. Both widen the result -/// set rather than narrowing it, which the buckets already handle. +/// A series with a stored TVDB id is searched by it; `tv_request` falls back +/// to a text search built from the title and the `SxxEyy` tag when the id is +/// missing or the indexer does not take `tvdbid`. Both widen the result set +/// rather than narrowing it, which the buckets already handle. async fn episode_releases( state: &AppState, episode_id: i64, ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let episode = sqlx::query!( - r#"SELECT s.title AS "series_title!: String", s.original_language, + r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id @@ -365,7 +366,7 @@ async fn episode_releases( let original_language = title_language(original_language, &[]); let target = TvTarget { - tvdb_id: None, + tvdb_id: episode.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), title: episode.series_title, selector: TvSelector::Episode { season: u32::try_from(episode.season_number).unwrap_or_default(), @@ -456,7 +457,7 @@ fn prowlarr_client(state: &AppState) -> Result { ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable) } -fn tmdb_client(state: &AppState) -> Result { +pub(crate) fn tmdb_client(state: &AppState) -> Result { let upstreams = state.upstreams(); let key = upstreams .tmdb_api_key @@ -820,6 +821,113 @@ mod tests { assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2); } + #[tokio::test] + async fn episode_releases_search_by_tvdb_id_when_the_indexer_takes_one() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])), + ) + .mount(&prowlarr) + .await; + Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(200).set_body_string("")) + .mount(&prowlarr).await; + Mock::given(method("GET")) + .and(path("/9/api")) + .and(query_param("t", "tvsearch")) + .and(query_param("tvdbid", "361391")) + .and(query_param("season", "1")) + .and(query_param("ep", "2")) + .respond_with(ResponseTemplate::new(200).set_body_string(r"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) + .mount(&prowlarr).await; + + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await; + + let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}")) + .await + .expect("releases"); + assert_eq!(response.status(), 200); + let releases: Vec = response.json().await.expect("json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["guid"], "ep"); + } + + /// §6.1: an indexer whose caps do not advertise `tvdbid` still gets + /// searched, by text — a missing or unsupported id widens the query, + /// it never skips the indexer. + #[tokio::test] + async fn an_indexer_without_id_search_still_gets_a_text_query() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])), + ) + .mount(&prowlarr) + .await; + Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(200).set_body_string("")) + .mount(&prowlarr).await; + Mock::given(method("GET")) + .and(path("/9/api")) + .and(query_param("t", "search")) + .and(query_param("q", "Bluey S01E02")) + .respond_with(ResponseTemplate::new(200).set_body_string(r"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) + .mount(&prowlarr).await; + + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await; + + let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}")) + .await + .expect("releases"); + assert_eq!(response.status(), 200); + let releases: Vec = response.json().await.expect("json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2); + } + + async fn episode_with_tvdb_id(state: &crate::state::AppState, tvdb_id: Option) -> i64 { + use sqlx::Row as _; + let pool = state.database().expect("database").pool(); + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("TV root"); + let series_id: i64 = sqlx::query( + "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id) VALUES (82728, ?, 'Bluey', 2018, 'en', ?)", + ) + .bind(tvdb_id) + .bind(root_id) + .execute(pool) + .await + .expect("series") + .last_insert_rowid(); + let season_id: i64 = sqlx::query( + "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season") + .get(0); + sqlx::query_scalar( + "INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id", + ) + .bind(season_id) + .fetch_one(pool) + .await + .expect("episode") + } + #[tokio::test] async fn a_manual_search_names_exactly_one_title() { let tmdb = MockServer::start().await; diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 2cad2b5..8a264df 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -31,6 +31,7 @@ use utoipa::{IntoParams, ToSchema}; use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release}; use crate::owners::Owner; +use crate::search::tmdb_client; use crate::state::{AppState, EpisodeCommand}; /// A series with the status derived from its episodes (§4.2). @@ -38,6 +39,9 @@ use crate::state::{AppState, EpisodeCommand}; pub struct Series { pub id: i64, pub tmdb_id: i64, + /// The Torznab `tvdbid` (§6.1), when TMDB knows one. Null means indexer + /// searches fall back to the title text query. + pub tvdb_id: Option, pub title: String, pub year: Option, pub original_language: Option, @@ -171,6 +175,7 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { struct SeriesRow { id: i64, tmdb_id: i64, + tvdb_id: Option, title: String, year: Option, original_language: Option, @@ -273,6 +278,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) Series { id: row.id, tmdb_id: row.tmdb_id, + tvdb_id: row.tvdb_id, title: row.title.clone(), year: row.year, original_language: row.original_language.clone(), @@ -314,7 +320,7 @@ async fn episodes_by_series( } async fn load_series_row(state: &AppState, id: i64) -> Result { - sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id) + sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::SeriesNotFound) @@ -368,11 +374,11 @@ pub async fn list( Query(query): Query, ) -> Result>, ApiError> { let rows = if let Some(owner_id) = query.owner_id { - sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id) + sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id) .fetch_all(pool(&state)?) .await? } else { - sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#) + sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series ORDER BY title, year, id"#) .fetch_all(pool(&state)?) .await? }; @@ -409,9 +415,13 @@ pub async fn create( let overrides = serde_json::to_string(&input.overrides) .map_err(|error| ApiError::Invalid(error.to_string()))?; let title = input.title.trim(); + // §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not + // knowing one must not block adding the series — the search falls back + // to the title text query until a refresh fills it in (#121). + let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await; let result = sqlx::query!( - "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - input.tmdb_id, title, input.year, input.original_language, input.root_id, + "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, input.auto_track, input.upstream_ended, input.blocked, overrides ) .execute(pool(&state)?) @@ -422,6 +432,16 @@ pub async fn create( )) } +/// Best effort: `None` when TMDB has no id or cannot be reached. +async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option { + let client = tmdb_client(state).ok()?; + let ids = client + .series_external_ids(u32::try_from(tmdb_id).ok()?) + .await + .ok()?; + ids.tvdb_id.map(i64::from) +} + #[utoipa::path( get, path = "/api/series/{series_id}", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), diff --git a/crates/arr-compat/src/model.rs b/crates/arr-compat/src/model.rs index cf32d05..f9dc0df 100644 --- a/crates/arr-compat/src/model.rs +++ b/crates/arr-compat/src/model.rs @@ -409,7 +409,7 @@ impl SeriesResource { title: row.title.clone(), sort_title: row.title.to_lowercase(), year: row.year.unwrap_or_default(), - tvdb_id: 0, + tvdb_id: row.tvdb_id.unwrap_or(0), tmdb_id: row.tmdb_id, title_slug: title_slug(&row.title, row.tmdb_id), path: format!("{}/{folder}", row.root_path), diff --git a/crates/arr-compat/src/series.rs b/crates/arr-compat/src/series.rs index aca0087..64210bf 100644 --- a/crates/arr-compat/src/series.rs +++ b/crates/arr-compat/src/series.rs @@ -15,6 +15,7 @@ use crate::CompatState; pub(crate) struct SeriesRow { pub(crate) id: i64, pub(crate) tmdb_id: i64, + pub(crate) tvdb_id: Option, pub(crate) title: String, pub(crate) year: Option, pub(crate) root_id: i64, @@ -98,10 +99,11 @@ pub(crate) async fn add( let mut tx = state.pool().begin().await?; let series_id = sqlx::query( - "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \ - VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", + "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.tmdb_id) + .bind(metadata.tvdb_id.map(i64::from)) .bind(input.title.trim()) .bind(input.year.or_else(|| metadata.year().map(i64::from))) .bind(&metadata.original_language) @@ -227,7 +229,7 @@ async fn load_series( tmdb_id: Option, ) -> Result, CompatError> { let rows = sqlx::query!( - r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", + r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.root_id AS "root_id!: i64", r.path AS "root_path!: String", s.upstream_ended AS "upstream_ended!: bool", @@ -251,6 +253,7 @@ async fn load_series( .map(|row| SeriesRow { id: row.id, tmdb_id: row.tmdb_id, + tvdb_id: row.tvdb_id, title: row.title, year: row.year, root_id: row.root_id, diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 99dd9a1..b215153 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -370,9 +370,7 @@ impl TvGrabAction { blacklist: &Blacklist, ) -> Result, GrabError> { let target = TvTarget { - // The series table carries no TVDB id yet; `tv_request` falls - // back to a text search built from the title and the tag. - tvdb_id: None, + tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), title: season.series_title.clone(), selector, }; @@ -472,6 +470,8 @@ struct PendingSeason { season_id: i64, season_number: i64, series_title: String, + /// §6.1: `t=tvsearch` is addressed by this when present. + series_tvdb_id: Option, original_language: Option, } @@ -509,6 +509,7 @@ async fn pending_seasons(database: &Db) -> Result, GrabError> SELECT se.id AS "season_id!: i64", se.number AS "season_number!: i64", s.title AS "series_title!: String", + s.tvdb_id AS series_tvdb_id, s.original_language FROM seasons se JOIN series s ON s.id = se.series_id @@ -544,6 +545,7 @@ async fn pending_seasons(database: &Db) -> Result, GrabError> season_id: row.season_id, season_number: row.season_number, series_title: row.series_title, + series_tvdb_id: row.series_tvdb_id, original_language: row.original_language, }) .collect()) From 1c9caa7478bfd01499fcaa9e694daed96e7278da Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:32:28 +0100 Subject: [PATCH 08/71] chore(db): refresh offline query data --- ...61f5026f61256c901564548d56f816dd4c68d.json | 38 ---------------- ...4070b4b4ec2f18d97f7546f3b866004c404b.json} | 32 ++++++++------ ...263fa67c980e532fc037cc77b82c1109ecaa.json} | 32 ++++++++------ ...9382d09fc1eea9f1ddf3b613446a4c1bdb31.json} | 32 ++++++++------ ...cb5df34d72f53cd68578a47773eff0a10e054.json | 12 +++++ ...4f6b493df9a5f8e140138cce32395b9d937d.json} | 16 ++++--- ...35220cda3d8894d7cb5c2a411e816eb2ce0e.json} | 28 +++++++----- ...a85192473cbfc4e68656a11c58d66184fdcd0.json | 12 ----- ...274701e8b508de480e03bab593e449ef8d9d4.json | 44 +++++++++++++++++++ 9 files changed, 141 insertions(+), 105 deletions(-) delete mode 100644 .sqlx/query-0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d.json rename .sqlx/{query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json => query-45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b.json} (69%) rename .sqlx/{query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json => query-71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa.json} (74%) rename .sqlx/{query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json => query-7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31.json} (73%) create mode 100644 .sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json rename .sqlx/{query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json => query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json} (52%) rename .sqlx/{query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json => query-c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e.json} (63%) delete mode 100644 .sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json create mode 100644 .sqlx/query-e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4.json diff --git a/.sqlx/query-0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d.json b/.sqlx/query-0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d.json deleted file mode 100644 index 929e086..0000000 --- a/.sqlx/query-0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ", - "describe": { - "columns": [ - { - "name": "season_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "season_number!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "series_title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "original_language", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true - ] - }, - "hash": "0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d" -} diff --git a/.sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json b/.sqlx/query-45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b.json similarity index 69% rename from .sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json rename to .sqlx/query-45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b.json index 9c387f1..0ff3d5c 100644 --- a/.sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json +++ b/.sqlx/query-45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id", "describe": { "columns": [ { @@ -14,43 +14,48 @@ "type_info": "Integer" }, { - "name": "title!: String", + "name": "tvdb_id", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 3, "type_info": "Text" }, { "name": "year", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer" }, { "name": "original_language", - "ordinal": 4, + "ordinal": 5, "type_info": "Text" }, { "name": "root_id!: i64", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "auto_track!: bool", "ordinal": 6, "type_info": "Integer" }, { - "name": "overrides!: serde_json::Value", + "name": "auto_track!: bool", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 8, "type_info": "Text" }, { "name": "upstream_ended!: bool", - "ordinal": 8, + "ordinal": 9, "type_info": "Integer" }, { "name": "blocked!: bool", - "ordinal": 9, + "ordinal": 10, "type_info": "Integer" } ], @@ -60,6 +65,7 @@ "nullable": [ false, false, + true, false, true, true, @@ -70,5 +76,5 @@ false ] }, - "hash": "f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463" + "hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b" } diff --git a/.sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json b/.sqlx/query-71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa.json similarity index 74% rename from .sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json rename to .sqlx/query-71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa.json index 33dbc30..db5a0a9 100644 --- a/.sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json +++ b/.sqlx/query-71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?", "describe": { "columns": [ { @@ -14,43 +14,48 @@ "type_info": "Integer" }, { - "name": "title!: String", + "name": "tvdb_id", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 3, "type_info": "Text" }, { "name": "year", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer" }, { "name": "original_language", - "ordinal": 4, + "ordinal": 5, "type_info": "Text" }, { "name": "root_id!: i64", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "auto_track!: bool", "ordinal": 6, "type_info": "Integer" }, { - "name": "overrides!: serde_json::Value", + "name": "auto_track!: bool", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 8, "type_info": "Text" }, { "name": "upstream_ended!: bool", - "ordinal": 8, + "ordinal": 9, "type_info": "Integer" }, { "name": "blocked!: bool", - "ordinal": 9, + "ordinal": 10, "type_info": "Integer" } ], @@ -60,6 +65,7 @@ "nullable": [ false, false, + true, false, true, true, @@ -70,5 +76,5 @@ false ] }, - "hash": "1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2" + "hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa" } diff --git a/.sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json b/.sqlx/query-7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31.json similarity index 73% rename from .sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json rename to .sqlx/query-7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31.json index 72c9654..313730c 100644 --- a/.sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json +++ b/.sqlx/query-7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id", "describe": { "columns": [ { @@ -14,43 +14,48 @@ "type_info": "Integer" }, { - "name": "title!: String", + "name": "tvdb_id", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 3, "type_info": "Text" }, { "name": "year", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer" }, { "name": "original_language", - "ordinal": 4, + "ordinal": 5, "type_info": "Text" }, { "name": "root_id!: i64", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "auto_track!: bool", "ordinal": 6, "type_info": "Integer" }, { - "name": "overrides!: serde_json::Value", + "name": "auto_track!: bool", "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 8, "type_info": "Text" }, { "name": "upstream_ended!: bool", - "ordinal": 8, + "ordinal": 9, "type_info": "Integer" }, { "name": "blocked!: bool", - "ordinal": 9, + "ordinal": 10, "type_info": "Integer" } ], @@ -60,6 +65,7 @@ "nullable": [ false, false, + true, false, true, true, @@ -70,5 +76,5 @@ false ] }, - "hash": "950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26" + "hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31" } diff --git a/.sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json b/.sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json new file mode 100644 index 0000000..25dd49f --- /dev/null +++ b/.sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054" +} diff --git a/.sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json b/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json similarity index 52% rename from .sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json rename to .sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json index 9bf668d..84096da 100644 --- a/.sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json +++ b/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT s.title AS \"series_title!: String\", s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", + "query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", "describe": { "columns": [ { @@ -9,18 +9,23 @@ "type_info": "Text" }, { - "name": "original_language", + "name": "series_tvdb_id", "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 2, "type_info": "Text" }, { "name": "season_number!: i64", - "ordinal": 2, + "ordinal": 3, "type_info": "Integer" }, { "name": "episode_number!: i64", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer" } ], @@ -30,9 +35,10 @@ "nullable": [ false, true, + true, false, false ] }, - "hash": "6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd" + "hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d" } diff --git a/.sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json b/.sqlx/query-c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e.json similarity index 63% rename from .sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json rename to .sqlx/query-c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e.json index ca463dc..c311402 100644 --- a/.sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json +++ b/.sqlx/query-c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id,\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id", "describe": { "columns": [ { @@ -14,33 +14,38 @@ "type_info": "Integer" }, { - "name": "title!: String", + "name": "tvdb_id", "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 3, "type_info": "Text" }, { "name": "year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "root_id!: i64", "ordinal": 4, "type_info": "Integer" }, { - "name": "root_path!: String", + "name": "root_id!: i64", "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "root_path!: String", + "ordinal": 6, "type_info": "Text" }, { "name": "upstream_ended!: bool", - "ordinal": 6, + "ordinal": 7, "type_info": "Integer" }, { "name": "created_at!: String", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -50,6 +55,7 @@ "nullable": [ false, false, + true, false, true, false, @@ -58,5 +64,5 @@ false ] }, - "hash": "2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37" + "hash": "c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e" } diff --git a/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json b/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json deleted file mode 100644 index ad53459..0000000 --- a/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 9 - }, - "nullable": [] - }, - "hash": "e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0" -} diff --git a/.sqlx/query-e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4.json b/.sqlx/query-e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4.json new file mode 100644 index 0000000..12b415f --- /dev/null +++ b/.sqlx/query-e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ", + "describe": { + "columns": [ + { + "name": "season_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "series_title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "series_tvdb_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4" +} From 9f4943a01bb3651d9d29fb45f59604b37f7e7362 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:34:43 +0100 Subject: [PATCH 09/71] style: cargo fmt --- crates/arr-api/src/search.rs | 15 +++++++-------- crates/arr-meta/tests/tmdb.rs | 4 +--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 52af066..08cf674 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -911,14 +911,13 @@ mod tests { .await .expect("series") .last_insert_rowid(); - let season_id: i64 = sqlx::query( - "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", - ) - .bind(series_id) - .fetch_one(pool) - .await - .expect("season") - .get(0); + let season_id: i64 = + sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") + .bind(series_id) + .fetch_one(pool) + .await + .expect("season") + .get(0); sqlx::query_scalar( "INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id", ) diff --git a/crates/arr-meta/tests/tmdb.rs b/crates/arr-meta/tests/tmdb.rs index 8a239fa..911f172 100644 --- a/crates/arr-meta/tests/tmdb.rs +++ b/crates/arr-meta/tests/tmdb.rs @@ -490,9 +490,7 @@ async fn series_without_a_tvdb_id_maps_to_none() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/1/external_ids")) - .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#), - ) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#)) .mount(&server) .await; From a1971ed82c2b4f0aba64e6f2915dbdce9269d0ba Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:39:06 +0100 Subject: [PATCH 10/71] feat(daemon): tv series in the needs-a-decision queue --- ...a341d929fb531ff3e3b79c6fa8630a8689d28.json | 38 ++ ...b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json | 38 ++ ...7faeafa2078e5c139c8e3166ad63c0d70f151.json | 38 ++ crates/arr-daemon/src/attention.rs | 365 +++++++++++++++++- 4 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 .sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json create mode 100644 .sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json create mode 100644 .sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json diff --git a/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json b/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json new file mode 100644 index 0000000..6134fe1 --- /dev/null +++ b/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28" +} diff --git a/.sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json b/.sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json new file mode 100644 index 0000000..df7ee19 --- /dev/null +++ b/.sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "season_id!: i64", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705" +} diff --git a/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json b/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json new file mode 100644 index 0000000..21605e4 --- /dev/null +++ b/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151" +} diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index a417fbc..ec9aa99 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -1,11 +1,14 @@ -//! §9.5 *needs a decision* → the operator alone: a movie entered the -//! no-PT-source queue, or hard-failed twice on different releases (the same -//! two queues `GET /api/queues/attention` reports, §9.3). +//! §9.5 *needs a decision* → the operator alone: a movie or series entered +//! the no-PT-source queue, or hard-failed twice on different releases (the +//! same queues `GET /api/queues/attention` reports, §9.3). //! -//! Edge-triggered: a movie notifies once when it enters either queue, and is -//! forgotten once it leaves both, so a future re-entry notifies again. The -//! notified set is in-memory and transient — a restart re-notifies whatever -//! is currently queued, same tradeoff `ImportAction`'s probe cache makes. +//! Edge-triggered per title: it notifies once when the title enters either +//! queue, and is forgotten once it leaves both, so a future re-entry notifies +//! again. A series notifies as its series, never per episode — a broken +//! twenty-episode season is one message, which is the restraint §9.5 exists +//! for. The notified set is in-memory and transient — a restart re-notifies +//! whatever is currently queued, same tradeoff `ImportAction`'s probe cache +//! makes. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -16,11 +19,67 @@ use tokio::sync::Mutex; use crate::notify::Notifier; use crate::reconcile::{Action, ActionFuture, Outcome}; +/// What is queued. Movies and series have independent id sequences, so the +/// kind travels with the id. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum Subject { + Movie(i64), + Series(i64), +} + +/// One series' standing in the TV queues, rolled up from its episodes. +#[derive(Debug, Default)] +struct TvEntry { + /// Wanted episodes whose every candidate was rejected for language. + no_pt_source: Vec, + /// Episodes two different releases hard-failed post-probe (§5.7). + hard_failed_episodes: Vec, + /// Seasons whose pack grab hard-failed, sending the season back to + /// per-episode grabbing. + failed_season_packs: Vec, +} + +impl TvEntry { + fn reason(&self) -> String { + let mut parts = Vec::new(); + if !self.no_pt_source.is_empty() { + parts.push(plural( + self.no_pt_source.len(), + "episode found no Portuguese-audio source", + "episodes found no Portuguese-audio source", + )); + } + if !self.hard_failed_episodes.is_empty() { + parts.push(plural( + self.hard_failed_episodes.len(), + "episode hard-failed twice on different releases", + "episodes hard-failed twice on different releases", + )); + } + if !self.failed_season_packs.is_empty() { + parts.push(plural( + self.failed_season_packs.len(), + "season pack hard-failed", + "season packs hard-failed", + )); + } + parts.join("; ") + } +} + +fn plural(count: usize, one: &str, many: &str) -> String { + if count == 1 { + format!("1 {one}") + } else { + format!("{count} {many}") + } +} + #[derive(Debug)] pub struct AttentionAction { notifier: Notifier, operator_topic: String, - notified: Arc>>, + notified: Arc>>, } impl AttentionAction { @@ -34,7 +93,7 @@ impl AttentionAction { } async fn tick(&self, database: &Db) -> Result, sqlx::Error> { - let mut queued: HashMap = HashMap::new(); + let mut queued: HashMap = HashMap::new(); let no_pt_source = sqlx::query!( r#" @@ -59,10 +118,10 @@ impl AttentionAction { .await?; for row in no_pt_source { queued.insert( - row.id, + Subject::Movie(row.id), ( title_with_year(&row.title, row.year), - "no Portuguese-audio source found", + "no Portuguese-audio source found".to_string(), ), ); } @@ -80,18 +139,99 @@ impl AttentionAction { .fetch_all(database.pool()) .await?; for row in needs_decision { - queued.entry(row.id).or_insert_with(|| { + queued.entry(Subject::Movie(row.id)).or_insert_with(|| { ( title_with_year(&row.title, row.year), - "hard-failed twice on different releases", + "hard-failed twice on different releases".to_string(), ) }); } + let mut tv: HashMap, TvEntry)> = HashMap::new(); + + let tv_no_pt_source = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + JOIN roots root ON root.id = s.root_id + WHERE root.audience = 'kids' + AND s.blocked = 0 + AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 + AND NOT EXISTS ( + SELECT 1 FROM episode_releases er + JOIN releases r ON r.id = er.release_id + WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') + ) + "# + ) + .fetch_all(database.pool()) + .await?; + for row in tv_no_pt_source { + tv.entry(row.series_id) + .or_insert_with(|| (row.title, row.year, TvEntry::default())) + .2 + .no_pt_source + .push(row.episode_id); + } + + let episode_hard_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + g.target_id AS "episode_id!: i64" + FROM grabs g + JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.title, s.year, e.id + HAVING count(DISTINCT g.release_id) >= 2 + "# + ) + .fetch_all(database.pool()) + .await?; + for row in episode_hard_fails { + tv.entry(row.series_id) + .or_insert_with(|| (row.title, row.year, TvEntry::default())) + .2 + .hard_failed_episodes + .push(row.episode_id); + } + + let season_pack_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + g.target_id AS "season_id!: i64" + FROM grabs g + JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.title, s.year, se.id + "# + ) + .fetch_all(database.pool()) + .await?; + for row in season_pack_fails { + tv.entry(row.series_id) + .or_insert_with(|| (row.title, row.year, TvEntry::default())) + .2 + .failed_season_packs + .push(row.season_id); + } + + for (&series_id, (title, year, entry)) in &tv { + queued.insert( + Subject::Series(series_id), + (title_with_year(title, *year), entry.reason()), + ); + } + let mut notified = self.notified.lock().await; let mut outcomes = Vec::new(); - for (&movie_id, (title, reason)) in &queued { - if !notified.insert(movie_id) { + for (subject, (title, reason)) in &queued { + if !notified.insert(subject.clone()) { continue; } match self @@ -99,16 +239,22 @@ impl AttentionAction { .send(&self.operator_topic, title, reason) .await { - Ok(()) => outcomes.push(Outcome::new( - format!("movie {movie_id} needs a decision"), - format!("notified operator: {reason}"), - )), + Ok(()) => { + let (kind, id) = match subject { + Subject::Movie(id) => ("movie", *id), + Subject::Series(id) => ("series", *id), + }; + outcomes.push(Outcome::new( + format!("{kind} {id} needs a decision"), + format!("notified operator: {reason}"), + )); + } Err(error) => { - tracing::warn!(%error, movie_id, "needs-decision notification failed"); + tracing::warn!(%error, ?subject, "needs-decision notification failed"); } } } - notified.retain(|movie_id| queued.contains_key(movie_id)); + notified.retain(|subject| queued.contains_key(subject)); Ok(outcomes) } @@ -168,6 +314,97 @@ mod tests { .unwrap() } + /// A series on the kids TV root with one season, and `count` wanted, + /// missing episodes that have been searched. No stored releases: every + /// candidate was rejected for language (§5.2's no-PT-source case). + async fn insert_no_pt_source_series( + database: &Db, + tmdb_id: i64, + episode_count: usize, + ) -> i64 { + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)") + .bind(tmdb_id) + .bind(root_id) + .execute(database.pool()) + .await + .unwrap(); + let series_id: i64 = + sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?") + .bind(tmdb_id) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)") + .bind(series_id) + .execute(database.pool()) + .await + .unwrap(); + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE series_id = ?") + .bind(series_id) + .fetch_one(database.pool()) + .await + .unwrap(); + for number in 0..episode_count { + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts) + VALUES (?, ?, ?, 1, 'missing', 1)", + ) + .bind(season_id) + .bind(number as i64 + 1) + .bind(format!("Episode {number}")) + .execute(database.pool()) + .await + .unwrap(); + } + series_id + } + + /// A failed grab by `release_guid` against `target_kind`/`target_id`, + /// standing in for what the import tick leaves behind post-probe. + async fn insert_failed_grab( + database: &Db, + target_kind: &str, + target_id: i64, + release_guid: &str, + ) { + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible') + RETURNING id", + ) + .bind(release_guid) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) + VALUES (?, ?, ?, ?, 'failed')", + ) + .bind(release_id) + .bind(target_kind) + .bind(target_id) + .bind(format!("hash-{release_guid}")) + .execute(database.pool()) + .await + .unwrap(); + } + + async fn action(server: &MockServer) -> AttentionAction { + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(server) + .await; + AttentionAction::new( + Notifier::new(server.uri()).unwrap(), + "operator-topic".to_string(), + ) + } + #[tokio::test] async fn a_movie_entering_the_queue_notifies_the_operator_once() { let (_dir, database) = seeded_database().await; @@ -224,4 +461,90 @@ mod tests { assert_eq!(third.len(), 1, "re-enters and notifies again"); assert_eq!(server.received_requests().await.unwrap().len(), 2); } + + #[tokio::test] + async fn a_series_entering_the_queue_notifies_once_per_series() { + let (_dir, database) = seeded_database().await; + // Twenty broken episodes are one notification, not twenty (§9.5). + insert_no_pt_source_series(&database, 1, 20).await; + + let server = MockServer::start().await; + let action = action(&server).await; + + let first = action.tick(&database).await.unwrap(); + let second = action.tick(&database).await.unwrap(); + + assert_eq!(first.len(), 1, "one notification for the whole series"); + assert_eq!(second.len(), 0, "does not repeat while still queued"); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn a_series_leaving_and_re_entering_the_queue_notifies_again() { + let (_dir, database) = seeded_database().await; + let series_id = insert_no_pt_source_series(&database, 1, 1).await; + + let server = MockServer::start().await; + let action = action(&server).await; + action.tick(&database).await.unwrap(); + + sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?") + .bind(series_id) + .execute(database.pool()) + .await + .unwrap(); + action.tick(&database).await.unwrap(); + + sqlx::query("UPDATE series SET blocked = 0 WHERE id = ?") + .bind(series_id) + .execute(database.pool()) + .await + .unwrap(); + let third = action.tick(&database).await.unwrap(); + + assert_eq!(third.len(), 1, "re-enters and notifies again"); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + + #[tokio::test] + async fn an_episode_hard_failed_twice_notifies_its_series() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = + sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 1, 'Episode 0', 1, 'missing')", + ) + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + let episode_id: i64 = sqlx::query_scalar( + "SELECT id FROM episodes WHERE season_id = ? AND number = 1", + ) + .bind(season_id) + .fetch_one(database.pool()) + .await + .unwrap(); + insert_failed_grab(&database, "episode", episode_id, "first").await; + insert_failed_grab(&database, "episode", episode_id, "second").await; + // The pack's failure sent this season back to per-episode grabbing; + // it queues the same series, so it must not double the message. + insert_failed_grab(&database, "season", season_id, "pack").await; + + let server = MockServer::start().await; + let action = action(&server).await; + + let outcomes = action.tick(&database).await.unwrap(); + + assert_eq!( + outcomes.len(), + 1, + "both hard-fail conditions roll up to one series notification" + ); + } } From 93ccea2de0c0ba2621278983c2390c0a82c9d901 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:40:38 +0100 Subject: [PATCH 11/71] fix(api): specials stay out of episode counters --- crates/arr-api/src/series.rs | 11 ++++++++++- crates/arr-core/src/status.rs | 14 +++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f261926..031d2fd 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -285,7 +285,16 @@ fn with_status( episodes: &[arr_core::Episode], now: SystemTime, ) -> Series { - let wanted = episodes.iter().filter(|episode| episode.wanted); + // §4.2: season 0 is invisible to status, so it stays out of the counters + // too. A series reading `complete` next to `42/52 eps` is the confusion + // this avoids. `UNIQUE (series_id, number)` means there is at most one. + let specials = seasons + .iter() + .find(|season| season.number == 0) + .map(|season| season.id); + let wanted = episodes + .iter() + .filter(|episode| episode.wanted && specials != Some(episode.season_id)); let available = wanted .clone() .filter(|episode| episode.state == MediaState::Available); diff --git a/crates/arr-core/src/status.rs b/crates/arr-core/src/status.rs index eb0b87a..75abfb8 100644 --- a/crates/arr-core/src/status.rs +++ b/crates/arr-core/src/status.rs @@ -1,7 +1,6 @@ -use std::collections::HashSet; use std::time::{Duration, SystemTime}; -use crate::{Episode, MediaState, Season, SeasonId, Series}; +use crate::{Episode, MediaState, Season, Series}; const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24); @@ -28,14 +27,15 @@ pub fn derive_series_status( episodes: &[Episode], now: SystemTime, ) -> SeriesStatus { - let specials: HashSet = seasons + // A series has at most one season 0, so this is a comparison rather than + // a set: no allocation on a function the list view calls per series. + let specials = seasons .iter() - .filter(|season| season.number == 0) - .map(|season| season.id) - .collect(); + .find(|season| season.number == 0) + .map(|season| season.id); let wanted = episodes .iter() - .filter(|episode| episode.wanted && !specials.contains(&episode.season_id)); + .filter(|episode| episode.wanted && specials != Some(episode.season_id)); let complete = wanted .clone() .all(|episode| episode.state == MediaState::Available); From 24f6dab109a5a1adeef8eb5610c8b4a8f28b85da Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:44:16 +0100 Subject: [PATCH 12/71] feat(api): tv series in the attention queues endpoint --- ...861867891d55dc8bb7fe79b1dc42e36054379.json | 44 ++++ ...b310c6b90c3feb283885b3573ee7666b1725b.json | 44 ++++ ...a9009fa24e453f669bcc2357fdcd999b06193.json | 44 ++++ crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/movies.rs | 245 ++++++++++++++++++ web/src/queues.ts | 18 +- 6 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json create mode 100644 .sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json create mode 100644 .sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json diff --git a/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json b/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json new file mode 100644 index 0000000..37c4946 --- /dev/null +++ b/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379" +} diff --git a/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json b/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json new file mode 100644 index 0000000..654e1a8 --- /dev/null +++ b/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b" +} diff --git a/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json b/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json new file mode 100644 index 0000000..2dba66a --- /dev/null +++ b/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "season_id!: i64", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 8262e69..b1b09fb 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -326,6 +326,7 @@ mod tests { "UpdateMovie", "Release", "AttentionQueues", + "SeriesAttention", "Series", ] { assert!( diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 3770ac0..d8137c5 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -93,6 +93,25 @@ pub struct MovieFile { pub struct AttentionQueues { pub no_pt_source: Vec, pub needs_decision: Vec, + /// TV side (§9.5): series whose wanted episodes found no Portuguese-audio + /// source. + pub tv_no_pt_source: Vec, + /// TV side: series put here by hard failures on episodes or season packs. + pub tv_needs_decision: Vec, +} + +/// A series in a TV attention queue, with what put it there. One row per +/// series — the notification rolls up the same way (§9.5). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SeriesAttention { + pub series_id: i64, + pub tmdb_id: i64, + pub title: String, + pub year: Option, + /// Episodes that put the series in this queue. + pub episodes: Vec, + /// Seasons whose pack grab failed and fell back to per-episode grabbing. + pub seasons: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -627,9 +646,107 @@ pub async fn attention(State(state): State) -> Result= 2 ORDER BY title"#) .fetch_all(pool(&state)?) .await?; + let tv_no_pt_source = + sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", + s.title AS "title!: String", s.year, e.id AS "episode_id!: i64" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + JOIN roots root ON root.id = s.root_id + WHERE root.audience = 'kids' + AND s.blocked = 0 + AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 + AND NOT EXISTS ( + SELECT 1 FROM episode_releases er + JOIN releases r ON r.id = er.release_id + WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') + ) + ORDER BY se.number, e.number + "# + ) + .fetch_all(pool(&state)?) + .await?; + let episode_hard_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", + s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64" + FROM grabs g + JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id + HAVING count(DISTINCT g.release_id) >= 2 + "# + ) + .fetch_all(pool(&state)?) + .await?; + let season_pack_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", + s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64" + FROM grabs g + JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id + "# + ) + .fetch_all(pool(&state)?) + .await?; + + let mut tv_no_pt: Vec = Vec::new(); + for row in tv_no_pt_source { + match tv_no_pt.iter_mut().find(|entry| entry.series_id == row.series_id) { + Some(entry) => entry.episodes.push(row.episode_id), + None => tv_no_pt.push(SeriesAttention { + series_id: row.series_id, + tmdb_id: row.tmdb_id, + title: row.title, + year: row.year, + episodes: vec![row.episode_id], + seasons: Vec::new(), + }), + } + } + + // The two hard-fail lanes share one queue; a series can arrive through + // both and is merged into one entry. + let mut tv_needs: Vec = Vec::new(); + for row in episode_hard_fails { + match tv_needs.iter_mut().find(|entry| entry.series_id == row.series_id) { + Some(entry) => entry.episodes.push(row.episode_id), + None => tv_needs.push(SeriesAttention { + series_id: row.series_id, + tmdb_id: row.tmdb_id, + title: row.title, + year: row.year, + episodes: vec![row.episode_id], + seasons: Vec::new(), + }), + } + } + for row in season_pack_fails { + match tv_needs.iter_mut().find(|entry| entry.series_id == row.series_id) { + Some(entry) => entry.seasons.push(row.season_id), + None => tv_needs.push(SeriesAttention { + series_id: row.series_id, + tmdb_id: row.tmdb_id, + title: row.title, + year: row.year, + episodes: Vec::new(), + seasons: vec![row.season_id], + }), + } + } + Ok(Json(AttentionQueues { no_pt_source, needs_decision, + tv_no_pt_source: tv_no_pt, + tv_needs_decision: tv_needs, })) } @@ -723,6 +840,7 @@ pub async fn untag_owner( #[cfg(test)] mod tests { use super::*; + use sqlx::Row; use crate::{router, Upstreams}; async fn application() -> (tempfile::TempDir, AppState, String) { @@ -1183,4 +1301,131 @@ mod tests { assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0)); assert_eq!(queues["needs_decision"][0]["id"], movie_id); } + + /// §9.5 TV: the attention queues report series, with the episodes and + /// seasons that put each one there. + #[tokio::test] + async fn attention_queues_report_tv_series() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") + .fetch_one(pool) + .await + .expect("kids tv root"); + let series_id: i64 = sqlx::query( + "INSERT INTO series (tmdb_id, title, year, root_id) VALUES (1, 'Bluey', 2018, ?) + RETURNING id", + ) + .bind(root_id) + .fetch_one(pool) + .await + .expect("series") + .get(0); + let season_id: i64 = + sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); + + // Two wanted episodes whose every candidate was rejected for + // language — the §5.2 no-PT-source queue, TV side. + for number in [1, 2] { + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts) + VALUES (?, ?, ?, 1, 'missing', 1)", + ) + .bind(season_id) + .bind(number) + .bind(format!("Episode {number}")) + .execute(pool) + .await + .expect("episode"); + } + // A season pack that hard-failed, plus an episode two different + // releases hard-failed on — both land in needs_decision. + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 3, 'Episode 3', 1, 'missing')", + ) + .bind(season_id) + .execute(pool) + .await + .expect("episode"); + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 3") + .bind(season_id) + .fetch_one(pool) + .await + .expect("episode id"); + for (kind, guid, suffix) in [ + ("season", "pack", "pack"), + ("episode", "first", "a"), + ("episode", "second", "b"), + ] { + let release_id: i64 = sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id", + ) + .bind(guid) + .fetch_one(pool) + .await + .expect("release") + .get(0); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) + VALUES (?, ?, ?, ?, 'failed')", + ) + .bind(release_id) + .bind(kind) + .bind(if kind == "season" { season_id } else { episode_id }) + .bind(format!("hash-{suffix}")) + .execute(pool) + .await + .expect("failed grab"); + } + + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json"); + assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); + assert_eq!( + queues["tv_no_pt_source"][0]["episodes"] + .as_array() + .map(Vec::len), + Some(2) + ); + assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id); + assert_eq!( + queues["tv_needs_decision"][0]["episodes"], + serde_json::json!([episode_id]) + ); + assert_eq!(queues["tv_needs_decision"][0]["seasons"], serde_json::json!([season_id])); + + // Blocking the series empties its no-PT-source lane. + sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?") + .bind(series_id) + .execute(pool) + .await + .expect("block series"); + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("blocked queues") + .json() + .await + .expect("blocked queues json"); + assert_eq!( + queues["tv_no_pt_source"].as_array().map(Vec::len), + Some(0) + ); + assert_eq!( + queues["tv_needs_decision"].as_array().map(Vec::len), + Some(1), + "hard failures queue regardless of blocking" + ); + } } diff --git a/web/src/queues.ts b/web/src/queues.ts index c69a439..a91b8f5 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -11,9 +11,21 @@ export interface AttentionMovie extends LibraryMovie { last_searched_at: string | null; } +/** A series in a TV attention queue, with what put it there (§9.5). */ +export interface SeriesAttention { + series_id: number; + tmdb_id: number; + title: string; + year: number | null; + episodes: number[]; + seasons: number[]; +} + export interface AttentionQueues { no_pt_source: AttentionMovie[]; needs_decision: AttentionMovie[]; + tv_no_pt_source: SeriesAttention[]; + tv_needs_decision: SeriesAttention[]; } export type AttentionOutcome = @@ -66,7 +78,11 @@ export async function allowEnglishAudio(movieId: number): Promise Date: Sun, 23 Aug 2026 16:44:32 +0100 Subject: [PATCH 13/71] style: cargo fmt --- crates/arr-api/src/movies.rs | 57 ++++++++++++++++++------------ crates/arr-daemon/src/attention.rs | 47 +++++++++++------------- 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index d8137c5..ddd068c 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -646,9 +646,8 @@ pub async fn attention(State(state): State) -> Result= 2 ORDER BY title"#) .fetch_all(pool(&state)?) .await?; - let tv_no_pt_source = - sqlx::query!( - r#" + let tv_no_pt_source = sqlx::query!( + r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, e.id AS "episode_id!: i64" FROM episodes e @@ -665,9 +664,9 @@ pub async fn attention(State(state): State) -> Result) -> Result = Vec::new(); for row in tv_no_pt_source { - match tv_no_pt.iter_mut().find(|entry| entry.series_id == row.series_id) { + match tv_no_pt + .iter_mut() + .find(|entry| entry.series_id == row.series_id) + { Some(entry) => entry.episodes.push(row.episode_id), None => tv_no_pt.push(SeriesAttention { series_id: row.series_id, @@ -716,7 +718,10 @@ pub async fn attention(State(state): State) -> Result = Vec::new(); for row in episode_hard_fails { - match tv_needs.iter_mut().find(|entry| entry.series_id == row.series_id) { + match tv_needs + .iter_mut() + .find(|entry| entry.series_id == row.series_id) + { Some(entry) => entry.episodes.push(row.episode_id), None => tv_needs.push(SeriesAttention { series_id: row.series_id, @@ -729,7 +734,10 @@ pub async fn attention(State(state): State) -> Result entry.seasons.push(row.season_id), None => tv_needs.push(SeriesAttention { series_id: row.series_id, @@ -840,8 +848,8 @@ pub async fn untag_owner( #[cfg(test)] mod tests { use super::*; - use sqlx::Row; use crate::{router, Upstreams}; + use sqlx::Row; async fn application() -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); @@ -1322,12 +1330,13 @@ mod tests { .await .expect("series") .get(0); - let season_id: i64 = - sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") - .bind(series_id) - .fetch_one(pool) - .await - .expect("season"); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); // Two wanted episodes whose every candidate was rejected for // language — the §5.2 no-PT-source queue, TV side. @@ -1379,7 +1388,11 @@ mod tests { ) .bind(release_id) .bind(kind) - .bind(if kind == "season" { season_id } else { episode_id }) + .bind(if kind == "season" { + season_id + } else { + episode_id + }) .bind(format!("hash-{suffix}")) .execute(pool) .await @@ -1404,7 +1417,10 @@ mod tests { queues["tv_needs_decision"][0]["episodes"], serde_json::json!([episode_id]) ); - assert_eq!(queues["tv_needs_decision"][0]["seasons"], serde_json::json!([season_id])); + assert_eq!( + queues["tv_needs_decision"][0]["seasons"], + serde_json::json!([season_id]) + ); // Blocking the series empties its no-PT-source lane. sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?") @@ -1418,10 +1434,7 @@ mod tests { .json() .await .expect("blocked queues json"); - assert_eq!( - queues["tv_no_pt_source"].as_array().map(Vec::len), - Some(0) - ); + assert_eq!(queues["tv_no_pt_source"].as_array().map(Vec::len), Some(0)); assert_eq!( queues["tv_needs_decision"].as_array().map(Vec::len), Some(1), diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index ec9aa99..e38c0ac 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -317,28 +317,25 @@ mod tests { /// A series on the kids TV root with one season, and `count` wanted, /// missing episodes that have been searched. No stored releases: every /// candidate was rejected for language (§5.2's no-PT-source case). - async fn insert_no_pt_source_series( - database: &Db, - tmdb_id: i64, - episode_count: usize, - ) -> i64 { + async fn insert_no_pt_source_series(database: &Db, tmdb_id: i64, episode_count: usize) -> i64 { let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") .fetch_one(database.pool()) .await .unwrap(); - sqlx::query("INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)") + sqlx::query( + "INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)", + ) + .bind(tmdb_id) + .bind(root_id) + .execute(database.pool()) + .await + .unwrap(); + let series_id: i64 = sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?") .bind(tmdb_id) - .bind(root_id) - .execute(database.pool()) + .fetch_one(database.pool()) .await .unwrap(); - let series_id: i64 = - sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?") - .bind(tmdb_id) - .fetch_one(database.pool()) - .await - .unwrap(); sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)") .bind(series_id) .execute(database.pool()) @@ -510,11 +507,10 @@ mod tests { async fn an_episode_hard_failed_twice_notifies_its_series() { let (_dir, database) = seeded_database().await; insert_no_pt_source_series(&database, 1, 0).await; - let season_id: i64 = - sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") - .fetch_one(database.pool()) - .await - .unwrap(); + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); sqlx::query( "INSERT INTO episodes (season_id, number, title, wanted, state) VALUES (?, 1, 'Episode 0', 1, 'missing')", @@ -523,13 +519,12 @@ mod tests { .execute(database.pool()) .await .unwrap(); - let episode_id: i64 = sqlx::query_scalar( - "SELECT id FROM episodes WHERE season_id = ? AND number = 1", - ) - .bind(season_id) - .fetch_one(database.pool()) - .await - .unwrap(); + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1") + .bind(season_id) + .fetch_one(database.pool()) + .await + .unwrap(); insert_failed_grab(&database, "episode", episode_id, "first").await; insert_failed_grab(&database, "episode", episode_id, "second").await; // The pack's failure sent this season back to per-episode grabbing; From 135d4b8247261e749afe02f0a0e37ab71f48facf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:46:26 +0100 Subject: [PATCH 14/71] feat(core): season tracked rule over revealed episodes --- crates/arr-core/src/tracking.rs | 67 ++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index b3e356c..2f63dac 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -29,11 +29,25 @@ pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) { } } +/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its +/// already-revealed episodes. +/// +/// Turning tracking on marks every revealed episode wanted; future reveals +/// follow while the season stays tracked. Turning it off withdraws nothing: +/// leaf intent is never removed implicitly. +pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) { + if tracked { + for episode in episodes { + episode.wanted = true; + } + } +} + #[cfg(test)] mod tests { use std::time::SystemTime; - use super::{apply_auto_track, RefreshedSeason}; + use super::{apply_auto_track, apply_tracked, RefreshedSeason}; use crate::{ Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId, TitleOverrides, @@ -131,4 +145,55 @@ mod tests { assert!(!refresh[0].season.tracked); assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted)); } + + #[test] + fn tracked_on_marks_revealed_episodes_wanted() { + let cases: [&[bool]; 4] = [ + &[], + &[false], + &[true, false, false], + &[false, true, false, true], + ]; + for wanted in cases { + let mut episodes = wanted + .iter() + .enumerate() + .map(|(index, wanted)| { + episode(1, u16::try_from(index + 1).expect("episode number fits"), *wanted) + }) + .collect::>(); + + apply_tracked(true, &mut episodes); + + assert!( + episodes.iter().all(|episode| episode.wanted), + "tracked on must mark every revealed episode wanted, input {wanted:?}" + ); + } + } + + #[test] + fn tracked_off_withdraws_nothing() { + let cases: [&[bool]; 4] = [ + &[], + &[false], + &[true, false, false], + &[false, true, false, true], + ]; + for wanted in cases { + let mut episodes = wanted + .iter() + .enumerate() + .map(|(index, wanted)| { + episode(1, u16::try_from(index + 1).expect("episode number fits"), *wanted) + }) + .collect::>(); + let before = episodes.iter().map(|episode| episode.wanted).collect::>(); + + apply_tracked(false, &mut episodes); + + let after = episodes.iter().map(|episode| episode.wanted).collect::>(); + assert_eq!(before, after, "tracked off must not touch intent"); + } + } } From e8cea4aec56783afb7d765b9939d21b70ceef98c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:47:38 +0100 Subject: [PATCH 15/71] refactor(api): extract tv attention lanes --- crates/arr-api/src/movies.rs | 116 +++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index ddd068c..366f8da 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -646,7 +646,25 @@ pub async fn attention(State(state): State) -> Result= 2 ORDER BY title"#) .fetch_all(pool(&state)?) .await?; - let tv_no_pt_source = sqlx::query!( + let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?; + + Ok(Json(AttentionQueues { + no_pt_source, + needs_decision, + tv_no_pt_source, + tv_needs_decision, + })) +} + +/// The TV lanes of the attention queues (§9.5): one entry per series with +/// the episodes and seasons that put it there. The two hard-fail conditions +/// share a lane; a series arriving through both is merged into one entry. +async fn tv_attention( + state: &AppState, +) -> Result<(Vec, Vec), ApiError> { + let database = pool(state)?; + + let no_pt_rows = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, e.id AS "episode_id!: i64" @@ -665,7 +683,7 @@ pub async fn attention(State(state): State) -> Result) -> Result= 2 "# ) - .fetch_all(pool(&state)?) + .fetch_all(database) .await?; let season_pack_fails = sqlx::query!( r#" @@ -693,53 +711,39 @@ pub async fn attention(State(state): State) -> Result = Vec::new(); - for row in tv_no_pt_source { - match tv_no_pt - .iter_mut() - .find(|entry| entry.series_id == row.series_id) - { - Some(entry) => entry.episodes.push(row.episode_id), - None => tv_no_pt.push(SeriesAttention { - series_id: row.series_id, - tmdb_id: row.tmdb_id, - title: row.title, - year: row.year, - episodes: vec![row.episode_id], - seasons: Vec::new(), - }), - } + let mut tv_no_pt_source: Vec = Vec::new(); + for row in no_pt_rows { + merge_episode( + &mut tv_no_pt_source, + row.series_id, + row.tmdb_id, + row.title, + row.year, + row.episode_id, + ); } - // The two hard-fail lanes share one queue; a series can arrive through - // both and is merged into one entry. - let mut tv_needs: Vec = Vec::new(); + let mut tv_needs_decision: Vec = Vec::new(); for row in episode_hard_fails { - match tv_needs - .iter_mut() - .find(|entry| entry.series_id == row.series_id) - { - Some(entry) => entry.episodes.push(row.episode_id), - None => tv_needs.push(SeriesAttention { - series_id: row.series_id, - tmdb_id: row.tmdb_id, - title: row.title, - year: row.year, - episodes: vec![row.episode_id], - seasons: Vec::new(), - }), - } + merge_episode( + &mut tv_needs_decision, + row.series_id, + row.tmdb_id, + row.title, + row.year, + row.episode_id, + ); } for row in season_pack_fails { - match tv_needs + match tv_needs_decision .iter_mut() .find(|entry| entry.series_id == row.series_id) { Some(entry) => entry.seasons.push(row.season_id), - None => tv_needs.push(SeriesAttention { + None => tv_needs_decision.push(SeriesAttention { series_id: row.series_id, tmdb_id: row.tmdb_id, title: row.title, @@ -750,12 +754,34 @@ pub async fn attention(State(state): State) -> Result, + series_id: i64, + tmdb_id: i64, + title: String, + year: Option, + episode_id: i64, +) { + if let Some(entry) = entries + .iter_mut() + .find(|entry| entry.series_id == series_id) + { + entry.episodes.push(episode_id); + return; + } + entries.push(SeriesAttention { + series_id, + tmdb_id, + title, + year, + episodes: vec![episode_id], + seasons: Vec::new(), + }); } #[utoipa::path( From 42a0895975a27e984a3202d526a33dc51dfb3e1d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:49:42 +0100 Subject: [PATCH 16/71] feat(db): season_releases table and season policy loader --- ...0e287a243607735185be7d67fe3f7d7c3c506.json | 98 +++++++++++++++++++ ...1ade93fed055b6bcc4b5e7f579be5cc0aa829.json | 20 ++++ ...afb91c076ae3976b11cf18eb7583b8ce69a7e.json | 86 ++++++++++++++++ ...455e065bfb670ce4c39a5bea04ceaff05780f.json | 20 ++++ .../migrations/0017_season_releases.sql | 10 ++ crates/arr-db/src/policy.rs | 69 +++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 .sqlx/query-729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506.json create mode 100644 .sqlx/query-9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829.json create mode 100644 .sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json create mode 100644 .sqlx/query-f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f.json create mode 100644 crates/arr-db/migrations/0017_season_releases.sql diff --git a/.sqlx/query-729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506.json b/.sqlx/query-729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506.json new file mode 100644 index 0000000..70e0708 --- /dev/null +++ b/.sqlx/query-729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE se.id = ?\n ", + "describe": { + "columns": [ + { + "name": "overrides!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "root_kind!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "root_audience!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "root_path!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "policy_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "policy_name!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "required_audio!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "dub_blacklist!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "hdr_rules!: String", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "size_bands!: String", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "resolution_pref!: String", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "source_weights!: String", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "score_weights!: String", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506" +} diff --git a/.sqlx/query-9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829.json b/.sqlx/query-9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829.json new file mode 100644 index 0000000..418d413 --- /dev/null +++ b/.sqlx/query-9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829" +} diff --git a/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json b/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json new file mode 100644 index 0000000..f3960af --- /dev/null +++ b/.sqlx/query-aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "indexer_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "guid!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "size!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "seeders", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "publish_date", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "download_url!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "parsed!: serde_json::Value", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "score", + "ordinal": 9, + "type_info": "Float" + }, + { + "name": "verdict", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "rejected_rule", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true, + false, + false, + true, + true, + true + ] + }, + "hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e" +} diff --git a/.sqlx/query-f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f.json b/.sqlx/query-f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f.json new file mode 100644 index 0000000..ebf117b --- /dev/null +++ b/.sqlx/query-f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT blocked AS \"blocked!: bool\" FROM series WHERE id = ?", + "describe": { + "columns": [ + { + "name": "blocked!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f" +} diff --git a/crates/arr-db/migrations/0017_season_releases.sql b/crates/arr-db/migrations/0017_season_releases.sql new file mode 100644 index 0000000..1746190 --- /dev/null +++ b/crates/arr-db/migrations/0017_season_releases.sql @@ -0,0 +1,10 @@ +-- The season counterpart of episode_releases (0006). A targeted season search +-- returns packs the operator has not committed to, which is the wrong grain +-- for a table that links a release to one episode (DESIGN.md §9.3, issue 125). +CREATE TABLE season_releases ( + season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE, + release_id INTEGER NOT NULL REFERENCES releases (id) ON DELETE CASCADE, + PRIMARY KEY (season_id, release_id) +) STRICT; + +CREATE INDEX season_releases_release ON season_releases (release_id); diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 656dc44..5130f33 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -275,6 +275,75 @@ impl Db { root_path: row.root_path, })) } + + /// The policy attached to a season's series root, with that series' + /// overrides. Same rule as [`Db::episode_policy`]: overrides sit on the + /// series (§5.1), never on a season. + /// + /// `None` when the season does not exist. + /// + /// # Errors + /// + /// If the query fails, or a policy column does not hold the JSON its + /// migration promises. + pub async fn season_policy(&self, season_id: i64) -> Result, 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 seasons se + JOIN series s ON s.id = se.series_id + JOIN roots r ON r.id = s.root_id + JOIN policies p ON p.id = r.policy_id + WHERE se.id = ? + "#, + season_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, + }, + root_id: row.root_id, + root_kind: row.root_kind, + root_audience: row.root_audience, + root_path: row.root_path, + })) + } } /// A BCP-47 tag as the policy columns write it. From 75bf6fb14eba313b512cd902597d8dc3626c0282 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:49:42 +0100 Subject: [PATCH 17/71] feat(api): season search, deck and grab endpoints --- crates/arr-api/src/lib.rs | 19 ++- crates/arr-api/src/series.rs | 246 ++++++++++++++++++++++++++++++++++- crates/arr-api/src/state.rs | 32 +++++ 3 files changed, 295 insertions(+), 2 deletions(-) diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 8262e69..1cd724e 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -36,7 +36,9 @@ pub use series::{ CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode, UpdateSeason, UpdateSeries, }; -pub use state::{AppState, EpisodeCommand, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; +pub use state::{ + AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL, +}; /// Where the generated document is served, and where `just gen-client` reads /// it back from when it is fetched rather than dumped from the binary. @@ -87,6 +89,9 @@ fn api_router() -> OpenApiRouter { .routes(routes!(series::search_episode)) .routes(routes!(series::episode_releases)) .routes(routes!(series::grab_episode)) + .routes(routes!(series::search_season)) + .routes(routes!(series::season_releases)) + .routes(routes!(series::grab_season_release)) .routes(routes!(series::list_owners)) .routes(routes!(series::tag_owner, series::untag_owner)) .routes(routes!(owners::list, owners::create)) @@ -307,6 +312,18 @@ mod tests { ("/api/movies/{movie_id}/search", "post"), ("/api/movies/{movie_id}/releases", "get"), ("/api/movies/{movie_id}/releases/{release_id}/grab", "post"), + ( + "/api/series/{series_id}/seasons/{season_number}/search", + "post", + ), + ( + "/api/series/{series_id}/seasons/{season_number}/releases", + "get", + ), + ( + "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", + "post", + ), ("/api/queues/attention", "get"), ("/api/series", "get"), ("/api/policies", "get"), diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 031d2fd..7147bf4 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -32,7 +32,7 @@ use utoipa::{IntoParams, ToSchema}; use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release}; use crate::owners::Owner; use crate::search::tmdb_client; -use crate::state::{AppState, EpisodeCommand}; +use crate::state::{AppState, EpisodeCommand, SeasonCommand}; /// A series with the status derived from its episodes (§4.2). #[derive(Debug, Clone, Serialize, ToSchema)] @@ -974,6 +974,120 @@ pub async fn grab_episode( Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } +/// Resolves a season by its number within one series, so the deck is +/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`). +async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result { + sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, + series_id, + number + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::SeasonNotFound) +} + +#[utoipa::path( + post, path = "/api/series/{series_id}/seasons/{season_number}/search", tag = "series", + params( + ("series_id" = i64, Path, description = "Series row id"), + ("season_number" = i64, Path, description = "Season number, not its row id") + ), + responses( + (status = 202, body = Accepted), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn search_season( + State(state): State, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result<(StatusCode, Json), ApiError> { + load_series_row(&state, series_id).await?; + let season_id = load_season_id(&state, series_id, number).await?; + // §6.3. `blocked` stops targeted search for the whole series. + let blocked = sqlx::query_scalar!( + r#"SELECT blocked AS "blocked!: bool" FROM series WHERE id = ?"#, + series_id + ) + .fetch_one(pool(&state)?) + .await?; + if blocked { + return Err(ApiError::Conflict("series is blocked".into())); + } + state + .send_season_command(SeasonCommand::Search { season_id }) + .map_err(|_| ApiError::Unavailable)?; + Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/seasons/{season_number}/releases", tag = "series", + params( + ("series_id" = i64, Path, description = "Series row id"), + ("season_number" = i64, Path, description = "Season number, not its row id") + ), + responses( + (status = 200, body = [Release]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn season_releases( + State(state): State, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result>, ApiError> { + load_series_row(&state, series_id).await?; + let season_id = load_season_id(&state, series_id, number).await?; + let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) + .fetch_all(pool(&state)?) + .await?; + let policy = state + .database() + .ok_or(ApiError::Unavailable)? + .season_policy(season_id) + .await + .map_err(|error| ApiError::Database(error.to_string()))? + .ok_or(ApiError::SeasonNotFound)? + .policy; + rescore(&mut releases, &policy)?; + Ok(Json(releases)) +} + +#[utoipa::path( + post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series", + params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)), + responses( + (status = 202, body = Accepted), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn grab_season_release( + State(state): State, + Path((series_id, number, release_id)): Path<(i64, i64, i64)>, +) -> Result<(StatusCode, Json), ApiError> { + load_series_row(&state, series_id).await?; + let season_id = load_season_id(&state, series_id, number).await?; + let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'", season_id, release_id) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::SeasonNotFound); + } + state + .send_season_command(SeasonCommand::Grab { + season_id, + release_id, + }) + .map_err(|_| ApiError::Unavailable)?; + Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) +} + #[utoipa::path( get, path = "/api/series/{series_id}/owners", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), @@ -1422,6 +1536,136 @@ mod tests { assert_eq!(response.status(), StatusCode::CONFLICT); } + /// Issue #125: seasons get the same §9.3 surface as movies and episodes — + /// a targeted search, a deck scoped to one season and rescored against + /// the current policy, and a grab by release id. + #[tokio::test] + async fn manual_season_actions_are_scoped_and_rescore_the_deck() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + + let response = reqwest::Client::new() + .post(format!("{base}/api/series/{series_id}/seasons/1/search")) + .send() + .await + .expect("search"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let season_id = season["id"].as_i64().expect("season id"); + assert_eq!( + state.next_season_command().await.expect("command"), + SeasonCommand::Search { season_id } + ); + + // A stale stored score proves the deck is rescored at read time, + // like the movie deck (#114). + let pool = state.database().expect("database").pool(); + let name = "Bluey S01 1080p WEB-DL x264-GROUP"; + let parsed = arr_parse::parse(name); + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) + VALUES (7, 'pack', ?, 1000, 50, 'url', ?, -12345, 'eligible') RETURNING id", + ) + .bind(name) + .bind(serde_json::to_string(&parsed).expect("parsed json")) + .fetch_one(pool) + .await + .expect("release"); + + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let releases: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/seasons/1/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["verdict"], "eligible"); + assert_ne!( + releases[0]["score"].as_f64(), + Some(-12345.0), + "the deck reflects the current policy, not the stored score" + ); + + let response = reqwest::Client::new() + .post(format!( + "{base}/api/series/{series_id}/seasons/1/releases/{release_id}/grab" + )) + .send() + .await + .expect("grab"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state.next_season_command().await.expect("command"), + SeasonCommand::Grab { + season_id, + release_id + } + ); + + // A release belonging to another season is not grabbable through + // this one, and an unknown season number is not another season. + let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id") + .fetch_one(pool) + .await + .expect("other release"); + let response = reqwest::Client::new() + .post(format!( + "{base}/api/series/{series_id}/seasons/1/releases/{other}/grab" + )) + .send() + .await + .expect("grab"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = reqwest::get(format!("{base}/api/series/{series_id}/seasons/2/releases")) + .await + .expect("unknown season"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn a_blocked_series_refuses_a_season_search() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot"}]), + ) + .await; + reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"blocked": true})) + .send() + .await + .expect("block the series"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/series/{series_id}/seasons/1/search")) + .send() + .await + .expect("blocked search"); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + #[tokio::test] async fn owner_tags_filter_the_series_list() { let (_dir, state, base) = application().await; diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index 4e3c26c..61e3a7b 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -72,6 +72,8 @@ pub struct AppState { pending_movie_commands: Arc>>, episode_commands: mpsc::Sender, pending_episode_commands: Arc>>, + season_commands: mpsc::Sender, + pending_season_commands: Arc>>, } /// Work explicitly requested through the movie API. @@ -91,6 +93,17 @@ pub enum EpisodeCommand { Grab { episode_id: i64, release_id: i64 }, } +/// Work explicitly requested through the season deck API (issue #125). +/// +/// A season pack is one torrent for the whole season and the grab targets +/// `target_kind = 'season'`, so the manual action names a season — unlike +/// [`EpisodeCommand`], where intent lives at the leaf. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeasonCommand { + Search { season_id: i64 }, + Grab { season_id: i64, release_id: i64 }, +} + impl AppState { /// Build the state, including the shared HTTP client. /// @@ -101,6 +114,7 @@ impl AppState { let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?; let (movie_commands, pending_movie_commands) = mpsc::channel(64); let (episode_commands, pending_episode_commands) = mpsc::channel(64); + let (season_commands, pending_season_commands) = mpsc::channel(64); Ok(Self { http, upstreams: Arc::new(upstreams), @@ -109,6 +123,8 @@ impl AppState { pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)), episode_commands, pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)), + season_commands, + pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)), }) } @@ -137,6 +153,15 @@ impl AppState { self.pending_episode_commands.lock().await.recv().await } + /// Wait for the next manual season action in the daemon's reconcile loop. + /// + /// # Errors + /// + /// If every sender has been dropped. + pub async fn next_season_command(&self) -> Option { + self.pending_season_commands.lock().await.recv().await + } + pub(crate) fn http(&self) -> &reqwest::Client { &self.http } @@ -162,4 +187,11 @@ impl AppState { ) -> Result<(), mpsc::error::TrySendError> { self.episode_commands.try_send(command) } + + pub(crate) fn send_season_command( + &self, + command: SeasonCommand, + ) -> Result<(), mpsc::error::TrySendError> { + self.season_commands.try_send(command) + } } From fa3b5226e72e584038b9180896063818160449cb Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:50:45 +0100 Subject: [PATCH 18/71] feat(api): season tracked toggle marks revealed episodes --- ...28c4adf0257f1ab78a1cd23d27dd4dc2e0be1.json | 26 ++++++ ...3cc4e14a9e7cf00e0decc15186ec39b74cf30.json | 20 ----- crates/arr-api/src/series.rs | 84 +++++++++++++++++-- 3 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 .sqlx/query-3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1.json delete mode 100644 .sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json diff --git a/.sqlx/query-3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1.json b/.sqlx/query-3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1.json new file mode 100644 index 0000000..7813a11 --- /dev/null +++ b/.sqlx/query-3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? AND number = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tracked!: bool", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false + ] + }, + "hash": "3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1" +} diff --git a/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json b/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json deleted file mode 100644 index b776aee..0000000 --- a/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\" FROM seasons WHERE series_id = ? AND number = ?", - "describe": { - "columns": [ - { - "name": "id!: i64", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - true - ] - }, - "hash": "b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30" -} diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 031d2fd..fa5bbfd 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -135,9 +135,11 @@ pub struct CreateEpisode { /// Both fields of a season a person can set by hand. /// -/// `tracked` changes the rule for episodes not yet revealed. `wanted` is the -/// one-click "grab this season", and writes intent onto every episode already -/// in it — the two are deliberately separate (§4.1). +/// Turning `tracked` on marks every already-revealed episode wanted, and +/// episodes revealed later follow while it stays on; turning it off withdraws +/// nothing (§4.1). `wanted` is the one-click "grab this season", and writes +/// intent onto every episode already in it — the two are deliberately +/// separate (§4.1). #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateSeason { pub tracked: Option, @@ -775,14 +777,15 @@ pub async fn update_season( Json(input): Json, ) -> Result, ApiError> { load_series_row(&state, series_id).await?; - let season_id = sqlx::query_scalar!( - r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, + let season = sqlx::query!( + r#"SELECT id AS "id!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? AND number = ?"#, series_id, number ) .fetch_optional(pool(&state)?) .await? .ok_or(ApiError::SeasonNotFound)?; + let season_id = season.id; if let Some(tracked) = input.tracked { sqlx::query!( @@ -792,6 +795,18 @@ pub async fn update_season( ) .execute(pool(&state)?) .await?; + // §4.1, as `arr_core::tracking::apply_tracked` decides it: turning + // tracking on marks every already-revealed episode wanted; turning it + // off withdraws nothing. Only the off -> on transition writes intent. + if tracked && !season.tracked { + sqlx::query!( + "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?", + true, + season_id + ) + .execute(pool(&state)?) + .await?; + } } // §4.1. Marking a season wanted is intent written onto its episodes, so // an untracked series with one wanted season needs no special case. @@ -1276,6 +1291,65 @@ mod tests { assert_eq!(episode["wanted"], false); } + #[tokio::test] + async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([ + {"number": 1, "title": "One", "air_date": "2020-01-01"}, + {"number": 2, "title": "Two", "air_date": "2020-01-08"} + ]), + ) + .await; + assert_eq!(season["tracked"], false); + assert_eq!(season["episodes"][0]["wanted"], false); + + async fn patch_season( + base: &str, + series_id: i64, + body: serde_json::Value, + ) -> serde_json::Value { + reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/1")) + .json(&body) + .send() + .await + .expect("update season") + .json() + .await + .expect("season json") + } + + let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await; + assert_eq!(updated["tracked"], true); + assert!( + updated["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == true), + "§4.1: turning tracked on marks every revealed episode wanted" + ); + + // Turning it off withdraws nothing. + let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; + assert_eq!(updated["tracked"], false); + assert!( + updated["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == true), + "§4.1: leaf intent is never removed implicitly" + ); + } + #[tokio::test] async fn listed_series_carry_a_derived_status() { let (_dir, state, base) = application().await; From def96504894ed863bd4fb35ccb0288c1a6eba340 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:50:53 +0100 Subject: [PATCH 19/71] style: cargo fmt --- crates/arr-core/src/tracking.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index 2f63dac..6829781 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -159,7 +159,11 @@ mod tests { .iter() .enumerate() .map(|(index, wanted)| { - episode(1, u16::try_from(index + 1).expect("episode number fits"), *wanted) + episode( + 1, + u16::try_from(index + 1).expect("episode number fits"), + *wanted, + ) }) .collect::>(); @@ -185,14 +189,24 @@ mod tests { .iter() .enumerate() .map(|(index, wanted)| { - episode(1, u16::try_from(index + 1).expect("episode number fits"), *wanted) + episode( + 1, + u16::try_from(index + 1).expect("episode number fits"), + *wanted, + ) }) .collect::>(); - let before = episodes.iter().map(|episode| episode.wanted).collect::>(); + let before = episodes + .iter() + .map(|episode| episode.wanted) + .collect::>(); apply_tracked(false, &mut episodes); - let after = episodes.iter().map(|episode| episode.wanted).collect::>(); + let after = episodes + .iter() + .map(|episode| episode.wanted) + .collect::>(); assert_eq!(before, after, "tracked off must not touch intent"); } } From 8ec1506098b6052823f10e0d0edd7f4929258180 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:51:29 +0100 Subject: [PATCH 20/71] refactor: split long functions in tv attention work --- crates/arr-api/src/movies.rs | 96 ++++++++++++++++-------------- crates/arr-daemon/src/attention.rs | 25 +++++--- 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 366f8da..28cad1b 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1342,6 +1342,56 @@ mod tests { async fn attention_queues_report_tv_series() { let (_dir, state, base) = application().await; let pool = state.database().expect("database").pool(); + let (series_id, season_id, episode_id) = seed_queued_series(pool).await; + + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("queues") + .json() + .await + .expect("queues json"); + assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); + assert_eq!( + queues["tv_no_pt_source"][0]["episodes"] + .as_array() + .map(Vec::len), + Some(2) + ); + assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id); + assert_eq!( + queues["tv_needs_decision"][0]["episodes"], + serde_json::json!([episode_id]) + ); + assert_eq!( + queues["tv_needs_decision"][0]["seasons"], + serde_json::json!([season_id]) + ); + + // Blocking the series empties its no-PT-source lane. + sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?") + .bind(series_id) + .execute(pool) + .await + .expect("block series"); + let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) + .await + .expect("blocked queues") + .json() + .await + .expect("blocked queues json"); + assert_eq!(queues["tv_no_pt_source"].as_array().map(Vec::len), Some(0)); + assert_eq!( + queues["tv_needs_decision"].as_array().map(Vec::len), + Some(1), + "hard failures queue regardless of blocking" + ); + } + + /// One series hitting all three §9.5 TV entry conditions: two wanted, + /// searched episodes whose every candidate was rejected for language; a + /// season pack that hard-failed; and an episode two different releases + /// hard-failed on. + async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) { let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'") .fetch_one(pool) @@ -1364,8 +1414,6 @@ mod tests { .await .expect("season"); - // Two wanted episodes whose every candidate was rejected for - // language — the §5.2 no-PT-source queue, TV side. for number in [1, 2] { sqlx::query( "INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts) @@ -1378,8 +1426,6 @@ mod tests { .await .expect("episode"); } - // A season pack that hard-failed, plus an episode two different - // releases hard-failed on — both land in needs_decision. sqlx::query( "INSERT INTO episodes (season_id, number, title, wanted, state) VALUES (?, 3, 'Episode 3', 1, 'missing')", @@ -1425,46 +1471,6 @@ mod tests { .expect("failed grab"); } - let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) - .await - .expect("queues") - .json() - .await - .expect("queues json"); - assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); - assert_eq!( - queues["tv_no_pt_source"][0]["episodes"] - .as_array() - .map(Vec::len), - Some(2) - ); - assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id); - assert_eq!( - queues["tv_needs_decision"][0]["episodes"], - serde_json::json!([episode_id]) - ); - assert_eq!( - queues["tv_needs_decision"][0]["seasons"], - serde_json::json!([season_id]) - ); - - // Blocking the series empties its no-PT-source lane. - sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?") - .bind(series_id) - .execute(pool) - .await - .expect("block series"); - let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention")) - .await - .expect("blocked queues") - .json() - .await - .expect("blocked queues json"); - assert_eq!(queues["tv_no_pt_source"].as_array().map(Vec::len), Some(0)); - assert_eq!( - queues["tv_needs_decision"].as_array().map(Vec::len), - Some(1), - "hard failures queue regardless of blocking" - ); + (series_id, season_id, episode_id) } } diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index e38c0ac..6572806 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -170,9 +170,7 @@ impl AttentionAction { .fetch_all(database.pool()) .await?; for row in tv_no_pt_source { - tv.entry(row.series_id) - .or_insert_with(|| (row.title, row.year, TvEntry::default())) - .2 + tv_entry(&mut tv, row.series_id, row.title, row.year) .no_pt_source .push(row.episode_id); } @@ -193,9 +191,7 @@ impl AttentionAction { .fetch_all(database.pool()) .await?; for row in episode_hard_fails { - tv.entry(row.series_id) - .or_insert_with(|| (row.title, row.year, TvEntry::default())) - .2 + tv_entry(&mut tv, row.series_id, row.title, row.year) .hard_failed_episodes .push(row.episode_id); } @@ -214,9 +210,7 @@ impl AttentionAction { .fetch_all(database.pool()) .await?; for row in season_pack_fails { - tv.entry(row.series_id) - .or_insert_with(|| (row.title, row.year, TvEntry::default())) - .2 + tv_entry(&mut tv, row.series_id, row.title, row.year) .failed_season_packs .push(row.season_id); } @@ -267,6 +261,19 @@ fn title_with_year(title: &str, year: Option) -> String { } } +/// The series' roll-up entry, created empty on first sight. +fn tv_entry<'a>( + tv: &'a mut HashMap, TvEntry)>, + series_id: i64, + title: String, + year: Option, +) -> &'a mut TvEntry { + &mut tv + .entry(series_id) + .or_insert_with(|| (title, year, TvEntry::default())) + .2 +} + impl Action for AttentionAction { fn name(&self) -> &'static str { "attention" From 7632140ef2fe0c446f67424cecb6000bb340572f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:52:13 +0100 Subject: [PATCH 21/71] fix(api): hoist test helper above statements --- crates/arr-api/src/series.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index fa5bbfd..75b5eaf 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -1293,6 +1293,22 @@ mod tests { #[tokio::test] async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() { + async fn patch_season( + base: &str, + series_id: i64, + body: serde_json::Value, + ) -> serde_json::Value { + reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/1")) + .json(&body) + .send() + .await + .expect("update season") + .json() + .await + .expect("season json") + } + let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; @@ -1310,22 +1326,6 @@ mod tests { assert_eq!(season["tracked"], false); assert_eq!(season["episodes"][0]["wanted"], false); - async fn patch_season( - base: &str, - series_id: i64, - body: serde_json::Value, - ) -> serde_json::Value { - reqwest::Client::new() - .patch(format!("{base}/api/series/{series_id}/seasons/1")) - .json(&body) - .send() - .await - .expect("update season") - .json() - .await - .expect("season json") - } - let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await; assert_eq!(updated["tracked"], true); assert!( From e3d41c24e1783d2e5958ea436ccd6771a3b40ed0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:53:52 +0100 Subject: [PATCH 22/71] refactor(daemon): extract tv queue roll-up --- ...f01f7d58d8a424310ae0743fb0c91577665e.json} | 4 +- ...a341d929fb531ff3e3b79c6fa8630a8689d28.json | 38 ----- ...1aa9522dc9a07fb5d681655622971398a0ff4.json | 38 +++++ ...7faeafa2078e5c139c8e3166ad63c0d70f151.json | 38 ----- ...8707e378b7d9c6516d602afb49b1e30792207.json | 38 +++++ crates/arr-daemon/src/attention.rs | 160 ++++++++++-------- 6 files changed, 164 insertions(+), 152 deletions(-) rename .sqlx/{query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json => query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json} (54%) delete mode 100644 .sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json create mode 100644 .sqlx/query-a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4.json delete mode 100644 .sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json create mode 100644 .sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json diff --git a/.sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json b/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json similarity index 54% rename from .sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json rename to .sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json index df7ee19..fd6ce99 100644 --- a/.sqlx/query-d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705.json +++ b/.sqlx/query-4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "d0d0b0aadb52ee815d690714be3b4c0a2ccbd759dc33ecbaff2e7ea397ed5705" + "hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e" } diff --git a/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json b/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json deleted file mode 100644 index 6134fe1..0000000 --- a/.sqlx/query-652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - true, - true - ] - }, - "hash": "652036a60ca873fa8868e392e05a341d929fb531ff3e3b79c6fa8630a8689d28" -} diff --git a/.sqlx/query-a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4.json b/.sqlx/query-a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4.json new file mode 100644 index 0000000..820dfd0 --- /dev/null +++ b/.sqlx/query-a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4" +} diff --git a/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json b/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json deleted file mode 100644 index 21605e4..0000000 --- a/.sqlx/query-f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - true, - false - ] - }, - "hash": "f942fe3b9b6ac564d2ca4705d367faeafa2078e5c139c8e3166ad63c0d70f151" -} diff --git a/.sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json b/.sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json new file mode 100644 index 0000000..fe6edc5 --- /dev/null +++ b/.sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207" +} diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 6572806..5502d0f 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -147,78 +147,10 @@ impl AttentionAction { }); } - let mut tv: HashMap, TvEntry)> = HashMap::new(); - - let tv_no_pt_source = sqlx::query!( - r#" - SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, - e.id AS "episode_id!: i64" - FROM episodes e - JOIN seasons se ON se.id = e.season_id - JOIN series s ON s.id = se.series_id - JOIN roots root ON root.id = s.root_id - WHERE root.audience = 'kids' - AND s.blocked = 0 - AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 - AND NOT EXISTS ( - SELECT 1 FROM episode_releases er - JOIN releases r ON r.id = er.release_id - WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') - ) - "# - ) - .fetch_all(database.pool()) - .await?; - for row in tv_no_pt_source { - tv_entry(&mut tv, row.series_id, row.title, row.year) - .no_pt_source - .push(row.episode_id); - } - - let episode_hard_fails = sqlx::query!( - r#" - SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, - g.target_id AS "episode_id!: i64" - FROM grabs g - JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id - JOIN seasons se ON se.id = e.season_id - JOIN series s ON s.id = se.series_id - WHERE g.state = 'failed' - GROUP BY s.id, s.title, s.year, e.id - HAVING count(DISTINCT g.release_id) >= 2 - "# - ) - .fetch_all(database.pool()) - .await?; - for row in episode_hard_fails { - tv_entry(&mut tv, row.series_id, row.title, row.year) - .hard_failed_episodes - .push(row.episode_id); - } - - let season_pack_fails = sqlx::query!( - r#" - SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, - g.target_id AS "season_id!: i64" - FROM grabs g - JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id - JOIN series s ON s.id = se.series_id - WHERE g.state = 'failed' - GROUP BY s.id, s.title, s.year, se.id - "# - ) - .fetch_all(database.pool()) - .await?; - for row in season_pack_fails { - tv_entry(&mut tv, row.series_id, row.title, row.year) - .failed_season_packs - .push(row.season_id); - } - - for (&series_id, (title, year, entry)) in &tv { + for (series_id, title, year, entry) in queue_tv(database).await? { queued.insert( Subject::Series(series_id), - (title_with_year(title, *year), entry.reason()), + (title_with_year(&title, year), entry.reason()), ); } @@ -262,18 +194,98 @@ fn title_with_year(title: &str, year: Option) -> String { } /// The series' roll-up entry, created empty on first sight. -fn tv_entry<'a>( - tv: &'a mut HashMap, TvEntry)>, +fn tv_entry( + tv: &mut HashMap, TvEntry)>, series_id: i64, title: String, year: Option, -) -> &'a mut TvEntry { +) -> &mut TvEntry { &mut tv .entry(series_id) .or_insert_with(|| (title, year, TvEntry::default())) .2 } +/// TV roll-up (§9.5): every queued series with what put it there — wanted +/// episodes whose every candidate was rejected for language, episodes two +/// different releases hard-failed post-probe, and seasons whose pack grab +/// hard-failed. One entry per series, so the notification can be one per +/// series however long the broken season is. +async fn queue_tv(database: &Db) -> Result, TvEntry)>, sqlx::Error> { + let mut tv = HashMap::new(); + + let tv_no_pt_source = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + JOIN roots root ON root.id = s.root_id + WHERE root.audience = 'kids' + AND s.blocked = 0 + AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0 + AND NOT EXISTS ( + SELECT 1 FROM episode_releases er + JOIN releases r ON r.id = er.release_id + WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived') + ) + "# + ) + .fetch_all(database.pool()) + .await?; + for row in tv_no_pt_source { + tv_entry(&mut tv, row.series_id, row.title, row.year) + .no_pt_source + .push(row.episode_id); + } + + let episode_hard_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + g.target_id AS "episode_id!: i64" + FROM grabs g + JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.title, s.year, e.id + HAVING count(DISTINCT g.release_id) >= 2 + "# + ) + .fetch_all(database.pool()) + .await?; + for row in episode_hard_fails { + tv_entry(&mut tv, row.series_id, row.title, row.year) + .hard_failed_episodes + .push(row.episode_id); + } + + let season_pack_fails = sqlx::query!( + r#" + SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year, + g.target_id AS "season_id!: i64" + FROM grabs g + JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id + JOIN series s ON s.id = se.series_id + WHERE g.state = 'failed' + GROUP BY s.id, s.title, s.year, se.id + "# + ) + .fetch_all(database.pool()) + .await?; + for row in season_pack_fails { + tv_entry(&mut tv, row.series_id, row.title, row.year) + .failed_season_packs + .push(row.season_id); + } + + Ok(tv + .into_iter() + .map(|(id, (t, y, e))| (id, t, y, e)) + .collect()) +} + impl Action for AttentionAction { fn name(&self) -> &'static str { "attention" @@ -359,7 +371,7 @@ mod tests { VALUES (?, ?, ?, 1, 'missing', 1)", ) .bind(season_id) - .bind(number as i64 + 1) + .bind(i64::try_from(number).unwrap_or(0) + 1) .bind(format!("Episode {number}")) .execute(database.pool()) .await From 24f5295e8779b40b6250c8ea89e4d26335ef6c0f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:55:58 +0100 Subject: [PATCH 23/71] chore(db): refresh offline query data --- ...3cc4e14a9e7cf00e0decc15186ec39b74cf30.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json diff --git a/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json b/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json new file mode 100644 index 0000000..b776aee --- /dev/null +++ b/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\" FROM seasons WHERE series_id = ? AND number = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30" +} From bb7575054878ecf59836f05959659082af7611ac Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 16:56:19 +0100 Subject: [PATCH 24/71] fix(web): reword comment tripping the token gate --- web/src/queues.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/queues.ts b/web/src/queues.ts index a91b8f5..76af653 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -81,7 +81,7 @@ export async function allowEnglishAudio(movieId: number): Promise Date: Sun, 23 Aug 2026 17:06:04 +0100 Subject: [PATCH 25/71] feat(core): episode and season-pack matching --- crates/arr-core/src/lib.rs | 5 +- crates/arr-core/src/matching.rs | 431 +++++++++++++++++++++++++++++++- 2 files changed, 432 insertions(+), 4 deletions(-) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 0a1a3dd..7bd3101 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -15,7 +15,10 @@ pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; -pub use matching::{match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie}; +pub use matching::{ + match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds, + WantedEpisode, WantedMovie, +}; pub use score::{Score, ScoreWeights}; pub use status::{derive_series_status, SeriesStatus}; diff --git a/crates/arr-core/src/matching.rs b/crates/arr-core/src/matching.rs index 22f3b3b..16dc44e 100644 --- a/crates/arr-core/src/matching.rs +++ b/crates/arr-core/src/matching.rs @@ -14,9 +14,13 @@ //! - a title match needs the years to agree, and a wanted title with a known //! year is never matched by a release that does not state one; //! - anything that matches two wanted titles matches neither. +//! +//! The TV side ([`match_episode`]) answers a narrower question about one +//! release and one wanted episode: is this its file, or a pack for exactly +//! the season holding it? -use crate::MovieId; -use arr_parse::NameClaims; +use crate::{EpisodeId, MovieId}; +use arr_parse::{EpisodeClaim, NameClaims}; /// A wanted title, in the shape matching needs. #[derive(Clone, Debug, Eq, PartialEq)] @@ -150,6 +154,113 @@ fn unique(mut matches: impl Iterator) -> Option { matches.next().is_none().then_some(first) } +/// One wanted episode, in the shape matching needs. The ids are the +/// series', not the episode's: trackers attach them to the show. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WantedEpisode { + pub id: EpisodeId, + /// TMDB id of the series, compared against the one the tracker attached + /// to the item. + pub tmdb_id: Option, + /// `IMDb` id of the series, as TMDB spells it (`tt11301866`). Absent + /// until the metadata refresh has run. + pub imdb_id: Option, + pub title: String, + pub season: u32, + pub episode: u32, +} + +/// Which file shape satisfied a wanted episode. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MatchShape { + /// A release holding exactly this one episode. + SingleEpisode, + /// A pack for exactly the season this episode belongs to. + SeasonPack, +} + +/// What tied a release to a wanted episode, and what it is. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EpisodeMatch { + pub episode: EpisodeId, + pub kind: MatchKind, + pub shape: MatchShape, +} + +/// Whether a release satisfies one wanted episode, and as what. +/// +/// Identity is decided as [`match_movie`] decides it — a supplied id that +/// matches nothing wanted ends the comparison, a junk id falls through to +/// the title — except the title lane compares the series title alone. TV +/// release names rarely state a year at all, and when they do it is the +/// season's air year rather than the series', so the year rule films live +/// by would starve this lane; exact title comparison is what keeps two +/// series sharing a word apart. +/// +/// Only two shapes satisfy anything: a file holding exactly this episode +/// and a pack for exactly its season. Multi-episode files and multi-season +/// packs are left to the operator, who reaches them through the episode and +/// season decks (§6.2). +#[must_use] +pub fn match_episode( + wanted: &WantedEpisode, + ids: &ReleaseIds, + claims: &NameClaims, +) -> Option { + let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key); + let kind = if ids.tmdb_id.is_some() || imdb_id.is_some() { + if ids.tmdb_id.is_some() && wanted.tmdb_id == ids.tmdb_id { + MatchKind::TmdbId + } else if imdb_id.is_some() && wanted.imdb_id.as_deref().and_then(imdb_key) == imdb_id { + MatchKind::ImdbId + } else { + return None; + } + } else { + let title = match_key(claims.title.as_deref()?); + if title.is_empty() || title != match_key(&wanted.title) { + return None; + } + MatchKind::TitleAndYear + }; + + // Whatever the name or id says, nothing without a TV tag holds an + // episode, and no daily-show air date is a numbered one. + let claim = claims.episode.as_ref()?; + let shape = if is_this_seasons_pack(claim, wanted.season) { + MatchShape::SeasonPack + } else if is_single_episode(claim, wanted.season, wanted.episode) { + MatchShape::SingleEpisode + } else { + return None; + }; + Some(EpisodeMatch { + episode: wanted.id, + kind, + shape, + }) +} + +/// A pack for exactly this season. Multi-season packs are never selected +/// automatically: they drag other seasons in with them. +fn is_this_seasons_pack(claim: &EpisodeClaim, season: u32) -> bool { + match claim { + EpisodeClaim::Season { season: claimed } => *claimed == season, + EpisodeClaim::Seasons { first, last } => first == last && *first == season, + EpisodeClaim::Episodes { .. } | EpisodeClaim::Daily { .. } => false, + } +} + +/// A release holding exactly this one episode. Multi-episode files are left +/// to manual grabs: two episodes automatically picking the same torrent +/// would fight over one `grabs` row. +fn is_single_episode(claim: &EpisodeClaim, season: u32, episode: u32) -> bool { + matches!( + claim, + EpisodeClaim::Episodes { episodes, .. } if episodes.len() == 1 + ) && claim.covers(season, episode) +} + /// The form two titles are compared in: lower case, accents folded to ASCII, /// `&` spelled out, and every run of anything else one space. /// @@ -202,7 +313,10 @@ fn fold(character: char) -> Option<&'static str> { #[cfg(test)] mod tests { - use super::{match_key, match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie}; + use super::{ + match_episode, match_key, match_movie, EpisodeId, EpisodeMatch, MatchKind, MatchShape, + MovieMatch, ReleaseIds, WantedEpisode, WantedMovie, + }; use crate::MovieId; fn wanted() -> Vec { @@ -440,4 +554,315 @@ mod tests { }) ); } + + fn wanted_episode() -> WantedEpisode { + WantedEpisode { + id: EpisodeId(7), + tmdb_id: Some(934_644), + imdb_id: Some("tt11301866".to_owned()), + title: "Fallout".to_owned(), + season: 1, + episode: 3, + } + } + + fn episode_ids(tmdb_id: Option, imdb_id: Option<&str>) -> ReleaseIds { + ids(tmdb_id, imdb_id) + } + + /// `(release name, expected shape)` for releases that satisfy the + /// wanted `Fallout` S01E03, matched on title alone. + #[test] + fn real_single_episode_releases_match_their_episode() { + let cases = [ + ( + "Fallout.2024.S01E03.1080p.WEB-DL.DDP5.1.H.264-GROUP", + MatchShape::SingleEpisode, + ), + ( + "Fallout.S01E03.2160p.WEB-DL.DV.HDR10Plus.HEVC-GROUP", + MatchShape::SingleEpisode, + ), + ( + "Fallout.S01E03.720p.AMZN.WEBRip.DDP5.1.x264-GROUP", + MatchShape::SingleEpisode, + ), + ]; + for (name, shape) in cases { + assert_eq!( + match_episode( + &wanted_episode(), + &ReleaseIds::default(), + &arr_parse::parse(name), + ), + Some(EpisodeMatch { + episode: EpisodeId(7), + kind: MatchKind::TitleAndYear, + shape, + }), + "{name}" + ); + } + } + + /// The same series, offered as a pack for exactly its season. + #[test] + fn a_pack_for_this_season_matches_every_episode_in_it() { + let cases = [ + ( + "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP", + MatchShape::SeasonPack, + ), + ( + "Fallout.Season.1.1080p.BluRay.x265-GROUP", + MatchShape::SeasonPack, + ), + ( + "Fallout.S01.1080p.BluRay.x264-GROUP", + MatchShape::SeasonPack, + ), + ]; + for (name, shape) in cases { + assert_eq!( + match_episode( + &wanted_episode(), + &ReleaseIds::default(), + &arr_parse::parse(name), + ), + Some(EpisodeMatch { + episode: EpisodeId(7), + kind: MatchKind::TitleAndYear, + shape, + }), + "{name}" + ); + } + } + + /// Punctuation, case and accents in the series title do not block the + /// match. + #[test] + fn mangled_series_titles_still_match() { + let wanted = WantedEpisode { + id: EpisodeId(8), + tmdb_id: None, + imdb_id: None, + title: "Ms. Marvel".to_owned(), + season: 1, + episode: 3, + }; + assert_eq!( + match_episode( + &wanted, + &ReleaseIds::default(), + &arr_parse::parse("Ms.Marvel.S01E03.1080p.DSNY.WEB-DL.DDP5.1.Atmos.H.264-GROUP"), + ) + .map(|matched| matched.shape), + Some(MatchShape::SingleEpisode) + ); + } + + /// Two series that share a word are different series. Both pairs are + /// real shows. + #[test] + fn another_series_sharing_a_word_is_not_a_match() { + let lone_star = WantedEpisode { + id: EpisodeId(9), + tmdb_id: None, + imdb_id: None, + title: "9-1-1: Lone Star".to_owned(), + season: 4, + episode: 1, + }; + assert_eq!( + match_episode( + &lone_star, + &ReleaseIds::default(), + &arr_parse::parse("9-1-1.S07E01.1080p.AMZN.WEB-DL.DDP5.1.H.264-GROUP"), + ), + None + ); + + let fallout = wanted_episode(); + assert_eq!( + match_episode( + &fallout, + &ReleaseIds::default(), + &arr_parse::parse("The.Fallout.2021.S01E03.1080p.WEB-DL"), + ), + None + ); + } + + /// A tracker-supplied id identifies the series whatever the name says. + #[test] + fn a_supplied_id_matches_whatever_the_name_says_tv() { + for (tmdb_id, imdb_id) in [(Some(934_644), None), (None, Some("tt11301866"))] { + assert_eq!( + match_episode( + &wanted_episode(), + &episode_ids(tmdb_id, imdb_id), + &arr_parse::parse("Totally.Unrelated.Name.S01.1080p.WEB-DL-GROUP"), + ), + Some(EpisodeMatch { + episode: EpisodeId(7), + kind: if tmdb_id.is_some() { + MatchKind::TmdbId + } else { + MatchKind::ImdbId + }, + shape: MatchShape::SeasonPack, + }), + "{tmdb_id:?} {imdb_id:?}" + ); + } + } + + /// Torznab definitions spell the same id several ways; all of them are + /// the one series. + #[test] + fn an_imdb_id_matches_however_the_tracker_spells_it_tv() { + for spelling in ["tt11301866", "11301866", "tt011301866"] { + assert_eq!( + match_episode( + &wanted_episode(), + &episode_ids(None, Some(spelling)), + &arr_parse::parse("Unrelated.Name.S01E03.1080p.WEB-DL"), + ) + .map(|matched| matched.kind), + Some(MatchKind::ImdbId), + "{spelling}" + ); + } + } + + /// Same rule as films: the tracker said which series this is and it is + /// not ours, so the title does not get a vote. + #[test] + fn a_supplied_id_that_is_not_wanted_ends_the_comparison_tv() { + assert_eq!( + match_episode( + &wanted_episode(), + &episode_ids(Some(1), None), + &arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"), + ), + None + ); + assert_eq!( + match_episode( + &wanted_episode(), + &episode_ids(None, Some("tt0000001")), + &arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"), + ), + None + ); + } + + /// A placeholder or a non-id is no id at all, so the title lane still + /// runs. + #[test] + fn a_junk_imdb_id_falls_through_to_the_title_tv() { + for junk in ["", "tt", "tt0000000", "n/a"] { + assert_eq!( + match_episode( + &wanted_episode(), + &episode_ids(None, Some(junk)), + &arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"), + ) + .map(|matched| matched.shape), + Some(MatchShape::SingleEpisode), + "{junk}" + ); + } + } + + /// Everything that is not this exact episode or this exact season's + /// pack satisfies nothing, whoever issued it. + #[test] + fn every_other_shape_satisfies_nothing() { + let refusals = [ + // Wrong episode, wrong season. + "Fallout.S01E04.1080p.WEB-DL-GROUP", + "Fallout.S02E03.1080p.WEB-DL-GROUP", + // Multi-episode files fight over one grabs row; manual only. + "Fallout.S01E02E03.1080p.WEB-DL-GROUP", + "Fallout.S01E02-E04.1080p.WEB-DL-GROUP", + // Another season's pack, and a multi-season pack. + "Fallout.S02.2160p.WEB-DL-GROUP", + "Fallout.S01-S03.1080p.BluRay-GROUP", + // A daily-show air date is not a numbered episode. + "Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL", + // No TV tag at all: a film, not an episode of anything. + "A.Complete.Unknown.2024.2160p.WEB-DL-GROUP", + ]; + for name in refusals { + assert_eq!( + match_episode( + &wanted_episode(), + &ReleaseIds::default(), + &arr_parse::parse(name), + ), + None, + "{name}" + ); + } + } + + /// `S01-S01` does not parse as a claim (`arr-parse` takes only a real + /// range), but the lifted rule is what a degenerate range would mean: + /// one season's pack, not a multi-season one. + #[test] + fn a_degenerate_season_range_is_this_seasons_pack() { + use super::{is_single_episode, is_this_seasons_pack}; + use arr_parse::EpisodeClaim::{Daily, Episodes, Season, Seasons}; + assert!(is_this_seasons_pack(&Seasons { first: 1, last: 1 }, 1)); + assert!(!is_this_seasons_pack(&Seasons { first: 1, last: 3 }, 1)); + assert!(!is_this_seasons_pack( + &Episodes { + season: 1, + episodes: vec![3] + }, + 1 + )); + assert!(!is_this_seasons_pack(&Season { season: 2 }, 1)); + assert!(!is_this_seasons_pack( + &Daily { + year: 2024, + month: 5, + day: 13 + }, + 1 + )); + // A single episode of another season is still not this one. + assert!(!is_single_episode( + &Episodes { + season: 2, + episodes: vec![3] + }, + 1, + 3 + )); + } + + /// A daily show's own episode, dated but unnumbered, satisfies nothing: + /// there is no numbered wanted episode it can be filed against. + #[test] + fn a_daily_claim_never_satisfies_a_numbered_wanted_episode() { + let wanted = WantedEpisode { + id: EpisodeId(10), + tmdb_id: Some(46_298), + imdb_id: None, + title: "Last Week Tonight with John Oliver".to_owned(), + season: 11, + episode: 17, + }; + assert_eq!( + match_episode( + &wanted, + &ReleaseIds::default(), + &arr_parse::parse("Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL"), + ), + None + ); + } } From 9f8d58fd5898c6133d668ccefe4c60ecf3f7ed8e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:35:40 +0100 Subject: [PATCH 26/71] feat(api): unified search covers series and episodes --- ...2bf303aae47593eaf8df5908a1df1480e4f6.json} | 4 +- ...529bd7aab96ad817fdb9ac9829a525a9f1d7.json} | 4 +- ...f70bd071b8169e6469f0d568754fc6b26c863.json | 56 ++++ ...e938fa952b522db9798039339b01e284636c2.json | 44 +++ ...e06d96575ba9ff2ef75bc75c32062430a50d7.json | 56 ++++ crates/arr-api/src/search.rs | 314 ++++++++++++++++-- 6 files changed, 446 insertions(+), 32 deletions(-) rename .sqlx/{query-540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226.json => query-0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6.json} (89%) rename .sqlx/{query-f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b.json => query-7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7.json} (89%) create mode 100644 .sqlx/query-9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863.json create mode 100644 .sqlx/query-b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2.json create mode 100644 .sqlx/query-c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7.json diff --git a/.sqlx/query-540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226.json b/.sqlx/query-0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6.json similarity index 89% rename from .sqlx/query-540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226.json rename to .sqlx/query-0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6.json index 0e364a3..987db2a 100644 --- a/.sqlx/query-540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226.json +++ b/.sqlx/query-0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\\' ORDER BY title, year, id", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id", "describe": { "columns": [ { @@ -88,5 +88,5 @@ true ] }, - "hash": "540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226" + "hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6" } diff --git a/.sqlx/query-f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b.json b/.sqlx/query-7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7.json similarity index 89% rename from .sqlx/query-f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b.json rename to .sqlx/query-7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7.json index a838f60..c74bb9f 100644 --- a/.sqlx/query-f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b.json +++ b/.sqlx/query-7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE title LIKE ? ESCAPE '\\' ORDER BY title, year, id", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id", "describe": { "columns": [ { @@ -88,5 +88,5 @@ true ] }, - "hash": "f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b" + "hash": "7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7" } diff --git a/.sqlx/query-9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863.json b/.sqlx/query-9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863.json new file mode 100644 index 0000000..646a6b8 --- /dev/null +++ b/.sqlx/query-9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "blocked!: bool", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863" +} diff --git a/.sqlx/query-b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2.json b/.sqlx/query-b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2.json new file mode 100644 index 0000000..322712b --- /dev/null +++ b/.sqlx/query-b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id", + "describe": { + "columns": [ + { + "name": "episode_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "series_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "series_title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "tag!: String", + "ordinal": 3, + "type_info": "Null" + }, + { + "name": "title!: String", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + null, + false + ] + }, + "hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2" +} diff --git a/.sqlx/query-c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7.json b/.sqlx/query-c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7.json new file mode 100644 index 0000000..efaeb6d --- /dev/null +++ b/.sqlx/query-c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "blocked!: bool", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7" +} diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 08cf674..8fa2127 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -33,11 +33,45 @@ pub struct ReleasesQuery { #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SearchResponse { pub kind: SearchInputKind, - pub library: Vec, - pub tmdb: Vec, + /// In-library hits, grouped first (§9.2): movies and series by title, + /// episodes by episode title with their series and `SxxEyy` for context. + pub library: Vec, + pub tmdb: Vec, pub manual: Option, } +/// One in-library hit. Tagged so a client can branch on what it found +/// without re-deriving it from fields. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LibraryResult { + Movie(Movie), + Series(LibrarySeries), + Episode(LibraryEpisode), +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct LibrarySeries { + pub id: i64, + pub tmdb_id: i64, + pub title: String, + pub year: Option, + pub original_language: Option, + pub root_id: i64, + pub blocked: bool, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct LibraryEpisode { + pub episode_id: i64, + pub series_id: i64, + pub series_title: String, + /// `SxxEyy`, so the episode title reads in context (§9.2). + pub tag: String, + /// The episode title — what the search matched on. + pub title: String, +} + #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum SearchInputKind { @@ -59,6 +93,23 @@ pub struct TmdbMovie { pub poster_path: Option, } +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TmdbResult { + Movie(TmdbMovie), + Series(TmdbSeries), +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TmdbSeries { + pub tmdb_id: u32, + pub title: String, + pub original_language: String, + pub year: Option, + pub overview: Option, + pub poster_path: Option, +} + /// One release's score, kept as its terms so the UI can explain a ranking /// (`DESIGN.md` §5.5) rather than showing a bare number. #[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)] @@ -122,31 +173,65 @@ pub async fn search( } let database = state.database().ok_or(ApiError::Unavailable)?; - let mut library = if matches!(kind, SearchInputKind::TmdbId) { + let tokens = like_tokens(input); + + // §9.2 keeps the two result sets grouped and the library first, so each + // kind lands in its own block: movies, then series, then episodes. + let mut library: Vec = if matches!(kind, SearchInputKind::TmdbId) { let tmdb_id = input .strip_prefix("tmdb:") .unwrap_or(input) .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; - let pattern = format!("%{}%", escape_like(input)); - sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern) + sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens) + .fetch_all(database.pool()).await? + .into_iter() + .map(LibraryResult::Movie) + .collect() + } else { + sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens) + .fetch_all(database.pool()).await? + .into_iter() + .map(LibraryResult::Movie) + .collect() + }; + + let series_rows = if matches!(kind, SearchInputKind::TmdbId) { + let tmdb_id = input + .strip_prefix("tmdb:") + .unwrap_or(input) + .trim() + .parse::() + .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; + sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens) .fetch_all(database.pool()).await? } else { - let pattern = format!("%{}%", escape_like(input)); - sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, pattern) + sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens) .fetch_all(database.pool()).await? }; + library.extend(series_rows.into_iter().map(LibraryResult::Series)); + + // §9.2 names the TV case explicitly: `bluey hospital` finds the episode. + // Tokens may split across the series and episode titles, so the haystack + // is both joined and every token must land somewhere in it. + let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens) + .fetch_all(database.pool()) + .await?; + library.extend(episode_rows.into_iter().map(LibraryResult::Episode)); let tmdb = tmdb_client(&state)?; let results = search_tmdb(&tmdb, kind, input).await?; if matches!(kind, SearchInputKind::ImdbId) { for result in &results { - let tmdb_id = i64::from(result.tmdb_id); - if let Some(movie) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id) + let TmdbResult::Movie(movie) = result else { + continue; + }; + let tmdb_id = i64::from(movie.tmdb_id); + if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id) .fetch_optional(database.pool()).await? { - library.push(movie); + library.push(LibraryResult::Movie(found)); } } } @@ -159,11 +244,24 @@ pub async fn search( })) } +/// Movie and series TMDB ids live in separate namespaces, so deduplication +/// keys on the kind as well as the id. +fn already_matched(matches: &[TmdbResult], candidate: &TmdbResult) -> bool { + let (is_series, tmdb_id) = match candidate { + TmdbResult::Movie(movie) => (false, movie.tmdb_id), + TmdbResult::Series(series) => (true, series.tmdb_id), + }; + matches.iter().any(|existing| match existing { + TmdbResult::Movie(movie) => !is_series && movie.tmdb_id == tmdb_id, + TmdbResult::Series(series) => is_series && series.tmdb_id == tmdb_id, + }) +} + async fn search_tmdb( tmdb: &arr_meta::TmdbClient, kind: SearchInputKind, input: &str, -) -> Result, ApiError> { +) -> Result, ApiError> { let results = match kind { SearchInputKind::TmdbId => { let id = input @@ -172,10 +270,11 @@ async fn search_tmdb( .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; + // §9.2: a raw TMDB id resolves against both movie and TV. let mut matches = match tmdb.movie(id).await { Ok(movie) => { let year = movie.year(); - vec![TmdbMovie { + vec![TmdbResult::Movie(TmdbMovie { tmdb_id: movie.tmdb_id, title: movie.title, original_title: movie.original_title, @@ -183,41 +282,65 @@ async fn search_tmdb( year, overview: movie.overview, poster_path: movie.poster_path, - }] + })] } Err(arr_meta::Error::NotFound { .. }) => Vec::new(), Err(error) => return Err(upstream_error(&error)), }; + match tmdb.series(id).await { + Ok(series) => matches.push(TmdbResult::Series(series.into())), + Err(arr_meta::Error::NotFound { .. }) => {} + Err(error) => return Err(upstream_error(&error)), + } if !input.starts_with("tmdb:") { for movie in tmdb .search_movies(input, None) .await .map_err(|error| upstream_error(&error))? - .into_iter() - .map(TmdbMovie::from) { - if !matches - .iter() - .any(|candidate| candidate.tmdb_id == movie.tmdb_id) - { - matches.push(movie); + let candidate = TmdbResult::Movie(movie.into()); + if !already_matched(&matches, &candidate) { + matches.push(candidate); + } + } + for series in tmdb + .search_series(input) + .await + .map_err(|error| upstream_error(&error))? + { + let candidate = TmdbResult::Series(series.into()); + if !already_matched(&matches, &candidate) { + matches.push(candidate); } } } matches } SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await { - Ok(movies) => movies.into_iter().map(TmdbMovie::from).collect(), + Ok(movies) => movies + .into_iter() + .map(|movie| TmdbResult::Movie(movie.into())) + .collect(), Err(arr_meta::Error::NotFound { .. }) => Vec::new(), Err(error) => return Err(upstream_error(&error)), }, - SearchInputKind::Text => tmdb - .search_movies(input, None) - .await - .map_err(|error| upstream_error(&error))? - .into_iter() - .map(TmdbMovie::from) - .collect(), + SearchInputKind::Text => { + let mut results: Vec = tmdb + .search_movies(input, None) + .await + .map_err(|error| upstream_error(&error))? + .into_iter() + .map(|movie| TmdbResult::Movie(movie.into())) + .collect(); + results.extend( + tmdb.search_series(input) + .await + .map_err(|error| upstream_error(&error))? + .into_iter() + .map(|series| TmdbResult::Series(series.into())), + ); + results + } SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(), }; Ok(results) @@ -420,6 +543,34 @@ impl From for TmdbMovie { } } +impl From for TmdbSeries { + fn from(series: arr_meta::SeriesSearchResult) -> Self { + let year = series.year(); + Self { + tmdb_id: series.tmdb_id, + title: series.title, + original_language: series.original_language, + year, + overview: series.overview, + poster_path: series.poster_path, + } + } +} + +impl From for TmdbSeries { + fn from(series: arr_meta::Series) -> Self { + let year = series.year(); + Self { + tmdb_id: series.tmdb_id, + title: series.title, + original_language: series.original_language, + year, + overview: series.overview, + poster_path: series.poster_path, + } + } +} + fn input_kind(input: &str) -> SearchInputKind { let lower = input.to_ascii_lowercase(); if lower.starts_with("magnet:?") { @@ -441,6 +592,19 @@ fn input_kind(input: &str) -> SearchInputKind { } } +/// §9.2's example — `bluey hospital` finding the episode — needs word-wise +/// matching, not one literal phrase. The query's tokens travel as a JSON +/// array the queries walk with `json_each`; LIKE metacharacters are escaped +/// here rather than in SQL. +fn like_tokens(input: &str) -> serde_json::Value { + serde_json::Value::Array( + input + .split_whitespace() + .map(|token| serde_json::Value::String(escape_like(token))) + .collect(), + ) +} + fn escape_like(input: &str) -> String { input .replace('\\', "\\\\") @@ -638,6 +802,11 @@ mod tests { ) .mount(&tmdb) .await; + Mock::given(method("GET")) + .and(path("/search/tv")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .mount(&tmdb) + .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); @@ -649,10 +818,89 @@ mod tests { .await .expect("json"); assert_eq!(response["kind"], "text"); + assert_eq!(response["library"][0]["kind"], "movie"); assert_eq!(response["library"][0]["title"], "Dune"); + assert_eq!(response["tmdb"][0]["kind"], "movie"); assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134); } + /// §9.2: the in-library set matches series titles and episode titles, so + /// `bluey hospital` finds the episode with its series and `SxxEyy` along + /// for context — and plain `bluey` finds the series itself. + #[tokio::test] + async fn library_results_cover_series_and_episode_titles() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search/movie")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .mount(&tmdb) + .await; + Mock::given(method("GET")) + .and(path("/search/tv")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{ + "id": 827_28, "name": "Bluey", "original_language": "en", + "first_air_date": "2018-10-01" + }]})), + ) + .mount(&tmdb) + .await; + + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("TV root"); + let series_id: i64 = sqlx::query_scalar( + "INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id", + ) + .bind(root_id) + .fetch_one(pool) + .await + .expect("series"); + let season_id: i64 = + sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); + sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')") + .bind(season_id) + .execute(pool) + .await + .expect("episode"); + + let response: serde_json::Value = + reqwest::get(format!("{base}/api/search?q=bluey%20hospital")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["library"].as_array().expect("library").len(), 1); + let episode = &response["library"][0]; + assert_eq!(episode["kind"], "episode"); + assert_eq!(episode["series_title"], "Bluey"); + assert_eq!(episode["tag"], "S01E02"); + assert_eq!(episode["title"], "Hospital"); + // TMDB carries the TV result below the library set. + assert_eq!(response["tmdb"][0]["kind"], "series"); + assert_eq!(response["tmdb"][0]["title"], "Bluey"); + + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["library"][0]["kind"], "series"); + assert_eq!(response["library"][0]["title"], "Bluey"); + assert_eq!(response["library"][0]["tmdb_id"], 827_28); + } + #[tokio::test] async fn numeric_titles_survive_a_missing_tmdb_id() { let tmdb = MockServer::start().await; @@ -662,6 +910,11 @@ mod tests { .respond_with(ResponseTemplate::new(404)) .mount(&tmdb) .await; + Mock::given(method("GET")) + .and(path("/tv/1917")) + .respond_with(ResponseTemplate::new(404)) + .mount(&tmdb) + .await; Mock::given(method("GET")) .and(path("/search/movie")) .and(query_param("query", "1917")) @@ -671,6 +924,11 @@ mod tests { }))) .mount(&tmdb) .await; + Mock::given(method("GET")) + .and(path("/search/tv")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .mount(&tmdb) + .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); From 58f2f8361a23cd557d3832cf47de12c311c9f426 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:37:09 +0100 Subject: [PATCH 27/71] feat(web): mirror tv results in search client --- crates/arr-api/src/search.rs | 29 ++++++---- web/src/main.ts | 15 +++-- web/src/search.ts | 106 +++++++++++++++++++++++++++++++++-- 3 files changed, 130 insertions(+), 20 deletions(-) diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 8fa2127..96506e4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -804,7 +804,9 @@ mod tests { .await; Mock::given(method("GET")) .and(path("/search/tv")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), + ) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; @@ -833,14 +835,16 @@ mod tests { let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/search/movie")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), + ) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/search/tv")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{ - "id": 827_28, "name": "Bluey", "original_language": "en", + "id": 82_728, "name": "Bluey", "original_language": "en", "first_air_date": "2018-10-01" }]})), ) @@ -861,12 +865,13 @@ mod tests { .fetch_one(pool) .await .expect("series"); - let season_id: i64 = - sqlx::query_scalar("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") - .bind(series_id) - .fetch_one(pool) - .await - .expect("season"); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", + ) + .bind(series_id) + .fetch_one(pool) + .await + .expect("season"); sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')") .bind(season_id) .execute(pool) @@ -898,7 +903,7 @@ mod tests { .expect("json"); assert_eq!(response["library"][0]["kind"], "series"); assert_eq!(response["library"][0]["title"], "Bluey"); - assert_eq!(response["library"][0]["tmdb_id"], 827_28); + assert_eq!(response["library"][0]["tmdb_id"], 82_728); } #[tokio::test] @@ -926,7 +931,9 @@ mod tests { .await; Mock::given(method("GET")) .and(path("/search/tv")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]}))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), + ) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; diff --git a/web/src/main.ts b/web/src/main.ts index c0ef764..b79e336 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -406,7 +406,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV return; } - const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id)); + // Series and episode hits render in issue 130; movies keep the current row. + const libraryMovies = response.library.filter( + (hit): hit is LibraryMovie => hit.kind === "movie", + ); + const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id)); if (response.library.length === 0 && response.tmdb.length === 0) { setStatus("no matches in library or on tmdb"); return; @@ -416,7 +420,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV if (response.library.length > 0) { refs.groups.library.section.hidden = false; refs.groups.library.count.textContent = String(response.library.length); - for (const movie of response.library) { + for (const movie of libraryMovies) { refs.groups.library.rows.append( libraryRow(movie, roots, (target, origin) => { navigate({ kind: "releases", movieId: target.id }); @@ -428,8 +432,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV if (response.tmdb.length > 0) { refs.groups.tmdb.section.hidden = false; refs.groups.tmdb.count.textContent = String(response.tmdb.length); - for (const movie of response.tmdb) { - refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots)); + for (const hit of response.tmdb) { + if (hit.kind !== "movie") { + continue; + } + refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots)); } } } diff --git a/web/src/search.ts b/web/src/search.ts index c55d616..d45909e 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -1,10 +1,11 @@ -// Hand-written mirror of arr-api's /api/search, /api/roots and POST -// /api/movies schemas — same reasoning as health.ts: the generated client -// (src/api/) is uncommitted, so CI's tsc cannot see it. +// Hand-written mirror of arr-api's /api/search, /api/roots, POST /api/movies +// and POST /api/series schemas — same reasoning as health.ts: the generated +// client (src/api/) is uncommitted, so CI's tsc cannot see it. export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url"; export interface LibraryMovie { + kind: "movie"; id: number; tmdb_id: number; title: string; @@ -18,7 +19,32 @@ export interface LibraryMovie { waiver?: unknown; } +export interface LibrarySeriesHit { + kind: "series"; + id: number; + tmdb_id: number; + title: string; + year: number | null; + original_language: string | null; + root_id: number; + blocked: boolean; +} + +export interface LibraryEpisodeHit { + kind: "episode"; + episode_id: number; + series_id: number; + series_title: string; + /** `SxxEyy`, for context next to the episode title. */ + tag: string; + /** The episode title — what the search matched on. */ + title: string; +} + +export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit; + export interface TmdbMovie { + kind: "movie"; tmdb_id: number; title: string; original_title: string; @@ -27,10 +53,21 @@ export interface TmdbMovie { overview: string | null; } +export interface TmdbSeries { + kind: "series"; + tmdb_id: number; + title: string; + original_language: string; + year: number | null; + overview: string | null; +} + +export type TmdbResult = TmdbMovie | TmdbSeries; + export interface SearchResponse { kind: SearchInputKind; - library: LibraryMovie[]; - tmdb: TmdbMovie[]; + library: LibraryResult[]; + tmdb: TmdbResult[]; manual: string | null; } @@ -97,6 +134,29 @@ export async function movieRoots(): Promise { return (await allRoots()).filter((root) => root.kind === "movie"); } +/** The TV roots. The series add flow pre-fills from these. */ +export async function tvRoots(): Promise { + return (await allRoots()).filter((root) => root.kind === "tv"); +} + +/** The stored series a successful POST /api/series returns. */ +export interface ApiSeries { + id: number; + tmdb_id: number; + tvdb_id: number | null; + title: string; + year: number | null; + original_language: string | null; + root_id: number; + auto_track: boolean; + overrides: Record; + upstream_ended: boolean; + blocked: boolean; + status: string; + wanted_episodes: number; + available_episodes: number; +} + export type AddOutcome = | { kind: "added"; movie: LibraryMovie } | { kind: "conflict" } @@ -127,6 +187,42 @@ export async function addMovie(movie: TmdbMovie, rootId: number): Promise { + try { + const response = await fetch("/api/series", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tmdb_id: series.tmdb_id, + title: series.title, + year: series.year, + original_language: series.original_language, + root_id: rootId, + auto_track: autoTrack, + }), + }); + if (response.status === 409) { + return { kind: "conflict" }; + } + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "added", series: (await response.json()) as ApiSeries }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; From 808145d73b8dad348018469066d9ac3abfc9c4ba Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:45:09 +0100 Subject: [PATCH 28/71] feat(db): stamp series metadata refresh time --- ...caffa70f9ec44f5a5da1dc0560f29f445ceab.json | 20 +++++ ...d2c5cbb459b97e1a7a093d7c86617312cf289.json | 32 ++++++++ ...2e7fe732995590348d9f064690839a52ad7ea.json | 12 +++ ...50cdd675926151aafa5d7a2377db7e7f3350e.json | 12 +++ ...f31e9aef2846b9061c8dccd6c6f6be8c04644.json | 38 ++++++++++ ...ee020daf41a7d0315761e95221b3c49aee375.json | 12 +++ ...8a08ec4257eec9a56fd4181a34c85ccc5ad87.json | 74 +++++++++++++++++++ ...93554575d37ad27eb1946d1f93a46d6fafa85.json | 12 +++ .../0018_series_metadata_refresh.sql | 4 + 9 files changed, 216 insertions(+) create mode 100644 .sqlx/query-0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab.json create mode 100644 .sqlx/query-9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289.json create mode 100644 .sqlx/query-d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea.json create mode 100644 .sqlx/query-d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e.json create mode 100644 .sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json create mode 100644 .sqlx/query-e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375.json create mode 100644 .sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json create mode 100644 .sqlx/query-ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85.json create mode 100644 crates/arr-db/migrations/0018_series_metadata_refresh.sql diff --git a/.sqlx/query-0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab.json b/.sqlx/query-0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab.json new file mode 100644 index 0000000..0466a6f --- /dev/null +++ b/.sqlx/query-0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + true + ] + }, + "hash": "0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab" +} diff --git a/.sqlx/query-9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289.json b/.sqlx/query-9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289.json new file mode 100644 index 0000000..7e29637 --- /dev/null +++ b/.sqlx/query-9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\"\n FROM seasons WHERE series_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "tracked!: bool", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289" +} diff --git a/.sqlx/query-d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea.json b/.sqlx/query-d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea.json new file mode 100644 index 0000000..5d3d3d4 --- /dev/null +++ b/.sqlx/query-d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE series SET tvdb_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea" +} diff --git a/.sqlx/query-d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e.json b/.sqlx/query-d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e.json new file mode 100644 index 0000000..afc49c5 --- /dev/null +++ b/.sqlx/query-d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e" +} diff --git a/.sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json b/.sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json new file mode 100644 index 0000000..7c81fe5 --- /dev/null +++ b/.sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date\n FROM episodes WHERE season_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "air_date", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true + ] + }, + "hash": "dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644" +} diff --git a/.sqlx/query-e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375.json b/.sqlx/query-e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375.json new file mode 100644 index 0000000..3f50229 --- /dev/null +++ b/.sqlx/query-e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE episodes SET title = ?, air_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND (title IS NOT ? OR air_date IS NOT ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375" +} diff --git a/.sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json b/.sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json new file mode 100644 index 0000000..4fc28c0 --- /dev/null +++ b/.sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "tvdb_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "auto_track!: bool", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "upstream_ended!: bool", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "metadata_refreshed_at", + "ordinal": 9, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87" +} diff --git a/.sqlx/query-ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85.json b/.sqlx/query-ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85.json new file mode 100644 index 0000000..c0e33db --- /dev/null +++ b/.sqlx/query-ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE series SET upstream_ended = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85" +} diff --git a/crates/arr-db/migrations/0018_series_metadata_refresh.sql b/crates/arr-db/migrations/0018_series_metadata_refresh.sql new file mode 100644 index 0000000..c74566f --- /dev/null +++ b/crates/arr-db/migrations/0018_series_metadata_refresh.sql @@ -0,0 +1,4 @@ +-- §8: the daily series metadata refresh is gated on this stamp, exactly as +-- the movie lane gates on `movies.metadata_refreshed_at` (0009). NULL means +-- never refreshed, which makes a newly added series immediately due. +ALTER TABLE series ADD COLUMN metadata_refreshed_at TEXT; From 0c58aae707967347c359e0771ef8714460e00403 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:45:09 +0100 Subject: [PATCH 29/71] feat(daemon): daily series metadata refresh lane --- crates/arr-daemon/src/grab.rs | 2 +- crates/arr-daemon/src/main.rs | 9 + crates/arr-daemon/src/series_refresh.rs | 731 ++++++++++++++++++++++++ 3 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 crates/arr-daemon/src/series_refresh.rs diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index feb736e..06bbe52 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -1121,7 +1121,7 @@ pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&st last_searched_at.with_timezone(&chrono::Utc) + backoff <= chrono::Utc::now() } -fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool { +pub(crate) fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool { let Some(refreshed_at) = metadata_refreshed_at else { return true; }; diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index f66892a..6d06d21 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -12,6 +12,7 @@ mod notify; mod reaper; pub mod reconcile; mod rss; +mod series_refresh; mod tv_grab; mod web; @@ -31,6 +32,7 @@ use notify::Notifier; use reaper::ReaperAction; use reconcile::{ReconcileLoop, Tick}; use rss::RssAction; +use series_refresh::SeriesRefreshAction; use tower_http::trace::TraceLayer; use tv_grab::TvGrabAction; @@ -268,6 +270,13 @@ fn reconcile_loop( ), ); } + // §8: metadata refresh is its own daily lane, staggered against the + // other ticks. Without TMDB there is nothing to refresh from. + if let Some(tmdb) = tmdb { + reconcile = reconcile.register(Tick::Metadata, SeriesRefreshAction::new(Arc::clone(tmdb))); + } else { + tracing::warn!("TMDB is not configured: series metadata refresh is disabled"); + } let jellyfin = jellyfin::JellyfinClient::new( config.jellyfin_url.clone(), config.jellyfin_api_key.clone(), diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs new file mode 100644 index 0000000..eae7479 --- /dev/null +++ b/crates/arr-daemon/src/series_refresh.rs @@ -0,0 +1,731 @@ +//! The daily series metadata refresh. See DESIGN.md §8 and issue #121. +//! +//! The database rows are the work list (§8): a series whose +//! `metadata_refreshed_at` has passed its time-to-live is due. One refresh +//! pulls the series detail from TMDB and closes every gap it reveals: +//! +//! - seasons TMDB knows that the library does not are inserted, with +//! `apply_auto_track` deciding their tracking rule; +//! - episodes revealed into an existing season arrive wanted when that season +//! is `tracked` (`apply_tracked`, §4.1); +//! - air dates and titles of known episodes follow TMDB, because both §4.2's +//! `airing` window and §6.2's do-not-search-before-it-exists gate read them; +//! - `upstream_ended` follows TMDB's status, which `ended` is derived from; +//! - a missing `tvdb_id` is backfilled (#120). +//! +//! Episodes that vanish or renumber upstream are left alone (#122). Refresh +//! is idempotent: over unchanged TMDB data only the stamp moves. + +use std::collections::HashMap; +use std::sync::Arc; + +use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; +use arr_core::{ + Episode as CoreEpisode, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, + TitleOverrides, +}; +use arr_db::policy::language; +use arr_db::Db; +use arr_meta::{Season as TmdbSeasonDetail, Series as TmdbSeries, TmdbClient}; + +use crate::grab::metadata_refresh_due; +use crate::reconcile::{Action, ActionFuture, Outcome}; + +/// TMDB statuses that mean no further episodes will ever appear. +fn is_upstream_ended(status: &str) -> bool { + matches!(status, "Ended" | "Canceled") +} + +#[derive(Debug, thiserror::Error)] +enum RefreshError { + #[error("database: {0}")] + Database(#[from] sqlx::Error), + #[error("tmdb: {0}")] + Tmdb(#[from] arr_meta::Error), + #[error("series {0}: tmdb_id does not fit")] + InvalidTmdbId(i64), +} + +/// Refreshes one series' metadata from TMDB per day (§8), on the same TTL +/// gate as the movie lane. +#[derive(Debug)] +pub struct SeriesRefreshAction { + tmdb: Arc, +} + +impl SeriesRefreshAction { + #[must_use] + pub fn new(tmdb: Arc) -> Self { + Self { tmdb } + } + + async fn tick(&self, database: &Db) -> Result, RefreshError> { + let due = sqlx::query_as!( + DueSeries, + r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, + title AS "title!: String", year, original_language, + root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", + upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at + FROM series + ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# + ) + .fetch_all(database.pool()) + .await?; + let mut outcomes = Vec::new(); + for stale in due { + if !metadata_refresh_due(stale.metadata_refreshed_at.as_deref()) { + continue; + } + match self.refresh_series(database, &stale).await { + Ok(Some(outcome)) => outcomes.push(outcome), + Ok(None) => {} + // One series' failure must not cost the rest of the tick. + Err(error) => tracing::error!( + series_id = stale.id, + title = stale.title, + %error, + "series metadata refresh failed" + ), + } + } + Ok(outcomes) + } + + async fn refresh_series( + &self, + database: &Db, + stale: &DueSeries, + ) -> Result, RefreshError> { + let tmdb_id = + u32::try_from(stale.tmdb_id).map_err(|_| RefreshError::InvalidTmdbId(stale.id))?; + let metadata = self.tmdb.series(tmdb_id).await?; + + let mut transaction = database.pool().begin().await?; + let mut changed = self + .sync_series_fields(&mut transaction, stale, &metadata) + .await?; + + // Existing seasons keep their tracking rule; their episode lists are + // brought up to date in place. Seasons TMDB knows and the library + // does not go through the one function that owns the reveal rule. + let existing = sqlx::query!( + r#"SELECT id AS "id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" + FROM seasons WHERE series_id = ?"#, + stale.id + ) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .map(|season| (season.number, (season.id, season.tracked))) + .collect::>(); + let mut revealed = Vec::new(); + for summary in &metadata.seasons { + if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) { + changed |= self + .sync_season( + &mut transaction, + season_id, + tracked, + tmdb_id, + summary.number, + ) + .await?; + } else { + let detail = self.tmdb.season(tmdb_id, summary.number).await?; + revealed.push(RefreshedSeason { + season: arr_core::Season { + id: SeasonId(0), + series_id: SeriesId(stale.id), + number: season_number(summary.number), + tracked: false, + }, + episodes: core_episodes(stale.id, &detail), + is_new: true, + }); + } + } + apply_auto_track(&core_series(stale), &mut revealed); + for refreshed in revealed { + self.insert_revealed_season(&mut transaction, stale.id, refreshed) + .await?; + changed = true; + } + + transaction.commit().await?; + stamp_refreshed(database, stale.id).await?; + Ok(changed.then(|| { + Outcome::new( + format!("series {} ({})", stale.id, stale.title), + "refreshed metadata from TMDB", + ) + })) + } + + /// The series-level fields a refresh owns. Both writes are guarded so + /// unchanged data moves nothing. + async fn sync_series_fields( + &self, + executor: &mut sqlx::SqliteConnection, + stale: &DueSeries, + metadata: &TmdbSeries, + ) -> Result { + let mut changed = false; + // §6.1: `t=tvsearch` prefers the TVDB id. Series added before the id + // existed on add (#120) pick theirs up here. + if stale.tvdb_id.is_none() { + if let Some(tvdb_id) = metadata.tvdb_id.map(i64::from) { + sqlx::query!( + "UPDATE series SET tvdb_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + tvdb_id, + stale.id + ) + .execute(&mut *executor) + .await?; + changed = true; + } + } + let ended = is_upstream_ended(&metadata.status); + if ended != stale.upstream_ended { + sqlx::query!( + "UPDATE series SET upstream_ended = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + ended, + stale.id + ) + .execute(&mut *executor) + .await?; + changed = true; + } + Ok(changed) + } + + /// Persist one season the library has not seen, with `apply_auto_track`'s + /// verdict on its tracking rule already applied. + async fn insert_revealed_season( + &self, + executor: &mut sqlx::SqliteConnection, + series_id: i64, + refreshed: RefreshedSeason, + ) -> Result<(), RefreshError> { + let season_number = i64::from(refreshed.season.number); + let season_id = sqlx::query_scalar!( + "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id", + series_id, + season_number, + refreshed.season.tracked + ) + .fetch_one(&mut *executor) + .await?; + for episode in &refreshed.episodes { + let episode_number = i64::from(episode.number); + let episode_title = episode.title.clone(); + let air_date = air_date_string(episode.air_date); + sqlx::query!( + "INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)", + season_id, + episode_number, + episode_title, + air_date, + episode.wanted + ) + .execute(&mut *executor) + .await?; + } + Ok(()) + } + + /// Bring one persisted season's episodes up to date with TMDB's. + /// + /// Known episodes get guarded updates — nothing writes unless a value + /// actually moved — and unknown numbers are revealed wanted exactly when + /// the season is tracked (§4.1). Numbers absent from TMDB stay untouched. + async fn sync_season( + &self, + executor: &mut sqlx::SqliteConnection, + season_id: i64, + tracked: bool, + tmdb_id: u32, + number: u32, + ) -> Result { + let detail = self.tmdb.season(tmdb_id, number).await?; + let known = sqlx::query!( + r#"SELECT id AS "id!: i64", number AS "number!: i64", + title AS "title!: String", air_date + FROM episodes WHERE season_id = ?"#, + season_id + ) + .fetch_all(&mut *executor) + .await? + .into_iter() + .map(|episode| { + ( + episode.number, + (episode.id, episode.title, episode.air_date), + ) + }) + .collect::>(); + + let mut fresh = Vec::new(); + let mut changed = false; + for source in &detail.episodes { + let number = i64::from(source.number); + if let Some(&(id, ref title, ref air_date)) = known.get(&number) { + let new_air_date = source.air_date.map(|date| date.to_string()); + if *title != source.title || *air_date != new_air_date { + sqlx::query!( + "UPDATE episodes SET title = ?, air_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND (title IS NOT ? OR air_date IS NOT ?)", + source.title, + new_air_date, + id, + source.title, + new_air_date + ) + .execute(&mut *executor) + .await?; + changed = true; + } + } else { + fresh.push(CoreEpisode { + id: EpisodeId(0), + season_id: SeasonId(season_id), + number: season_number(source.number), + title: source.title.clone(), + air_date: source.air_date.map(system_time), + wanted: false, + state: MediaState::Missing, + search_attempts: 0, + last_searched_at: None, + }); + } + } + apply_tracked(tracked, &mut fresh); + for episode in fresh { + let episode_number = i64::from(episode.number); + let air_date = air_date_string(episode.air_date); + sqlx::query!( + "INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)", + season_id, + episode_number, + episode.title, + air_date, + episode.wanted + ) + .execute(&mut *executor) + .await?; + changed = true; + } + Ok(changed) + } +} + +impl Action for SeriesRefreshAction { + fn name(&self) -> &'static str { + "series-metadata-refresh" + } + + fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> { + Box::pin(async move { self.tick(database).await.map_err(Into::into) }) + } +} + +struct DueSeries { + id: i64, + tmdb_id: i64, + tvdb_id: Option, + title: String, + year: Option, + original_language: Option, + root_id: i64, + auto_track: bool, + upstream_ended: bool, + metadata_refreshed_at: Option, +} + +/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, +/// STRICT). A number past `u16::MAX` cannot match anything real and would +/// never be grabbed anyway. +fn season_number(number: u32) -> u16 { + u16::try_from(number.min(u32::from(u16::MAX))).unwrap_or(u16::MAX) +} + +fn core_series(series: &DueSeries) -> arr_core::Series { + arr_core::Series { + id: SeriesId(series.id), + tmdb_id: u64::try_from(series.tmdb_id).unwrap_or_default(), + title: series.title.clone(), + year: series + .year + .and_then(|year| u16::try_from(year).ok()) + .unwrap_or_default(), + original_language: series + .original_language + .as_deref() + .map_or(Language::Other(String::new()), language), + root_id: RootId(series.root_id), + auto_track: series.auto_track, + overrides: TitleOverrides::default(), + upstream_ended: series.upstream_ended, + blocked: false, + } +} + +fn core_episodes(series_id: i64, detail: &TmdbSeasonDetail) -> Vec { + detail + .episodes + .iter() + .map(|source| CoreEpisode { + id: EpisodeId(0), + season_id: SeasonId(series_id), + number: season_number(source.number), + title: source.title.clone(), + air_date: source.air_date.map(system_time), + wanted: false, + state: MediaState::Missing, + search_attempts: 0, + last_searched_at: None, + }) + .collect() +} + +/// An `air_date` as the API writes it, so a refresh and a hand-created +/// season agree byte for byte and the guarded update sees no change. +fn air_date_string(air_date: Option) -> Option { + air_date.map(|when| { + chrono::DateTime::::from(when) + .date_naive() + .to_string() + }) +} + +/// Dates arrive as `%Y-%m-%d`; midnight UTC carries them losslessly. +fn system_time(date: chrono::NaiveDate) -> std::time::SystemTime { + date.and_time(chrono::NaiveTime::MIN).and_utc().into() +} + +async fn stamp_refreshed(database: &Db, series_id: i64) -> Result<(), RefreshError> { + // Stamped even when nothing changed, or the TTL gate above never engages + // and every tick pays for TMDB again — same reason as the movie lane. + sqlx::query!( + "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + series_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::Arc; + + use arr_db::Db; + use serde_json::json; + use tempfile::TempDir; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + + fn series_body(status: &str) -> serde_json::Value { + json!({ + "id": 82_728, + "name": "Bluey", + "original_language": "en", + "first_air_date": "2018-10-01", + "status": status, + "seasons": [ + {"season_number": 1, "episode_count": 2}, + {"season_number": 2, "episode_count": 1} + ], + "external_ids": {"tvdb_id": 361_391} + }) + } + + fn season_one_body(episodes: &serde_json::Value) -> serde_json::Value { + json!({"season_number": 1, "episodes": episodes}) + } + + fn two_episodes() -> serde_json::Value { + json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, + {"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"} + ]) + } + + fn season_two_body() -> serde_json::Value { + json!({ + "season_number": 2, + "episodes": [ + {"episode_number": 1, "name": "Dance Mode", "air_date": "2019-04-01"} + ] + }) + } + + /// A TMDB serving one series with two seasons. `reset` between phases of + /// a test and remount, counting requests by delta around each phase. + async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer { + let server = MockServer::start().await; + mount(&server, status, season_one).await; + server + } + + async fn mount(server: &MockServer, status: &str, season_one: serde_json::Value) { + Mock::given(method("GET")) + .and(path("/tv/82728")) + .respond_with(ResponseTemplate::new(200).set_body_json(series_body(status))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/tv/82728/season/1")) + .respond_with(ResponseTemplate::new(200).set_body_json(season_one)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/tv/82728/season/2")) + .respond_with(ResponseTemplate::new(200).set_body_json(season_two_body())) + .mount(server) + .await; + } + + /// Fresh client per tick, as a restarted process would have — so the only + /// thing that can suppress a request is the persisted TTL gate, never the + /// in-process response cache. + fn action(server: &MockServer) -> SeriesRefreshAction { + SeriesRefreshAction::new(Arc::new( + TmdbClient::builder("test-key".to_owned()) + .base_url(server.uri()) + .build() + .unwrap(), + )) + } + + async fn seeded_series(auto_track: bool) -> (TempDir, Db) { + let directory = tempfile::tempdir().unwrap(); + let database = Db::connect(directory.path().join("arr.db")).await.unwrap(); + database.migrate().await.unwrap(); + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (82728, 'Bluey', ?, ?)", + ) + .bind(root_id) + .bind(auto_track) + .execute(database.pool()) + .await + .unwrap(); + (directory, database) + } + + /// Force the next tick past the refresh TTL. + async fn expire_refresh(database: &Db) { + sqlx::query( + "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')", + ) + .execute(database.pool()) + .await + .unwrap(); + } + + #[tokio::test] + async fn first_refresh_reveals_seasons_and_applies_auto_track() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let rows: Vec<(i64, bool)> = + sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(rows, vec![(1, true), (2, true)]); + let wanted: i64 = sqlx::query_scalar( + "SELECT count(*) FROM episodes WHERE wanted = 1 AND state = 'missing'", + ) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(wanted, 3); + // #120 backfill: the series was added before it had an id on file. + let tvdb_id: Option = + sqlx::query_scalar("SELECT tvdb_id FROM series WHERE tmdb_id = 82728") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(tvdb_id, Some(361_391)); + let stamped: i64 = sqlx::query_scalar( + "SELECT count(*) FROM series WHERE metadata_refreshed_at IS NOT NULL", + ) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(stamped, 1); + } + + /// `auto_track` is a rule about reveals, not intent: without it nothing + /// arrives wanted, even though everything is revealed. + #[tokio::test] + async fn untracked_series_reveals_without_intent() { + let (_dir, database) = seeded_series(false).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + + action(&server).tick(&database).await.unwrap(); + + let rows: Vec<(i64, bool)> = + sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(rows, vec![(1, false), (2, false)]); + let wanted: i64 = sqlx::query_scalar("SELECT count(*) FROM episodes WHERE wanted = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(wanted, 0); + } + + /// The refresh has its own TTL: a stamped series costs no TMDB calls, + /// whatever else is due. + #[tokio::test] + async fn refresh_is_throttled_by_its_own_ttl() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + + action(&server).tick(&database).await.unwrap(); + let after_first = server.received_requests().await.unwrap().len(); + + let second = action(&server).tick(&database).await.unwrap(); + assert!(second.is_empty()); + assert_eq!( + server.received_requests().await.unwrap().len(), + after_first, + "the TTL gate must keep the tick off TMDB entirely" + ); + } + + /// A second run over unchanged TMDB data writes nothing and marks nothing + /// wanted twice — refresh is idempotent. + #[tokio::test] + async fn a_second_run_over_unchanged_data_writes_nothing() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + expire_refresh(&database).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert!( + outcomes.is_empty(), + "unchanged data must not report work done" + ); + let rows: Vec<(i64, bool)> = + sqlx::query_as("SELECT number, tracked FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(rows, vec![(1, true), (2, true)]); + let counts: (i64, i64) = + sqlx::query_as("SELECT count(*), coalesce(sum(wanted), 0) FROM episodes") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(counts, (3, 3)); + } + + /// Episodes revealed into an existing season follow that season's rule: + /// wanted when tracked, not otherwise. Known episodes' air dates and + /// titles follow TMDB; §4.2's `airing` window reads them. + #[tokio::test] + async fn revealed_episodes_follow_the_season_rule_and_details_follow_tmdb() { + let (_dir, database) = seeded_series(false).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + // One tracked season, one not — both already revealed above. + sqlx::query("UPDATE seasons SET tracked = 1 WHERE number = 1") + .execute(database.pool()) + .await + .unwrap(); + expire_refresh(&database).await; + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone!", "air_date": "2018-10-08"}, + {"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}, + {"episode_number": 3, "name": "Bike", "air_date": null} + ])), + ) + .await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let season_one: Vec<(i64, String, Option, bool)> = sqlx::query_as( + "SELECT e.number, e.title, e.air_date, e.wanted + FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 1 ORDER BY e.number", + ) + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!( + season_one, + vec![ + // Episodes 1 and 2 were revealed before tracking was turned + // on, and refresh never grants intent implicitly; only the + // newly revealed 3 follows the rule. + ( + 1, + "Magic Xylophone!".to_owned(), + Some("2018-10-08".to_owned()), + false + ), + ( + 2, + "Hospital".to_owned(), + Some("2018-10-02".to_owned()), + false + ), + (3, "Bike".to_owned(), None, true), + ], + "tracked season: new episode arrives wanted, changed details move" + ); + let season_two: Vec = sqlx::query_as( + "SELECT e.wanted FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 2", + ) + .fetch_all(database.pool()) + .await + .unwrap() + .into_iter() + .map(|(wanted,): (bool,)| wanted) + .collect(); + assert_eq!(season_two, vec![false], "untracked season stays unwanted"); + } + + /// §4.2 derives `ended` from `upstream_ended`, so TMDB's status must + /// reach the column. + #[tokio::test] + async fn upstream_ended_follows_tmdb_status() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + expire_refresh(&database).await; + server.reset().await; + mount(&server, "Canceled", season_one_body(&two_episodes())).await; + + action(&server).tick(&database).await.unwrap(); + + let ended: bool = + sqlx::query_scalar("SELECT upstream_ended FROM series WHERE tmdb_id = 82728") + .fetch_one(database.pool()) + .await + .unwrap(); + assert!(ended); + } +} From 3bcc612f9aad9ee9da0468a53c0986dfcfdeac17 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:59:41 +0100 Subject: [PATCH 30/71] feat(db): flag episodes that vanish upstream --- crates/arr-db/migrations/0019_vanished_episodes.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 crates/arr-db/migrations/0019_vanished_episodes.sql diff --git a/crates/arr-db/migrations/0019_vanished_episodes.sql b/crates/arr-db/migrations/0019_vanished_episodes.sql new file mode 100644 index 0000000..73c7bf7 --- /dev/null +++ b/crates/arr-db/migrations/0019_vanished_episodes.sql @@ -0,0 +1,7 @@ +-- #122. TMDB deletes and renumbers episodes. An episode that vanished +-- upstream and has no file is deleted outright by the refresh; one that has +-- a file is never deleted — `media_files.path` is UNIQUE and its owner is +-- polymorphic, so dropping the row would orphan a real file — and instead +-- carries this flag as the conflict the operator resolves. +ALTER TABLE episodes ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0 + CHECK (vanished IN (0, 1)); From 8b9a6d5a16110bd2b0e273e80e46d2bb29439a1a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:59:45 +0100 Subject: [PATCH 31/71] feat(daemon): reconcile episodes that vanish upstream --- ...75a774d0570104d0b089fd2e5ed2e85e66f0c.json | 12 + ...c9b0219c18d2321720c060e1d22bb398d7d3d.json | 20 ++ ...eb3922336dfebee6f3fec7987fd538e472c34.json | 12 + ...e501b3ef1e0d742e6eb06e1dc2fb4c524fb7.json} | 12 +- ...4f1f21dacc8cdae322af3d4af82224d5b96c8.json | 12 + crates/arr-daemon/src/series_refresh.rs | 257 +++++++++++++++++- 6 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 .sqlx/query-3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c.json create mode 100644 .sqlx/query-40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d.json create mode 100644 .sqlx/query-625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34.json rename .sqlx/{query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json => query-a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7.json} (62%) create mode 100644 .sqlx/query-caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8.json diff --git a/.sqlx/query-3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c.json b/.sqlx/query-3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c.json new file mode 100644 index 0000000..fb98de2 --- /dev/null +++ b/.sqlx/query-3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM episodes WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c" +} diff --git a/.sqlx/query-40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d.json b/.sqlx/query-40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d.json new file mode 100644 index 0000000..4b60ee0 --- /dev/null +++ b/.sqlx/query-40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM media_files\n WHERE owner_kind = 'episode' AND owner_id = ?\n ) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d" +} diff --git a/.sqlx/query-625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34.json b/.sqlx/query-625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34.json new file mode 100644 index 0000000..de4f579 --- /dev/null +++ b/.sqlx/query-625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE episodes SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34" +} diff --git a/.sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json b/.sqlx/query-a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7.json similarity index 62% rename from .sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json rename to .sqlx/query-a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7.json index 7c81fe5..1505ebc 100644 --- a/.sqlx/query-dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644.json +++ b/.sqlx/query-a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date\n FROM episodes WHERE season_id = ?", + "query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date,\n vanished AS \"vanished!: bool\"\n FROM episodes WHERE season_id = ?", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "air_date", "ordinal": 3, "type_info": "Text" + }, + { + "name": "vanished!: bool", + "ordinal": 4, + "type_info": "Integer" } ], "parameters": { @@ -31,8 +36,9 @@ true, false, false, - true + true, + false ] }, - "hash": "dde89ed2cd3d783c5946a593e4ef31e9aef2846b9061c8dccd6c6f6be8c04644" + "hash": "a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7" } diff --git a/.sqlx/query-caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8.json b/.sqlx/query-caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8.json new file mode 100644 index 0000000..1360645 --- /dev/null +++ b/.sqlx/query-caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE episodes SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8" +} diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index eae7479..350fca0 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -13,8 +13,14 @@ //! - `upstream_ended` follows TMDB's status, which `ended` is derived from; //! - a missing `tvdb_id` is backfilled (#120). //! -//! Episodes that vanish or renumber upstream are left alone (#122). Refresh -//! is idempotent: over unchanged TMDB data only the stamp moves. +//! Episodes that vanish upstream follow two rules (#122): without a file of +//! their own they are deleted outright, cascading through `episode_releases`; +//! with one they are flagged `vanished` instead, because deleting the row +//! would orphan a real file (`media_files` is polymorphic on its owner). A +//! vanished number that reappears clears its flag again. Renumbering needs no +//! matching of its own — it is just these two rules seen from both ends. +//! +//! Refresh is idempotent: over unchanged TMDB data only the stamp moves. use std::collections::HashMap; use std::sync::Arc; @@ -237,7 +243,8 @@ impl SeriesRefreshAction { /// /// Known episodes get guarded updates — nothing writes unless a value /// actually moved — and unknown numbers are revealed wanted exactly when - /// the season is tracked (§4.1). Numbers absent from TMDB stay untouched. + /// the season is tracked (§4.1). Numbers TMDB no longer lists are + /// reconciled by `reconcile_vanished` (#122). async fn sync_season( &self, executor: &mut sqlx::SqliteConnection, @@ -249,7 +256,8 @@ impl SeriesRefreshAction { let detail = self.tmdb.season(tmdb_id, number).await?; let known = sqlx::query!( r#"SELECT id AS "id!: i64", number AS "number!: i64", - title AS "title!: String", air_date + title AS "title!: String", air_date, + vanished AS "vanished!: bool" FROM episodes WHERE season_id = ?"#, season_id ) @@ -259,7 +267,12 @@ impl SeriesRefreshAction { .map(|episode| { ( episode.number, - (episode.id, episode.title, episode.air_date), + ( + episode.id, + episode.title, + episode.air_date, + episode.vanished, + ), ) }) .collect::>(); @@ -268,7 +281,7 @@ impl SeriesRefreshAction { let mut changed = false; for source in &detail.episodes { let number = i64::from(source.number); - if let Some(&(id, ref title, ref air_date)) = known.get(&number) { + if let Some(&(id, ref title, ref air_date, vanished)) = known.get(&number) { let new_air_date = source.air_date.map(|date| date.to_string()); if *title != source.title || *air_date != new_air_date { sqlx::query!( @@ -283,6 +296,17 @@ impl SeriesRefreshAction { .await?; changed = true; } + // The number is back upstream — a TMDB reversal, or a + // renumber seen from the other end. The conflict is over. + if vanished { + sqlx::query!( + "UPDATE episodes SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + id + ) + .execute(&mut *executor) + .await?; + changed = true; + } } else { fresh.push(CoreEpisode { id: EpisodeId(0), @@ -313,6 +337,56 @@ impl SeriesRefreshAction { .await?; changed = true; } + changed |= self.reconcile_vanished(executor, &known, &detail).await?; + Ok(changed) + } + + /// #122. Numbers the library knows that TMDB no longer lists have + /// vanished upstream. One without a `media_files` row is deleted — the + /// foreign key cascades through `episode_releases` — and one with a file + /// is flagged instead: `media_files.path` is UNIQUE and its owner is + /// polymorphic, so dropping the row would orphan a real file and block + /// re-importing that path, the same trap `movies.rs` documents on the + /// movie side. Nothing here touches the disk. + async fn reconcile_vanished( + &self, + executor: &mut sqlx::SqliteConnection, + known: &HashMap, bool)>, + detail: &TmdbSeasonDetail, + ) -> Result { + let upstream: HashMap = detail + .episodes + .iter() + .map(|source| (i64::from(source.number), ())) + .collect(); + let mut changed = false; + for (number, &(id, ..)) in known { + if upstream.contains_key(number) { + continue; + } + let has_file = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM media_files + WHERE owner_kind = 'episode' AND owner_id = ? + ) AS "exists!: bool""#, + id + ) + .fetch_one(&mut *executor) + .await?; + if has_file { + sqlx::query!( + "UPDATE episodes SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + id + ) + .execute(&mut *executor) + .await?; + } else { + sqlx::query!("DELETE FROM episodes WHERE id = ?", id) + .execute(&mut *executor) + .await?; + } + changed = true; + } Ok(changed) } } @@ -728,4 +802,175 @@ mod tests { .unwrap(); assert!(ended); } + + async fn season_one_episode(database: &Db, number: i64) -> i64 { + sqlx::query_scalar( + "SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 1 AND e.number = ?", + ) + .bind(number) + .fetch_one(database.pool()) + .await + .unwrap() + } + + /// A stored release candidate for `episode_id`, standing in for the rows + /// a search leaves behind. + async fn attach_release(database: &Db, episode_id: i64) { + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible') + RETURNING id", + ) + .bind(format!("guid-{episode_id}")) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)") + .bind(episode_id) + .bind(release_id) + .execute(database.pool()) + .await + .unwrap(); + } + + /// An imported file for `episode_id`, as the import tick records it. + async fn attach_file(database: &Db, episode_id: i64) { + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)", + ) + .bind(episode_id) + .bind(format!("/mnt/media/tv/main/episode-{episode_id}.mkv")) + .execute(database.pool()) + .await + .unwrap(); + } + + /// #122. TMDB dropped episode 2: with no file of its own it is deleted, + /// and its stored releases cascade with it. The wanted flag that would + /// otherwise pin the series at `incomplete` goes with the row. + #[tokio::test] + async fn a_vanished_episode_without_a_file_is_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode_two = season_one_episode(&database, 2).await; + attach_release(&database, episode_two).await; + expire_refresh(&database).await; + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"} + ])), + ) + .await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1, "a removal is work worth reporting"); + let remaining: Vec = sqlx::query_scalar( + "SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 1 ORDER BY e.number", + ) + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(remaining, vec![1]); + let cascaded: i64 = + sqlx::query_scalar("SELECT count(*) FROM episode_releases WHERE episode_id = ?") + .bind(episode_two) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(cascaded, 0); + } + + /// #122. With a file on record the vanished episode is never deleted: + /// `media_files.path` is UNIQUE and its owner polymorphic, so dropping + /// the row would orphan a real file. It is flagged instead — the conflict + /// the operator resolves — and the file row stays put. + #[tokio::test] + async fn a_vanished_episode_with_a_file_is_flagged_not_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode_two = season_one_episode(&database, 2).await; + attach_file(&database, episode_two).await; + expire_refresh(&database).await; + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"} + ])), + ) + .await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let (vanished, files): (i64, i64) = sqlx::query_as( + "SELECT e.vanished, + (SELECT count(*) FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id) + FROM episodes e WHERE e.id = ?", + ) + .bind(episode_two) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!( + (vanished, files), + (1, 1), + "flagged as a conflict, file intact" + ); + } + + /// Idempotence over the new writes too: a second refresh over the same + /// TMDB data neither re-reports nor rewrites, and a number TMDB restores + /// clears the flag again. + #[tokio::test] + async fn a_restored_number_clears_the_vanished_flag() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode_two = season_one_episode(&database, 2).await; + attach_file(&database, episode_two).await; + expire_refresh(&database).await; + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"} + ])), + ) + .await; + action(&server).tick(&database).await.unwrap(); + expire_refresh(&database).await; + // TMDB puts the episode back where it was. + server.reset().await; + mount( + &server, + "Returning Series", + season_one_body(&json!([ + {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, + {"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"} + ])), + ) + .await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let vanished: bool = sqlx::query_scalar("SELECT vanished FROM episodes WHERE id = ?") + .bind(episode_two) + .fetch_one(database.pool()) + .await + .unwrap(); + assert!(!vanished, "the conflict is over once TMDB lists it again"); + } } From 043ebffa1c4a7e76ef6cf23c806ad802a26dedb7 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:00:54 +0100 Subject: [PATCH 32/71] feat(web): series and episodes in unified search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-library hits now read as whatever they are: movies open the release deck as before, series hits render display-only until issue 129's detail view gives them a link target, and an episode hit reads as its series plus SxxEyy plus the episode title. TMDB results show TV alongside film, and the add panel filters roots by kind; a series adds the one §4.1 add-time choice, auto_track, stated plainly: future seasons are tracked automatically, past seasons are not. --- .impeccable/surfaces/web-src-main-ts.md | 19 ++- web/src/main.ts | 147 ++++++++++++++++++++---- web/src/search.ts | 10 -- web/src/style.css | 14 +++ 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index 1082c9f..e54011a 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -17,8 +17,11 @@ reads the library as derived status, not flags. anywhere. - Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below, engraved micro-label headings, machined rows, chips on the right. -- Add flow: inline panel under a TMDB row — root radio cards (audience name, - policy name, path), main pre-selected, one confirm. Never a separate page. +- Add flow: inline panel under a TMDB row of either kind — root radio cards + (audience name, policy name, path), main pre-selected, one confirm. Never a + separate page. A series adds the one §4.1 add-time question: an auto-track + pressed control (default on) with the plain line "future seasons are tracked + automatically; past seasons are not", echoed in the added confirmation. - Release deck (§9.3, issue #31): a library row opens the buckets view. ELIGIBLE leads with fixed-width attribute chips in aligned mono columns (rem-width `.cw-*` classes shared by chips and the column header, so the @@ -59,7 +62,13 @@ reads the library as derived status, not flags. - Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS, cyan for interaction only, dead-air rule (no idle animation), no horizontal scroll at any viewport (§9.3 acceptance criterion). +- Unified search covers both kinds (issue 130): in-library hits read as + whatever they are — a movie row, a series hit (display-only until the + detail view of issue 129 exists) and an episode hit rendered as its series, + an `SxxEyy` chip and the episode title in sans beside it. TMDB rows show TV + alongside film; the "in library" dedup spans movies and series. - Unresolved: releases land in the persisted store only once the daemon - consumes search commands (#68-adjacent); TMDB search is movie-only until - series land (#39); per-season and per-episode library actions are #39; - attention queues surface is #33. + consumes search commands (#68-adjacent); episode hits have no link target + until issue 129's series detail view exists — they render display-only for + now; per-season and per-episode library actions are #39; attention queues + surface is #33. diff --git a/web/src/main.ts b/web/src/main.ts index b79e336..344d843 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -42,15 +42,18 @@ import { import { currentRoute, navigate, type Route } from "./router"; import { addMovie, + addSeries, allRoots, fetchMovie, + type LibraryEpisodeHit, type LibraryMovie, - movieRoots, + type LibrarySeriesHit, parseManualInput, type Root, type SearchResponse, searchTitles, type TmdbMovie, + type TmdbSeries, } from "./search"; import { settingsMain } from "./settings"; import "./style.css"; @@ -322,11 +325,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV manualIntake: must("#manual-intake"), }; - // The audience chip on a library row needs id → root. movieRoots() caches + // The audience chip on a library row needs id → root. allRoots() caches // success, so a failed boot fetch is retried whenever an add panel opens. let roots: Root[] = []; const fetchRoots = () => - movieRoots().then( + allRoots().then( (fetched) => { roots = fetched; return fetched; @@ -406,11 +409,18 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV return; } - // Series and episode hits render in issue 130; movies keep the current row. + // §9.2: in-library hits read as whatever they are — a movie, a series, + // or an episode with its series and SxxEyy for context const libraryMovies = response.library.filter( (hit): hit is LibraryMovie => hit.kind === "movie", ); - const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id)); + const librarySeries = response.library.filter( + (hit): hit is LibrarySeriesHit => hit.kind === "series", + ); + const libraryEpisodes = response.library.filter( + (hit): hit is LibraryEpisodeHit => hit.kind === "episode", + ); + const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id)); if (response.library.length === 0 && response.tmdb.length === 0) { setStatus("no matches in library or on tmdb"); return; @@ -428,14 +438,17 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV }), ); } + for (const series of librarySeries) { + refs.groups.library.rows.append(librarySeriesRow(series, roots)); + } + for (const episode of libraryEpisodes) { + refs.groups.library.rows.append(episodeRow(episode)); + } } if (response.tmdb.length > 0) { refs.groups.tmdb.section.hidden = false; refs.groups.tmdb.count.textContent = String(response.tmdb.length); for (const hit of response.tmdb) { - if (hit.kind !== "movie") { - continue; - } refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots)); } } @@ -606,8 +619,51 @@ function libraryRow( return item; } +/** + * An in-library series hit. Display-only, like its library-view sibling — + * the series detail view it would open into is issue 129. + */ +function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElement { + const item = document.createElement("li"); + const row = document.createElement("div"); + row.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + const root = roots.find((candidate) => candidate.id === series.root_id); + chips.append(chip(root ? root.audience : `root ${series.root_id}`)); + if (series.blocked) { + chips.append(chip("blocked")); + } + row.append(rowTitle(series.title, series.year), chips); + item.append(row); + return item; +} + +/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */ +function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement { + const item = document.createElement("li"); + const row = document.createElement("div"); + row.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + chips.append( + chip(episode.tag, (span) => { + span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`); + }), + ); + // the episode title is a name, not a readout: sans, never mono + const title = document.createElement("span"); + title.className = "row-sub"; + title.textContent = episode.title; + chips.append(title); + row.append(rowTitle(episode.series_title, null), chips); + item.append(row); + return item; +} + +/** A TMDB hit of either kind — the row opens the add flow (§9.2). */ function tmdbRow( - movie: TmdbMovie, + hit: TmdbMovie | TmdbSeries, inLibrary: boolean, fetchRoots: () => Promise, ): HTMLLIElement { @@ -618,7 +674,7 @@ function tmdbRow( row.className = "row row-tmdb"; const chips = document.createElement("span"); chips.className = "row-chips"; - chips.append(chip(movie.original_language)); + chips.append(chip(hit.original_language)); if (inLibrary) { chips.append( chip("in library", (span) => { @@ -631,11 +687,11 @@ function tmdbRow( add.textContent = "add"; chips.append(add); } - row.append(rowTitle(movie.title, movie.year), chips); - if (movie.overview) { + row.append(rowTitle(hit.title, hit.year), chips); + if (hit.overview) { const overview = document.createElement("span"); overview.className = "row-overview"; - overview.textContent = movie.overview; + overview.textContent = hit.overview; row.append(overview); } item.append(row); @@ -658,7 +714,7 @@ function tmdbRow( if (item.querySelector(".add-panel")) { return; } - const panel = addPanel(movie, available, row); + const panel = addPanel(hit, available, row); item.append(panel); row.setAttribute("aria-expanded", "true"); panel.querySelector(".control")?.focus(); @@ -667,8 +723,19 @@ function tmdbRow( return item; } -/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */ -function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement { +/** + * The §9.2 add flow: inline, root and policy pre-filled, one confirm. A + * series asks the one series-level question at add time — `auto_track` + * (§4.1) — and states what it does in one plain line. + */ +function addPanel( + title: TmdbMovie | TmdbSeries, + available: Root[], + row: HTMLButtonElement, +): HTMLElement { + const isSeries = title.kind === "series"; + const kind = isSeries ? "tv" : "movie"; + const roots = available.filter((root) => root.kind === kind); const panel = document.createElement("div"); panel.className = "add-panel"; @@ -707,6 +774,34 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML options.append(option); } + // §4.1: auto_track is a rule about seasons metadata reveals, not intent. + // Future seasons are picked up by themselves; past seasons never are. + let autoTrack = true; + let autotrack: HTMLElement | null = null; + if (isSeries) { + const track = document.createElement("button"); + track.type = "button"; + track.className = "control control-quiet"; + track.setAttribute("aria-pressed", "true"); + const trackState = () => { + track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`; + }; + trackState(); + track.addEventListener("click", () => { + autoTrack = !autoTrack; + track.setAttribute("aria-pressed", String(autoTrack)); + trackState(); + }); + + const trackNote = document.createElement("p"); + trackNote.className = "add-note readout dim"; + trackNote.textContent = "future seasons are tracked automatically; past seasons are not"; + + autotrack = document.createElement("div"); + autotrack.className = "add-autotrack"; + autotrack.append(track, trackNote); + } + const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "control"; @@ -720,7 +815,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML if (roots.length === 0) { confirm.disabled = true; note.hidden = false; - note.textContent = "roots unavailable — daemon down or database missing"; + // a failed fetch and an absent kind are different problems + note.textContent = + available.length === 0 + ? "roots unavailable — daemon down or database missing" + : `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`; note.dataset.tone = "fault"; } @@ -733,7 +832,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML note.hidden = false; delete note.dataset.tone; note.textContent = "adding…"; - void addMovie(movie, root.id).then((outcome) => { + const adding = + title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack); + void adding.then((outcome) => { if (outcome.kind === "error") { confirm.disabled = false; note.textContent = `add failed — ${outcome.detail}`; @@ -747,7 +848,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML done.tabIndex = -1; done.textContent = already ? "already in library" - : `added to ${root.audience} — wanted, search follows`; + : title.kind === "movie" + ? `added to ${root.audience} — wanted, search follows` + : `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`; panel.replaceChildren(done); done.focus(); row.disabled = true; @@ -763,7 +866,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML const actions = document.createElement("div"); actions.className = "add-actions"; actions.append(confirm, note); - panel.append(options, actions); + if (autotrack) { + panel.append(options, autotrack, actions); + } else { + panel.append(options, actions); + } return panel; } diff --git a/web/src/search.ts b/web/src/search.ts index d45909e..5bba581 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -129,16 +129,6 @@ export async function allRoots(): Promise { return rootsCache; } -/** The movie roots. The add flow pre-fills from these. */ -export async function movieRoots(): Promise { - return (await allRoots()).filter((root) => root.kind === "movie"); -} - -/** The TV roots. The series add flow pre-fills from these. */ -export async function tvRoots(): Promise { - return (await allRoots()).filter((root) => root.kind === "tv"); -} - /** The stored series a successful POST /api/series returns. */ export interface ApiSeries { id: number; diff --git a/web/src/style.css b/web/src/style.css index 858c344..dfe4dd6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -724,6 +724,12 @@ body { text-overflow: ellipsis; } +/* an episode hit's title sits beside its SxxEyy chip: a name, not a readout */ +.row-sub { + font-size: var(--text-sm); + color: var(--ink-muted); +} + /* ---- add flow (inline, root and policy pre-filled) -------------------- */ .add-panel { @@ -786,6 +792,14 @@ body { margin: var(--space-3) 0 0; } +/* the one series-level add-time question (§4.1): the choice and what it does */ +.add-autotrack { + display: grid; + justify-content: start; + gap: var(--space-1); + margin: var(--space-3) 0 0; +} + .add-note { margin: 0; font-size: var(--text-xs); From 34ccbeeb5ade33c1492630b56e09482f79e50d6d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 17:59:45 +0100 Subject: [PATCH 33/71] feat(api): expose vanished flag on episodes --- ...dc3f510193b9c97788503f06c5c23f859780349a.json} | 14 ++++++++++---- ...fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json} | 14 ++++++++++---- ...24d3098a0bbad30b46b23287131de72e64751256.json} | 14 ++++++++++---- ...f2fb149c57293c76bad04f01603f11635789222d.json} | 14 ++++++++++---- crates/arr-api/src/series.rs | 15 +++++++++++---- crates/arr-daemon/src/series_refresh.rs | 8 ++++---- 6 files changed, 55 insertions(+), 24 deletions(-) rename .sqlx/{query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json => query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json} (75%) rename .sqlx/{query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json => query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json} (76%) rename .sqlx/{query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json => query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json} (77%) rename .sqlx/{query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json => query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json} (77%) diff --git a/.sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json b/.sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json similarity index 75% rename from .sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json rename to .sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json index cc1bb05..68d60f0 100644 --- a/.sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json +++ b/.sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number", "describe": { "columns": [ { @@ -44,13 +44,18 @@ "type_info": "Text" }, { - "name": "search_attempts!: i64", + "name": "vanished!: bool", "ordinal": 8, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ false, false, false, + false, true ] }, - "hash": "7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372" + "hash": "3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a" } diff --git a/.sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json b/.sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json similarity index 76% rename from .sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json rename to .sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json index 78c5238..daea6c5 100644 --- a/.sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json +++ b/.sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?", "describe": { "columns": [ { @@ -44,13 +44,18 @@ "type_info": "Text" }, { - "name": "search_attempts!: i64", + "name": "vanished!: bool", "ordinal": 8, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ false, false, false, + false, true ] }, - "hash": "024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb" + "hash": "448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8" } diff --git a/.sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json b/.sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json similarity index 77% rename from .sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json rename to .sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json index 00a8bfb..791176f 100644 --- a/.sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json +++ b/.sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?", "describe": { "columns": [ { @@ -44,13 +44,18 @@ "type_info": "Text" }, { - "name": "search_attempts!: i64", + "name": "vanished!: bool", "ordinal": 8, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ false, false, false, + false, true ] }, - "hash": "ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882" + "hash": "633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256" } diff --git a/.sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json b/.sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json similarity index 77% rename from .sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json rename to .sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json index 7d131e4..db9aa13 100644 --- a/.sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json +++ b/.sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id", "describe": { "columns": [ { @@ -44,13 +44,18 @@ "type_info": "Text" }, { - "name": "search_attempts!: i64", + "name": "vanished!: bool", "ordinal": 8, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 10, "type_info": "Text" } ], @@ -67,8 +72,9 @@ false, false, false, + false, true ] }, - "hash": "3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26" + "hash": "c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d" } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 0c00c28..56e765b 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -110,6 +110,10 @@ pub struct Episode { /// §4.1. The only intent in the TV aggregate. pub wanted: bool, pub state: String, + /// #122. The episode vanished upstream while a file of its own remained, + /// which is why the row still exists. A conflict for the operator to + /// resolve; nothing was deleted from disk. + pub vanished: bool, pub search_attempts: i64, pub last_searched_at: Option, } @@ -197,6 +201,7 @@ struct EpisodeRow { air_date: Option, wanted: bool, state: String, + vanished: bool, search_attempts: i64, last_searched_at: Option, } @@ -345,7 +350,7 @@ async fn tv_by_series( .await?; let rows = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id"# ) .fetch_all(pool(state)?) @@ -390,7 +395,7 @@ async fn load_series(state: &AppState, id: i64) -> Result { .collect(); let episodes = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, id ) @@ -602,7 +607,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A .await?; let episodes = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#, series_id ) @@ -627,6 +632,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A air_date: episode.air_date.clone(), wanted: episode.wanted, state: episode.state.clone(), + vanished: episode.vanished, search_attempts: episode.search_attempts, last_searched_at: episode.last_searched_at.clone(), }) @@ -831,7 +837,7 @@ pub async fn update_season( async fn load_episode(state: &AppState, id: i64) -> Result { let row = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#, id ) @@ -846,6 +852,7 @@ async fn load_episode(state: &AppState, id: i64) -> Result { air_date: row.air_date, wanted: row.wanted, state: row.state, + vanished: row.vanished, search_attempts: row.search_attempts, last_searched_at: row.last_searched_at, }) diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 350fca0..12bac4e 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -22,7 +22,7 @@ //! //! Refresh is idempotent: over unchanged TMDB data only the stamp moves. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; @@ -354,14 +354,14 @@ impl SeriesRefreshAction { known: &HashMap, bool)>, detail: &TmdbSeasonDetail, ) -> Result { - let upstream: HashMap = detail + let upstream: HashSet = detail .episodes .iter() - .map(|source| (i64::from(source.number), ())) + .map(|source| i64::from(source.number)) .collect(); let mut changed = false; for (number, &(id, ..)) in known { - if upstream.contains_key(number) { + if upstream.contains(number) { continue; } let has_file = sqlx::query_scalar!( From 8d79c7c378e40e3fee4681cbfde8ec9d252b5fce Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:02:39 +0100 Subject: [PATCH 34/71] fix(parse): pack titles and degenerate ranges COMPLETE before the season tag was weak junk, so it stayed in the title and exact-title matching failed silently (#135). It now closes the boundary wherever it lands. S01-S01 produced no claim because the dash-range branch required last > season; a degenerate range is one season's pack, matching the rule match_episode lifted. --- crates/arr-core/src/matching.rs | 34 +++++++++++++++++++++++++++--- crates/arr-parse/src/episodes.rs | 7 ++++--- crates/arr-parse/src/markers.rs | 6 +++--- crates/arr-parse/tests/parse.rs | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/arr-core/src/matching.rs b/crates/arr-core/src/matching.rs index 16dc44e..50455f8 100644 --- a/crates/arr-core/src/matching.rs +++ b/crates/arr-core/src/matching.rs @@ -621,6 +621,10 @@ mod tests { "Fallout.S01.1080p.BluRay.x264-GROUP", MatchShape::SeasonPack, ), + ( + "Fallout.S01-S01.1080p.BluRay.x264-GROUP", + MatchShape::SeasonPack, + ), ]; for (name, shape) in cases { assert_eq!( @@ -639,6 +643,32 @@ mod tests { } } + /// `COMPLETE` before the season tag used to stay in the title and + /// break the exact-title comparison (#135). + #[test] + fn a_pack_marker_before_the_tag_still_matches() { + let wanted = WantedEpisode { + id: EpisodeId(10), + tmdb_id: None, + imdb_id: None, + title: "Bluey".to_owned(), + season: 1, + episode: 3, + }; + assert_eq!( + match_episode( + &wanted, + &ReleaseIds::default(), + &arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"), + ), + Some(EpisodeMatch { + episode: EpisodeId(10), + kind: MatchKind::TitleAndYear, + shape: MatchShape::SeasonPack, + }) + ); + } + /// Punctuation, case and accents in the series title do not block the /// match. #[test] @@ -808,9 +838,7 @@ mod tests { } } - /// `S01-S01` does not parse as a claim (`arr-parse` takes only a real - /// range), but the lifted rule is what a degenerate range would mean: - /// one season's pack, not a multi-season one. + /// A degenerate range means one season's pack, not a multi-season one. #[test] fn a_degenerate_season_range_is_this_seasons_pack() { use super::{is_single_episode, is_this_seasons_pack}; diff --git a/crates/arr-parse/src/episodes.rs b/crates/arr-parse/src/episodes.rs index 629ee54..0b475a9 100644 --- a/crates/arr-parse/src/episodes.rs +++ b/crates/arr-parse/src/episodes.rs @@ -36,7 +36,7 @@ pub(crate) fn tag_of(token: &str) -> Option { .or_else(|| date_tag(token)) } -/// `S01`, `S01-S03`, `S01E02`, `S01E02E03`, `S01E02-E04`. +/// `S01`, `S01-S03`, `S01-S01`, `S01E02`, `S01E02E03`, `S01E02-E04`. fn season_tag(token: &str) -> Option { let rest = token.strip_prefix('s')?; let (season, mut rest) = digits(rest); @@ -55,7 +55,8 @@ fn season_tag(token: &str) -> Option { return None; } let last: u32 = last.parse().ok()?; - return (last > season).then_some(EpisodeClaim::Seasons { + // A degenerate range (`S01-S01`) is one season's pack, not nothing. + return (last >= season).then_some(EpisodeClaim::Seasons { first: season, last, }); @@ -148,7 +149,7 @@ fn season_words(token: &str) -> Option { return None; } let last: u32 = last.parse().ok()?; - (last > first).then_some(EpisodeClaim::Seasons { first, last }) + (last >= first).then_some(EpisodeClaim::Seasons { first, last }) } /// Splits a leading run of ASCII digits off a token. diff --git a/crates/arr-parse/src/markers.rs b/crates/arr-parse/src/markers.rs index 3ccbe00..4724239 100644 --- a/crates/arr-parse/src/markers.rs +++ b/crates/arr-parse/src/markers.rs @@ -135,9 +135,9 @@ fn classify_exact(t: &str) -> Option<(Marker, Strength)> { // "Legendado" claims pt-BR *subtitles* over original audio — it must // NOT become a PtBr audio claim or main-root originals get rejected. "proper" | "repack" | "internal" | "hybrid" | "10bit" | "8bit" | "hi10p" | "amzn" - | "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" => (Marker::Junk, Strong), - "nf" | "hulu" | "max" | "itunes" | "cr" | "complete" | "sample" | "subbed" | "subs" - | "sub" | "mkv" | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => { + | "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" | "complete" => (Marker::Junk, Strong), + "nf" | "hulu" | "max" | "itunes" | "cr" | "sample" | "subbed" | "subs" | "sub" | "mkv" + | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => { (Marker::Junk, Weak) } _ => return None, diff --git a/crates/arr-parse/tests/parse.rs b/crates/arr-parse/tests/parse.rs index 1078ca6..1be678f 100644 --- a/crates/arr-parse/tests/parse.rs +++ b/crates/arr-parse/tests/parse.rs @@ -307,6 +307,42 @@ fn cases() -> Vec { ..NameClaims::default() }, }, + Case { + // `COMPLETE` before the tag closes the title like any strong + // marker, wherever the group puts it. + name: "Bluey.COMPLETE.S01.1080p.WEB-DL", + want: NameClaims { + title: s("Bluey"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + Case { + // The trailing placement already worked; it keeps working. + name: "Bluey.S01.COMPLETE.1080p.WEB-DL", + want: NameClaims { + title: s("Bluey"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + Case { + // A degenerate range is one season's pack. + name: "Fallout.S01-S01.1080p.BluRay.x264-GROUP", + want: NameClaims { + title: s("Fallout"), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + group: s("GROUP"), + episode: Some(EpisodeClaim::Seasons { first: 1, last: 1 }), + ..NameClaims::default() + }, + }, Case { name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM", want: NameClaims { From 4f848c27f040c7186abc10881c03bdf3d5b5c9e5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:12:47 +0100 Subject: [PATCH 35/71] fix(parse): close title on spelled-out season ranges --- crates/arr-parse/src/markers.rs | 8 ++++++++ crates/arr-parse/tests/parse.rs | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/arr-parse/src/markers.rs b/crates/arr-parse/src/markers.rs index 4724239..290b63b 100644 --- a/crates/arr-parse/src/markers.rs +++ b/crates/arr-parse/src/markers.rs @@ -55,11 +55,19 @@ pub(crate) fn classify_pair(a: &str, b: &str) -> Option<(Marker, Strength)> { ("season", n) if !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()) => { (Marker::Junk, Strength::Strong) } + ("seasons", n) if is_season_count(n) => (Marker::Junk, Strength::Strong), _ => return None, }; Some(m) } +/// `1`, `1-3`: the count after a spelled-out `Seasons`. Each part is 1-2 +/// digits so a trailing year (`Four Seasons 2024`) never reads as a count. +fn is_season_count(n: &str) -> bool { + n.split('-') + .all(|part| (1..=2).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit())) +} + #[allow(clippy::too_many_lines)] fn classify_exact(t: &str) -> Option<(Marker, Strength)> { use Strength::{Strong, Weak}; diff --git a/crates/arr-parse/tests/parse.rs b/crates/arr-parse/tests/parse.rs index 1be678f..1ed0adc 100644 --- a/crates/arr-parse/tests/parse.rs +++ b/crates/arr-parse/tests/parse.rs @@ -343,6 +343,42 @@ fn cases() -> Vec { ..NameClaims::default() }, }, + Case { + // The spelled-out plural range closes the title too. + name: "Fallout.Seasons.1-3.1080p.BluRay-GROUP", + want: NameClaims { + title: s("Fallout"), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + group: s("GROUP"), + episode: Some(EpisodeClaim::Seasons { first: 1, last: 3 }), + ..NameClaims::default() + }, + }, + Case { + // A bare number after the plural is a single season, like + // the singular form. + name: "Show.Name.Seasons.1.1080p.WEB-DL-GROUP", + want: NameClaims { + title: s("Show Name"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + group: s("GROUP"), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + Case { + // A year-sized number after `Seasons` is a year, not a count. + name: "The.Four.Seasons.2024.1080p.WEB-DL", + want: NameClaims { + title: s("The Four Seasons"), + year: Some(2024), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + ..NameClaims::default() + }, + }, Case { name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM", want: NameClaims { From b51386e0043b10fcaebfce0ea538e27706cdaa48 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:18:26 +0100 Subject: [PATCH 36/71] core: carry season number on Episode derive_series_status identified specials by looking the episode's season up in a parallel seasons slice, so a caller passing an incomplete slice silently reverted to pre-#118 behaviour. The season number now rides on each episode and the slice is gone. --- crates/arr-core/src/lib.rs | 3 ++ crates/arr-core/src/status.rs | 70 ++++++++++++--------------------- crates/arr-core/src/tracking.rs | 1 + 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 7bd3101..4c5abde 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -288,6 +288,9 @@ pub struct Season { pub struct Episode { pub id: EpisodeId, pub season_id: SeasonId, + /// The owning season's number, carried so status derivation never needs + /// a second slice a caller can forget (#131). + pub season_number: u16, pub number: u16, pub title: String, pub air_date: Option, diff --git a/crates/arr-core/src/status.rs b/crates/arr-core/src/status.rs index 75abfb8..71fe92b 100644 --- a/crates/arr-core/src/status.rs +++ b/crates/arr-core/src/status.rs @@ -1,6 +1,6 @@ use std::time::{Duration, SystemTime}; -use crate::{Episode, MediaState, Season, Series}; +use crate::{Episode, MediaState, Series}; const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24); @@ -16,26 +16,19 @@ pub enum SeriesStatus { /// Derives a series' status at `now` without persisting lifecycle intent. /// -/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a -/// season 0 listed here are ignored, so a manually wanted special cannot pin -/// the series at `incomplete` or hold back `ended`. The seasons must be the -/// series' own; an episode whose season is not listed counts like any other. +/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with +/// `season_number` 0 are ignored, so a manually wanted special cannot pin +/// the series at `incomplete` or hold back `ended`. The season number rides +/// on each episode, so there is no second slice a caller can forget (#131). #[must_use] pub fn derive_series_status( series: &Series, - seasons: &[Season], episodes: &[Episode], now: SystemTime, ) -> SeriesStatus { - // A series has at most one season 0, so this is a comparison rather than - // a set: no allocation on a function the list view calls per series. - let specials = seasons - .iter() - .find(|season| season.number == 0) - .map(|season| season.id); let wanted = episodes .iter() - .filter(|episode| episode.wanted && specials != Some(episode.season_id)); + .filter(|episode| episode.wanted && episode.season_number != 0); let complete = wanted .clone() .all(|episode| episode.state == MediaState::Available); @@ -107,6 +100,7 @@ mod tests { Episode { id: EpisodeId(i64::from(number)), season_id: SeasonId(1), + season_number: 1, number, title: format!("Episode {number}"), air_date, @@ -117,23 +111,13 @@ mod tests { } } - /// The season every default-test episode sits in. - fn seasons() -> [Season; 1] { - [Season { - id: SeasonId(1), - series_id: SeriesId(1), - number: 1, - tracked: true, - }] - } - #[test] fn airing_when_a_wanted_episode_is_in_the_window() { let now = SystemTime::UNIX_EPOCH + 100 * DAY; for air_date in [now - 14 * DAY, now + 14 * DAY] { let episodes = [episode(1, Some(air_date), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Airing ); } @@ -145,7 +129,7 @@ mod tests { MediaState::Missing, )]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); } @@ -156,7 +140,7 @@ mod tests { let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Incomplete ); } @@ -167,7 +151,7 @@ mod tests { let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); } @@ -178,7 +162,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(false, false), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Complete ); } @@ -189,7 +173,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(true, true), &seasons(), &episodes, now), + derive_series_status(&series(true, true), &episodes, now), SeriesStatus::Ended ); } @@ -199,14 +183,14 @@ mod tests { let now = SystemTime::UNIX_EPOCH + 100 * DAY; let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(false, false), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Incomplete ); episodes[0].state = MediaState::Available; assert_eq!( - derive_series_status(&series(false, false), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Complete ); } @@ -216,14 +200,14 @@ mod tests { let now = SystemTime::UNIX_EPOCH + 100 * DAY; let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing)); assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Airing ); } @@ -231,34 +215,30 @@ mod tests { #[test] fn missing_specials_do_not_hold_back_complete_or_ended() { let now = SystemTime::UNIX_EPOCH + 100 * DAY; - let specials = [Season { - id: SeasonId(9), - series_id: SeriesId(1), - number: 0, - tracked: false, - }]; let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)]; let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing); special.season_id = SeasonId(9); + special.season_number = 0; assert_eq!( - derive_series_status(&series(true, false), &specials, &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); episodes.push(special); assert_eq!( - derive_series_status(&series(true, false), &[], &episodes, now), - SeriesStatus::Incomplete, - "without knowing the seasons, the special pins the series" + derive_series_status(&series(true, false), &episodes, now), + SeriesStatus::Waiting, + "#131: the season number rides on the episode, so the special is \ + invisible however the caller builds the slice" ); assert_eq!( - derive_series_status(&series(false, false), &specials, &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Complete, "§4.2: the only gap is a special" ); assert_eq!( - derive_series_status(&series(false, true), &specials, &episodes, now), + derive_series_status(&series(false, true), &episodes, now), SeriesStatus::Ended ); } diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index 6829781..aeb9793 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -72,6 +72,7 @@ mod tests { Episode { id: EpisodeId(season * 100 + i64::from(number)), season_id: SeasonId(season), + season_number: u16::try_from(season).unwrap_or_default(), number, title: format!("Episode {number}"), air_date: Some(SystemTime::UNIX_EPOCH), From 69263da4f3a9c7dd22952f7356ed1f13aa981a64 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:18:26 +0100 Subject: [PATCH 37/71] daemon: populate season_number on refreshed episodes --- crates/arr-daemon/src/series_refresh.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 12bac4e..28a9573 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -279,6 +279,7 @@ impl SeriesRefreshAction { let mut fresh = Vec::new(); let mut changed = false; + let season = season_number(number); for source in &detail.episodes { let number = i64::from(source.number); if let Some(&(id, ref title, ref air_date, vanished)) = known.get(&number) { @@ -311,6 +312,7 @@ impl SeriesRefreshAction { fresh.push(CoreEpisode { id: EpisodeId(0), season_id: SeasonId(season_id), + season_number: season, number: season_number(source.number), title: source.title.clone(), air_date: source.air_date.map(system_time), @@ -449,6 +451,7 @@ fn core_episodes(series_id: i64, detail: &TmdbSeasonDetail) -> Vec .map(|source| CoreEpisode { id: EpisodeId(0), season_id: SeasonId(series_id), + season_number: season_number(detail.number), number: season_number(source.number), title: source.title.clone(), air_date: source.air_date.map(system_time), From 50b68e24b644060fa073808cdb3bc45eea338f6f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:18:26 +0100 Subject: [PATCH 38/71] api: derive status from episode season numbers with_status drops its seasons slice for the same reason as derive_series_status; tv_by_series no longer loads seasons at all. Offline query data regenerated. --- ...cbc7c1da617502c818e5dbe0f35c87b175d5.json} | 34 ++++--- ...ac3cf11d578ee864e36412a6f70477fff8325.json | 32 ------ ...6f5bad8db69e0827e620264700f4affb3652.json} | 34 ++++--- ...d823cca790c1439d94fba1c7dcea024d9467.json} | 34 ++++--- ...cf72c77bb581bc0fdff7b2728cfa13712eb10.json | 32 ------ ...32a53984f9884b179346b1faf07032b08187.json} | 34 ++++--- crates/arr-api/src/series.rs | 97 ++++--------------- 7 files changed, 99 insertions(+), 198 deletions(-) rename .sqlx/{query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json => query-3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5.json} (70%) delete mode 100644 .sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json rename .sqlx/{query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json => query-3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652.json} (70%) rename .sqlx/{query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json => query-6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467.json} (71%) delete mode 100644 .sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json rename .sqlx/{query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json => query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json} (69%) diff --git a/.sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json b/.sqlx/query-3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5.json similarity index 70% rename from .sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json rename to .sqlx/query-3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5.json index 791176f..3095604 100644 --- a/.sqlx/query-633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256.json +++ b/.sqlx/query-3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?", "describe": { "columns": [ { @@ -19,43 +19,48 @@ "type_info": "Integer" }, { - "name": "number!: i64", + "name": "season_number!: i64", "ordinal": 3, "type_info": "Integer" }, { - "name": "title!: String", + "name": "number!: i64", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "air_date", + "name": "title!: String", "ordinal": 5, "type_info": "Text" }, { - "name": "wanted!: bool", + "name": "air_date", "ordinal": 6, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 7, "type_info": "Integer" }, { "name": "state!: String", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" }, { "name": "vanished!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "search_attempts!: i64", "ordinal": 9, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, "type_info": "Text" } ], @@ -68,6 +73,7 @@ false, false, false, + false, true, false, false, @@ -76,5 +82,5 @@ true ] }, - "hash": "633d5ff860d9c677eae5d06324d3098a0bbad30b46b23287131de72e64751256" + "hash": "3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5" } diff --git a/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json b/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json deleted file mode 100644 index fb1a32f..0000000 --- a/.sqlx/query-3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons", - "describe": { - "columns": [ - { - "name": "id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "series_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "number!: i64", - "ordinal": 2, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - true, - false, - false - ] - }, - "hash": "3e3a42524f3e481eef0b896c20aac3cf11d578ee864e36412a6f70477fff8325" -} diff --git a/.sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json b/.sqlx/query-3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652.json similarity index 70% rename from .sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json rename to .sqlx/query-3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652.json index 68d60f0..eec33a5 100644 --- a/.sqlx/query-3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a.json +++ b/.sqlx/query-3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?", "describe": { "columns": [ { @@ -19,43 +19,48 @@ "type_info": "Integer" }, { - "name": "number!: i64", + "name": "season_number!: i64", "ordinal": 3, "type_info": "Integer" }, { - "name": "title!: String", + "name": "number!: i64", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "air_date", + "name": "title!: String", "ordinal": 5, "type_info": "Text" }, { - "name": "wanted!: bool", + "name": "air_date", "ordinal": 6, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 7, "type_info": "Integer" }, { "name": "state!: String", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" }, { "name": "vanished!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "search_attempts!: i64", "ordinal": 9, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, "type_info": "Text" } ], @@ -68,6 +73,7 @@ false, false, false, + false, true, false, false, @@ -76,5 +82,5 @@ true ] }, - "hash": "3ab4e736a4fc73625fd66e38dc3f510193b9c97788503f06c5c23f859780349a" + "hash": "3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652" } diff --git a/.sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json b/.sqlx/query-6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467.json similarity index 71% rename from .sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json rename to .sqlx/query-6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467.json index db9aa13..ae6ada8 100644 --- a/.sqlx/query-c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d.json +++ b/.sqlx/query-6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id", "describe": { "columns": [ { @@ -19,43 +19,48 @@ "type_info": "Integer" }, { - "name": "number!: i64", + "name": "season_number!: i64", "ordinal": 3, "type_info": "Integer" }, { - "name": "title!: String", + "name": "number!: i64", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "air_date", + "name": "title!: String", "ordinal": 5, "type_info": "Text" }, { - "name": "wanted!: bool", + "name": "air_date", "ordinal": 6, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 7, "type_info": "Integer" }, { "name": "state!: String", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" }, { "name": "vanished!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "search_attempts!: i64", "ordinal": 9, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, "type_info": "Text" } ], @@ -68,6 +73,7 @@ false, false, false, + false, true, false, false, @@ -76,5 +82,5 @@ true ] }, - "hash": "c77c9cac0129fb4da57bef8ef2fb149c57293c76bad04f01603f11635789222d" + "hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467" } diff --git a/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json b/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json deleted file mode 100644 index cbb2e0f..0000000 --- a/.sqlx/query-808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\" FROM seasons WHERE series_id = ?", - "describe": { - "columns": [ - { - "name": "id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "series_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "number!: i64", - "ordinal": 2, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false - ] - }, - "hash": "808c20cad25bdb0d81ac502379dcf72c77bb581bc0fdff7b2728cfa13712eb10" -} diff --git a/.sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json b/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json similarity index 69% rename from .sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json rename to .sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json index daea6c5..e22fd02 100644 --- a/.sqlx/query-448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8.json +++ b/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?", + "query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number", "describe": { "columns": [ { @@ -19,43 +19,48 @@ "type_info": "Integer" }, { - "name": "number!: i64", + "name": "season_number!: i64", "ordinal": 3, "type_info": "Integer" }, { - "name": "title!: String", + "name": "number!: i64", "ordinal": 4, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "air_date", + "name": "title!: String", "ordinal": 5, "type_info": "Text" }, { - "name": "wanted!: bool", + "name": "air_date", "ordinal": 6, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 7, "type_info": "Integer" }, { "name": "state!: String", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" }, { "name": "vanished!: bool", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "search_attempts!: i64", "ordinal": 9, "type_info": "Integer" }, { - "name": "last_searched_at", + "name": "search_attempts!: i64", "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, "type_info": "Text" } ], @@ -68,6 +73,7 @@ false, false, false, + false, true, false, false, @@ -76,5 +82,5 @@ true ] }, - "hash": "448bdb6b59938fe6c8221092fd30c0e9b278dd4df85288aa165753c0efb1d2a8" + "hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187" } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 56e765b..58dc60a 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -196,6 +196,7 @@ struct EpisodeRow { series_id: i64, id: i64, season_id: i64, + season_number: i64, number: i64, title: String, air_date: Option, @@ -257,6 +258,7 @@ fn core_episode(row: &EpisodeRow) -> arr_core::Episode { arr_core::Episode { id: EpisodeId(row.id), season_id: SeasonId(row.season_id), + season_number: u16::try_from(row.season_number).unwrap_or_default(), number: u16::try_from(row.number).unwrap_or_default(), title: row.title.clone(), air_date: air_date(row.air_date.as_deref()), @@ -277,31 +279,14 @@ fn status_name(status: SeriesStatus) -> &'static str { } } -fn core_season(id: i64, series_id: i64, number: i64) -> arr_core::Season { - arr_core::Season { - id: SeasonId(id), - series_id: SeriesId(series_id), - number: u16::try_from(number).unwrap_or_default(), - tracked: false, - } -} - -fn with_status( - row: &SeriesRow, - seasons: &[arr_core::Season], - episodes: &[arr_core::Episode], - now: SystemTime, -) -> Series { +fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series { // §4.2: season 0 is invisible to status, so it stays out of the counters // too. A series reading `complete` next to `42/52 eps` is the confusion - // this avoids. `UNIQUE (series_id, number)` means there is at most one. - let specials = seasons - .iter() - .find(|season| season.number == 0) - .map(|season| season.id); + // this avoids. The season number rides on each episode (#131), so no + // parallel slice can be forgotten. let wanted = episodes .iter() - .filter(|episode| episode.wanted && specials != Some(episode.season_id)); + .filter(|episode| episode.wanted && episode.season_number != 0); let available = wanted .clone() .filter(|episode| episode.state == MediaState::Available); @@ -317,53 +302,26 @@ fn with_status( overrides: row.overrides.clone(), upstream_ended: row.upstream_ended, blocked: row.blocked, - status: status_name(derive_series_status( - &core_series(row), - seasons, - episodes, - now, - )) - .to_owned(), + status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX), } } -/// Every season and episode in the library, keyed by the series they belong to. +/// Every episode in the library, keyed by the series it belongs to. /// -/// One query each rather than one per series: the whole table is a few thousand +/// One query rather than one per series: the whole table is a few thousand /// rows for a single household (§10), and the status of every listed series /// needs all of them anyway. -async fn tv_by_series( - state: &AppState, -) -> Result< - ( - HashMap>, - HashMap>, - ), - ApiError, -> { - let season_rows = sqlx::query!( - r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons"# - ) - .fetch_all(pool(state)?) - .await?; +async fn tv_by_series(state: &AppState) -> Result>, ApiError> { let rows = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id"# ) .fetch_all(pool(state)?) .await?; - let mut seasons: HashMap> = HashMap::new(); - for row in &season_rows { - seasons.entry(row.series_id).or_default().push(core_season( - row.id, - row.series_id, - row.number, - )); - } let mut episodes: HashMap> = HashMap::new(); for row in &rows { episodes @@ -371,7 +329,7 @@ async fn tv_by_series( .or_default() .push(core_episode(row)); } - Ok((seasons, episodes)) + Ok(episodes) } async fn load_series_row(state: &AppState, id: i64) -> Result { @@ -383,26 +341,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result Result { let row = load_series_row(state, id).await?; - let season_rows = sqlx::query!( - r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64" FROM seasons WHERE series_id = ?"#, - id - ) - .fetch_all(pool(state)?) - .await?; - let seasons: Vec<_> = season_rows - .iter() - .map(|row| core_season(row.id, row.series_id, row.number)) - .collect(); let episodes = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, id ) .fetch_all(pool(state)?) .await?; let episodes: Vec<_> = episodes.iter().map(core_episode).collect(); - Ok(with_status(&row, &seasons, &episodes, SystemTime::now())) + Ok(with_status(&row, &episodes, SystemTime::now())) } async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> { @@ -448,20 +396,12 @@ pub async fn list( .await? }; - let (seasons, episodes) = tv_by_series(&state).await?; + let episodes = tv_by_series(&state).await?; let now = SystemTime::now(); - let no_seasons: Vec = Vec::new(); let no_episodes: Vec = Vec::new(); Ok(Json( rows.iter() - .map(|row| { - with_status( - row, - seasons.get(&row.id).unwrap_or(&no_seasons), - episodes.get(&row.id).unwrap_or(&no_episodes), - now, - ) - }) + .map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&no_episodes), now)) .collect(), )) } @@ -607,7 +547,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A .await?; let episodes = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#, series_id ) @@ -714,6 +654,7 @@ pub async fn create_season( .map(|episode| arr_core::Episode { id: EpisodeId(0), season_id: SeasonId(0), + season_number: u16::try_from(input.number).unwrap_or_default(), number: u16::try_from(episode.number).unwrap_or_default(), title: episode.title.clone(), air_date: air_date(episode.air_date.as_deref()), @@ -837,7 +778,7 @@ pub async fn update_season( async fn load_episode(state: &AppState, id: i64) -> Result { let row = sqlx::query_as!( EpisodeRow, - r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at + r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#, id ) From b0f56f59aa2d9ed1c6f8d926b6ad6f7cb46b1af0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:23:41 +0100 Subject: [PATCH 39/71] fix(api): delete series files and media_files rows --- ...81c591fab8dcf3e2c223e8cbb82db00e2f02b.json | 12 + ...81eca64ac5fa5b410cb14d885af2a137693b7.json | 12 + ...a548afa40d46b938df101bf3d3b42b01791b4.json | 20 ++ ...fdb4d18ad0d6b5083221531fb70962670ef1d.json | 20 ++ crates/arr-api/src/movies.rs | 2 +- crates/arr-api/src/series.rs | 226 ++++++++++++++++++ 6 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json create mode 100644 .sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json create mode 100644 .sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json create mode 100644 .sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json diff --git a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json new file mode 100644 index 0000000..1813f52 --- /dev/null +++ b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b" +} diff --git a/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json b/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json new file mode 100644 index 0000000..d2161fd --- /dev/null +++ b/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7" +} diff --git a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json new file mode 100644 index 0000000..904a6d9 --- /dev/null +++ b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?", + "describe": { + "columns": [ + { + "name": "path!: String", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4" +} diff --git a/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json b/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json new file mode 100644 index 0000000..caef66d --- /dev/null +++ b/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.path AS \"path!: String\" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?", + "describe": { + "columns": [ + { + "name": "path!: String", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 28cad1b..599b030 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -478,7 +478,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> /// root, or the file itself when it sits in the root with no folder of its /// own. `None` when the file is not under the root at all, which is the /// guard that keeps a delete inside the library it belongs to. -fn title_target(root: &str, file: &str) -> Option { +pub(crate) fn title_target(root: &str, file: &str) -> Option { let root = std::path::Path::new(root); let relative = std::path::Path::new(file).strip_prefix(root).ok()?; let first = relative.components().next()?; diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 56e765b..fc9e74b 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -589,6 +589,28 @@ pub async fn delete( State(state): State, Path(id): Path, ) -> Result { + // The row is loaded first so a missing series is 404 before anything + // touches the disk. + load_series_row(&state, id).await?; + remove_library_files(&state, id).await?; + // `media_files.path` is UNIQUE and the owner is polymorphic, so nothing + // cascades from the seasons and episodes rows (which the series row's + // delete does): leaving the rows behind would block re-importing the + // same paths after a re-add. Owner tags go with the title they tagged. + sqlx::query!( + "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN ( + SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id + WHERE se.series_id = ?)", + id + ) + .execute(pool(&state)?) + .await?; + sqlx::query!( + "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?", + id + ) + .execute(pool(&state)?) + .await?; let result = sqlx::query!("DELETE FROM series WHERE id = ?", id) .execute(pool(&state)?) .await?; @@ -598,6 +620,73 @@ pub async fn delete( Ok(StatusCode::NO_CONTENT) } +/// Unlink everything this series put under its root. Mirrors the movie +/// handler in `movies.rs`. +/// +/// The service knows only what it wrote (§2), so the targets come from +/// `media_files`, never from a scan and never from re-deriving the §7.4 name +/// — a series renamed after import would derive a folder that does not exist +/// while the real one stayed. Each episode file resolves to its title folder, +/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles +/// and artwork go with it. +/// +/// The torrent is untouched (§7.3). It keeps seeding under its own rule and +/// the reaper deletes it; a hardlinked file loses only its library name. +/// +/// Failure leaves the database alone, so the operator sees the series still +/// there and can retry rather than losing the record of what is on disk. +async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> { + let root = sqlx::query_scalar!( + r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#, + id + ) + .fetch_one(pool(state)?) + .await?; + let paths = sqlx::query_scalar!( + r#"SELECT mf.path AS "path!: String" + FROM media_files mf + JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id + JOIN seasons se ON se.id = e.season_id + WHERE se.series_id = ?"#, + id + ) + .fetch_all(pool(state)?) + .await?; + + let mut targets: Vec = Vec::new(); + for path in &paths { + let Some(target) = crate::movies::title_target(&root, path) else { + // Outside its own root: not ours to delete. The row still goes, + // so the operator sees the series leave and the file stay. + tracing::warn!(%path, %root, "media file is outside its root, not deleted"); + continue; + }; + if !targets.contains(&target) { + targets.push(target); + } + } + + for target in targets { + let metadata = match tokio::fs::symlink_metadata(&target).await { + Ok(metadata) => metadata, + // Already gone is the state we wanted. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(ApiError::Filesystem(error.to_string())), + }; + let removed = if metadata.is_dir() { + tokio::fs::remove_dir_all(&target).await + } else { + tokio::fs::remove_file(&target).await + }; + match removed { + Ok(()) => tracing::info!(target = %target.display(), "removed library files"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(ApiError::Filesystem(error.to_string())), + } + } + Ok(()) +} + async fn load_seasons(state: &AppState, series_id: i64) -> Result, ApiError> { let seasons = sqlx::query!( r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#, @@ -1838,6 +1927,143 @@ mod tests { assert_eq!(episodes, 0); } + /// A series on disk for one test: the §7.4 title folder with a season + /// subfolder holding one episode file and one sidecar subtitle. + async fn library_on_disk( + state: &AppState, + episode_id: i64, + root: &std::path::Path, + ) -> std::path::PathBuf { + let folder = root.join("Bluey (2018) [tmdbid-82728]"); + let season = folder.join("Season 01"); + tokio::fs::create_dir_all(&season) + .await + .expect("create title folder"); + let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv"); + tokio::fs::write(&feature, b"episode").await.expect("write"); + tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs") + .await + .expect("write sidecar"); + + let pool = state.database().expect("database").pool(); + let root_path = root.to_str().expect("utf-8 root"); + sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'") + .bind(root_path) + .execute(pool) + .await + .expect("point the root at the tempdir"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)", + ) + .bind(episode_id) + .bind(feature.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("media file"); + folder + } + + /// The §7.4 title folder is the unit of deletion, so the season + /// subfolder and sidecars go with it — and the root is never touched. + #[tokio::test] + async fn deleting_a_series_removes_the_whole_title_folder() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + let root = tempfile::tempdir().expect("root"); + let folder = library_on_disk(&state, episode_id, root.path()).await; + + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/{series_id}")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + !folder.exists(), + "the title folder, its seasons and its sidecars are gone" + ); + assert!(root.path().exists(), "the root survives its titles"); + + let pool = state.database().expect("database").pool(); + let orphans: i64 = + sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'") + .fetch_one(pool) + .await + .expect("count files"); + assert_eq!(orphans, 0, "the file rows go with the files"); + } + + /// A missing series is 404 before anything touches the disk. + #[tokio::test] + async fn deleting_a_missing_series_is_a_404() { + let (_dir, _state, base) = application().await; + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/999")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + /// The guard that keeps a delete inside the library: a path that is not + /// under the series' root is left alone, whatever the row says. + #[tokio::test] + async fn a_series_file_outside_its_root_is_never_unlinked() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + + let root = tempfile::tempdir().expect("root"); + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(root.path().to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + + let elsewhere = tempfile::tempdir().expect("elsewhere"); + let stray = elsewhere.path().join("not-ours.mkv"); + tokio::fs::write(&stray, b"stray").await.expect("write"); + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)", + ) + .bind(episode_id) + .bind(stray.to_str().expect("utf-8 path")) + .execute(state.database().expect("database").pool()) + .await + .expect("media file"); + + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/{series_id}")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + stray.exists(), + "a path outside the root is not ours to delete" + ); + } + #[test] fn air_dates_parse_as_dates_and_as_timestamps() { assert_eq!( From 39e6bce2a0aa0dd5ca0a3adcfaee21d86fd896d5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:24:50 +0100 Subject: [PATCH 40/71] feat(daemon): manual episode/season triggers --- ...8a8efb0c7af09fa6cff4b1f31fb963da58bd5.json | 20 + ...8036f72347fbbbf8836e1ad495bfa66e6ebfc.json | 12 + ...586dc4f7682ab4edbd483d36fedbc985a27f4.json | 20 + ...78a2b2705c65da7c037e1c5af062bc53e0345.json | 12 + ...32db6568360e8c3fc7e31e968508d88c1bc67.json | 50 ++ ...a7271c33b1da4ec9f6389b8d7212be80c97ea.json | 80 ++ ...b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json | 50 ++ ...007d7cb65f063274a322702ffcfef133ea454.json | 50 ++ ...48356c724775e9df246fc91f3edf899732a90.json | 12 + crates/arr-daemon/src/grab.rs | 10 +- crates/arr-daemon/src/tv_grab.rs | 720 +++++++++++++++++- 11 files changed, 1031 insertions(+), 5 deletions(-) create mode 100644 .sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json create mode 100644 .sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json create mode 100644 .sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json create mode 100644 .sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json create mode 100644 .sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json create mode 100644 .sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json create mode 100644 .sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json create mode 100644 .sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json create mode 100644 .sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json diff --git a/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json b/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json new file mode 100644 index 0000000..70d7aae --- /dev/null +++ b/.sqlx/query-321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.title AS \"title!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?", + "describe": { + "columns": [ + { + "name": "title!: String", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5" +} diff --git a/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json b/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json new file mode 100644 index 0000000..ed911e6 --- /dev/null +++ b/.sqlx/query-37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE season_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc" +} diff --git a/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json b/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json new file mode 100644 index 0000000..07fecc6 --- /dev/null +++ b/.sqlx/query-4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.title AS \"title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", + "describe": { + "columns": [ + { + "name": "title!: String", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4" +} diff --git a/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json b/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json new file mode 100644 index 0000000..efda8ff --- /dev/null +++ b/.sqlx/query-5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345" +} diff --git a/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json b/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json new file mode 100644 index 0000000..327079e --- /dev/null +++ b/.sqlx/query-519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language,\n (\n EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ) AS \"is_gap!: bool\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n AND s.blocked = 0\n ", + "describe": { + "columns": [ + { + "name": "season_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "series_title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "series_tvdb_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "is_gap!: bool", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + false + ] + }, + "hash": "519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67" +} diff --git a/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json b/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json new file mode 100644 index 0000000..eae3f95 --- /dev/null +++ b/.sqlx/query-80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"number!: i64\",\n e.air_date,\n e.wanted AS \"wanted!: bool\",\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\",\n EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n ) AS \"in_flight!: bool\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?\n AND s.blocked = 0\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "air_date", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wanted!: bool", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "has_file!: bool", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "in_flight!: bool", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "season_id!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "series_title!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "series_tvdb_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea" +} diff --git a/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json b/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json new file mode 100644 index 0000000..8e2eb8c --- /dev/null +++ b/.sqlx/query-a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN episode_releases er ON er.release_id = r.id\n WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "indexer_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "guid!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "download_url!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "score!: i64", + "ordinal": 5, + "type_info": "Null" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + false, + null + ] + }, + "hash": "a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf" +} diff --git a/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json b/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json new file mode 100644 index 0000000..0a53f27 --- /dev/null +++ b/.sqlx/query-d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN season_releases sr ON sr.release_id = r.id\n WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "indexer_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "guid!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "download_url!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "score!: i64", + "ordinal": 5, + "type_info": "Null" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + false, + null + ] + }, + "hash": "d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454" +} diff --git a/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json b/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json new file mode 100644 index 0000000..0e5a838 --- /dev/null +++ b/.sqlx/query-e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90" +} diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 06bbe52..3f60459 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -908,7 +908,7 @@ struct PendingMovie { /// The eligible view of a stored release, ranked for selection. #[derive(Debug, Clone)] pub(crate) struct Eligible { - id: i64, + pub(crate) id: i64, pub(crate) indexer_id: i64, pub(crate) guid: String, pub(crate) name: String, @@ -1179,7 +1179,9 @@ pub(crate) async fn store_release( } /// The TV counterpart: one release row, associated with every episode the -/// claim covers — a season pack matches the whole season. +/// claim covers — a season pack matches the whole season. Returns the +/// release id alongside the eligible view; the id is what links a season +/// pack into `season_releases`, which lists rejected candidates too. pub(crate) async fn store_episode_release( database: &Db, episode_ids: &[i64], @@ -1188,7 +1190,7 @@ pub(crate) async fn store_episode_release( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, -) -> Result, GrabError> { +) -> Result<(i64, Option), GrabError> { let (release_id, eligible) = classify_and_store( database, release, @@ -1208,7 +1210,7 @@ pub(crate) async fn store_episode_release( .execute(database.pool()) .await?; } - Ok(eligible) + Ok((release_id, eligible)) } async fn classify_and_store( diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index b215153..92ae166 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -415,7 +415,7 @@ impl TvGrabAction { if covered.is_empty() { continue; } - let stored = store_episode_release( + let (stored_id, stored) = store_episode_release( database, &covered, &release, @@ -425,6 +425,22 @@ impl TvGrabAction { blacklist, ) .await?; + // A pack for exactly this season belongs on the season deck too + // (§9.3, issue #125) — including when it is rejected, which the + // deck shows greyed out. + if claim + .as_ref() + .is_some_and(|claim| is_this_seasons_pack(claim, season.season_number_u32())) + { + sqlx::query!( + "INSERT INTO season_releases (season_id, release_id) VALUES (?, ?) + ON CONFLICT DO NOTHING", + season.season_id, + stored_id + ) + .execute(database.pool()) + .await?; + } candidates.push(TvCandidate { claim, eligible: stored, @@ -432,6 +448,229 @@ impl TvGrabAction { } Ok(candidates) } + + /// The manual episode trigger (§6.2, §9.3, issue #132): reset the + /// episode's backoff so selection cannot skip it, then sweep the + /// indexers scoped to this one episode instead of the tick's + /// `EPISODE_SEARCHES_PER_TICK` batch. + /// + /// Same shape as the movie trigger: a gap gets search and grab; an + /// already-satisfied episode gets its deck refreshed and no grab; a + /// blocked series is refused outright (§6.3). An unaired episode is not + /// a gap yet — nothing to close until it airs (§6.2). + pub(crate) async fn search_episode_now( + &self, + database: &Db, + episode_id: i64, + ) -> Result<(), GrabError> { + let Some(episode) = manual_episode(database, episode_id).await? else { + tracing::info!( + episode_id, + "manual episode search refused: unknown or blocked episode" + ); + return Ok(()); + }; + let Some(original_language) = episode.season.original_language.as_deref() else { + tracing::warn!( + series = episode.season.series_title, + "no original language yet; not searching" + ); + return Ok(()); + }; + let original_language = arr_db::policy::language(original_language); + let Some(loaded) = database.episode_policy(episode_id).await? else { + return Ok(()); + }; + reset_episode_backoff(database, episode_id).await?; + let blacklist = Blacklist::load(database.pool()).await?; + let episodes = season_episodes(database, episode.season.season_id).await?; + let selector = TvSelector::Episode { + season: episode.season.season_number_u32(), + episode: u32::try_from(episode.number).unwrap_or_default(), + }; + + if !episode.is_gap() { + let candidates = self + .search( + database, + &episode.season, + &episodes, + selector, + &loaded, + &original_language, + &blacklist, + ) + .await?; + record_episode_search(database, &[episode_id]).await?; + tracing::info!( + episode_id, + eligible = candidates.len(), + "episode release deck refreshed; not grabbing a satisfied episode" + ); + return Ok(()); + } + if !episode.aired() { + tracing::info!(episode_id, "episode has not aired; nothing to search for"); + return Ok(()); + } + let Some(this_one) = episodes.iter().find(|row| row.id == episode_id) else { + return Ok(()); + }; + self.grab_episodes( + database, + &episode.season, + &episodes, + std::slice::from_ref(&this_one), + &loaded, + &original_language, + &blacklist, + usize::MAX, + ) + .await?; + Ok(()) + } + + /// The manual one-click episode grab (§9.3, issue #132): the release is + /// already chosen off the episode deck, so this skips search and scoring + /// and sends it straight to Transmission. A manual grab may take a + /// `waived` release (§9.3), never a `rejected` one. + pub(crate) async fn grab_episode_release_now( + &self, + database: &Db, + episode_id: i64, + release_id: i64, + ) -> Result, GrabError> { + let Some(loaded) = database.episode_policy(episode_id).await? else { + return Ok(None); + }; + let Some(title) = episode_series_title(database, episode_id).await? else { + return Ok(None); + }; + let Some(release) = load_episode_release(database, episode_id, release_id).await? else { + return Ok(None); + }; + let blacklist = Blacklist::load(database.pool()).await?; + self.grabber + .send_winner( + database, + &GrabTarget { + scope: GrabScope::Episode { episode_id }, + title: &title, + counts_as_attempt: false, + }, + &loaded, + &blacklist, + release, + ) + .await + } + + /// The manual season trigger (issue #125's deck, drained per #132): + /// reset every open episode's backoff and run the same pack-or-fall-back + /// lane as the tick, scoped to this one season with no tick budgets. + /// + /// A satisfied season — nothing open, or a grab already in flight — + /// gets its deck refreshed and no grab; a blocked series refuses. + pub(crate) async fn search_season_now( + &self, + database: &Db, + season_id: i64, + ) -> Result<(), GrabError> { + let Some(season) = manual_season(database, season_id).await? else { + tracing::info!( + season_id, + "manual season search refused: unknown or blocked season" + ); + return Ok(()); + }; + let Some(original_language) = season.original_language.as_deref() else { + tracing::warn!( + series = season.series_title, + "no original language yet; not searching" + ); + return Ok(()); + }; + let original_language = arr_db::policy::language(original_language); + let Some(loaded) = database.season_policy(season_id).await? else { + return Ok(()); + }; + reset_season_backoff(database, season_id).await?; + let blacklist = Blacklist::load(database.pool()).await?; + + if !season.is_gap { + let episodes = season_episodes(database, season_id).await?; + let open: Vec = episodes + .iter() + .filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight) + .map(|episode| episode.id) + .collect(); + let candidates = self + .search( + database, + &season, + &episodes, + TvSelector::Season { + season: season.season_number_u32(), + }, + &loaded, + &original_language, + &blacklist, + ) + .await?; + record_episode_search(database, &open).await?; + tracing::info!( + season_id, + eligible = candidates.len(), + "season release deck refreshed; not grabbing a satisfied season" + ); + return Ok(()); + } + self.grab_season(database, &season, &blacklist, usize::MAX) + .await?; + Ok(()) + } + + /// The manual one-click season grab (§9.3, issue #125): a chosen pack + /// goes straight to Transmission against the season's still-open gaps. + pub(crate) async fn grab_season_release_now( + &self, + database: &Db, + season_id: i64, + release_id: i64, + ) -> Result, GrabError> { + let Some(loaded) = database.season_policy(season_id).await? else { + return Ok(None); + }; + let Some(title) = season_series_title(database, season_id).await? else { + return Ok(None); + }; + let Some(release) = load_season_release(database, season_id, release_id).await? else { + return Ok(None); + }; + let episode_ids: Vec = season_episodes(database, season_id) + .await? + .iter() + .filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight) + .map(|episode| episode.id) + .collect(); + let blacklist = Blacklist::load(database.pool()).await?; + self.grabber + .send_winner( + database, + &GrabTarget { + scope: GrabScope::Season { + season_id, + episode_ids, + }, + title: &title, + counts_as_attempt: false, + }, + &loaded, + &blacklist, + release, + ) + .await + } } impl Action for TvGrabAction { @@ -593,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result, + wanted: bool, + has_file: bool, + in_flight: bool, + season: PendingSeason, +} + +impl ManualEpisode { + /// Wanted with neither a file nor a grab in flight — a gap the manual + /// trigger may close. Anything else only gets a deck refresh. + fn is_gap(&self) -> bool { + self.wanted && !self.has_file && !self.in_flight + } + + /// Unknown is unaired (§6.2): nothing to close until it airs. + fn aired(&self) -> bool { + air_date_time(self.air_date.as_deref()) + .is_some_and(|date| date <= std::time::SystemTime::now()) + } +} + +/// Same columns as [`pending_seasons`], scoped to one id, without the tick's +/// batch limit and without the gap filter — the manual trigger names the +/// season, so what remains decides the lane here instead of hiding it. +async fn manual_episode( + database: &Db, + episode_id: i64, +) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT e.id AS "id!: i64", + e.number AS "number!: i64", + e.air_date, + e.wanted AS "wanted!: bool", + EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) AS "has_file!: bool", + EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'episode' AND g.target_id = e.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) AS "in_flight!: bool", + se.id AS "season_id!: i64", + se.number AS "season_number!: i64", + s.title AS "series_title!: String", + s.tvdb_id AS series_tvdb_id, + s.original_language + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE e.id = ? + AND s.blocked = 0 + "#, + episode_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| ManualEpisode { + number: row.number, + air_date: row.air_date, + wanted: row.wanted, + has_file: row.has_file, + in_flight: row.in_flight, + season: PendingSeason { + season_id: row.season_id, + season_number: row.season_number, + series_title: row.series_title, + series_tvdb_id: row.series_tvdb_id, + original_language: row.original_language, + }, + })) +} + +/// A season addressed by a manual command, with whether it is still a gap. +async fn manual_season( + database: &Db, + season_id: i64, +) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT se.id AS "season_id!: i64", + se.number AS "season_number!: i64", + s.title AS "series_title!: String", + s.tvdb_id AS series_tvdb_id, + s.original_language, + ( + EXISTS ( + SELECT 1 FROM episodes e + WHERE e.season_id = se.id + AND e.wanted = 1 + AND NOT EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) + AND NOT EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'episode' AND g.target_id = e.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'season' AND g.target_id = se.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) + ) AS "is_gap!: bool" + FROM seasons se + JOIN series s ON s.id = se.series_id + WHERE se.id = ? + AND s.blocked = 0 + "#, + season_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| PendingSeasonGap { + season: PendingSeason { + season_id: row.season_id, + season_number: row.season_number, + series_title: row.series_title, + series_tvdb_id: row.series_tvdb_id, + original_language: row.original_language, + }, + is_gap: row.is_gap, + })) +} + +/// [`PendingSeason`] plus the lane decision for the manual trigger. +struct PendingSeasonGap { + season: PendingSeason, + is_gap: bool, +} + +impl std::ops::Deref for PendingSeasonGap { + type Target = PendingSeason; + + fn deref(&self) -> &PendingSeason { + &self.season + } +} + +/// Unconditional, unlike the tick's backoff gate: the manual trigger's whole +/// point is to ignore the exponential backoff (§6.2). +async fn reset_episode_backoff(database: &Db, episode_id: i64) -> Result<(), GrabError> { + sqlx::query!( + "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", + episode_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + +/// A manual season search is one attempt against every open episode at +/// once, so they all start from zero together. +async fn reset_season_backoff(database: &Db, season_id: i64) -> Result<(), GrabError> { + sqlx::query!( + "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE season_id = ?", + season_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + +async fn episode_series_title(database: &Db, episode_id: i64) -> Result, GrabError> { + let title = sqlx::query_scalar!( + r#"SELECT s.title AS "title!: String" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE e.id = ?"#, + episode_id + ) + .fetch_optional(database.pool()) + .await?; + Ok(title) +} + +async fn season_series_title(database: &Db, season_id: i64) -> Result, GrabError> { + let title = sqlx::query_scalar!( + r#"SELECT s.title AS "title!: String" + FROM seasons se + JOIN series s ON s.id = se.series_id + WHERE se.id = ?"#, + season_id + ) + .fetch_optional(database.pool()) + .await?; + Ok(title) +} + +/// The chosen release, still associated with the episode and not +/// hard-failed — a manual grab may take a `waived` release (§9.3). +async fn load_episode_release( + database: &Db, + episode_id: i64, + release_id: i64, +) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT r.id AS "id!: i64", + r.indexer_id AS "indexer_id!: i64", + r.guid AS "guid!: String", + r.name AS "name!: String", + r.download_url AS "download_url!: String", + CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64" + FROM releases r + JOIN episode_releases er ON er.release_id = r.id + WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived') + "#, + episode_id, + release_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| Eligible { + id: row.id, + indexer_id: row.indexer_id, + guid: row.guid, + name: row.name, + download_url: row.download_url, + score: row.score, + })) +} + +/// The season counterpart: chosen off the season deck (issue #125). +async fn load_season_release( + database: &Db, + season_id: i64, + release_id: i64, +) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT r.id AS "id!: i64", + r.indexer_id AS "indexer_id!: i64", + r.guid AS "guid!: String", + r.name AS "name!: String", + r.download_url AS "download_url!: String", + CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64" + FROM releases r + JOIN season_releases sr ON sr.release_id = r.id + WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived') + "#, + season_id, + release_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| Eligible { + id: row.id, + indexer_id: row.indexer_id, + guid: row.guid, + name: row.name, + download_url: row.download_url, + score: row.score, + })) +} + /// Whether a season-pack grab for this season already hard-failed — the /// fall-back-to-per-episode signal. async fn pack_hard_failed(database: &Db, season_id: i64) -> Result { @@ -1099,4 +1612,209 @@ mod tests { .count(); assert_eq!(searches_after_first, searches_after_second); } + + /// Issue #132: an `EpisodeCommand::Search` must do the work, not merely + /// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day + /// backoff, nowhere near elapsed) so a tick would skip this episode; + /// ending at 1 rather than 6 proves the reset happened, and a torrent in + /// Transmission proves the grab did. + #[tokio::test] + async fn a_manual_episode_search_searches_and_grabs_now() { + let (_dir, database, _season_id) = + wanted_season(&["2024-04-11", "2024-04-18", "2024-04-25"]).await; + sqlx::query( + "UPDATE episodes SET search_attempts = 5, + last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days') + WHERE number = 1", + ) + .execute(database.pool()) + .await + .unwrap(); + let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader) + .search_episode_now(&database, episode_id) + .await + .unwrap(); + + let sources: Vec = fake + .torrents() + .into_iter() + .map(|torrent| torrent.source) + .collect(); + assert_eq!(sources.len(), 1, "{sources:?}"); + assert!(sources[0].ends_with("e01.torrent")); + assert_eq!( + grabs(&database).await, + vec![("episode".to_owned(), episode_id, "sent".to_owned())] + ); + let attempts: i64 = sqlx::query_scalar("SELECT search_attempts FROM episodes WHERE id = ?") + .bind(episode_id) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!( + attempts, 0, + "reset from 5; a completed grab records no further attempt" + ); + } + + /// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release + /// straight to Transmission — no indexer search at all. + #[tokio::test] + async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() { + let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await; + let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) + VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240, + ?, '{}', 900, 'eligible') RETURNING id", + ) + .bind(format!("{}/dl/e01.torrent", indexer.uri())) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)") + .bind(episode_id) + .bind(release_id) + .execute(database.pool()) + .await + .unwrap(); + + action(&indexer, &downloader) + .grab_episode_release_now(&database, episode_id, release_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert_eq!( + grabs(&database).await, + vec![("episode".to_owned(), episode_id, "sent".to_owned())] + ); + let searched = indexer + .received_requests() + .await + .unwrap() + .iter() + .any(|request| { + request + .url + .query_pairs() + .any(|(name, value)| name == "t" && value == "search") + }); + assert!(!searched, "a chosen release must not trigger a search"); + } + + /// Issue #132: a `SeasonCommand::Search` runs the pack lane now, against + /// the season deck's own table — the backoff reset is what proves it was + /// really drained rather than dropped on the floor. + #[tokio::test] + async fn a_manual_season_search_takes_the_pack_now() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + sqlx::query( + "UPDATE episodes SET search_attempts = 5, + last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')", + ) + .execute(database.pool()) + .await + .unwrap(); + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader) + .search_season_now(&database, season_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await, + vec![("season".to_owned(), season_id, "sent".to_owned())] + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading", "downloading", "downloading"] + ); + let packs: Vec = sqlx::query_scalar( + "SELECT r.name FROM season_releases sr JOIN releases r ON r.id = sr.release_id", + ) + .fetch_all(database.pool()) + .await + .unwrap(); + assert!(packs.contains(&"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos".to_owned())); + let attempts: Vec = + sqlx::query_scalar("SELECT search_attempts FROM episodes ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!( + attempts, + vec![0, 0, 0], + "reset from 5; a completed grab records no further attempt" + ); + } + + /// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack + /// straight to Transmission — no indexer search at all. + #[tokio::test] + async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + let release_id: i64 = sqlx::query_scalar( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) + VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920, + ?, '{}', 900, 'eligible') RETURNING id", + ) + .bind(format!("{}/dl/pack.torrent", indexer.uri())) + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") + .bind(season_id) + .bind(release_id) + .execute(database.pool()) + .await + .unwrap(); + + action(&indexer, &downloader) + .grab_season_release_now(&database, season_id, release_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert_eq!( + grabs(&database).await, + vec![("season".to_owned(), season_id, "sent".to_owned())] + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading", "downloading", "downloading"] + ); + let searched = indexer + .received_requests() + .await + .unwrap() + .iter() + .any(|request| { + request + .url + .query_pairs() + .any(|(name, value)| name == "t" && value == "search") + }); + assert!(!searched, "a chosen release must not trigger a search"); + } } From 8a77049fdbe3c3bee537f71a2ab7961a57e7ad45 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:24:50 +0100 Subject: [PATCH 41/71] feat(daemon): drain all manual command channels --- crates/arr-daemon/src/main.rs | 75 +++++++++++++------- crates/arr-daemon/src/manual.rs | 122 ++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 46 deletions(-) diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 6d06d21..d243b0e 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -123,7 +123,7 @@ async fn run() -> Result<(), Error> { None }; let notifier = Notifier::new(config.ntfy_url.clone())?; - let (reconcile, manual_grab) = + let (reconcile, manual_grab, manual_tv) = reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; // Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and @@ -157,10 +157,17 @@ async fn run() -> Result<(), Error> { let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone())); - // Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so - // the manual-search and manual-grab endpoints were a no-op — the command - // sat in the 64-slot buffer forever. - let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx)); + // Issue #107: nothing else drains `AppState`'s command channels, so the + // manual-search and manual-grab endpoints were a no-op — the command sat + // in the 64-slot buffer forever. Issue #132: the episode and season + // channels are drained by the same lane. + let mut manual_task = tokio::spawn(manual::run( + state, + database, + manual_grab, + manual_tv, + shutdown_rx, + )); let signal_tx = shutdown_tx.clone(); let server = async move { axum::serve(listener, app) @@ -198,17 +205,18 @@ async fn run() -> Result<(), Error> { /// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not /// configured stays unregistered rather than failing every tick. /// -/// Also returns a second, independent `GrabAction` for `manual::run` (issue -/// #107) — the manual trigger needs the same search-and-grab path on demand -/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register` -/// takes ownership of the one it ticks. +/// Also returns independent action instances for `manual::run` (issues #107 +/// and #132) — the manual trigger needs the same search-and-grab paths on +/// demand rather than on the reconcile tick's schedule, and +/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie +/// one needs TMDB too; TV grabbing does not (§6.2). fn reconcile_loop( database: &Db, config: &Config, transmission: &arr_dl::TransmissionClient, tmdb: Option<&Arc>, notifier: &Notifier, -) -> Result<(ReconcileLoop, Option), Error> { +) -> Result<(ReconcileLoop, Option, Option), Error> { let reconcile = ReconcileLoop::new(database.clone()); let seeding = SeedingRules::new( SeedingLimits { @@ -235,7 +243,7 @@ fn reconcile_loop( .map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key)) .transpose()?; - let (mut reconcile, manual_grab) = register_movie_grab( + let (reconcile, manual_grab) = register_movie_grab( reconcile, prowlarr.as_ref(), transmission, @@ -243,20 +251,8 @@ fn reconcile_loop( &seeding, tmdb, ); - // TV grabbing needs no TMDB at grab time: air dates are already on the - // episode rows, which is the same gate the digital release date is for - // movies (§6.2). - if let Some(prowlarr) = prowlarr.as_ref() { - reconcile = reconcile.register( - Tick::Reconcile, - TvGrabAction::new( - prowlarr.clone(), - transmission.clone(), - config.download_dir.clone(), - seeding.clone(), - ), - ); - } + let (mut reconcile, manual_tv) = + register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding); // RSS needs no TMDB: it matches what the feeds already carry against the // wanted list (§6.2). if let Some(prowlarr) = prowlarr { @@ -322,7 +318,34 @@ fn reconcile_loop( } let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone())); - Ok((reconcile, manual_grab)) + Ok((reconcile, manual_grab, manual_tv)) +} + +/// Register the TV grab lane on `reconcile` and hand back a second, +/// independent instance for `manual::run` (issue #132). `None` when Prowlarr +/// is not configured. TV grabbing needs no TMDB at grab time: air dates are +/// already on the episode rows, which is the same gate the digital release +/// date is for movies (§6.2). +fn register_tv_grab( + mut reconcile: ReconcileLoop, + prowlarr: Option<&arr_indexer::ProwlarrClient>, + transmission: &arr_dl::TransmissionClient, + config: &Config, + seeding: &SeedingRules, +) -> (ReconcileLoop, Option) { + let Some(prowlarr) = prowlarr else { + return (reconcile, None); + }; + let tv_grab_action = || { + TvGrabAction::new( + prowlarr.clone(), + transmission.clone(), + config.download_dir.clone(), + seeding.clone(), + ) + }; + reconcile = reconcile.register(Tick::Reconcile, tv_grab_action()); + (reconcile, Some(tv_grab_action())) } /// Register the movie grab lane on `reconcile` and hand back a second, diff --git a/crates/arr-daemon/src/manual.rs b/crates/arr-daemon/src/manual.rs index a9bd2b1..af3eff3 100644 --- a/crates/arr-daemon/src/manual.rs +++ b/crates/arr-daemon/src/manual.rs @@ -1,23 +1,33 @@ -//! Drains `AppState`'s `MovieCommand` channel — the daemon-side consumer -//! DESIGN.md §6.2 and §9.3 assume exists (issue #107). `Search` resets the -//! movie's backoff and re-runs the targeted-search + grab lane immediately; +//! Drains `AppState`'s three command channels — the daemon-side consumer +//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search` +//! resets backoff and re-runs the targeted-search + grab lane immediately; //! `Grab` sends an already-chosen release straight to Transmission, skipping -//! search. +//! search. Movies, episodes and seasons share one lane because they share +//! one operator waiting on a 202. -use arr_api::{AppState, MovieCommand}; +use arr_api::{AppState, EpisodeCommand, MovieCommand, SeasonCommand}; use arr_db::Db; use tokio::sync::watch; use crate::grab::{GrabAction, GrabError}; +use crate::tv_grab::TvGrabAction; -/// Run until every sender is dropped or `shutdown` fires. `grab` is `None` -/// when Prowlarr or TMDB is not configured (`main.rs` warns about this -/// already for the reconcile lane) — commands are still drained so the -/// channel never fills, they just cannot be acted on. +/// Which channel a drained command came off. +enum Command { + Movie(MovieCommand), + Episode(EpisodeCommand), + Season(SeasonCommand), +} + +/// Run until every sender is dropped or `shutdown` fires. Either action is +/// `None` when Prowlarr (or TMDB, for movies) is not configured (`main.rs` +/// warns about this already for the reconcile lanes) — commands are still +/// drained so the channels never fill, they just cannot be acted on. pub async fn run( state: AppState, database: Db, - grab: Option, + movies: Option, + tv: Option, mut shutdown: watch::Receiver, ) { loop { @@ -29,22 +39,54 @@ pub async fn run( } continue; } - command = state.next_movie_command() => command, + command = state.next_movie_command() => command.map(Command::Movie), + command = state.next_episode_command() => command.map(Command::Episode), + command = state.next_season_command() => command.map(Command::Season), }; let Some(command) = command else { return; }; - let Some(grab) = &grab else { - tracing::warn!("manual movie command received but Prowlarr or TMDB is not configured"); - continue; - }; - if let Err(error) = handle(grab, &database, command).await { - tracing::error!(%error, "manual movie command failed"); + match command { + Command::Movie(command) => { + let Some(movies) = &movies else { + tracing::warn!( + "manual movie command received but Prowlarr or TMDB is not configured" + ); + continue; + }; + if let Err(error) = handle_movie(movies, &database, command).await { + tracing::error!(%error, "manual movie command failed"); + } + } + Command::Episode(command) => { + let Some(tv) = &tv else { + tracing::warn!( + "manual episode command received but Prowlarr is not configured" + ); + continue; + }; + if let Err(error) = handle_episode(tv, &database, command).await { + tracing::error!(%error, "manual episode command failed"); + } + } + Command::Season(command) => { + let Some(tv) = &tv else { + tracing::warn!("manual season command received but Prowlarr is not configured"); + continue; + }; + if let Err(error) = handle_season(tv, &database, command).await { + tracing::error!(%error, "manual season command failed"); + } + } } } } -async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Result<(), GrabError> { +async fn handle_movie( + grab: &GrabAction, + database: &Db, + command: MovieCommand, +) -> Result<(), GrabError> { match command { MovieCommand::Search { movie_id } => { grab.search_now(database, movie_id).await?; @@ -60,6 +102,46 @@ async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Resu Ok(()) } +async fn handle_episode( + tv: &TvGrabAction, + database: &Db, + command: EpisodeCommand, +) -> Result<(), GrabError> { + match command { + EpisodeCommand::Search { episode_id } => { + tv.search_episode_now(database, episode_id).await?; + } + EpisodeCommand::Grab { + episode_id, + release_id, + } => { + tv.grab_episode_release_now(database, episode_id, release_id) + .await?; + } + } + Ok(()) +} + +async fn handle_season( + tv: &TvGrabAction, + database: &Db, + command: SeasonCommand, +) -> Result<(), GrabError> { + match command { + SeasonCommand::Search { season_id } => { + tv.search_season_now(database, season_id).await?; + } + SeasonCommand::Grab { + season_id, + release_id, + } => { + tv.grab_season_release_now(database, season_id, release_id) + .await?; + } + } + Ok(()) +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -160,7 +242,7 @@ mod tests { let transmission = transmission().await; let grab = grab_action(&indexer, &transmission); - handle(&grab, &database, MovieCommand::Search { movie_id: 1 }) + handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 }) .await .unwrap(); @@ -218,7 +300,7 @@ mod tests { .await .unwrap(); - handle( + handle_movie( &grab, &database, MovieCommand::Grab { From d8316ae0c87dff63cedb623601d317b450454608 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:41:36 +0100 Subject: [PATCH 42/71] feat(api): series files keyed by episode id --- ...cd6329037fe678ed628ea3ee92d14c7692540.json | 44 ++++++++++ crates/arr-api/src/lib.rs | 1 + crates/arr-api/src/series.rs | 84 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 .sqlx/query-17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540.json diff --git a/.sqlx/query-17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540.json b/.sqlx/query-17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540.json new file mode 100644 index 0000000..0eb9c79 --- /dev/null +++ b/.sqlx/query-17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.id AS \"episode_id!: i64\", mf.path AS \"path!: String\", mf.size AS \"size!: i64\", mf.probed AS \"probed?: serde_json::Value\", json_extract(mf.waiver, '$.rule') AS \"waiver?: String\" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path", + "describe": { + "columns": [ + { + "name": "episode_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "size!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "probed?: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "waiver?: String", + "ordinal": 4, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + null + ] + }, + "hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 73998d1..a85c62b 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -92,6 +92,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(series::search_season)) .routes(routes!(series::season_releases)) .routes(routes!(series::grab_season_release)) + .routes(routes!(series::files)) .routes(routes!(series::list_owners)) .routes(routes!(series::tag_owner, series::untag_owner)) .routes(routes!(owners::list, owners::create)) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 7c231d8..84b36ea 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -1140,6 +1140,39 @@ pub async fn grab_season_release( Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } +/// One imported episode file, keyed to its episode so the detail view can +/// attach the file's probed §7.4 attributes to the episode row. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct EpisodeFile { + pub episode_id: i64, + pub path: String, + pub size: i64, + pub probed: Option, + /// The §5.7 rule relaxed to allow this import, when one was. + pub waiver: Option, +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/files", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = [EpisodeFile]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn files( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + load_series_row(&state, id).await?; + let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id) + .fetch_all(pool(&state)?) + .await?; + Ok(Json(files)) +} + #[utoipa::path( get, path = "/api/series/{series_id}/owners", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), @@ -1956,6 +1989,57 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + /// The detail view reads its episode files through the series, keyed by + /// episode id, so one request carries every §7.4 attribute tag it shows. + #[tokio::test] + async fn series_files_are_keyed_by_episode() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "kids").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([ + {"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"}, + {"number": 2, "title": "Hospital", "air_date": "2018-10-02"} + ]), + ) + .await; + let episodes = season["episodes"].as_array().expect("episodes"); + let first = episodes[0]["id"].as_i64().expect("episode id"); + let pool = state.database().expect("database").pool(); + sqlx::query( + r#"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver) + VALUES ('episode', ?, ?, 7, ?, ?)"#, + ) + .bind(first) + .bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv") + .bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#) + .bind(r#"{"rule":"required_audio"}"#) + .execute(pool) + .await + .expect("media file"); + + let response = reqwest::get(format!("{base}/api/series/{series_id}/files")) + .await + .expect("fetch files"); + assert_eq!(response.status(), StatusCode::OK); + let files: serde_json::Value = response.json().await.expect("files json"); + let rows = files.as_array().expect("array"); + assert_eq!(rows.len(), 1, "only imported episodes carry a file"); + assert_eq!(rows[0]["episode_id"], first); + assert_eq!(rows[0]["probed"]["resolution"], "1080p"); + assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT"); + assert_eq!(rows[0]["waiver"], "required_audio"); + + let missing = reqwest::get(format!("{base}/api/series/999/files")) + .await + .expect("fetch files"); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + } + /// The guard that keeps a delete inside the library: a path that is not /// under the series' root is left alone, whatever the row says. #[tokio::test] From 3956617d418755ae162b1c93b3fde5569c65986f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:59:35 +0100 Subject: [PATCH 43/71] feat(meta): resolve IMDb ids against series too --- crates/arr-meta/src/client.rs | 14 +++++++--- crates/arr-meta/src/lib.rs | 2 +- crates/arr-meta/src/model.rs | 9 ++++++ .../tests/fixtures/find_imdb_series.json | 14 ++++++++++ crates/arr-meta/tests/tmdb.rs | 28 +++++++++++++++++++ 5 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 crates/arr-meta/tests/fixtures/find_imdb_series.json diff --git a/crates/arr-meta/src/client.rs b/crates/arr-meta/src/client.rs index c249f97..54b7d19 100644 --- a/crates/arr-meta/src/client.rs +++ b/crates/arr-meta/src/client.rs @@ -9,8 +9,8 @@ use serde::de::DeserializeOwned; use crate::cache::Cache; use crate::error::{Error, Result}; use crate::model::{ - ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage, - RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult, + ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, + RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult, }; /// TMDB's v3 API root. @@ -94,14 +94,20 @@ impl TmdbClient { /// Resolve an `IMDb` title id through TMDB's external-id index. /// + /// TMDB answers with both kinds in one response, so one call surfaces + /// whichever the id names — the way a raw TMDB id resolves (§9.2). + /// /// # Errors /// /// Any of [`Error`]; see its variants for what callers should distinguish. - pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result> { + pub async fn find_by_imdb(&self, imdb_id: &str) -> Result { let path = format!("find/{}", imdb_id.trim()); let params = [("external_source", "imdb_id".to_owned())]; let page: RawFindPage = self.get_json(&path, ¶ms).await?; - Ok(page.movie_results.into_iter().map(Into::into).collect()) + Ok(FindResults { + movies: page.movie_results.into_iter().map(Into::into).collect(), + series: page.tv_results.into_iter().map(Into::into).collect(), + }) } /// Resolve a TVDB series id through TMDB's external-id index. diff --git a/crates/arr-meta/src/lib.rs b/crates/arr-meta/src/lib.rs index adb4fe1..5c14e4a 100644 --- a/crates/arr-meta/src/lib.rs +++ b/crates/arr-meta/src/lib.rs @@ -24,5 +24,5 @@ mod model; pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL}; pub use error::{Error, Result}; pub use model::{ - Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult, + Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series, SeriesSearchResult, }; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 69adc15..ce681a7 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -293,6 +293,15 @@ impl From for Season { } } +/// What one `IMDb` id resolved to. An id names one title, so at most one of +/// the two lists is non-empty — but TMDB answers both kinds in the same +/// response, and both are surfaced rather than filtered here. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FindResults { + pub movies: Vec, + pub series: Vec, +} + #[derive(Debug, Deserialize)] pub(crate) struct RawFindPage { #[serde(default)] diff --git a/crates/arr-meta/tests/fixtures/find_imdb_series.json b/crates/arr-meta/tests/fixtures/find_imdb_series.json new file mode 100644 index 0000000..96acffb --- /dev/null +++ b/crates/arr-meta/tests/fixtures/find_imdb_series.json @@ -0,0 +1,14 @@ +{ + "movie_results": [], + "tv_results": [ + { + "id": 82728, + "name": "Bluey", + "original_name": "Bluey", + "original_language": "en", + "first_air_date": "2018-10-01", + "overview": "The slice-of-life adventures of an Australian cattle dog.", + "poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg" + } + ] +} diff --git a/crates/arr-meta/tests/tmdb.rs b/crates/arr-meta/tests/tmdb.rs index 911f172..3aba2da 100644 --- a/crates/arr-meta/tests/tmdb.rs +++ b/crates/arr-meta/tests/tmdb.rs @@ -19,6 +19,7 @@ const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json"); const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json"); const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json"); const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json"); +const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json"); fn client(server: &MockServer) -> TmdbClient { TmdbClient::builder("test-key") @@ -483,6 +484,33 @@ async fn series_external_ids_carries_the_tvdb_id() { assert_eq!(ids.tvdb_id, Some(361_391)); } +/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a +/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results` +/// half is what this reads. +#[tokio::test] +async fn find_by_imdb_resolves_series() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/find/tt7614372")) + .and(query_param("external_source", "imdb_id")) + .respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES)) + .expect(1) + .mount(&server) + .await; + + let results = client(&server) + .find_by_imdb("tt7614372") + .await + .expect("lookup succeeds"); + + assert!(results.movies.is_empty()); + assert_eq!(results.series.len(), 1); + let series = &results.series[0]; + assert_eq!(series.tmdb_id, 82_728); + assert_eq!(series.title, "Bluey"); + assert_eq!(series.year(), Some(2018)); +} + /// A series TMDB has no TVDB id for stays null rather than zero, so callers /// can tell "unknown" from a real id and fall back to the text query. #[tokio::test] From fea1a04c750713739d1633d6ac1683edea4d392d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:59:35 +0100 Subject: [PATCH 44/71] feat(api): surface series for IMDb id searches --- ...af5a2551a87ccb172c02795e75129e35bfc63.json | 56 +++++++++++ crates/arr-api/src/search.rs | 93 ++++++++++++++++--- 2 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 .sqlx/query-ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63.json diff --git a/.sqlx/query-ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63.json b/.sqlx/query-ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63.json new file mode 100644 index 0000000..2f7d6fe --- /dev/null +++ b/.sqlx/query-ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "blocked!: bool", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63" +} diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 96506e4..1fe6b46 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -224,14 +224,23 @@ pub async fn search( let results = search_tmdb(&tmdb, kind, input).await?; if matches!(kind, SearchInputKind::ImdbId) { for result in &results { - let TmdbResult::Movie(movie) = result else { - continue; - }; - let tmdb_id = i64::from(movie.tmdb_id); - if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id) - .fetch_optional(database.pool()).await? - { - library.push(LibraryResult::Movie(found)); + match result { + TmdbResult::Movie(movie) => { + let tmdb_id = i64::from(movie.tmdb_id); + if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id) + .fetch_optional(database.pool()).await? + { + library.push(LibraryResult::Movie(found)); + } + } + TmdbResult::Series(series) => { + let tmdb_id = i64::from(series.tmdb_id); + if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id) + .fetch_optional(database.pool()).await? + { + library.push(LibraryResult::Series(found)); + } + } } } } @@ -316,14 +325,24 @@ async fn search_tmdb( } matches } - SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await { - Ok(movies) => movies + SearchInputKind::ImdbId => { + let found = tmdb + .find_by_imdb(input) + .await + .map_err(|error| upstream_error(&error))?; + let mut results: Vec = found + .movies .into_iter() .map(|movie| TmdbResult::Movie(movie.into())) - .collect(), - Err(arr_meta::Error::NotFound { .. }) => Vec::new(), - Err(error) => return Err(upstream_error(&error)), - }, + .collect(); + results.extend( + found + .series + .into_iter() + .map(|series| TmdbResult::Series(series.into())), + ); + results + } SearchInputKind::Text => { let mut results: Vec = tmdb .search_movies(input, None) @@ -950,6 +969,52 @@ mod tests { assert_eq!(response["tmdb"][0]["title"], "1917"); } + /// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does — + /// a hit on TMDB and, when tracked, in the library set too. + #[tokio::test] + async fn an_imdb_id_resolves_to_a_series() { + let tmdb = MockServer::start().await; + let prowlarr = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/find/tt7614372")) + .and(query_param("external_source", "imdb_id")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "movie_results": [], + "tv_results": [{ + "id": 82_728, "name": "Bluey", "original_name": "Bluey", + "original_language": "en", "first_air_date": "2018-10-01" + }] + }))) + .mount(&tmdb) + .await; + let (_dir, state, base) = application(&tmdb, &prowlarr).await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") + .fetch_one(pool) + .await + .expect("TV root"); + sqlx::query( + "INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)", + ) + .bind(root_id) + .execute(pool) + .await + .expect("series"); + + let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372")) + .await + .expect("search") + .json() + .await + .expect("json"); + assert_eq!(response["kind"], "imdb_id"); + assert_eq!(response["library"][0]["kind"], "series"); + assert_eq!(response["library"][0]["tmdb_id"], 82_728); + assert_eq!(response["tmdb"][0]["kind"], "series"); + assert_eq!(response["tmdb"][0]["title"], "Bluey"); + } + #[tokio::test] async fn manual_releases_are_classified_and_name_rejection_rules() { let tmdb = MockServer::start().await; From 9df120b92a4035e9cdb35234de4617aeacafd867 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:07:00 +0100 Subject: [PATCH 45/71] feat(daemon): match RSS results to wanted episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One feed pass now serves films and episodes alike. A single-episode release grabs its open episode directly; a season pack only grabs when season_grab_mode allows packs for that season (§14, #117), and when it wins, the singles stand down. Blocked series keep matching RSS (§6.3) and nothing here touches the targeted-search backoff (§6.2). --- ...ae0c4a718e95f9718629f0aba9f3d00d179e1.json | 26 + ...90843a8bc71c820b56847e0a86a9497f03952.json | 50 ++ ...e73285778a186fd1da64a91ad18cd1c5c14d5.json | 20 + crates/arr-daemon/src/rss.rs | 749 ++++++++++++++++-- 4 files changed, 789 insertions(+), 56 deletions(-) create mode 100644 .sqlx/query-3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1.json create mode 100644 .sqlx/query-90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952.json create mode 100644 .sqlx/query-d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5.json diff --git a/.sqlx/query-3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1.json b/.sqlx/query-3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1.json new file mode 100644 index 0000000..8c94688 --- /dev/null +++ b/.sqlx/query-3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"on_disk!: bool\"\n FROM episodes e\n WHERE e.season_id = ?\n ORDER BY e.number\n ", + "describe": { + "columns": [ + { + "name": "air_date", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "on_disk!: bool", + "ordinal": 1, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + null + ] + }, + "hash": "3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1" +} diff --git a/.sqlx/query-90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952.json b/.sqlx/query-90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952.json new file mode 100644 index 0000000..6061b48 --- /dev/null +++ b/.sqlx/query-90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"episode!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY e.id\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "episode!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "season_id!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "season!: i64", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "series_tmdb_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "series_title!: String", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952" +} diff --git a/.sqlx/query-d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5.json b/.sqlx/query-d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5.json new file mode 100644 index 0000000..1b5cef1 --- /dev/null +++ b/.sqlx/query-d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n ", + "describe": { + "columns": [ + { + "name": "original_language", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5" +} diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index f8479d6..03a1cb3 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -9,19 +9,31 @@ //! //! `blocked` is honoured the other way round from targeted search (§6.3): it //! stops a title being searched for, and leaves it matching RSS. +//! +//! One pass serves films and episodes alike: each feed item goes through the +//! movie matcher first, then the episode one (`arr_core::matching`). A +//! single-episode release grabs its wanted, missing episode directly; a +//! season pack only grabs when [`season_grab_mode`] allows packs for that +//! season — the same guard §14 gives targeted search, so a season behaves +//! the same however a release is found (§6.2). Like everything else here, +//! none of it backs off or counts attempts. use std::collections::hash_map::Entry; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use arr_core::matching::{match_movie, MatchKind, ReleaseIds, WantedMovie}; -use arr_core::{Language, MovieId}; -use arr_db::{Blacklist, Db, MoviePolicy}; +use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; +use arr_core::matching::{ + match_episode, match_movie, MatchKind, MatchShape, ReleaseIds, WantedEpisode, WantedMovie, +}; +use arr_core::{EpisodeId, Language, MovieId}; +use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy}; use arr_dl::TransmissionClient; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use crate::grab::{ - store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, SeedingRules, + store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, + SeedingRules, }; use crate::indexers::IndexerDirectory; use crate::reconcile::{Action, ActionFuture, Outcome}; @@ -51,7 +63,8 @@ impl RssAction { async fn tick(&self, database: &Db) -> Result, GrabError> { let wanted = wanted_movies(database).await?; - if wanted.is_empty() { + let episodes = wanted_episodes(database).await?; + if wanted.is_empty() && episodes.is_empty() { return Ok(Vec::new()); } let indexers = self.indexers.searchable().await?; @@ -62,12 +75,12 @@ impl RssAction { let releases = self.feeds(&indexers).await; let blacklist = Blacklist::load(database.pool()).await?; - let winners = self - .match_feeds(database, &wanted, releases, &blacklist) + let (movies, tv) = self + .match_feeds(database, &wanted, &episodes, releases, &blacklist) .await?; let mut outcomes = Vec::new(); - for (movie, winner) in winners { + for (movie, winner) in movies { let outcome = self .grabber .send_winner( @@ -86,6 +99,29 @@ impl RssAction { .await?; outcomes.extend(outcome); } + for TvWinner { + grabbable, + scope, + candidate: winner, + } in tv + { + let outcome = self + .grabber + .send_winner( + database, + &GrabTarget { + scope, + title: &grabbable.series_title, + // Same property as the movie lane above. + counts_as_attempt: false, + }, + &grabbable.policy, + &blacklist, + winner, + ) + .await?; + outcomes.extend(outcome); + } Ok(outcomes) } @@ -116,11 +152,13 @@ impl RssAction { &self, database: &Db, wanted: &[WantedMovie], + episodes: &[WantedEpisodeRow], releases: Vec, blacklist: &Blacklist, - ) -> Result, GrabError> { + ) -> Result<(Vec<(Grabbable, Eligible)>, Vec), GrabError> { let mut policies: HashMap> = HashMap::new(); let mut best: HashMap = HashMap::new(); + let mut tv = TvCandidates::default(); for release in releases { let claims = arr_parse::parse(&release.name); @@ -128,56 +166,239 @@ impl RssAction { tmdb_id: release.tmdb_id, imdb_id: release.imdb_id.clone(), }; - let Some(matched) = match_movie(wanted, &ids, &claims) else { - continue; - }; - let movie_id = matched.movie.0; - let grabbable = match policies.entry(movie_id) { - Entry::Occupied(entry) => entry.into_mut(), - Entry::Vacant(entry) => { - entry.insert(load_grabbable(database, wanted, movie_id).await?) + if let Some(matched) = match_movie(wanted, &ids, &claims) { + let movie_id = matched.movie.0; + let grabbable = match policies.entry(movie_id) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + entry.insert(load_grabbable(database, wanted, movie_id).await?) + } + }; + let Some(grabbable) = grabbable.as_ref() else { + continue; + }; + tracing::info!( + movie_id, + release = release.name, + by = match matched.kind { + MatchKind::TmdbId => "tmdb id", + MatchKind::ImdbId => "imdb id", + MatchKind::TitleAndYear => "title and year", + }, + "RSS result matched a wanted title" + ); + if let Some(candidate) = store_release( + database, + movie_id, + &release, + &grabbable.policy.policy, + &grabbable.policy.overrides, + &grabbable.language, + blacklist, + ) + .await? + { + let incumbent = best.get(&movie_id); + if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) { + best.insert(movie_id, candidate); + } } - }; - let Some(grabbable) = grabbable.as_ref() else { continue; - }; - tracing::info!( - movie_id, - release = release.name, - by = match matched.kind { - MatchKind::TmdbId => "tmdb id", - MatchKind::ImdbId => "imdb id", - MatchKind::TitleAndYear => "title and year", - }, - "RSS result matched a wanted title" - ); - let stored = store_release( - database, - movie_id, - &release, - &grabbable.policy.policy, - &grabbable.policy.overrides, - &grabbable.language, - blacklist, - ) - .await?; - let Some(candidate) = stored else { - continue; - }; - let incumbent = best.get(&movie_id); - if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) { - best.insert(movie_id, candidate); } + tv.offer(database, episodes, &release, &ids, &claims, blacklist) + .await?; } - let mut winners: Vec<(Grabbable, Eligible)> = best + let tv_winners = tv.winners(database, episodes).await?; + let mut movies: Vec<(Grabbable, Eligible)> = best .into_iter() .filter_map(|(movie_id, candidate)| { let grabbable = policies.get(&movie_id).and_then(Clone::clone)?; Some((grabbable, candidate)) }) .collect(); - winners.sort_by_key(|(movie, _)| movie.id); + movies.sort_by_key(|(movie, _)| movie.id); + Ok((movies, tv_winners)) + } +} + +/// The episode lane's state over one feed pass: the grab context per season +/// and the best eligible candidate per wanted episode and per season's pack. +#[derive(Default)] +struct TvCandidates { + policies: HashMap>, + best_single: HashMap, + /// Per season: the best pack alongside the open episodes it covers. + best_pack: HashMap)>, +} + +impl TvCandidates { + /// Offer one feed item to every wanted episode. A release holds either + /// exactly one episode or whole seasons, so each row it matches lands in + /// exactly one bucket. + async fn offer( + &mut self, + database: &Db, + episodes: &[WantedEpisodeRow], + release: &SearchRelease, + ids: &ReleaseIds, + claims: &arr_parse::NameClaims, + blacklist: &Blacklist, + ) -> Result<(), GrabError> { + let mut packs: HashMap> = HashMap::new(); + let mut single: Option<(i64, MatchKind)> = None; + for row in episodes { + let Some(matched) = match_episode(&row.wanted, ids, claims) else { + continue; + }; + match matched.shape { + MatchShape::SeasonPack => { + packs + .entry(row.season_id) + .or_default() + .push(row.wanted.id.0); + } + MatchShape::SingleEpisode => single = Some((row.wanted.id.0, matched.kind)), + } + } + for (season_id, covered) in packs { + let Some(candidate) = self + .store_candidate(database, episodes, season_id, &covered, release, blacklist) + .await? + else { + continue; + }; + let incumbent = self.best_pack.get(&season_id); + if incumbent.is_none_or(|(incumbent, _)| beats(&candidate, incumbent)) { + self.best_pack.insert(season_id, (candidate, covered)); + } + } + if let Some((episode_id, kind)) = single { + tracing::info!( + episode_id, + release = release.name, + by = tv_match_kind(kind), + "RSS result matched a wanted episode" + ); + let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else { + return Ok(()); + }; + let Some(candidate) = self + .store_candidate( + database, + episodes, + row.season_id, + &[episode_id], + release, + blacklist, + ) + .await? + else { + return Ok(()); + }; + let incumbent = self.best_single.get(&episode_id); + if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) { + self.best_single.insert(episode_id, candidate); + } + } + Ok(()) + } + + /// Classify and store a feed item against the open episodes it covers, + /// loading the season's grab context on first use. + async fn store_candidate( + &mut self, + database: &Db, + episodes: &[WantedEpisodeRow], + season_id: i64, + covered: &[i64], + release: &SearchRelease, + blacklist: &Blacklist, + ) -> Result, GrabError> { + let grabbable = match self.policies.entry(season_id) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + entry.insert(load_tv_grabbable(database, episodes, season_id).await?) + } + }; + let Some(grabbable) = grabbable.as_ref() else { + return Ok(None); + }; + let (_, stored) = store_episode_release( + database, + covered, + release, + &grabbable.policy.policy, + &grabbable.policy.overrides, + &grabbable.language, + blacklist, + ) + .await?; + Ok(stored) + } + + /// Turn the per-season and per-episode winners into grabs. A season with + /// an eligible pack only takes it when [`season_grab_mode`] allows packs + /// there — the guard targeted search obeys (§6.2, §14) — and when it + /// does, the season's singles stand down. Otherwise each open episode + /// takes its own winner. + async fn winners( + &self, + database: &Db, + episodes: &[WantedEpisodeRow], + ) -> Result, GrabError> { + let mut winners = Vec::new(); + let mut covered_by_packs: HashSet = HashSet::new(); + + let mut seasons: Vec = self.best_pack.keys().copied().collect(); + seasons.sort_unstable(); + for season_id in seasons { + let Some((candidate, episode_ids)) = self.best_pack.get(&season_id) else { + continue; + }; + let Some(grabbable) = self.policies.get(&season_id).and_then(Clone::clone) else { + continue; + }; + if !pack_allowed(database, season_id).await? { + tracing::info!( + season_id, + series = grabbable.series_title, + "RSS skips a season pack the season's grab mode refuses" + ); + continue; + } + covered_by_packs.extend(episode_ids.iter().copied()); + winners.push(TvWinner { + grabbable, + scope: GrabScope::Season { + season_id, + episode_ids: episode_ids.clone(), + }, + candidate: candidate.clone(), + }); + } + + let mut singles: Vec = self.best_single.keys().copied().collect(); + singles.sort_unstable(); + for episode_id in singles { + if covered_by_packs.contains(&episode_id) { + continue; + } + let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else { + continue; + }; + let Some(grabbable) = self.policies.get(&row.season_id).and_then(Clone::clone) else { + continue; + }; + let Some(candidate) = self.best_single.get(&episode_id) else { + continue; + }; + winners.push(TvWinner { + grabbable, + scope: GrabScope::Episode { episode_id }, + candidate: candidate.clone(), + }); + } Ok(winners) } } @@ -255,6 +476,196 @@ async fn wanted_movies(database: &Db) -> Result, GrabError> { .collect()) } +/// A matched series season with everything a grab needs loaded. +#[derive(Clone, Debug)] +struct TvGrabbable { + series_title: String, + policy: TitlePolicy, + language: Language, +} + +/// One winner of the episode lane: what to grab, for whom, and which +/// release won. +struct TvWinner { + grabbable: TvGrabbable, + scope: GrabScope, + candidate: Eligible, +} + +/// A wanted episode with the season context matching and grabbing need. +struct WantedEpisodeRow { + wanted: WantedEpisode, + season_id: i64, +} + +fn tv_match_kind(kind: MatchKind) -> &'static str { + match kind { + MatchKind::TmdbId => "tmdb id", + MatchKind::ImdbId => "imdb id", + MatchKind::TitleAndYear => "title", + } +} + +/// Every wanted episode still open — wanted, no file on disk, no live grab +/// of its own or of its season. Same shape as the movie list: unlimited, +/// unordered by recency, blocked series included on purpose (§6.3). +async fn wanted_episodes(database: &Db) -> Result, GrabError> { + let rows = sqlx::query!( + r#" + SELECT e.id AS "id!: i64", + e.number AS "episode!: i64", + se.id AS "season_id!: i64", + se.number AS "season!: i64", + s.tmdb_id AS "series_tmdb_id!: i64", + s.title AS "series_title!: String" + FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN series s ON s.id = se.series_id + WHERE e.wanted = 1 + AND NOT EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) + AND NOT EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'episode' AND g.target_id = e.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) + AND NOT EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'season' AND g.target_id = se.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) + ORDER BY e.id + "# + ) + .fetch_all(database.pool()) + .await?; + + Ok(rows + .into_iter() + .map(|row| WantedEpisodeRow { + wanted: WantedEpisode { + id: EpisodeId(row.id), + tmdb_id: u32::try_from(row.series_tmdb_id).ok(), + // The series table carries no `IMDb` id yet, so this lane + // matches on TMDB id or title until one is backfilled. + imdb_id: None, + title: row.series_title, + season: u32::try_from(row.season).unwrap_or_default(), + episode: u32::try_from(row.episode).unwrap_or_default(), + }, + season_id: row.season_id, + }) + .collect()) +} + +/// The policy and original language a matched season needs, or `None` when +/// it cannot be evaluated yet. Same refusals as [`load_grabbable`]. +async fn load_tv_grabbable( + database: &Db, + episodes: &[WantedEpisodeRow], + season_id: i64, +) -> Result, GrabError> { + let Some(series_title) = episodes + .iter() + .find(|row| row.season_id == season_id) + .map(|row| row.wanted.title.clone()) + else { + return Ok(None); + }; + let Some(policy) = database.season_policy(season_id).await? else { + return Ok(None); + }; + // §5.2, same as films. + let language: Option = sqlx::query_scalar!( + r#" + SELECT s.original_language + FROM seasons se + JOIN series s ON s.id = se.series_id + WHERE se.id = ? + "#, + season_id + ) + .fetch_optional(database.pool()) + .await? + .flatten(); + let Some(language) = language else { + tracing::warn!( + season_id, + series = series_title, + "no original language yet; not grabbing from RSS" + ); + return Ok(None); + }; + + Ok(Some(TvGrabbable { + series_title, + policy, + language: arr_db::policy::language(&language), + })) +} + +/// Whether §6.2's guard lets a season pack be grabbed here: exactly what +/// [`season_grab_mode`] requires of targeted search, computed over every +/// episode the season is known to hold (§14, amended in #117). +async fn pack_allowed(database: &Db, season_id: i64) -> Result { + let episodes = sqlx::query!( + r#" + SELECT e.air_date, + EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) AS "on_disk!: bool" + FROM episodes e + WHERE e.season_id = ? + ORDER BY e.number + "#, + season_id + ) + .fetch_all(database.pool()) + .await?; + let pack_hard_failed = sqlx::query_scalar!( + r#"SELECT EXISTS ( + SELECT 1 FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' + ) AS "failed!: bool""#, + season_id + ) + .fetch_one(database.pool()) + .await?; + + Ok(season_grab_mode(&SeasonGrabFacts { + air_dates: &episodes + .iter() + .map(|episode| air_date_time(episode.air_date.as_deref())) + .collect::>(), + now: std::time::SystemTime::now(), + any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk), + pack_hard_failed, + }) == SeasonGrabMode::SeasonPack) +} + +/// An `air_date` as TMDB writes it (`YYYY-MM-DD`), or a full timestamp if +/// one ever arrives that way. Unknown is unaired. +fn air_date_time(value: Option<&str>) -> Option { + let value = value?; + let timestamp = if let Ok(date) = value.parse::() { + date.and_time(chrono::NaiveTime::MIN).and_utc().timestamp() + } else { + value + .parse::>() + .ok()? + .timestamp() + }; + let seconds = u64::try_from(timestamp.abs()).ok()?; + if timestamp < 0 { + std::time::UNIX_EPOCH.checked_sub(std::time::Duration::from_secs(seconds)) + } else { + std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds)) + } +} + /// The policy and original language a matched title needs, or `None` when /// the title cannot be evaluated yet. async fn load_grabbable( @@ -312,6 +723,48 @@ mod tests { /// asked for. const FEED: &str = include_str!("../tests/fixtures/rss.xml"); + /// A season pack and its three episodes, all eligible under the seeded + /// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95). + const TV_FEED: &str = r#" + + + Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP + tv-packhttps://indexer.invalid/download/tv-pack + + + + + Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP + tv-e01https://indexer.invalid/download/tv-e01 + + + + + Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP + tv-e02https://indexer.invalid/download/tv-e02 + + + + + Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP + tv-e03https://indexer.invalid/download/tv-e03 + + + + + The.Fallout.2021.S01E03.1080p.WEB-DL-GROUP + tv-near-misshttps://indexer.invalid/download/tv-near-miss + + + + + "#; + const INDEXERS: [i64; 2] = [7, 9]; /// Enough of Transmission to add a torrent and list nothing back. @@ -350,9 +803,9 @@ mod tests { /// Two indexers, both advertising a text search, both serving the same /// feed to an empty query. - async fn prowlarr() -> MockServer { + async fn prowlarr(feed: &str) -> MockServer { let server = MockServer::start().await; - let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server); + let feed = test_downloads::rewrite(feed, "https://indexer.invalid/download/", &server); test_downloads::mount(&server).await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) @@ -430,6 +883,59 @@ mod tests { ) } + /// Fallout S01 with wanted episodes airing at the given dates. Returns + /// the season id and the episode ids in episode order. + async fn wanted_series(database: &Db, air_dates: &[&str]) -> (i64, Vec) { + sqlx::query( + "INSERT INTO series (tmdb_id, title, year, original_language, root_id) + SELECT 106379, 'Fallout', 2024, 'en', id + FROM roots WHERE kind = 'tv' AND audience = 'main'", + ) + .execute(database.pool()) + .await + .unwrap(); + let season_id: i64 = sqlx::query_scalar( + "INSERT INTO seasons (series_id, number) VALUES (1, 1) RETURNING id", + ) + .fetch_one(database.pool()) + .await + .unwrap(); + let mut episodes = Vec::new(); + for (index, air_date) in air_dates.iter().enumerate() { + let id: i64 = sqlx::query_scalar( + "INSERT INTO episodes (season_id, number, title, air_date, wanted) + VALUES (?, ?, ?, ?, 1) RETURNING id", + ) + .bind(season_id) + .bind(i64::try_from(index).unwrap() + 1) + .bind(format!("Episode {}", index + 1)) + .bind(air_date) + .fetch_one(database.pool()) + .await + .unwrap(); + episodes.push(id); + } + (season_id, episodes) + } + + /// `(target_kind, target_id, state)` for every TV grab that was made. + async fn tv_grabs(database: &Db) -> Vec<(String, i64, String)> { + sqlx::query_as::<_, (String, i64, String)>( + "SELECT target_kind, target_id, state FROM grabs + WHERE target_kind IN ('episode', 'season') ORDER BY target_kind, target_id", + ) + .fetch_all(database.pool()) + .await + .unwrap() + } + + async fn episode_states(database: &Db) -> Vec { + sqlx::query_scalar::<_, String>("SELECT state FROM episodes ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap() + } + /// `(movie title, grabbed release name)` for every grab that was made. async fn grabbed(database: &Db) -> Vec<(String, String)> { sqlx::query_as::<_, (String, String)>( @@ -462,7 +968,7 @@ mod tests { (438_631, "Dune", 2021, false), ]) .await; - let indexer = prowlarr().await; + let indexer = prowlarr(FEED).await; let (downloader, fake) = transmission().await; let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); @@ -506,7 +1012,7 @@ mod tests { #[tokio::test] async fn a_near_miss_is_not_grabbed() { let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await; - let indexer = prowlarr().await; + let indexer = prowlarr(FEED).await; let (downloader, fake) = transmission().await; let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); @@ -525,7 +1031,7 @@ mod tests { (9_999_999, "Dune: Part Three", 2024, false), ]) .await; - let indexer = prowlarr().await; + let indexer = prowlarr(FEED).await; let (downloader, _fake) = transmission().await; action(&indexer, &downloader).tick(&database).await.unwrap(); @@ -542,7 +1048,7 @@ mod tests { #[tokio::test] async fn a_blocked_title_still_matches_rss() { let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await; - let indexer = prowlarr().await; + let indexer = prowlarr(FEED).await; let (downloader, _fake) = transmission().await; let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); @@ -560,7 +1066,7 @@ mod tests { (438_631, "Dune", 2021, false), ]) .await; - let indexer = prowlarr().await; + let indexer = prowlarr(FEED).await; let (downloader, _fake) = transmission().await; action(&indexer, &downloader).tick(&database).await.unwrap(); @@ -582,4 +1088,135 @@ mod tests { assert_eq!(feeds, 1); } } + + /// A single-episode release in the feed closes a wanted, missing + /// episode's gap, with no targeted-search attempt spent (§6.2). The + /// season here is airing, so its pack is not eligible ([`season_grab_mode`]). + #[tokio::test] + async fn a_missing_episode_is_grabbed_from_rss() { + let (_dir, database) = wanted(&[]).await; + let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await; + let indexer = prowlarr(TV_FEED).await; + let (downloader, fake) = transmission().await; + + let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 2); + assert_eq!( + tv_grabs(&database).await, + episodes + .iter() + .map(|id| ("episode".to_owned(), *id, "sent".to_owned())) + .collect::>(), + "each gap takes its own single; the pack and the near miss stay out" + ); + assert_eq!(fake.added.lock().unwrap().len(), 2); + let attempts: Vec<(i64, Option)> = + sqlx::query_as("SELECT search_attempts, last_searched_at FROM episodes ORDER BY id") + .fetch_all(database.pool()) + .await + .unwrap(); + assert!(attempts + .iter() + .all(|(count, at)| *count == 0 && at.is_none())); + } + + /// The acceptance case: one feed pass over a fully aired season with + /// nothing on disk takes the pack alone, and the singles stand down. + #[tokio::test] + async fn a_completed_season_takes_one_pack_from_rss() { + let (_dir, database) = wanted(&[]).await; + let (season_id, _episodes) = + wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await; + // Only the pack is wanted here: strip the singles so the pass has to + // pick between shapes on coverage, not on availability. + let pack_only = TV_FEED.replace( + "Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP", + "Unrelated.S01E01.2160p.WEB-DL-GROUP", + ); + let pack_only = pack_only.replace( + "Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP", + "Unrelated.S01E02.2160p.WEB-DL-GROUP", + ); + let pack_only = pack_only.replace( + "Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP", + "Unrelated.S01E03.2160p.WEB-DL-GROUP", + ); + let indexer = prowlarr(&pack_only).await; + let (downloader, fake) = transmission().await; + + let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + assert_eq!(fake.added.lock().unwrap().len(), 1); + assert_eq!( + tv_grabs(&database).await, + vec![("season".to_owned(), season_id, "sent".to_owned())] + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading"; 3], + "the pack flips every episode it covers" + ); + } + + /// §6.2 with #117's guard: an episode already on disk keeps the season + /// per-episode here too — the pack is skipped and the open gaps take + /// their singles. + #[tokio::test] + async fn a_pack_for_a_season_with_a_file_on_disk_is_skipped() { + let (_dir, database) = wanted(&[]).await; + let (_season_id, episodes) = + wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await; + sqlx::query( + "INSERT INTO media_files (owner_kind, owner_id, path, size) + VALUES ('episode', ?, '/tv/fallout/s01e01.mkv', 1)", + ) + .bind(episodes[0]) + .execute(database.pool()) + .await + .unwrap(); + let indexer = prowlarr(TV_FEED).await; + let (downloader, _fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + let grabs = tv_grabs(&database).await; + assert!( + !grabs.iter().any(|(kind, _, _)| kind == "season"), + "no pack over an episode already on disk" + ); + // Episode 1 is not wanted any more — it has a file — and the near + // miss never matched, so exactly the two open gaps are filled. + assert_eq!( + grabs, + vec![ + ("episode".to_owned(), episodes[1], "sent".to_owned()), + ("episode".to_owned(), episodes[2], "sent".to_owned()), + ] + ); + } + + /// §6.3: `blocked` stops targeted search for a series and leaves RSS + /// matching on. The one-episode season here is fully aired, so what + /// matches is its pack — the same release targeted search would take. + #[tokio::test] + async fn a_blocked_series_still_matches_rss() { + let (_dir, database) = wanted(&[]).await; + let (season_id, _episodes) = wanted_series(&database, &["2024-04-11"]).await; + sqlx::query("UPDATE series SET blocked = 1") + .execute(database.pool()) + .await + .unwrap(); + let indexer = prowlarr(TV_FEED).await; + let (downloader, _fake) = transmission().await; + + let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + assert_eq!( + tv_grabs(&database).await, + vec![("season".to_owned(), season_id, "sent".to_owned())] + ); + } } From a28b455fff4a1b1a201d907802f4527eb94be09e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:07:29 +0100 Subject: [PATCH 46/71] fix: gate hard-fail attention on unsatisfied titles --- crates/arr-daemon/src/attention.rs | 72 +++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/arr-daemon/src/attention.rs b/crates/arr-daemon/src/attention.rs index 5502d0f..9012f30 100644 --- a/crates/arr-daemon/src/attention.rs +++ b/crates/arr-daemon/src/attention.rs @@ -130,7 +130,8 @@ impl AttentionAction { r#" SELECT id AS "id!: i64", title AS "title!: String", year FROM movies - WHERE (SELECT count(DISTINCT g.release_id) + WHERE movies.wanted = 1 AND movies.state != 'available' + AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 @@ -249,6 +250,7 @@ async fn queue_tv(database: &Db) -> Result, TvEntr JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' + AND e.wanted = 1 AND e.state != 'available' GROUP BY s.id, s.title, s.year, e.id HAVING count(DISTINCT g.release_id) >= 2 "# @@ -561,4 +563,72 @@ mod tests { "both hard-fail conditions roll up to one series notification" ); } + + #[tokio::test] + async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() { + let (_dir, database) = seeded_database().await; + let movie_id = insert_no_pt_source_movie(&database).await; + insert_failed_grab(&database, "movie", movie_id, "first").await; + insert_failed_grab(&database, "movie", movie_id, "second").await; + + let server = MockServer::start().await; + let action = action(&server).await; + + let first = action.tick(&database).await.unwrap(); + assert_eq!(first.len(), 1, "queued while unsatisfied"); + + // Imported from a third release: satisfied, no decision needed. + sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?") + .bind(movie_id) + .execute(database.pool()) + .await + .unwrap(); + let second = action.tick(&database).await.unwrap(); + + assert_eq!(second.len(), 0, "leaves the queue once imported"); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() { + let (_dir, database) = seeded_database().await; + insert_no_pt_source_series(&database, 1, 0).await; + let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO episodes (season_id, number, title, wanted, state) + VALUES (?, 1, 'Episode 0', 1, 'missing')", + ) + .bind(season_id) + .execute(database.pool()) + .await + .unwrap(); + let episode_id: i64 = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1") + .bind(season_id) + .fetch_one(database.pool()) + .await + .unwrap(); + insert_failed_grab(&database, "episode", episode_id, "first").await; + insert_failed_grab(&database, "episode", episode_id, "second").await; + + let server = MockServer::start().await; + let action = action(&server).await; + + let first = action.tick(&database).await.unwrap(); + assert_eq!(first.len(), 1, "queued while unsatisfied"); + + // Imported from a third release: satisfied, no decision needed. + sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") + .bind(episode_id) + .execute(database.pool()) + .await + .unwrap(); + let second = action.tick(&database).await.unwrap(); + + assert_eq!(second.len(), 0, "leaves the queue once imported"); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } } From 91414a043fd64ad1f0099b596b99462e1d2a6d73 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:07:29 +0100 Subject: [PATCH 47/71] chore: regenerate sqlx query data --- ...664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json} | 4 ++-- ...6569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename .sqlx/{query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json => query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json} (80%) rename .sqlx/{query-ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42.json => query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json} (61%) diff --git a/.sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json b/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json similarity index 80% rename from .sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json rename to .sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json index fe6edc5..953970e 100644 --- a/.sqlx/query-faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207.json +++ b/.sqlx/query-1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "faa9e5cf0cf869161dd4878f6fb8707e378b7d9c6516d602afb49b1e30792207" + "hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6" } diff --git a/.sqlx/query-ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42.json b/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json similarity index 61% rename from .sqlx/query-ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42.json rename to .sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json index bbe24ef..1c3c1b6 100644 --- a/.sqlx/query-ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42.json +++ b/.sqlx/query-91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ", + "query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ", "describe": { "columns": [ { @@ -28,5 +28,5 @@ true ] }, - "hash": "ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42" + "hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d" } From 64e711feec1b044f52e2351b7525fb99f38a9962 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:13:49 +0100 Subject: [PATCH 48/71] fix(core): guard movie id lane against episode tags --- crates/arr-core/src/matching.rs | 48 +++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/arr-core/src/matching.rs b/crates/arr-core/src/matching.rs index 50455f8..1422d49 100644 --- a/crates/arr-core/src/matching.rs +++ b/crates/arr-core/src/matching.rs @@ -6,9 +6,10 @@ //! nearly free, because the feed is read again ten minutes later and the //! targeted search still runs. So every rule here is deliberately strict: //! -//! - an ID the tracker itself supplied beats anything read off the name, and -//! an ID that matches nothing wanted ends the comparison rather than -//! falling back to the title; +//! - an ID the tracker itself supplied beats anything read off the name, +//! except a season or episode tag in the name, which makes the release a +//! TV one whatever id came with it; and an ID that matches nothing wanted +//! ends the comparison rather than falling back to the title; //! - two supplied IDs that disagree about which title this is are an //! ambiguity like any other, and match nothing; //! - a title match needs the years to agree, and a wanted title with a known @@ -75,6 +76,13 @@ pub fn match_movie( ids: &ReleaseIds, claims: &NameClaims, ) -> Option { + // A season or episode tag makes this a TV release, whatever the title + // or the tracker-supplied id says. Movies are the only thing RSS grabs + // today (§13, phase 4), and trackers do mislabel ids. + if claims.episode.is_some() { + return None; + } + let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key); if ids.tmdb_id.is_some() || imdb_id.is_some() { // A title matched by both ids is still one title, so each wanted @@ -95,11 +103,6 @@ pub fn match_movie( })); } - // A season or episode tag makes this a TV release, whatever the title - // says. Movies are the only thing RSS grabs today (§13, phase 4). - if claims.episode.is_some() { - return None; - } let title = match_key(claims.title.as_deref()?); if title.is_empty() { return None; @@ -428,6 +431,35 @@ mod tests { assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None); } + /// The same rule on the id lane (#139): trackers mislabel ids, so a + /// season or episode tag in the name vetoes an id that matches too. + #[test] + fn an_episode_tag_beats_a_matching_supplied_id() { + let wanted = vec![WantedMovie { + id: MovieId(1), + tmdb_id: Some(693_134), + imdb_id: Some("tt15239678".to_owned()), + title: "Dune: Part Two".to_owned(), + year: Some(2024), + }]; + let cases = [ + ("Fallout.S01E03.1080p.WEB-DL", &ids(Some(693_134), None)), + ("Fallout.S01.1080p.BluRay-GROUP", &ids(Some(693_134), None)), + ( + "Fallout.2024.S01E03.1080p.WEB-DL", + &ids(None, Some("tt15239678")), + ), + ("Fallout.S01E03.1080p.WEB-DL", &ids(None, Some("15239678"))), + ]; + for (name, release_ids) in cases { + assert_eq!( + match_movie(&wanted, release_ids, &arr_parse::parse(name)), + None, + "{name}" + ); + } + } + #[test] fn two_wanted_titles_that_both_match_are_an_ambiguity() { let wanted = vec![ From 160c00c77f2816f0ce39c2e243363cd1a2f69d07 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:23:19 +0100 Subject: [PATCH 49/71] feat(web): render tv lanes in attention queues --- web/index.html | 6 ++++-- web/src/main.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ web/src/queues.ts | 13 +++++++------ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/web/index.html b/web/index.html index d090179..26779e0 100644 --- a/web/index.html +++ b/web/index.html @@ -171,7 +171,8 @@

kids titles with no qualifying pt release — parked here, sometimes for months, by - design. allow english writes this title's override and the normal search proceeds. + design. allow english writes a movie's override and its search proceeds; series wait + for the detail view.

    @@ -182,7 +183,8 @@

    - hard-failed twice on different releases — open the releases and decide by hand. + hard-failed twice on different releases — open a movie's releases and decide by hand; + series entries are display-only for now.

      diff --git a/web/src/main.ts b/web/src/main.ts index 344d843..0bdfacf 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -13,6 +13,7 @@ import { attemptsLabel, attentionTotal, fetchAttention, + type SeriesAttention, } from "./queues"; import { bucketOf, @@ -1826,6 +1827,15 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV for (const movie of queues.needs_decision) { groups.decision.rows.append(libraryRow(movie, roots, openReleases)); } + // the TV lanes share the two sections: one queue per reason, whatever + // the kind. Series entries are display-only — their release deck and + // overrides arrive with the series detail view (#129, #39). + for (const series of queues.tv_no_pt_source) { + groups.noPt.rows.append(seriesAttentionRow(series)); + } + for (const series of queues.tv_needs_decision) { + groups.decision.rows.append(seriesAttentionRow(series)); + } for (const group of [groups.noPt, groups.decision]) { emptyLine(group); } @@ -1911,6 +1921,38 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV return { hide, open, refreshBadge }; } +/** + * One TV lane entry: the series plus chips counting the episodes or + * seasons that put it there. The API carries internal row ids, not SxxEyy + * numbers, so counts are what can be named honestly. Inert — see above. + */ +function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement { + const item = document.createElement("li"); + const row = document.createElement("div"); + row.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + if (entry.episodes.length > 0) { + const n = entry.episodes.length; + chips.append( + chip(`${n} ${n === 1 ? "episode" : "episodes"}`, (span) => { + span.setAttribute("aria-label", `${n} ${n === 1 ? "episode" : "episodes"} waiting`); + }), + ); + } + if (entry.seasons.length > 0) { + const n = entry.seasons.length; + chips.append( + chip(`${n} ${n === 1 ? "season" : "seasons"}`, (span) => { + span.setAttribute("aria-label", `${n} ${n === 1 ? "season" : "seasons"} waiting`); + }), + ); + } + row.append(rowTitle(entry.title, entry.year), chips); + item.append(row); + return item; +} + /** * One no-PT-source entry: the line opens the release deck as evidence; the * §5.2 one click sits beside it and empties the item. diff --git a/web/src/queues.ts b/web/src/queues.ts index 76af653..f14bda0 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -78,13 +78,14 @@ export async function allowEnglishAudio(movieId: number): Promise Date: Sun, 23 Aug 2026 19:26:43 +0100 Subject: [PATCH 50/71] fix(web): reword comment that tripped the token gate --- web/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main.ts b/web/src/main.ts index 0bdfacf..1638353 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1829,7 +1829,7 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV } // the TV lanes share the two sections: one queue per reason, whatever // the kind. Series entries are display-only — their release deck and - // overrides arrive with the series detail view (#129, #39). + // overrides arrive with the series detail view (issues 129 and 39). for (const series of queues.tv_no_pt_source) { groups.noPt.rows.append(seriesAttentionRow(series)); } From 943f18cdf58926e2ecef3da0b3578337b45a5138 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:34:55 +0100 Subject: [PATCH 51/71] feat(db): add seasons.vanished conflict flag --- crates/arr-db/migrations/0020_vanished_seasons.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 crates/arr-db/migrations/0020_vanished_seasons.sql diff --git a/crates/arr-db/migrations/0020_vanished_seasons.sql b/crates/arr-db/migrations/0020_vanished_seasons.sql new file mode 100644 index 0000000..c065b10 --- /dev/null +++ b/crates/arr-db/migrations/0020_vanished_seasons.sql @@ -0,0 +1,6 @@ +-- #137. TMDB deletes whole seasons as well as episodes (#122). A season that +-- vanished upstream with a file anywhere under it cannot be deleted — the +-- file row would orphan — so it is flagged instead, the same conflict marker +-- episodes carry. Seasons without any file cascade away outright. +ALTER TABLE seasons ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0 + CHECK (vanished IN (0, 1)); From 409967eef65594f0ca7e559b2a273ace0fa70810 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:34:55 +0100 Subject: [PATCH 52/71] feat(daemon): reconcile vanished seasons (#137) --- ...60547c4fbac9959c79a59e2192c5fb448b4bb.json | 20 ++ ...bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json | 12 + ...d521094bc6db7c6bb1e1d5daca4e746c56506.json | 12 + ...055d4e169a4a13d4897d03820557a65ddf61f.json | 12 + crates/arr-daemon/src/series_refresh.rs | 259 +++++++++++++++++- 5 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 .sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json create mode 100644 .sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json create mode 100644 .sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json create mode 100644 .sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json diff --git a/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json b/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json new file mode 100644 index 0000000..df3a631 --- /dev/null +++ b/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id\n WHERE f.owner_kind = 'episode' AND e.season_id = ?\n ) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb" +} diff --git a/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json b/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json new file mode 100644 index 0000000..152c509 --- /dev/null +++ b/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc" +} diff --git a/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json b/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json new file mode 100644 index 0000000..ed4bc81 --- /dev/null +++ b/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM seasons WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506" +} diff --git a/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json b/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json new file mode 100644 index 0000000..a177682 --- /dev/null +++ b/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f" +} diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 28a9573..00cc10a 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -13,12 +13,15 @@ //! - `upstream_ended` follows TMDB's status, which `ended` is derived from; //! - a missing `tvdb_id` is backfilled (#120). //! -//! Episodes that vanish upstream follow two rules (#122): without a file of -//! their own they are deleted outright, cascading through `episode_releases`; -//! with one they are flagged `vanished` instead, because deleting the row -//! would orphan a real file (`media_files` is polymorphic on its owner). A -//! vanished number that reappears clears its flag again. Renumbering needs no -//! matching of its own — it is just these two rules seen from both ends. +//! Episodes and seasons that vanish upstream follow two rules (#122, #137): +//! without a file of their own they are deleted outright, cascading through +//! `episode_releases`; with one they are flagged `vanished` instead, because +//! deleting the row would orphan a real file (`media_files` is polymorphic on +//! its owner). A vanished number that reappears clears its flag again. +//! Season 0 is exempt — TMDB drops and re-adds it routinely, and #118 already +//! keeps specials out of status, so churning it is noise rather than signal. +//! Renumbering needs no matching of its own — it is just these two rules seen +//! from both ends. //! //! Refresh is idempotent: over unchanged TMDB data only the stamp moves. @@ -125,6 +128,11 @@ impl SeriesRefreshAction { .map(|season| (season.number, (season.id, season.tracked))) .collect::>(); let mut revealed = Vec::new(); + let upstream_seasons = metadata + .seasons + .iter() + .map(|summary| i64::from(summary.number)) + .collect::>(); for summary in &metadata.seasons { if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) { changed |= self @@ -136,6 +144,15 @@ impl SeriesRefreshAction { summary.number, ) .await?; + // The season is back upstream — a TMDB reversal, or a + // renumber seen from the other end. The conflict is over. + let restored = sqlx::query!( + "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + season_id + ) + .execute(&mut *transaction) + .await?; + changed |= restored.rows_affected() > 0; } else { let detail = self.tmdb.season(tmdb_id, summary.number).await?; revealed.push(RefreshedSeason { @@ -157,6 +174,10 @@ impl SeriesRefreshAction { changed = true; } + changed |= self + .reconcile_vanished_seasons(&mut transaction, &existing, &upstream_seasons) + .await?; + transaction.commit().await?; stamp_refreshed(database, stale.id).await?; Ok(changed.then(|| { @@ -391,6 +412,52 @@ impl SeriesRefreshAction { } Ok(changed) } + + /// #137, one level up from `reconcile_vanished`. Seasons the library + /// knows that TMDB no longer lists have vanished upstream. One with no + /// file on any of its episodes is deleted — the foreign key cascades + /// through its episodes and their `episode_releases` — and one with a + /// file anywhere under it is flagged instead: dropping the rows would + /// orphan a real file, the same trap #122 documents. Season 0 is exempt: + /// TMDB drops and re-adds specials routinely, and §4.2 already keeps them + /// out of derived status, so churning the flag is noise rather than + /// signal. + async fn reconcile_vanished_seasons( + &self, + executor: &mut sqlx::SqliteConnection, + known: &HashMap, + upstream: &HashSet, + ) -> Result { + let mut changed = false; + for (&number, &(season_id, _)) in known { + if number == 0 || upstream.contains(&number) { + continue; + } + let has_file = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id + WHERE f.owner_kind = 'episode' AND e.season_id = ? + ) AS "exists!: bool""#, + season_id + ) + .fetch_one(&mut *executor) + .await?; + if has_file { + sqlx::query!( + "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + season_id + ) + .execute(&mut *executor) + .await?; + } else { + sqlx::query!("DELETE FROM seasons WHERE id = ?", season_id) + .execute(&mut *executor) + .await?; + } + changed = true; + } + Ok(changed) + } } impl Action for SeriesRefreshAction { @@ -538,6 +605,46 @@ mod tests { }) } + /// The series detail as `mount` serves it, restricted to the given + /// season numbers — a dropped number is how a vanished season presents. + fn series_body_with(status: &str, numbers: &[u32]) -> serde_json::Value { + json!({ + "id": 82_728, + "name": "Bluey", + "original_language": "en", + "first_air_date": "2018-10-01", + "status": status, + "seasons": numbers + .iter() + .map(|number| json!({"season_number": number, "episode_count": 0})) + .collect::>(), + "external_ids": {"tvdb_id": 361_391} + }) + } + + /// Mounts only the season detail endpoints listed; a request for any + /// other season would fail the test loudly. + async fn mount_with(server: &MockServer, status: &str, numbers: &[u32]) { + Mock::given(method("GET")) + .and(path("/tv/82728")) + .respond_with(ResponseTemplate::new(200).set_body_json(series_body_with( + status, numbers, + ))) + .mount(server) + .await; + for (number, body) in [(1u32, season_one_body(&two_episodes())), (2, season_two_body())] { + if !numbers.contains(&number) { + continue; + } + Mock::given(method("GET")) + .and(path(format!("/tv/82728/season/{number}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; + } + } + + /// A TMDB serving one series with two seasons. `reset` between phases of /// a test and remount, counting requests by delta around each phase. async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer { @@ -976,4 +1083,144 @@ mod tests { .unwrap(); assert!(!vanished, "the conflict is over once TMDB lists it again"); } + + async fn season_two_episode(database: &Db) -> i64 { + sqlx::query_scalar( + "SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 2 AND e.number = 1", + ) + .fetch_one(database.pool()) + .await + .unwrap() + } + + /// #137. TMDB dropped season 2 entirely: with no file under any of its + /// episodes the season is deleted, and its episodes and their stored + /// releases cascade with it. + #[tokio::test] + async fn a_vanished_season_without_files_is_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_release(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1, "a season removal is work worth reporting"); + let seasons: Vec = + sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(seasons, vec![1]); + let episodes: i64 = + sqlx::query_scalar("SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(episodes, 0, "the vanished season's episodes cascade"); + let releases: i64 = sqlx::query_scalar( + "SELECT count(*) FROM episode_releases WHERE episode_id = ?", + ) + .bind(episode) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(releases, 0); + } + + /// #137. With a file anywhere under it the vanished season is never + /// deleted: dropping the rows would orphan a real file. The season is + /// flagged — the same conflict marker an episode gets (#122) — and its + /// episodes and file stay put. + #[tokio::test] + async fn a_vanished_season_with_a_file_is_flagged_not_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_file(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let (vanished, episodes, files): (i64, i64, i64) = sqlx::query_as( + "SELECT s.vanished, + (SELECT count(*) FROM episodes e WHERE e.season_id = s.id), + (SELECT count(*) FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = ?) + FROM seasons s WHERE s.number = 2", + ) + .bind(episode) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!( + (vanished, episodes, files), + (1, 1, 1), + "flagged as a conflict, episodes and file intact" + ); + } + + /// Idempotence at season level too: a season TMDB restores clears the + /// flag again, the way a restored episode number does (#122). + #[tokio::test] + async fn a_restored_season_clears_the_vanished_flag() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_file(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + action(&server).tick(&database).await.unwrap(); + expire_refresh(&database).await; + // TMDB puts the season back where it was. + server.reset().await; + mount(&server, "Returning Series", season_one_body(&two_episodes())).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let vanished: bool = sqlx::query_scalar("SELECT vanished FROM seasons WHERE number = 2") + .fetch_one(database.pool()) + .await + .unwrap(); + assert!(!vanished, "the conflict is over once TMDB lists it again"); + } + + /// §4.2 keeps specials out of derived status, so nothing downstream can + /// notice their absence — and TMDB drops and re-adds season 0 routinely. + /// A vanished season 0 is therefore left alone either way. + #[tokio::test] + async fn a_vanished_season_zero_is_left_alone() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + sqlx::query("INSERT INTO seasons (series_id, number) SELECT id, 0 FROM series") + .execute(database.pool()) + .await + .unwrap(); + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1, 2]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert!(outcomes.is_empty(), "churning specials is not work to report"); + let seasons: Vec = + sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(seasons, vec![0, 1, 2]); + } } From 00ff4db26a0812b8eaa94608b09ed61899f2e905 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:35:07 +0100 Subject: [PATCH 53/71] style: cargo fmt --- crates/arr-daemon/src/series_refresh.rs | 66 +++++++++++++++---------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 00cc10a..8cf2372 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -627,12 +627,15 @@ mod tests { async fn mount_with(server: &MockServer, status: &str, numbers: &[u32]) { Mock::given(method("GET")) .and(path("/tv/82728")) - .respond_with(ResponseTemplate::new(200).set_body_json(series_body_with( - status, numbers, - ))) + .respond_with( + ResponseTemplate::new(200).set_body_json(series_body_with(status, numbers)), + ) .mount(server) .await; - for (number, body) in [(1u32, season_one_body(&two_episodes())), (2, season_two_body())] { + for (number, body) in [ + (1u32, season_one_body(&two_episodes())), + (2, season_two_body()), + ] { if !numbers.contains(&number) { continue; } @@ -644,7 +647,6 @@ mod tests { } } - /// A TMDB serving one series with two seasons. `reset` between phases of /// a test and remount, counting requests by delta around each phase. async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer { @@ -1110,26 +1112,29 @@ mod tests { let outcomes = action(&server).tick(&database).await.unwrap(); - assert_eq!(outcomes.len(), 1, "a season removal is work worth reporting"); - let seasons: Vec = - sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") - .fetch_all(database.pool()) - .await - .unwrap(); + assert_eq!( + outcomes.len(), + 1, + "a season removal is work worth reporting" + ); + let seasons: Vec = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); assert_eq!(seasons, vec![1]); - let episodes: i64 = - sqlx::query_scalar("SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)") - .fetch_one(database.pool()) - .await - .unwrap(); - assert_eq!(episodes, 0, "the vanished season's episodes cascade"); - let releases: i64 = sqlx::query_scalar( - "SELECT count(*) FROM episode_releases WHERE episode_id = ?", + let episodes: i64 = sqlx::query_scalar( + "SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)", ) - .bind(episode) .fetch_one(database.pool()) .await .unwrap(); + assert_eq!(episodes, 0, "the vanished season's episodes cascade"); + let releases: i64 = + sqlx::query_scalar("SELECT count(*) FROM episode_releases WHERE episode_id = ?") + .bind(episode) + .fetch_one(database.pool()) + .await + .unwrap(); assert_eq!(releases, 0); } @@ -1185,7 +1190,12 @@ mod tests { expire_refresh(&database).await; // TMDB puts the season back where it was. server.reset().await; - mount(&server, "Returning Series", season_one_body(&two_episodes())).await; + mount( + &server, + "Returning Series", + season_one_body(&two_episodes()), + ) + .await; let outcomes = action(&server).tick(&database).await.unwrap(); @@ -1215,12 +1225,14 @@ mod tests { let outcomes = action(&server).tick(&database).await.unwrap(); - assert!(outcomes.is_empty(), "churning specials is not work to report"); - let seasons: Vec = - sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") - .fetch_all(database.pool()) - .await - .unwrap(); + assert!( + outcomes.is_empty(), + "churning specials is not work to report" + ); + let seasons: Vec = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); assert_eq!(seasons, vec![0, 1, 2]); } } From 7f7f26dbc772f8bc715cc2191868a964225d2719 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:45:33 +0100 Subject: [PATCH 54/71] feat(api): name attention episodes and seasons --- ...80e5935b3550ee2c024e09aef9b76fd47a708.json | 56 +++++++++++++ ...077c27b647a6f0b411e13105398a2963cd51.json} | 10 ++- ...861867891d55dc8bb7fe79b1dc42e36054379.json | 44 ---------- ...6e5f88ffa4644999dee42af3e387dd2cf8702.json | 56 +++++++++++++ ...b310c6b90c3feb283885b3573ee7666b1725b.json | 44 ---------- crates/arr-api/src/movies.rs | 84 ++++++++++++++----- 6 files changed, 184 insertions(+), 110 deletions(-) create mode 100644 .sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json rename .sqlx/{query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json => query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json} (59%) delete mode 100644 .sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json create mode 100644 .sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json delete mode 100644 .sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json diff --git a/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json b/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json new file mode 100644 index 0000000..92132e9 --- /dev/null +++ b/.sqlx/query-29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "episode_number!: i64", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708" +} diff --git a/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json b/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json similarity index 59% rename from .sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json rename to .sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json index 2dba66a..bdd7c6c 100644 --- a/.sqlx/query-cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193.json +++ b/.sqlx/query-7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id\n ", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ", "describe": { "columns": [ { @@ -27,6 +27,11 @@ "name": "season_id!: i64", "ordinal": 4, "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" } ], "parameters": { @@ -37,8 +42,9 @@ false, false, true, + false, false ] }, - "hash": "cd09d4c34f0596319fa2ade088ea9009fa24e453f669bcc2357fdcd999b06193" + "hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51" } diff --git a/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json b/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json deleted file mode 100644 index 37c4946..0000000 --- a/.sqlx/query-a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tmdb_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 4, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true, - true - ] - }, - "hash": "a36a611d7c587f1c1880113e334861867891d55dc8bb7fe79b1dc42e36054379" -} diff --git a/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json b/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json new file mode 100644 index 0000000..b730f17 --- /dev/null +++ b/.sqlx/query-aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "episode_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "season_number!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "episode_number!: i64", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702" +} diff --git a/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json b/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json deleted file mode 100644 index 654e1a8..0000000 --- a/.sqlx/query-b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ", - "describe": { - "columns": [ - { - "name": "series_id!: i64", - "ordinal": 0, - "type_info": "Integer" - }, - { - "name": "tmdb_id!: i64", - "ordinal": 1, - "type_info": "Integer" - }, - { - "name": "title!: String", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "year", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "episode_id!: i64", - "ordinal": 4, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true, - false - ] - }, - "hash": "b4b58a625ce7b57ba423d39296cb310c6b90c3feb283885b3573ee7666b1725b" -} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 599b030..d4dc02e 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -100,6 +100,22 @@ pub struct AttentionQueues { pub tv_needs_decision: Vec, } +/// An episode that put its series in a TV attention queue, named for the +/// operator (`SxxEyy` comes from season and episode numbers, #140). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueuedEpisode { + pub id: i64, + pub season_number: i64, + pub episode_number: i64, +} + +/// A season whose failed pack put its series in a TV attention queue. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct QueuedSeason { + pub id: i64, + pub number: i64, +} + /// A series in a TV attention queue, with what put it there. One row per /// series — the notification rolls up the same way (§9.5). #[derive(Debug, Clone, Serialize, ToSchema)] @@ -109,9 +125,9 @@ pub struct SeriesAttention { pub title: String, pub year: Option, /// Episodes that put the series in this queue. - pub episodes: Vec, + pub episodes: Vec, /// Seasons whose pack grab failed and fell back to per-episode grabbing. - pub seasons: Vec, + pub seasons: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -667,7 +683,9 @@ async fn tv_attention( let no_pt_rows = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, e.id AS "episode_id!: i64" + s.title AS "title!: String", s.year, + e.id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id @@ -688,13 +706,15 @@ async fn tv_attention( let episode_hard_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, g.target_id AS "episode_id!: i64" + s.title AS "title!: String", s.year, + g.target_id AS "episode_id!: i64", + se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM grabs g JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' - GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id + GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number HAVING count(DISTINCT g.release_id) >= 2 "# ) @@ -703,12 +723,13 @@ async fn tv_attention( let season_pack_fails = sqlx::query!( r#" SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64", - s.title AS "title!: String", s.year, g.target_id AS "season_id!: i64" + s.title AS "title!: String", s.year, + g.target_id AS "season_id!: i64", se.number AS "season_number!: i64" FROM grabs g JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id JOIN series s ON s.id = se.series_id WHERE g.state = 'failed' - GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id + GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number "# ) .fetch_all(database) @@ -722,7 +743,11 @@ async fn tv_attention( row.tmdb_id, row.title, row.year, - row.episode_id, + QueuedEpisode { + id: row.episode_id, + season_number: row.season_number, + episode_number: row.episode_number, + }, ); } @@ -734,22 +759,30 @@ async fn tv_attention( row.tmdb_id, row.title, row.year, - row.episode_id, + QueuedEpisode { + id: row.episode_id, + season_number: row.season_number, + episode_number: row.episode_number, + }, ); } for row in season_pack_fails { + let season = QueuedSeason { + id: row.season_id, + number: row.season_number, + }; match tv_needs_decision .iter_mut() .find(|entry| entry.series_id == row.series_id) { - Some(entry) => entry.seasons.push(row.season_id), + Some(entry) => entry.seasons.push(season), None => tv_needs_decision.push(SeriesAttention { series_id: row.series_id, tmdb_id: row.tmdb_id, title: row.title, year: row.year, episodes: Vec::new(), - seasons: vec![row.season_id], + seasons: vec![season], }), } } @@ -765,13 +798,13 @@ fn merge_episode( tmdb_id: i64, title: String, year: Option, - episode_id: i64, + episode: QueuedEpisode, ) { if let Some(entry) = entries .iter_mut() .find(|entry| entry.series_id == series_id) { - entry.episodes.push(episode_id); + entry.episodes.push(episode); return; } entries.push(SeriesAttention { @@ -779,7 +812,7 @@ fn merge_episode( tmdb_id, title, year, - episodes: vec![episode_id], + episodes: vec![episode], seasons: Vec::new(), }); } @@ -1350,21 +1383,32 @@ mod tests { .json() .await .expect("queues json"); + let episode_ids: Vec = sqlx::query_scalar( + "SELECT id FROM episodes WHERE season_id = ? ORDER BY number", + ) + .bind(season_id) + .fetch_all(pool) + .await + .expect("episode ids"); + assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); assert_eq!( - queues["tv_no_pt_source"][0]["episodes"] - .as_array() - .map(Vec::len), - Some(2) + queues["tv_no_pt_source"][0]["episodes"], + serde_json::json!([ + { "id": episode_ids[0], "season_number": 1, "episode_number": 1 }, + { "id": episode_ids[1], "season_number": 1, "episode_number": 2 }, + ]) ); assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id); assert_eq!( queues["tv_needs_decision"][0]["episodes"], - serde_json::json!([episode_id]) + serde_json::json!([ + { "id": episode_id, "season_number": 1, "episode_number": 3 }, + ]) ); assert_eq!( queues["tv_needs_decision"][0]["seasons"], - serde_json::json!([season_id]) + serde_json::json!([{ "id": season_id, "number": 1 }]) ); // Blocking the series empties its no-PT-source lane. From 1d31ea7d1b5fb30743432ee85b062622cdbb5425 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:45:33 +0100 Subject: [PATCH 55/71] feat(web): render SxxEyy chips in attention queues --- web/src/main.ts | 36 ++++++++++++++++++++++-------------- web/src/queues.ts | 17 +++++++++++++++-- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 1638353..73f0f6a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -575,6 +575,18 @@ function rowTitle(title: string, year: number | null): HTMLElement { return wrap; } +function seasonTag(seasonNumber: number): string { + return `S${two(seasonNumber)}`; +} + +function episodeTag(seasonNumber: number, episodeNumber: number): string { + return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`; +} + +function two(value: number): string { + return value < 10 ? `0${value}` : String(value); +} + function libraryRow( movie: LibraryMovie, roots: Root[], @@ -1922,9 +1934,10 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV } /** - * One TV lane entry: the series plus chips counting the episodes or - * seasons that put it there. The API carries internal row ids, not SxxEyy - * numbers, so counts are what can be named honestly. Inert — see above. + * One TV lane entry: the series plus one chip per episode or season that put + * it there, named `SxxEyy` so the operator knows what to act on (#140). + * Inert — the release deck and overrides arrive with the series detail view + * (issues 129 and 39). */ function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement { const item = document.createElement("li"); @@ -1932,21 +1945,16 @@ function seriesAttentionRow(entry: SeriesAttention): HTMLLIElement { row.className = "row"; const chips = document.createElement("span"); chips.className = "row-chips"; - if (entry.episodes.length > 0) { - const n = entry.episodes.length; + for (const episode of entry.episodes) { + const tag = episodeTag(episode.season_number, episode.episode_number); chips.append( - chip(`${n} ${n === 1 ? "episode" : "episodes"}`, (span) => { - span.setAttribute("aria-label", `${n} ${n === 1 ? "episode" : "episodes"} waiting`); + chip(tag, (span) => { + span.setAttribute("aria-label", `${tag} waiting`); }), ); } - if (entry.seasons.length > 0) { - const n = entry.seasons.length; - chips.append( - chip(`${n} ${n === 1 ? "season" : "seasons"}`, (span) => { - span.setAttribute("aria-label", `${n} ${n === 1 ? "season" : "seasons"} waiting`); - }), - ); + for (const season of entry.seasons) { + chips.append(chip(seasonTag(season.number))); } row.append(rowTitle(entry.title, entry.year), chips); item.append(row); diff --git a/web/src/queues.ts b/web/src/queues.ts index f14bda0..719548b 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -11,14 +11,27 @@ export interface AttentionMovie extends LibraryMovie { last_searched_at: string | null; } +/** An episode that put its series here, named for SxxEyy rendering (#140). */ +export interface QueuedEpisode { + id: number; + season_number: number; + episode_number: number; +} + +/** A season whose failed pack put its series here. */ +export interface QueuedSeason { + id: number; + number: number; +} + /** A series in a TV attention queue, with what put it there (§9.5). */ export interface SeriesAttention { series_id: number; tmdb_id: number; title: string; year: number | null; - episodes: number[]; - seasons: number[]; + episodes: QueuedEpisode[]; + seasons: QueuedSeason[]; } export interface AttentionQueues { From e4c115803bf3bc2152059e12ec7ae8646f2f8a59 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:46:16 +0100 Subject: [PATCH 56/71] style: fix test formatting --- crates/arr-api/src/movies.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index d4dc02e..4323fac 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1383,13 +1383,12 @@ mod tests { .json() .await .expect("queues json"); - let episode_ids: Vec = sqlx::query_scalar( - "SELECT id FROM episodes WHERE season_id = ? ORDER BY number", - ) - .bind(season_id) - .fetch_all(pool) - .await - .expect("episode ids"); + let episode_ids: Vec = + sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number") + .bind(season_id) + .fetch_all(pool) + .await + .expect("episode ids"); assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id); assert_eq!( From 9129728622bc9b6e606bd77f11f4d5b7958c15ce Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:48:59 +0100 Subject: [PATCH 57/71] refactor(api): extract attention merge helpers --- crates/arr-api/src/movies.rs | 95 +++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 4323fac..676ee55 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -741,13 +741,11 @@ async fn tv_attention( &mut tv_no_pt_source, row.series_id, row.tmdb_id, - row.title, + &row.title, row.year, - QueuedEpisode { - id: row.episode_id, - season_number: row.season_number, - episode_number: row.episode_number, - }, + row.episode_id, + row.season_number, + row.episode_number, ); } @@ -757,49 +755,80 @@ async fn tv_attention( &mut tv_needs_decision, row.series_id, row.tmdb_id, - row.title, + &row.title, row.year, - QueuedEpisode { - id: row.episode_id, - season_number: row.season_number, - episode_number: row.episode_number, - }, + row.episode_id, + row.season_number, + row.episode_number, ); } for row in season_pack_fails { - let season = QueuedSeason { - id: row.season_id, - number: row.season_number, - }; - match tv_needs_decision - .iter_mut() - .find(|entry| entry.series_id == row.series_id) - { - Some(entry) => entry.seasons.push(season), - None => tv_needs_decision.push(SeriesAttention { - series_id: row.series_id, - tmdb_id: row.tmdb_id, - title: row.title, - year: row.year, - episodes: Vec::new(), - seasons: vec![season], - }), - } + merge_season( + &mut tv_needs_decision, + row.series_id, + row.tmdb_id, + &row.title, + row.year, + row.season_id, + row.season_number, + ); } Ok((tv_no_pt_source, tv_needs_decision)) } +/// One more qualifying season for its series, creating the series' entry on +/// first sight. +#[allow(clippy::too_many_arguments)] +fn merge_season( + entries: &mut Vec, + series_id: i64, + tmdb_id: i64, + title: &str, + year: Option, + season_id: i64, + season_number: i64, +) { + match entries + .iter_mut() + .find(|entry| entry.series_id == series_id) + { + Some(entry) => entry.seasons.push(QueuedSeason { + id: season_id, + number: season_number, + }), + None => entries.push(SeriesAttention { + series_id, + tmdb_id, + title: title.to_owned(), + year, + episodes: Vec::new(), + seasons: vec![QueuedSeason { + id: season_id, + number: season_number, + }], + }), + } +} + /// One more qualifying episode for its series, creating the series' entry on /// first sight. +#[allow(clippy::too_many_arguments)] fn merge_episode( entries: &mut Vec, series_id: i64, tmdb_id: i64, - title: String, + title: &str, year: Option, - episode: QueuedEpisode, + episode_id: i64, + season_number: i64, + episode_number: i64, ) { + let episode = QueuedEpisode { + id: episode_id, + season_number, + episode_number, + }; if let Some(entry) = entries .iter_mut() .find(|entry| entry.series_id == series_id) @@ -810,7 +839,7 @@ fn merge_episode( entries.push(SeriesAttention { series_id, tmdb_id, - title, + title: title.to_owned(), year, episodes: vec![episode], seasons: Vec::new(), From d86d975073c53aff54bd667171d16d6d7e63c761 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:51:52 +0100 Subject: [PATCH 58/71] fix(web): drop issue refs that trip the token linter --- web/src/main.ts | 2 +- web/src/queues.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 73f0f6a..719eb78 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1935,7 +1935,7 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV /** * One TV lane entry: the series plus one chip per episode or season that put - * it there, named `SxxEyy` so the operator knows what to act on (#140). + * it there, named `SxxEyy` so the operator knows what to act on. * Inert — the release deck and overrides arrive with the series detail view * (issues 129 and 39). */ diff --git a/web/src/queues.ts b/web/src/queues.ts index 719548b..b0f190e 100644 --- a/web/src/queues.ts +++ b/web/src/queues.ts @@ -11,7 +11,7 @@ export interface AttentionMovie extends LibraryMovie { last_searched_at: string | null; } -/** An episode that put its series here, named for SxxEyy rendering (#140). */ +/** An episode that put its series here, named for SxxEyy rendering. */ export interface QueuedEpisode { id: number; season_number: number; From 4efc7b1833e4fb169073c40c182631b1efdf64fa Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:54:42 +0100 Subject: [PATCH 59/71] fix(web): token linter ignores comments --- web/scripts/check-tokens.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs index a28ee17..63a01bb 100644 --- a/web/scripts/check-tokens.mjs +++ b/web/scripts/check-tokens.mjs @@ -99,7 +99,13 @@ function checkCss(source, rel) { } function checkPlain(source, rel) { - source.split("\n").forEach((rawLine, index) => { + // Comments are prose, not styling — `checkCss` already ignores them, and + // without the same treatment here an issue reference like `#140` reads as a + // three-digit hex colour and fails the gate. + const stripped = source + .replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " ")) + .replace(/\/\/.*$/gm, ""); + stripped.split("\n").forEach((rawLine, index) => { const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token("); if (COLOR_LITERAL.test(neutral)) { errors.push(`${rel}:${index + 1}: literal colour outside the token block`); From 6826170431abf5ec80159f69d7dc2724389d48da Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:12:06 +0100 Subject: [PATCH 60/71] feat(web): series data layer and /series route --- web/src/releases.ts | 2 +- web/src/router.ts | 11 +- web/src/series.ts | 288 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 web/src/series.ts diff --git a/web/src/releases.ts b/web/src/releases.ts index 7390102..7d83c8b 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -361,7 +361,7 @@ export function ruleLabel(rule: string | null): string { return RULE_LABEL[rule] ?? rule.replaceAll("_", " "); } -async function errorDetail(response: Response): Promise { +export async function errorDetail(response: Response): Promise { try { const body = (await response.json()) as { error?: string }; return body.error ?? `http ${response.status}`; diff --git a/web/src/router.ts b/web/src/router.ts index 3bc9544..48a737f 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -8,7 +8,8 @@ export type Route = | { kind: "queues" } | { kind: "settings" } | { kind: "search"; query: string } - | { kind: "releases"; movieId: number }; + | { kind: "releases"; movieId: number } + | { kind: "series"; seriesId: number }; export function parseRoute(url: URL): Route { const segments = url.pathname.split("/").filter(Boolean); @@ -31,6 +32,12 @@ export function parseRoute(url: URL): Route { return { kind: "releases", movieId }; } } + if (segments.length === 2 && segments[0] === "series") { + const seriesId = Number(segments[1]); + if (Number.isInteger(seriesId) && seriesId > 0) { + return { kind: "series", seriesId }; + } + } return { kind: "board" }; } @@ -48,6 +55,8 @@ export function routePath(route: Route): string { return `/search?q=${encodeURIComponent(route.query)}`; case "releases": return `/movies/${route.movieId}/releases`; + case "series": + return `/series/${route.seriesId}`; } } diff --git a/web/src/series.ts b/web/src/series.ts new file mode 100644 index 0000000..e111c07 --- /dev/null +++ b/web/src/series.ts @@ -0,0 +1,288 @@ +// Hand-written mirror of arr-api's series detail, season, episode and +// release schemas — same reasoning as search.ts: the generated client +// (src/api/) is uncommitted, so CI's tsc cannot see it. + +import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases"; +import { errorDetail, waiverOverride } from "./releases"; + +/** §4.2 derived status — displayed, never editable. */ +export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; + +export interface ApiSeries { + id: number; + tmdb_id: number; + title: string; + year: number | null; + root_id: number; + blocked: boolean; + status: SeriesStatus; + wanted_episodes: number; + available_episodes: number; +} + +export interface ApiEpisode { + id: number; + season_id: number; + number: number; + title: string; + air_date: string | null; + wanted: boolean; + state: string; + /** #122: gone upstream while a file of its own remained. */ + vanished: boolean; +} + +export interface ApiSeason { + id: number; + number: number; + tracked: boolean; + episodes: ApiEpisode[]; +} + +export type SeasonsOutcome = + | { kind: "seasons"; seasons: ApiSeason[] } + | { kind: "error"; detail: string }; + +export async function fetchSeries(seriesId: number): Promise { + try { + const response = await fetch(`/api/series/${seriesId}`); + return response.ok ? ((await response.json()) as ApiSeries) : null; + } catch { + return null; + } +} + +export async function fetchSeasons(seriesId: number): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/seasons`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "seasons", seasons: (await response.json()) as ApiSeason[] }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */ +export interface EpisodeFile { + episode_id: number; + path: string; + size: number; + probed: { + resolution?: string | null; + source?: string | null; + hdr?: string | null; + audio_tracks?: { language?: string | null }[] | null; + } | null; + waiver: string | null; +} + +export type SeriesFilesOutcome = + | { kind: "files"; files: EpisodeFile[] } + | { kind: "error"; detail: string }; + +export async function fetchSeriesFiles(seriesId: number): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/files`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "files", files: (await response.json()) as EpisodeFile[] }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** + * The season's tracked toggle (§4.1): turning it on marks every revealed + * episode wanted; turning it off withdraws nothing. + */ +export async function setSeasonTracked( + seriesId: number, + seasonNumber: number, + tracked: boolean, +): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tracked }), + }); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "done" }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +export async function setEpisodeWanted(episodeId: number, wanted: boolean): Promise { + try { + const response = await fetch(`/api/episodes/${episodeId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ wanted }), + }); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "done" }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/* ---- manual triggers and decks ---------------------------------------- */ + +/** §6.2 manual search, one targeted sweep, for a season or an episode. */ +export interface TvTarget { + releases: () => Promise; + grab: (releaseId: number) => Promise; + search: () => Promise; +} + +export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { + const base = `/api/series/${seriesId}/seasons/${seasonNumber}`; + return { + releases: () => fetchJson(`${base}/releases`), + grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), + search: () => post(`${base}/search`), + }; +} + +export function episodeTarget(episodeId: number): TvTarget { + const base = `/api/episodes/${episodeId}`; + return { + releases: () => fetchJson(`${base}/releases`), + grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), + search: () => post(`${base}/search`), + }; +} + +async function fetchJson(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "results", releases: (await response.json()) as MovieRelease[] }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +async function post(url: string): Promise { + try { + const response = await fetch(url, { method: "POST" }); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "done" }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** + * The one-click override on a waived TV row (§5.1, §9.3). Overrides sit on + * the series — `episode_policy` and `season_policy` both read them there — + * so the relaxed rule is written once, then the grab proceeds. + */ +export async function waiveAndGrabTv( + seriesId: number, + target: TvTarget, + releaseId: number, + rule: string | null, +): Promise { + const override = waiverOverride(rule); + let overrideWritten = false; + if (override) { + try { + const current = await fetch(`/api/series/${seriesId}`); + if (!current.ok) { + return { kind: "error", detail: await errorDetail(current), overrideWritten }; + } + const series = (await current.json()) as { overrides: Record }; + const patch = await fetch(`/api/series/${seriesId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ overrides: { ...series.overrides, ...override } }), + }); + if (!patch.ok) { + return { kind: "error", detail: await errorDetail(patch), overrideWritten }; + } + overrideWritten = true; + } catch { + return { kind: "error", detail: "daemon unreachable", overrideWritten }; + } + } + const grabbed = await target.grab(releaseId); + if (grabbed.kind === "error") { + return { kind: "error", detail: grabbed.detail, overrideWritten }; + } + return { kind: "done", overrideWritten }; +} + +/* ---- display helpers --------------------------------------------------- */ + +/** Wanted / on-disk counts for one season, whatever its number. */ +export function seasonCounts(season: ApiSeason): { wanted: number; available: number } { + let wanted = 0; + let available = 0; + for (const episode of season.episodes) { + if (episode.wanted) { + wanted += 1; + if (episode.state === "available") { + available += 1; + } + } + } + return { wanted, available }; +} + +/** + * §6.2: do not search before the thing exists. No air date yet counts as + * not aired — TMDB has not dated it, so neither will we. + */ +export function isUnaired(airDate: string | null, now = new Date()): boolean { + if (airDate === null) { + return true; + } + const parsed = Date.parse(airDate); + if (Number.isNaN(parsed)) { + return true; + } + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + return parsed > today.getTime() + 86_399_999; +} + +/** `2026-08-10` as the readout spells it; unknown stays a dash. */ +export function formatAirDate(airDate: string | null): string { + return airDate ?? "—"; +} + +/** The probed attributes a file row shows as chips (§5.6, §7.4). */ +export function fileAttributeTags(file: EpisodeFile): string[] { + const probed = file.probed; + if (!probed) { + return []; + } + const tags: string[] = []; + if (probed.resolution) { + tags.push(probed.resolution); + } + if (probed.source) { + tags.push(probed.source); + } + if (probed.hdr && probed.hdr !== "SDR") { + tags.push(probed.hdr); + } + for (const track of probed.audio_tracks ?? []) { + if (track.language) { + tags.push(track.language); + } + } + return tags; +} From 82100ef7aba0f7677040b9cc8d199e750360464a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:12:06 +0100 Subject: [PATCH 61/71] feat(web): series detail view with season and episode decks --- web/index.html | 38 ++ web/src/main.ts | 1096 ++++++++++++++++++++++++++++++++++++++++----- web/src/style.css | 143 ++++++ 3 files changed, 1161 insertions(+), 116 deletions(-) diff --git a/web/index.html b/web/index.html index d090179..a0818f2 100644 --- a/web/index.html +++ b/web/index.html @@ -306,6 +306,44 @@ + + +
      +
      + +
      +

      —

      + + +
      +
      + +
        +
        + +
        +
        + +
        +

        —

        + +
        + +
        + +
        +
        +
        diff --git a/web/src/main.ts b/web/src/main.ts index 344d843..58f9580 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -36,6 +36,7 @@ import { ruleLabel, sweepExpected, totalSize, + type WaiveOutcome, waiveAndGrab, waiverOverride, } from "./releases"; @@ -55,6 +56,25 @@ import { type TmdbMovie, type TmdbSeries, } from "./search"; +import { + type ApiEpisode, + type ApiSeason, + type ApiSeries, + type EpisodeFile, + episodeTarget, + fetchSeasons, + fetchSeries, + fetchSeriesFiles, + fileAttributeTags, + formatAirDate, + isUnaired, + seasonCounts, + seasonTarget, + setEpisodeWanted, + setSeasonTracked, + type TvTarget, + waiveAndGrabTv, +} from "./series"; import { settingsMain } from "./settings"; import "./style.css"; @@ -209,11 +229,15 @@ function main() { // views hide each other on open; the array is shared and filled once const views: HideableView[] = []; const releases = releasesMain(); - const library = libraryMain(board, releases, views); + // the tv deck registers its Escape handler before the series view does, + // so one Esc always steps back from the innermost layer first + const tvDeck = tvReleasesMain(); + const seriesDetail = seriesMain(board, tvDeck, views); + const library = libraryMain(board, releases, seriesDetail, views); const queues = queuesMain(board, releases, views); const settings = settingsMain(board, views); - views.push(library, queues, settings); - const search = searchMain(board, releases, views); + views.push(tvDeck, seriesDetail, library, queues, settings); + const search = searchMain(board, releases, seriesDetail, views); // a removed title must not survive on the surface the deck opened over releases.setRemoved((parent) => { switch (parent.kind) { @@ -270,6 +294,26 @@ function main() { }); break; } + case "series": { + const detail = await fetchSeries(route.seriesId); + if (!detail) { + navigate({ kind: "library" }, { replace: true }); + library.open(); + return; + } + // the surface underneath must be live before the detail view covers + // it, so back and Esc land somewhere real (same as a movie deep link) + library.open(); + seriesDetail.open( + route.seriesId, + must("#nav-library"), + must("#library"), + { + kind: "library", + }, + ); + break; + } } } @@ -303,7 +347,12 @@ interface SearchView { restore: (query: string) => void; } -function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): SearchView { +function searchMain( + board: HTMLElement, + releases: ReleasesView, + seriesDetail: SeriesView, + views: HideableView[], +): SearchView { const input = must("#search"); const hint = must("#search-hint"); const refs: DeckRefs = { @@ -338,6 +387,15 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV ); void fetchRoots(); + /** A series or episode hit opens the series detail view (#129). */ + const openSeries = (id: number, origin: HTMLElement) => { + navigate({ kind: "series", seriesId: id }); + void seriesDetail.open(id, origin, refs.deck, { + kind: "search", + query: input.value.trim(), + }); + }; + let timer: number | undefined; let controller: AbortController | null = null; @@ -439,10 +497,10 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV ); } for (const series of librarySeries) { - refs.groups.library.rows.append(librarySeriesRow(series, roots)); + refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries)); } for (const episode of libraryEpisodes) { - refs.groups.library.rows.append(episodeRow(episode)); + refs.groups.library.rows.append(episodeRow(episode, openSeries)); } } if (response.tmdb.length > 0) { @@ -620,13 +678,18 @@ function libraryRow( } /** - * An in-library series hit. Display-only, like its library-view sibling — - * the series detail view it would open into is issue 129. + * An in-library series hit: the whole row opens the series detail view, + * which is where its seasons, episodes and decks live (#129). */ -function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElement { +function librarySeriesRow( + series: LibrarySeriesHit, + roots: Root[], + open: (id: number, origin: HTMLElement) => void, +): HTMLLIElement { const item = document.createElement("li"); - const row = document.createElement("div"); - row.className = "row"; + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb"; const chips = document.createElement("span"); chips.className = "row-chips"; const root = roots.find((candidate) => candidate.id === series.root_id); @@ -634,16 +697,27 @@ function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElemen if (series.blocked) { chips.append(chip("blocked")); } + const affordance = document.createElement("span"); + affordance.className = "row-add readout"; + affordance.textContent = "episodes"; + chips.append(affordance); row.append(rowTitle(series.title, series.year), chips); + row.addEventListener("click", () => { + open(series.id, row); + }); item.append(row); return item; } /** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */ -function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement { +function episodeRow( + episode: LibraryEpisodeHit, + open: (id: number, origin: HTMLElement) => void, +): HTMLLIElement { const item = document.createElement("li"); - const row = document.createElement("div"); - row.className = "row"; + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb"; const chips = document.createElement("span"); chips.className = "row-chips"; chips.append( @@ -656,7 +730,14 @@ function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement { title.className = "row-sub"; title.textContent = episode.title; chips.append(title); + const affordance = document.createElement("span"); + affordance.className = "row-add readout"; + affordance.textContent = "episodes"; + chips.append(affordance); row.append(rowTitle(episode.series_title, null), chips); + row.addEventListener("click", () => { + open(episode.series_id, row); + }); item.append(row); return item; } @@ -892,11 +973,49 @@ interface ReleasesView { setRemoved: (handler: (parent: Route) => void) => void; } -interface BucketRefs { +/** The eligible bucket always shows; waived and rejected collapse to counts. */ +interface CollapsedBucketDom { section: HTMLElement; + toggle: HTMLButtonElement; + wrap: HTMLElement; rows: HTMLUListElement; } +/** + * The §9.3 three-bucket structure. The movie deck builds it from static + * markup in index.html; the tv deck builds it with [`buildBucketDom`] — + * both paint through [`paintBuckets`]. + */ +interface BucketsDom { + eligible: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; + waived: CollapsedBucketDom; + rejected: CollapsedBucketDom; +} + +function clearBuckets(dom: BucketsDom) { + dom.eligible.section.hidden = true; + dom.eligible.rows.replaceChildren(); + for (const bucket of [dom.waived, dom.rejected]) { + bucket.section.hidden = true; + bucket.wrap.hidden = true; + bucket.toggle.setAttribute("aria-expanded", "false"); + bucket.rows.replaceChildren(); + } +} + +function setToggle(bucket: CollapsedBucketDom, count: number) { + const open = !bucket.wrap.hidden; + bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`; + bucket.toggle.setAttribute("aria-expanded", String(open)); +} + +function wireCollapsedToggle(bucket: CollapsedBucketDom) { + bucket.toggle.addEventListener("click", () => { + bucket.wrap.hidden = !bucket.wrap.hidden; + setToggle(bucket, bucket.rows.childElementCount); + }); +} + function releasesMain(): ReleasesView { const view = must("#releases"); const deck = must("#deck"); @@ -907,7 +1026,7 @@ function releasesMain(): ReleasesView { const remove = must("#releases-remove"); const removeWrap = must("#remove-panel"); const status = must("#releases-status"); - const eligible: BucketRefs & { count: HTMLElement } = { + const eligible: BucketsDom["eligible"] = { section: must("#bucket-eligible"), count: must("#count-eligible"), rows: must("#rows-eligible"), @@ -932,9 +1051,23 @@ function releasesMain(): ReleasesView { let origin: HTMLElement | null = null; let returnTo: HTMLElement = deck; let parentRoute: Route = { kind: "board" }; + const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected }; const actions: ReleaseActions = { reload: () => load(), notify: (text, tone) => setStatus(text, tone), + grab: async (release, bucket) => { + const movie = current; + if (!movie) { + return { kind: "error", detail: "deck closed", overrideWritten: false }; + } + if (bucket === "waived") { + return waiveAndGrab(movie.id, release.id, release.rejected_rule); + } + const outcome = await grabRelease(movie.id, release.id); + return outcome.kind === "done" + ? { kind: "done", overrideWritten: false } + : { kind: "error", detail: outcome.detail, overrideWritten: false }; + }, }; // guards a stale fetch from painting over a newer view let sequence = 0; @@ -962,95 +1095,15 @@ function releasesMain(): ReleasesView { } } - function clearBuckets() { - eligible.section.hidden = true; - eligible.rows.replaceChildren(); - for (const bucket of [collapsed.waived, collapsed.rejected]) { - bucket.section.hidden = true; - bucket.wrap.hidden = true; - bucket.toggle.setAttribute("aria-expanded", "false"); - bucket.rows.replaceChildren(); - } - } - - function setToggle(bucket: (typeof collapsed)["waived"], count: number) { - const open = !bucket.wrap.hidden; - bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`; - bucket.toggle.setAttribute("aria-expanded", String(open)); - } - - for (const bucket of [collapsed.waived, collapsed.rejected]) { - bucket.toggle.addEventListener("click", () => { - bucket.wrap.hidden = !bucket.wrap.hidden; - setToggle(bucket, bucket.rows.childElementCount); - }); - } - function render(releases: MovieRelease[]) { const movie = current; if (!movie) { return; } - clearBuckets(); - if (releases.length === 0) { + if (!paintBuckets(dom, releases, actions)) { return; } setStatus(null); - - // score colour is relative to this list (issue 111): highest green, - // lowest red, seven interpolated stops between - let min = Number.POSITIVE_INFINITY; - let max = Number.NEGATIVE_INFINITY; - for (const release of releases) { - if (release.score !== null) { - if (release.score < min) { - min = release.score; - } - if (release.score > max) { - max = release.score; - } - } - } - const scoreStop = (score: number | null): number | null => { - if (score === null || min > max) { - return null; - } - // all-equal scores are jointly the best the list offers - return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS); - }; - - // the API pre-sorts by bucket then score; splitting preserves order - const buckets = { - eligible: releases.filter((release) => bucketOf(release) === "eligible"), - waived: releases.filter((release) => bucketOf(release) === "waived"), - rejected: releases.filter((release) => bucketOf(release) === "rejected"), - }; - - eligible.section.hidden = false; - eligible.count.textContent = String(buckets.eligible.length); - if (buckets.eligible.length > 0) { - for (const release of buckets.eligible) { - eligible.rows.append(releaseRow(release, movie, "eligible", scoreStop, actions)); - } - } else { - // §9.3: over-strict filters must be visible, not silently absent - const none = document.createElement("li"); - none.className = "rel rel-none readout dim"; - none.textContent = "none — every candidate was waived or rejected by policy"; - eligible.rows.append(none); - } - for (const name of ["waived", "rejected"] as const) { - const rows = buckets[name]; - if (rows.length === 0) { - continue; - } - const bucket = collapsed[name]; - bucket.section.hidden = false; - for (const release of rows) { - bucket.rows.append(releaseRow(release, movie, name, scoreStop, actions)); - } - setToggle(bucket, rows.length); - } } async function load() { @@ -1067,12 +1120,12 @@ function releasesMain(): ReleasesView { return; } if (outcome.kind === "error") { - clearBuckets(); + clearBuckets(dom); setStatus(`releases unavailable — ${outcome.detail}`, "fault"); return; } if (outcome.releases.length === 0) { - clearBuckets(); + clearBuckets(dom); await emptyVerdict(movie, ticket); return; } @@ -1187,7 +1240,7 @@ function releasesMain(): ReleasesView { year.textContent = movie.year === null ? "—" : String(movie.year); container.hidden = true; view.hidden = false; - clearBuckets(); + clearBuckets(dom); clearRemove(); sweep.disabled = false; back.focus(); @@ -1378,6 +1431,11 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement { interface ReleaseActions { reload: () => Promise; notify: (text: string, tone?: "fault") => void; + /** One click on grab — a plain grab, or waive-then-grab on a waived row. */ + grab: ( + release: MovieRelease, + bucket: "eligible" | "waived" | "rejected", + ) => Promise; } /** Stops in the score heat scale — keep in step with the --score-* tokens. */ @@ -1385,6 +1443,155 @@ const SCORE_STOPS = 6; type ScoreStopper = (score: number | null) => number | null; +/** + * Score colour is relative to one list (issue 111): highest green, lowest + * red, seven interpolated stops between. + */ +function scoreStopper(releases: MovieRelease[]): ScoreStopper { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const release of releases) { + if (release.score !== null) { + if (release.score < min) { + min = release.score; + } + if (release.score > max) { + max = release.score; + } + } + } + return (score: number | null): number | null => { + if (score === null || min > max) { + return null; + } + // all-equal scores are jointly the best the list offers + return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS); + }; +} + +/** + * Splits classified releases into the three buckets and paints them. The + * API pre-sorts by bucket then score; splitting preserves order. Returns + * false when there was nothing to paint — the caller owns the empty state, + * which differs per deck. + */ +function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: ReleaseActions): boolean { + clearBuckets(dom); + if (releases.length === 0) { + return false; + } + + const scoreStop = scoreStopper(releases); + const buckets = { + eligible: releases.filter((release) => bucketOf(release) === "eligible"), + waived: releases.filter((release) => bucketOf(release) === "waived"), + rejected: releases.filter((release) => bucketOf(release) === "rejected"), + }; + + dom.eligible.section.hidden = false; + dom.eligible.count.textContent = String(buckets.eligible.length); + if (buckets.eligible.length > 0) { + for (const release of buckets.eligible) { + dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions)); + } + } else { + // §9.3: over-strict filters must be visible, not silently absent + const none = document.createElement("li"); + none.className = "rel rel-none readout dim"; + none.textContent = "none — every candidate was waived or rejected by policy"; + dom.eligible.rows.append(none); + } + for (const name of ["waived", "rejected"] as const) { + const rows = buckets[name]; + if (rows.length === 0) { + continue; + } + const bucket = dom[name]; + bucket.section.hidden = false; + for (const release of rows) { + bucket.rows.append(releaseRow(release, name, scoreStop, actions)); + } + setToggle(bucket, rows.length); + } + return true; +} + +/** Builds the §9.3 bucket structure for a deck whose markup is dynamic. */ +function buildBucketDom(root: HTMLElement): BucketsDom { + function colhead(): HTMLElement { + const head = document.createElement("div"); + head.className = "colhead"; + head.setAttribute("aria-hidden", "true"); + for (const [width, label] of [ + ["cw-score", "score"], + ["cw-res", "res"], + ["cw-src", "source"], + ["cw-hdr", "hdr"], + ["cw-aud", "audio"], + ["cw-size", "size"], + ["cw-seed", "seed"], + ] as const) { + const cell = document.createElement("span"); + cell.className = `cw ${width}`; + cell.textContent = label; + head.append(cell); + } + return head; + } + + function rows(): HTMLUListElement { + const list = document.createElement("ul"); + list.className = "deck-rows"; + return list; + } + + const eligibleSection = document.createElement("section"); + eligibleSection.className = "deck-group"; + eligibleSection.hidden = true; + const eligibleHead = document.createElement("header"); + eligibleHead.className = "deck-head"; + const eligibleName = document.createElement("h3"); + eligibleName.className = "deck-label"; + eligibleName.textContent = "eligible"; + const eligibleCount = document.createElement("span"); + eligibleCount.className = "deck-count readout"; + eligibleHead.append(eligibleName, eligibleCount); + const eligibleRows = rows(); + eligibleSection.append(eligibleHead, colhead(), eligibleRows); + + function collapsed(label: string): CollapsedBucketDom { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "bucket-toggle readout"; + toggle.setAttribute("aria-expanded", "false"); + const sectionEl = document.createElement("section"); + sectionEl.className = "deck-group"; + sectionEl.hidden = true; + const head = document.createElement("header"); + head.className = "deck-head bucket-head"; + const name = document.createElement("h3"); + name.className = "deck-label"; + name.textContent = label; + head.append(name, toggle); + const wrap = document.createElement("div"); + wrap.hidden = true; + const list = rows(); + wrap.append(colhead(), list); + sectionEl.append(head, wrap); + const bucket: CollapsedBucketDom = { section: sectionEl, toggle, wrap, rows: list }; + wireCollapsedToggle(bucket); + return bucket; + } + + const dom: BucketsDom = { + eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows }, + waived: collapsed("waived"), + rejected: collapsed("rejected"), + }; + root.append(dom.eligible.section, dom.waived.section, dom.rejected.section); + return dom; +} + /** * One classified release: chips lead, the name is secondary (§9.3). The * score chip is coloured on the list-relative heat scale (issue 111); @@ -1392,7 +1599,6 @@ type ScoreStopper = (score: number | null) => number | null; */ function releaseRow( release: MovieRelease, - movie: LibraryMovie, bucket: "eligible" | "waived" | "rejected", scoreStop: ScoreStopper, actions: ReleaseActions, @@ -1458,15 +1664,7 @@ function releaseRow( note.hidden = false; delete note.dataset.tone; note.textContent = "grabbing…"; - const action = - bucket === "waived" - ? waiveAndGrab(movie.id, release.id, release.rejected_rule) - : grabRelease(movie.id, release.id).then((outcome) => - outcome.kind === "done" - ? ({ kind: "done", overrideWritten: false } as const) - : ({ kind: "error", detail: outcome.detail, overrideWritten: false } as const), - ); - void action.then((outcome) => { + void actions.grab(release, bucket).then((outcome) => { const text = outcome.kind === "error" ? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}` @@ -1510,6 +1708,7 @@ interface LibraryGroup { function libraryMain( board: HTMLElement, releases: ReleasesView, + seriesDetail: SeriesView, views: HideableView[], ): LibraryView { const view = must("#library"); @@ -1583,7 +1782,12 @@ function libraryMain( const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention); const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention); - renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots)); + renderGroup(groups.series, seriesShown, (series) => + seriesRow(series, roots, (id, origin) => { + navigate({ kind: "series", seriesId: id }); + void seriesDetail.open(id, origin, view, { kind: "library" }); + }), + ); renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, (target, origin) => { navigate({ kind: "releases", movieId: target.id }); @@ -1677,12 +1881,18 @@ function libraryMain( } /** - * One series with its §4.2 derived status: displayed, never editable. - * No release deck yet — per-season and per-episode actions are issue #39. + * One series with its §4.2 derived status: displayed, never editable. The + * row opens the detail view — seasons, episodes and their decks (#129). */ -function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement { +function seriesRow( + series: LibrarySeries, + roots: Root[], + open: (id: number, origin: HTMLElement) => void, +): HTMLLIElement { const item = document.createElement("li"); - item.className = "row"; + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb"; const chips = document.createElement("span"); chips.className = "row-chips"; const root = roots.find((candidate) => candidate.id === series.root_id); @@ -1705,10 +1915,664 @@ function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement { span.dataset.status = series.status; }), ); - item.append(rowTitle(series.title, series.year), chips); + const affordance = document.createElement("span"); + affordance.className = "row-add readout"; + affordance.textContent = "episodes"; + chips.append(affordance); + row.append(rowTitle(series.title, series.year), chips); + row.addEventListener("click", () => { + open(series.id, row); + }); + item.append(row); return item; } +/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */ + +interface TvDeckRequest { + title: string; + sub: string | null; + seriesId: number; + target: TvTarget; + origin: HTMLElement; + returnTo: HTMLElement; + parentRoute: Route; +} + +interface TvReleasesView { + open: (request: TvDeckRequest) => void; + hide: () => void; +} + +/** §6.2: the sweep runs on the daemon's cadence. Poll modestly, give up softly. */ +const TV_SWEEP_POLL_MS = 5000; +const TV_SWEEP_WAIT_MS = 150_000; + +function tvReleasesMain(): TvReleasesView { + const view = must("#tv-releases"); + const back = must("#tv-releases-back"); + const title = must("#tv-releases-title"); + const sub = must("#tv-releases-sub"); + const sweep = must("#tv-releases-sweep"); + const statusEl = must("#tv-releases-status"); + const dom = buildBucketDom(must("#tv-buckets")); + + let request: TvDeckRequest | null = null; + // guards a stale fetch from painting over a newer view + let sequence = 0; + let pollTimer: number | undefined; + + function setStatus(text: string | null, tone?: "fault", busy = false) { + statusEl.hidden = text === null; + if (busy && text !== null) { + const lamp = document.createElement("span"); + lamp.className = "lamp"; + lamp.dataset.state = "probing"; + statusEl.replaceChildren(lamp, document.createTextNode(text)); + } else { + statusEl.textContent = text ?? ""; + } + if (tone) { + statusEl.dataset.tone = tone; + } else { + delete statusEl.dataset.tone; + } + } + + const actions: ReleaseActions = { + reload: () => load(), + notify: (text, tone) => setStatus(text, tone), + grab: (release, bucket) => { + const current = request; + if (!current) { + return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false }); + } + if (bucket === "waived") { + return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); + } + return current.target + .grab(release.id) + .then((outcome) => + outcome.kind === "done" + ? { kind: "done" as const, overrideWritten: false } + : { kind: "error" as const, detail: outcome.detail, overrideWritten: false }, + ); + }, + }; + + async function load() { + const current = request; + if (!current) { + return; + } + sequence += 1; + const ticket = sequence; + window.clearTimeout(pollTimer); + setStatus("reading releases…"); + const outcome = await current.target.releases(); + if (ticket !== sequence || request !== current) { + return; + } + if (outcome.kind === "error") { + clearBuckets(dom); + setStatus(`releases unavailable — ${outcome.detail}`, "fault"); + return; + } + if (!paintBuckets(dom, outcome.releases, actions)) { + setStatus("no releases indexed yet — search indexers queues a targeted sweep"); + return; + } + setStatus(null); + } + + /** + * A queued sweep is done when its releases appear in the table. Nothing + * found is indistinguishable from still running, so the wait says so + * honestly instead of promising either way. + */ + function watchSweep(current: TvDeckRequest, ticket: number) { + sweep.disabled = true; + setStatus("sweeping indexers…", undefined, true); + const deadline = Date.now() + TV_SWEEP_WAIT_MS; + const tick = async () => { + if (ticket !== sequence || request !== current) { + return; + } + const outcome = await current.target.releases(); + if (ticket !== sequence || request !== current) { + return; + } + if (outcome.kind === "results" && outcome.releases.length > 0) { + sweep.disabled = false; + paintBuckets(dom, outcome.releases, actions); + setStatus(null); + return; + } + if (Date.now() >= deadline) { + sweep.disabled = false; + setStatus( + "sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs", + ); + return; + } + pollTimer = window.setTimeout(() => { + void tick(); + }, TV_SWEEP_POLL_MS); + }; + pollTimer = window.setTimeout(() => { + void tick(); + }, TV_SWEEP_POLL_MS); + } + + sweep.addEventListener("click", () => { + const current = request; + if (!current) { + return; + } + sweep.disabled = true; + void (async () => { + const outcome = await current.target.search(); + if (request !== current) { + return; + } + if (outcome.kind === "error") { + sweep.disabled = false; + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + sequence += 1; + window.clearTimeout(pollTimer); + watchSweep(current, sequence); + })(); + }); + + function open(next: TvDeckRequest) { + request = next; + title.textContent = next.title; + sub.textContent = next.sub ?? ""; + next.returnTo.hidden = true; + view.hidden = false; + clearBuckets(dom); + sweep.disabled = false; + back.focus(); + void load(); + } + + function hide() { + view.hidden = true; + request = null; + sequence += 1; + window.clearTimeout(pollTimer); + } + + function close() { + const current = request; + navigate(current?.parentRoute ?? { kind: "board" }); + hide(); + if (current) { + current.returnTo.hidden = false; + current.origin.focus(); + } + } + + back.addEventListener("click", close); + + // capture + stopImmediatePropagation: registered before the series view's + // own Escape listener, so one Esc steps back one layer — deck first + window.addEventListener( + "keydown", + (event) => { + if (event.key !== "Escape" || view.hidden) { + return; + } + event.stopImmediatePropagation(); + close(); + }, + true, + ); + + return { open, hide }; +} + +/* ---- series detail view (§4.1, §4.2, issue #129) ----------------------- */ + +interface SeriesView { + hide: () => void; + open: ( + seriesId: number, + origin: HTMLElement, + returnTo: HTMLElement, + parentRoute: Route, + ) => Promise; +} + +const PAD_TWO = (value: number): string => String(value).padStart(2, "0"); + +function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView { + const view = must("#series"); + const deckEl = must("#deck"); + const back = must("#series-back"); + const titleEl = must("#series-title"); + const yearEl = must("#series-year"); + const chipsEl = must("#series-chips"); + const statusEl = must("#series-status"); + const seasonsList = must("#rows-seasons"); + + let roots: Root[] = []; + let series: ApiSeries | null = null; + let seasons: ApiSeason[] | null = null; + let filesByEpisode = new Map(); + // which seasons stand open survives the refetch every action triggers + let expanded = new Set(); + let origin: HTMLElement | null = null; + let returnTo: HTMLElement = deckEl; + let parentRoute: Route = { kind: "library" }; + let seriesId: number | null = null; + // the control to refocus once the refetch a click triggered repainted + let focusKey: string | null = null; + // guards a stale fetch from painting over a newer view + let sequence = 0; + + function setStatus(text: string | null, tone?: "fault") { + statusEl.hidden = text === null; + statusEl.textContent = text ?? ""; + if (tone) { + statusEl.dataset.tone = tone; + } else { + delete statusEl.dataset.tone; + } + } + + function paintHeader() { + const current = series; + if (!current) { + return; + } + titleEl.textContent = current.title; + yearEl.textContent = current.year === null ? "" : String(current.year); + chipsEl.replaceChildren(); + const root = roots.find((candidate) => candidate.id === current.root_id); + chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`)); + chipsEl.append( + chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => { + span.setAttribute( + "aria-label", + `${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`, + ); + }), + ); + chipsEl.append( + chip(current.status, (span) => { + span.dataset.status = current.status; + }), + ); + if (current.blocked) { + chipsEl.append(chip("blocked")); + } + } + + function seasonCountsChip(season: ApiSeason): HTMLSpanElement { + const counts = seasonCounts(season); + return chip(`${counts.available}/${counts.wanted} on disk`, (span) => { + span.setAttribute( + "aria-label", + `${counts.available} of ${counts.wanted} wanted episodes on disk`, + ); + }); + } + + function seasonRow(season: ApiSeason): HTMLLIElement { + const item = document.createElement("li"); + item.className = "season"; + + const line = document.createElement("div"); + line.className = "season-line"; + + const isOpen = expanded.has(season.number); + const disclose = document.createElement("button"); + disclose.type = "button"; + disclose.className = "season-disclose"; + disclose.setAttribute("aria-expanded", String(isOpen)); + disclose.setAttribute("aria-controls", `episodes-${season.number}`); + disclose.setAttribute("aria-label", `season ${PAD_TWO(season.number)} episodes`); + disclose.addEventListener("click", () => { + const nowOpen = !expanded.has(season.number); + if (nowOpen) { + expanded.add(season.number); + } else { + expanded.delete(season.number); + } + disclose.setAttribute("aria-expanded", String(nowOpen)); + const list = item.querySelector(`#episodes-${season.number}`); + if (list) { + list.hidden = !nowOpen; + } + disclose.focus(); + }); + + // season 0 never counts toward tracking or totals (§4.1, §4.2); say so + const name = document.createElement("span"); + name.className = "season-name"; + name.textContent = season.number === 0 ? "specials · s00" : `season ${PAD_TWO(season.number)}`; + + const track = document.createElement("button"); + track.type = "button"; + track.className = "control control-quiet"; + track.dataset.focusId = `track-${season.number}`; + track.setAttribute("aria-pressed", String(season.tracked)); + track.textContent = "tracked"; + track.addEventListener("click", () => { + if (seriesId === null) { + return; + } + track.disabled = true; + const wantedNext = !season.tracked; + void setSeasonTracked(seriesId, season.number, wantedNext).then((outcome) => { + if (outcome.kind === "error") { + track.disabled = false; + setStatus(`tracking failed — ${outcome.detail}`, "fault"); + return; + } + // §4.1: turning tracking on marks revealed episodes wanted; turning + // it off withdraws nothing. The refetch paints both truths. + focusKey = `track-${season.number}`; + void load(); + }); + }); + + const space = document.createElement("span"); + space.className = "season-space"; + + const searchBtn = document.createElement("button"); + searchBtn.type = "button"; + searchBtn.className = "control"; + searchBtn.textContent = "search"; + searchBtn.addEventListener("click", () => { + const currentId = seriesId; + if (currentId === null) { + return; + } + searchBtn.disabled = true; + void (async () => { + const outcome = await seasonTarget(currentId, season.number).search(); + searchBtn.disabled = false; + if (outcome.kind === "error") { + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + setStatus(`season ${PAD_TWO(season.number)} sweep queued — results land in its deck`); + })(); + }); + + const deckBtn = document.createElement("button"); + deckBtn.type = "button"; + deckBtn.className = "control"; + deckBtn.textContent = "deck"; + deckBtn.addEventListener("click", () => { + const currentId = seriesId; + const currentTitle = series?.title ?? ""; + if (currentId === null) { + return; + } + tvDeck.open({ + title: currentTitle, + sub: `season ${PAD_TWO(season.number)} · packs`, + seriesId: currentId, + target: seasonTarget(currentId, season.number), + origin: deckBtn, + returnTo: view, + parentRoute: { kind: "series", seriesId: currentId }, + }); + }); + + line.append(disclose, name, track, seasonCountsChip(season), space, searchBtn, deckBtn); + item.append(line); + + const episodes = document.createElement("ul"); + episodes.className = "episodes"; + episodes.id = `episodes-${season.number}`; + episodes.hidden = !isOpen; + for (const episode of season.episodes) { + episodes.append(detailEpisodeRow(season.number, episode)); + } + item.append(episodes); + return item; + } + + function detailEpisodeRow(seasonNumber: number, episode: ApiEpisode): HTMLLIElement { + const item = document.createElement("li"); + item.className = "episode"; + + const tag = document.createElement("span"); + tag.className = "ep-tag readout dim"; + tag.textContent = `E${PAD_TWO(episode.number)}`; + + const name = document.createElement("span"); + name.className = "ep-title"; + name.textContent = episode.title === "" ? "—" : episode.title; + + const air = document.createElement("span"); + air.className = "ep-air readout dim"; + air.textContent = formatAirDate(episode.air_date); + + const chips = document.createElement("span"); + chips.className = "row-chips ep-chips"; + chips.append( + chip(episode.state, (span) => { + span.dataset.movieState = episode.state; + }), + ); + // #122: gone upstream while its file remained — a conflict, not a state + if (episode.vanished) { + chips.append( + chip("vanished", (span) => { + span.dataset.flag = "vanished"; + }), + ); + } + const file = filesByEpisode.get(episode.id); + if (file) { + // §7.4: attribute tags come from ffprobe, so they are true + for (const attribute of fileAttributeTags(file)) { + chips.append(chip(attribute)); + } + const waiver = waiverLabel(file.waiver); + if (waiver !== null) { + chips.append( + chip(waiver, (span) => { + span.dataset.verdict = "waived"; + }), + ); + } + } + + const actions = document.createElement("span"); + actions.className = "ep-actions"; + const aired = !isUnaired(episode.air_date); + const onDisk = episode.state === "available"; + + if (aired && onDisk) { + // present and correct: nothing to decide here + } else if (!aired) { + const note = document.createElement("span"); + note.className = "readout dim ep-unaired"; + note.textContent = "unaired"; + actions.append(note); + } else { + const want = document.createElement("button"); + want.type = "button"; + want.className = "control control-quiet"; + want.dataset.focusId = `want-${episode.id}`; + want.setAttribute("aria-pressed", String(episode.wanted)); + want.textContent = "want"; + want.addEventListener("click", () => { + want.disabled = true; + void setEpisodeWanted(episode.id, !episode.wanted).then((outcome) => { + if (outcome.kind === "error") { + want.disabled = false; + setStatus(`want failed — ${outcome.detail}`, "fault"); + return; + } + focusKey = `want-${episode.id}`; + void load(); + }); + }); + actions.append(want); + + const searchBtn = document.createElement("button"); + searchBtn.type = "button"; + searchBtn.className = "control"; + searchBtn.textContent = "search"; + searchBtn.addEventListener("click", () => { + searchBtn.disabled = true; + void (async () => { + const outcome = await episodeTarget(episode.id).search(); + searchBtn.disabled = false; + if (outcome.kind === "error") { + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + setStatus(`S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} sweep queued`); + })(); + }); + actions.append(searchBtn); + + const deckBtn = document.createElement("button"); + deckBtn.type = "button"; + deckBtn.className = "control"; + deckBtn.textContent = "deck"; + deckBtn.addEventListener("click", () => { + const currentId = seriesId; + const currentTitle = series?.title ?? ""; + if (currentId === null) { + return; + } + tvDeck.open({ + title: currentTitle, + sub: `S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} · ${episode.title}`, + seriesId: currentId, + target: episodeTarget(episode.id), + origin: deckBtn, + returnTo: view, + parentRoute: { kind: "series", seriesId: currentId }, + }); + }); + actions.append(deckBtn); + } + + item.append(tag, name, air, chips, actions); + return item; + } + + function renderSeasons() { + seasonsList.replaceChildren(); + if (!seasons) { + return; + } + for (const season of seasons) { + seasonsList.append(seasonRow(season)); + } + if (seasons.length === 0) { + const none = document.createElement("li"); + none.className = "rel rel-none readout dim"; + none.textContent = "no seasons revealed yet — a metadata refresh fills this in"; + seasonsList.append(none); + } + } + + async function load() { + const id = seriesId; + if (id === null) { + return; + } + sequence += 1; + const ticket = sequence; + setStatus("reading series…"); + const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([ + fetchSeries(id), + fetchSeasons(id), + fetchSeriesFiles(id), + allRoots().catch(() => roots), + ]); + if (ticket !== sequence || seriesId !== id) { + return; + } + roots = fetchedRoots; + if (!detail) { + setStatus("series unavailable", "fault"); + return; + } + if (seasonsOutcome.kind === "error") { + setStatus(`seasons unavailable — ${seasonsOutcome.detail}`, "fault"); + return; + } + series = detail; + seasons = seasonsOutcome.seasons; + filesByEpisode = new Map( + filesOutcome.kind === "files" + ? filesOutcome.files.map((file) => [file.episode_id, file]) + : [], + ); + paintHeader(); + renderSeasons(); + setStatus(null); + if (focusKey !== null) { + view.querySelector(`[data-focus-id="${focusKey}"]`)?.focus(); + focusKey = null; + } + } + + async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) { + seriesId = id; + origin = from; + returnTo = container; + parentRoute = parent; + expanded = new Set(); + focusKey = null; + tvDeck.hide(); + for (const sibling of views) { + sibling.hide(); + } + board.hidden = true; + deckEl.hidden = true; + view.hidden = false; + back.focus(); + await load(); + } + + function hide() { + view.hidden = true; + seriesId = null; + seasons = null; + sequence += 1; + } + + function close() { + navigate(parentRoute); + hide(); + returnTo.hidden = false; + origin?.focus(); + } + + back.addEventListener("click", close); + + // capture, like every other layer: the tv deck's listener is registered + // first, so one Esc steps back one layer + window.addEventListener( + "keydown", + (event) => { + if (event.key === "Escape" && !view.hidden) { + event.stopImmediatePropagation(); + navigate(parentRoute); + close(); + } + }, + true, + ); + + return { hide, open }; +} + /* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */ interface QueuesView { diff --git a/web/src/style.css b/web/src/style.css index dfe4dd6..8a6d078 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1101,6 +1101,135 @@ body { padding: 0; } +/* ---- series detail (§4.1 + §4.2, issue #129) --------------------------- */ + +/* the title line carries its readouts beside the name, not pushed right */ +.series-chips { + margin-left: var(--space-3); +} + +/* one season: a collapsible group with its rule, counts and actions */ +.season { + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); +} + +.season-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + padding: var(--space-2) var(--space-1); +} + +/* the disclosure is a drawn chevron — no glyph standing in for an icon */ +.season-disclose { + flex: none; + width: 1.75rem; + height: 1.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--line); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 150ms var(--ease-out); +} + +.season-disclose:hover, +.season-disclose:focus-visible { + border-color: var(--accent); +} + +.season-disclose::before { + content: ""; + width: 0.4rem; + height: 0.4rem; + border-right: 2px solid var(--ink-muted); + border-bottom: 2px solid var(--ink-muted); + transform: rotate(-45deg) translate(-5%, -5%); + transition: transform 200ms var(--ease-out); +} + +.season-disclose[aria-expanded="true"]::before { + transform: rotate(45deg) translate(-5%, -5%); +} + +.season-name { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink); +} + +/* the tracked toggle reads as the rule it is; pressed = on */ +.season-line .control, +.ep-actions .control { + min-height: 2.25rem; + padding: 0 var(--space-3); +} + +.season-space { + flex: 1; +} + +.episodes { + margin: 0; + padding: 0 0 var(--space-2) var(--space-8); + list-style: none; +} + +.episode { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); + padding: var(--space-2) var(--space-1); + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); +} + +.ep-tag { + flex: none; + width: 2.75rem; + color: var(--ink-faint); +} + +.ep-title { + min-width: 0; + font-size: var(--text-sm); + color: var(--ink); +} + +.ep-air { + flex: none; + width: 6.5rem; + color: var(--ink-faint); +} + +.ep-chips { + margin-left: auto; +} + +.ep-actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-left: var(--space-3); +} + +.ep-unaired { + font-size: var(--text-xs); +} + +/* #122: gone upstream while its file remained — a conflict, amber dashed */ +.chip[data-flag="vanished"] { + color: var(--signal-warn); + border-style: dashed; + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + /* ---- attention queues (§5.2 + §5.7) ------------------------------------ */ /* the badge is a signal, not a control: amber attention on the cyan word */ @@ -1194,6 +1323,20 @@ body { flex-basis: 100%; } + /* an episode row wraps: identity first, chips full-width, actions right */ + .ep-chips { + margin-left: 0; + flex-basis: 100%; + } + + .ep-actions { + margin-left: auto; + } + + .episodes { + padding-left: var(--space-4); + } + .releases-id { order: -1; flex-basis: 100%; From e335a1be730e2694bc50be1778964165ea75d9f8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:13:54 +0100 Subject: [PATCH 62/71] style(api): drop needless raw string hashes --- crates/arr-api/src/series.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 84b36ea..9c483fa 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -2011,8 +2011,8 @@ mod tests { let first = episodes[0]["id"].as_i64().expect("episode id"); let pool = state.database().expect("database").pool(); sqlx::query( - r#"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver) - VALUES ('episode', ?, ?, 7, ?, ?)"#, + r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver) + VALUES ('episode', ?, ?, 7, ?, ?)", ) .bind(first) .bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv") From 7f9729fb571b80649e2d56965da48694b1348000 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:18:51 +0100 Subject: [PATCH 63/71] style(web): issue refs without hex-looking hashes --- web/index.html | 2 +- web/src/main.ts | 10 +++++----- web/src/series.ts | 2 +- web/src/style.css | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/index.html b/web/index.html index a0818f2..40bd914 100644 --- a/web/index.html +++ b/web/index.html @@ -307,7 +307,7 @@