Compare commits

...

1 Commits

Author SHA1 Message Date
Miguel Palhas 8f7373d73d fix(core): auto-track skips the seeding refresh
A series' first metadata refresh reveals its whole back catalogue, and
apply_auto_track flagged every season as new — adding Rick and Morty
tracked S01-S09 and wanted 91 episodes (#160).

Per DESIGN.md 4.1 the rule applies from the second refresh onward.
The caller passes whether metadata_refreshed_at is set; arr-core stays
IO-free and decides. Wired through the daemon's daily refresh and the
API's create-season endpoint.
2026-08-24 11:40:44 +01:00
6 changed files with 176 additions and 23 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+49 -6
View File
@@ -208,6 +208,9 @@ struct SeriesRow {
poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
vote_average: Option<f64>,
/// 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<String>,
}
struct EpisodeRow {
@@ -353,7 +356,7 @@ async fn tv_by_series(state: &AppState) -> Result<HashMap<i64, Vec<arr_core::Epi
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
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<ListSeriesQuery>,
) -> Result<Json<Vec<Series>>, 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>(
+44 -5
View File
@@ -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));
+59 -6
View File
@@ -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<i64> =
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<i64> = 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: