diff --git a/.sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json b/.sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json similarity index 87% rename from .sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json rename to .sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json index 9906276..ec46c2b 100644 --- a/.sqlx/query-cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5.json +++ b/.sqlx/query-3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "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\", poster_path, vote_average 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\", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "vote_average", "ordinal": 12, "type_info": "Float" + }, + { + "name": "metadata_refreshed_at", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, false, true, + true, true ] }, - "hash": "cf040af066e35e9bcb969c84ac180784d5bc9a2bf0630cd6fef2bb8f4cb467b5" + "hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea" } diff --git a/.sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json b/.sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json similarity index 82% rename from .sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json rename to .sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json index 3822d6e..1596a41 100644 --- a/.sqlx/query-9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152.json +++ b/.sqlx/query-9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "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\", poster_path, vote_average 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\", poster_path, vote_average, s.metadata_refreshed_at 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": [ { @@ -67,6 +67,11 @@ "name": "vote_average", "ordinal": 12, "type_info": "Float" + }, + { + "name": "metadata_refreshed_at", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, false, true, + true, true ] }, - "hash": "9f0164d699ffd66f31e7916827c81e01d283f35bb91ec2f34f63240792c01152" + "hash": "9755322057875ad786326cf533bff5d6b78e7844476731b309a3859287ff8c2f" } diff --git a/.sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json b/.sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json similarity index 87% rename from .sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json rename to .sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json index 3c135a7..d375fa0 100644 --- a/.sqlx/query-ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32.json +++ b/.sqlx/query-fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "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\", poster_path, vote_average 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\", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "vote_average", "ordinal": 12, "type_info": "Float" + }, + { + "name": "metadata_refreshed_at", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, false, true, + true, true ] }, - "hash": "ab536da7864e712754f1742e1f1f67d1d5cef884dafcaae1ae9ce498fc852e32" + "hash": "fd340665e0593bf692f0ffbe60ba4bebbc1a3d80c1304ed1fb790ccbfc6cc420" } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index fa480c0..ea32782 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -208,6 +208,9 @@ struct SeriesRow { poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. vote_average: Option, + /// NULL until a metadata refresh has stamped it (#160): the fact the + /// auto-track rule needs to tell a seeding refresh from a later one. + metadata_refreshed_at: Option, } struct EpisodeRow { @@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result Result { - 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", poster_path, vote_average 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", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::SeriesNotFound) @@ -407,11 +410,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.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", poster_path, vote_average 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", poster_path, vote_average, s.metadata_refreshed_at 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", 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", poster_path, vote_average 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", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#) .fetch_all(pool(&state)?) .await? }; @@ -765,6 +768,9 @@ pub async fn create_season( // §4.1. The request does not say whether the episodes are wanted; the // series' auto_track rule does, through the one function that owns it. + // #160: whether a refresh has happened yet rides in from the row — a + // hand-added season on a never-refreshed series is still seeding, and + // §4.1 does not track what was there at add time. let mut revealed = [RefreshedSeason { season: arr_core::Season { id: SeasonId(0), @@ -790,7 +796,11 @@ pub async fn create_season( .collect(), is_new: true, }]; - apply_auto_track(&core_series(&series), &mut revealed); + apply_auto_track( + &core_series(&series), + series.metadata_refreshed_at.is_some(), + &mut revealed, + ); let [revealed] = revealed; // One transaction: a rejected episode must not leave the season behind, @@ -1457,15 +1467,21 @@ mod tests { assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } + /// #160. A season added before the series' first metadata refresh is part + /// of the back catalogue at add time, so the rule does not track it. Once + /// a refresh has happened, a hand-revealed season is genuinely new and + /// arrives tracked with its episodes wanted. #[tokio::test] async fn auto_track_decides_whether_a_new_season_arrives_wanted() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "kids").await; let tracked = add_series(&base, root_id, true).await; + let tracked_id = tracked["id"].as_i64().expect("id"); + // Seeding: no refresh has ever stamped this series. let season = add_season( &base, - tracked["id"].as_i64().expect("id"), + tracked_id, 1, serde_json::json!([ {"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"}, @@ -1473,6 +1489,33 @@ mod tests { ]), ) .await; + assert_eq!(season["tracked"], false); + assert!( + season["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == false), + "§4.1: the back catalogue at add time is never auto-tracked" + ); + + sqlx::query( + "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", + ) + .bind(tracked_id) + .execute(state.database().expect("database").pool()) + .await + .expect("stamp refreshed"); + let season = add_season( + &base, + tracked_id, + 2, + serde_json::json!([ + {"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"} + ]), + ) + .await; assert_eq!(season["tracked"], true); assert!( season["episodes"] @@ -1480,7 +1523,7 @@ mod tests { .expect("episodes") .iter() .all(|episode| episode["wanted"] == true), - "§4.1: auto_track marks the episodes of a revealed season wanted" + "§4.1: from the second refresh onward the rule tracks what is new" ); let untracked_id = sqlx::query_scalar::<_, i64>( diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index aeb9793..e6513ec 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -13,8 +13,14 @@ pub struct RefreshedSeason { /// Existing seasons keep their tracking rule and leaf-level intent unchanged. /// 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 { +/// +/// `ever_refreshed` says whether the series has had a metadata refresh yet +/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because +/// `arr-core` has no IO; seasons revealed by a series' first refresh are its +/// back catalogue as it stood at add time, and §4.1 never tracks those — the +/// rule applies from the second refresh onward. +pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) { + if !series.auto_track || !ever_refreshed { return; } @@ -114,7 +120,7 @@ mod tests { refreshed_season(3, true, &[false, false]), ]; - apply_auto_track(&series(true), &mut refresh); + apply_auto_track(&series(true), true, &mut refresh); assert!(!refresh[0].season.tracked); assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted)); @@ -122,6 +128,39 @@ mod tests { assert!(refresh[1].episodes.iter().all(|episode| episode.wanted)); } + /// #160. The first refresh of a newly added series reveals the whole back + /// catalogue at once; §4.1 never tracks those. + #[test] + fn the_seeding_refresh_tracks_nothing() { + let mut refresh = [ + refreshed_season(1, true, &[false, false]), + refreshed_season(2, true, &[false]), + ]; + + apply_auto_track(&series(true), false, &mut refresh); + + assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked)); + assert!(refresh + .iter() + .all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted))); + } + + /// #160. From the second refresh onward a season that genuinely did not + /// exist when the series was added is tracked, and its episodes arrive + /// wanted — while the seasons already on record stay as they were. + #[test] + fn a_later_refresh_tracks_a_genuinely_new_season() { + let mut refresh = [ + refreshed_season(1, false, &[true, true]), + refreshed_season(3, true, &[false]), + ]; + + apply_auto_track(&series(true), true, &mut refresh); + + assert!(refresh[1].season.tracked); + assert!(refresh[1].episodes.iter().all(|episode| episode.wanted)); + } + #[test] fn untracked_series_keeps_manual_leaf_intent_only() { let mut refresh = [ @@ -129,7 +168,7 @@ mod tests { refreshed_season(3, true, &[false, false]), ]; - apply_auto_track(&series(false), &mut refresh); + apply_auto_track(&series(false), true, &mut refresh); assert!(refresh[0].episodes.iter().all(|episode| episode.wanted)); assert!(!refresh[0].season.tracked); @@ -141,7 +180,7 @@ mod tests { fn auto_track_skips_season_zero() { let mut refresh = [refreshed_season(0, true, &[false])]; - apply_auto_track(&series(true), &mut refresh); + apply_auto_track(&series(true), true, &mut refresh); assert!(!refresh[0].season.tracked); assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted)); diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 1bb5f9f..8a7880a 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -168,7 +168,13 @@ impl SeriesRefreshAction { }); } } - apply_auto_track(&core_series(stale), &mut revealed); + // #160: the fact of a previous refresh rides in from the row, so the + // seeding refresh never tracks the back catalogue it reveals. + apply_auto_track( + &core_series(stale), + stale.metadata_refreshed_at.is_some(), + &mut revealed, + ); for refreshed in revealed { self.insert_revealed_season(&mut transaction, stale.id, refreshed) .await?; @@ -779,8 +785,11 @@ mod tests { assert_eq!(vote, Some(8.417)); } + /// #160. A series' first refresh reveals its back catalogue, but §4.1 + /// never tracks what was already there at add time: nothing is tracked, + /// nothing arrives wanted. #[tokio::test] - async fn first_refresh_reveals_seasons_and_applies_auto_track() { + async fn the_seeding_refresh_reveals_but_tracks_nothing() { let (_dir, database) = seeded_series(true).await; let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; @@ -792,14 +801,14 @@ mod tests { .fetch_all(database.pool()) .await .unwrap(); - assert_eq!(rows, vec![(1, true), (2, true)]); + assert_eq!(rows, vec![(1, false), (2, false)]); 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); + assert_eq!(wanted, 0); // #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") @@ -816,6 +825,50 @@ mod tests { assert_eq!(stamped, 1); } + /// #160. Once the series has been refreshed before, a season TMDB reveals + /// that the library has never seen genuinely appeared after add time, so + /// it is tracked and its episodes arrive wanted. + #[tokio::test] + async fn a_later_refresh_tracks_a_genuinely_new_season() { + let (_dir, database) = seeded_series(true).await; + let server = MockServer::start().await; + mount_with(&server, "Returning Series", &[1]).await; + + action(&server).tick(&database).await.unwrap(); + let tracked: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE tracked = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(tracked, 0, "the seeding refresh tracks nothing"); + + expire_refresh(&database).await; + 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 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, true)]); + let wanted: Vec = sqlx::query_scalar( + "SELECT e.number FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 2 AND e.wanted = 1", + ) + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(wanted, vec![1]); + } + /// `auto_track` is a rule about reveals, not intent: without it nothing /// arrives wanted, even though everything is revealed. #[tokio::test] @@ -877,13 +930,13 @@ mod tests { .fetch_all(database.pool()) .await .unwrap(); - assert_eq!(rows, vec![(1, true), (2, true)]); + assert_eq!(rows, vec![(1, false), (2, false)]); 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)); + assert_eq!(counts, (3, 0)); } /// Episodes revealed into an existing season follow that season's rule: