Files
arr/crates/arr-daemon/src/series_refresh.rs
Miguel PalhasandClaude Fable 5 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +01:00

1499 lines
57 KiB
Rust

//! 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 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.
use std::collections::{HashMap, HashSet};
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)]
pub(crate) 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<TmdbClient>,
}
impl SeriesRefreshAction {
#[must_use]
pub fn new(tmdb: Arc<TmdbClient>) -> Self {
Self { tmdb }
}
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, 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,
poster_path, backdrop_path, vote_average, runtime_minutes
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)
}
/// Refresh one series now, for the on-demand metadata lane (issue #176).
///
/// A series deleted between the command being sent and drained is not an
/// error — there is simply nothing to refresh. The sweep's TTL gate
/// applies here too, so a title the sweep already refreshed costs no
/// second TMDB call.
pub(crate) async fn refresh_now(
&self,
database: &Db,
series_id: i64,
) -> Result<Option<Outcome>, RefreshError> {
let stale = 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,
poster_path, backdrop_path, vote_average, runtime_minutes
FROM series WHERE id = ?"#,
series_id
)
.fetch_optional(database.pool())
.await?;
let Some(stale) = stale else {
return Ok(None);
};
if !metadata_refresh_due(stale.metadata_refreshed_at.as_deref()) {
return Ok(None);
}
self.refresh_series(database, &stale).await
}
async fn refresh_series(
&self,
database: &Db,
stale: &DueSeries,
) -> Result<Option<Outcome>, 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::<HashMap<_, _>>();
let mut revealed = Vec::new();
let upstream_seasons = metadata
.seasons
.iter()
.map(|summary| i64::from(summary.number))
.collect::<HashSet<_>>();
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?;
// 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 {
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,
});
}
}
// #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?;
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(|| {
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<bool, RefreshError> {
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;
}
}
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
// frequently empty; a known value is never overwritten by a missing
// one, so a series keeps its runtime across TMDB's blank spells.
let runtime = metadata.episode_runtime.map(i64::from);
if runtime.is_some() && runtime != stale.runtime_minutes {
sqlx::query!(
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
runtime,
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;
}
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let artwork_moved =
stale.poster_path != poster_path || stale.backdrop_path != backdrop_path;
if artwork_moved || stale.vote_average != vote_average {
sqlx::query!(
"UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
poster_path_ref,
backdrop_path_ref,
vote_average,
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 TMDB no longer lists are
/// reconciled by `reconcile_vanished` (#122).
async fn sync_season(
&self,
executor: &mut sqlx::SqliteConnection,
season_id: i64,
tracked: bool,
tmdb_id: u32,
number: u32,
) -> Result<bool, RefreshError> {
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,
vanished AS "vanished!: bool"
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,
episode.vanished,
),
)
})
.collect::<HashMap<_, _>>();
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) {
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;
}
// 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),
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),
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;
}
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<i64, (i64, String, Option<String>, bool)>,
detail: &TmdbSeasonDetail,
) -> Result<bool, RefreshError> {
let upstream: HashSet<i64> = detail
.episodes
.iter()
.map(|source| i64::from(source.number))
.collect();
let mut changed = false;
for (number, &(id, ..)) in known {
if upstream.contains(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)
}
/// #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<i64, (i64, bool)>,
upstream: &HashSet<i64>,
) -> Result<bool, RefreshError> {
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 {
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<i64>,
title: String,
year: Option<i64>,
original_language: Option<String>,
root_id: i64,
auto_track: bool,
upstream_ended: bool,
metadata_refreshed_at: Option<String>,
/// §9.6 artwork columns, compared against TMDB's on each refresh.
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
runtime_minutes: Option<i64>,
}
/// 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<CoreEpisode> {
detail
.episodes
.iter()
.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),
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<std::time::SystemTime>) -> Option<String> {
air_date.map(|when| {
chrono::DateTime::<chrono::Utc>::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::*;
use arr_meta::UNTITLED_EPISODE;
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"}
]
})
}
/// 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::<Vec<_>>(),
"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 {
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();
}
/// §9.6: the daily refresh writes the three stored artwork fields, so
/// pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = seeded_series(true).await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"seasons": []
})))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM series WHERE tmdb_id = 82728",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
/// by, and a later refresh with TMDB's frequently-empty
/// `episode_run_time` never blanks a known value.
#[tokio::test]
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
let (_dir, database) = seeded_series(false).await;
let server = MockServer::start().await;
let body = |episode_run_time: serde_json::Value| {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"episode_run_time": episode_run_time,
"seasons": []
})
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
server.reset().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
.mount(&server)
.await;
expire_refresh(&database).await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
}
/// #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 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;
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, 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, 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")
.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);
}
/// #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]
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, 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, 0));
}
/// 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<String>, 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<bool> = 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);
}
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<i64> = 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");
}
/// #153. TMDB has not named an unaired episode yet, so the refresh stores
/// the placeholder instead of an empty string — and #121's guarded update
/// swaps it for the real title once TMDB fills it in.
#[tokio::test]
async fn an_unnamed_episode_stores_the_placeholder_until_tmdb_names_it() {
let (_dir, database) = seeded_series(true).await;
let server = tmdb(
"Returning Series",
season_one_body(&json!([
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "", "air_date": null}
])),
)
.await;
action(&server).tick(&database).await.unwrap();
let unnamed = season_one_episode(&database, 2).await;
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
.bind(unnamed)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(title, UNTITLED_EPISODE);
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"},
{"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}
])),
)
.await;
action(&server).tick(&database).await.unwrap();
let title: String = sqlx::query_scalar("SELECT title FROM episodes WHERE id = ?")
.bind(unnamed)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(title, "Hospital");
}
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<i64> = 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<i64> = sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(seasons, vec![0, 1, 2]);
}
}