feat(core): derived status ignores season 0
This commit is contained in:
+32
@@ -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"
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
@@ -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<HashMap<i64, Vec<arr_core::Episode>>, ApiError> {
|
||||
) -> Result<
|
||||
(
|
||||
HashMap<i64, Vec<arr_core::Season>>,
|
||||
HashMap<i64, Vec<arr_core::Episode>>,
|
||||
),
|
||||
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<i64, Vec<arr_core::Episode>> = HashMap::new();
|
||||
let mut seasons: HashMap<i64, Vec<arr_core::Season>> = 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<i64, Vec<arr_core::Episode>> = 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<SeriesRow, ApiError> {
|
||||
@@ -322,6 +361,16 @@ async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiErro
|
||||
|
||||
async fn load_series(state: &AppState, id: i64) -> Result<Series, ApiError> {
|
||||
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<Series, ApiError> {
|
||||
.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<arr_core::Season> = Vec::new();
|
||||
let no_episodes: Vec<arr_core::Episode> = 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(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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<SeasonId> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user