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.
This commit is contained in:
Miguel Palhas
2026-08-24 11:40:44 +01:00
parent 2495895717
commit 8f7373d73d
6 changed files with 176 additions and 23 deletions
+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>(