diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 6ea2f87..93ed9c5 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -65,9 +65,11 @@ pub struct Series { pub metadata_refreshed_at: Option, /// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2). pub status: String, - /// Episodes currently marked wanted (§4.1 — the only intent). - pub wanted_episodes: i64, - /// Wanted episodes already on disk. + /// Episodes this series accounts for: wanted, on disk, or both (§4.1). + /// A season grabbed once and then untracked keeps its files counted — + /// nothing wanted is not the same as nothing there. + pub total_episodes: i64, + /// Counted episodes already on disk. pub available_episodes: i64, } @@ -317,10 +319,15 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) // too. A series reading `complete` next to `42/52 eps` is the confusion // this avoids. The season number rides on each episode (#131), so no // parallel slice can be forgotten. - let wanted = episodes - .iter() - .filter(|episode| episode.wanted && episode.season_number != 0); - let available = wanted + // + // The denominator is wanted-or-on-disk, not wanted alone. Untracking a + // season clears `wanted` on every episode (§4.1) while the files stay, + // so counting intent alone reads `0/0 eps` next to a season of green + // check glyphs. + let counted = episodes.iter().filter(|episode| { + episode.season_number != 0 && (episode.wanted || episode.state == MediaState::Available) + }); + let available = counted .clone() .filter(|episode| episode.state == MediaState::Available); Series { @@ -339,7 +346,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) vote_average: row.vote_average, metadata_refreshed_at: row.metadata_refreshed_at.clone(), status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), - wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), + total_episodes: i64::try_from(counted.count()).unwrap_or(i64::MAX), available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX), } } @@ -3109,7 +3116,7 @@ mod tests { listed[0]["status"], "incomplete", "§4.2: an aired wanted episode with no file" ); - assert_eq!(listed[0]["wanted_episodes"], 1); + assert_eq!(listed[0]["total_episodes"], 1); assert_eq!(listed[0]["available_episodes"], 0); sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") diff --git a/web/src/library.ts b/web/src/library.ts index 6cf2743..9f83d01 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -22,7 +22,8 @@ export interface LibrarySeries { /** TMDB's rating, out of 10; `null` when TMDB has no votes for it. */ vote_average: number | null; status: SeriesStatus; - wanted_episodes: number; + /** Episodes wanted, on disk, or both — season 0 excluded. */ + total_episodes: number; available_episodes: number; } @@ -62,15 +63,15 @@ export function seriesNeedsAttention(series: LibrarySeries): boolean { * hasn't already said — amber-partial or green-full. `airing`, `waiting` * and `ended` share a count with another status (a finished season reads * `complete` either way) so the word is what tells them apart, and stays. - * With no counts chip to lean on (`wanted_episodes === 0`) the word is the + * With no counts chip to lean on (`total_episodes === 0`) the word is the * only thing on the row saying it, so it stays too. */ export function seriesStatusChipRedundant(series: { status: SeriesStatus; - wanted_episodes: number; + total_episodes: number; }): boolean { return ( - series.wanted_episodes > 0 && (series.status === "incomplete" || series.status === "complete") + series.total_episodes > 0 && (series.status === "incomplete" || series.status === "complete") ); } diff --git a/web/src/main.ts b/web/src/main.ts index 8b7ed92..9562b0b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -915,21 +915,21 @@ function downloadChip(targetKind: string, targetId: number): HTMLSpanElement | n } /** The ramp read off N/M itself: green when full, amber while a gap remains. */ -function countsTone(available: number, wanted: number): "complete" | "partial" | null { - if (wanted === 0) { +function countsTone(available: number, total: number): "complete" | "partial" | null { + if (total === 0) { return null; } - return available >= wanted ? "complete" : "partial"; + return available >= total ? "complete" : "partial"; } function countsChip( label: string, available: number, - wanted: number, + total: number, ariaLabel: string, ): HTMLSpanElement { return chip(label, (span) => { - const tone = countsTone(available, wanted); + const tone = countsTone(available, total); if (tone !== null) { span.dataset.counts = tone; } @@ -3888,13 +3888,13 @@ function seriesRow( const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === series.root_id); chips.append(chip(root ? root.audience : `root ${series.root_id}`)); - if (series.wanted_episodes > 0) { + if (series.total_episodes > 0) { chips.append( countsChip( - `${series.available_episodes}/${series.wanted_episodes} eps`, + `${series.available_episodes}/${series.total_episodes} eps`, series.available_episodes, - series.wanted_episodes, - `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, + series.total_episodes, + `${series.available_episodes} of ${series.total_episodes} episodes on disk`, ), ); } @@ -3994,13 +3994,13 @@ function seriesCard( open: (id: number, origin: HTMLElement) => void, ): HTMLLIElement { const chips: HTMLSpanElement[] = []; - if (series.wanted_episodes > 0) { + if (series.total_episodes > 0) { chips.push( countsChip( - `${series.available_episodes}/${series.wanted_episodes} eps`, + `${series.available_episodes}/${series.total_episodes} eps`, series.available_episodes, - series.wanted_episodes, - `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, + series.total_episodes, + `${series.available_episodes} of ${series.total_episodes} episodes on disk`, ), ); } @@ -4568,14 +4568,18 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { chipsEl.replaceChildren(); const root = roots.find((candidate) => candidate.id === current.root_id); chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`)); - chipsEl.append( - countsChip( - `${current.available_episodes}/${current.wanted_episodes} eps`, - current.available_episodes, - current.wanted_episodes, - `${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`, - ), - ); + // Hidden rather than shown as `0/0` when the series accounts for + // nothing yet — the same rule the library rows follow. + if (current.total_episodes > 0) { + chipsEl.append( + countsChip( + `${current.available_episodes}/${current.total_episodes} eps`, + current.available_episodes, + current.total_episodes, + `${current.available_episodes} of ${current.total_episodes} episodes on disk`, + ), + ); + } if (!seriesStatusChipRedundant(current)) { chipsEl.append( chip(current.status, (span) => { @@ -4737,13 +4741,17 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } } - function seasonCountsChip(season: ApiSeason): HTMLSpanElement { + /** Null when the season accounts for nothing — no `0/0` chip on the row. */ + function seasonCountsChip(season: ApiSeason): HTMLSpanElement | null { const counts = seasonCounts(season); + if (counts.total === 0) { + return null; + } const span = countsChip( - `${counts.available}/${counts.wanted}`, + `${counts.available}/${counts.total}`, counts.available, - counts.wanted, - `${counts.available} of ${counts.wanted} wanted episodes on disk`, + counts.total, + `${counts.available} of ${counts.total} episodes on disk`, ); span.prepend(icon("disk")); return span; @@ -4863,7 +4871,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); }); - head.append(name, track, seasonCountsChip(season)); + head.append(name, track); + const counts = seasonCountsChip(season); + if (counts !== null) { + head.append(counts); + } // §9.8: a season row aggregates its own pack grab — one download for // the whole season, never one per episode. const download = downloadChip("season", season.id); diff --git a/web/src/search.ts b/web/src/search.ts index c5082f7..ba338ec 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -155,7 +155,7 @@ export interface ApiSeries { poster_path: string | null; vote_average: number | null; status: string; - wanted_episodes: number; + total_episodes: number; available_episodes: number; } diff --git a/web/src/series.ts b/web/src/series.ts index 701fa36..386b7de 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -26,7 +26,7 @@ export interface ApiSeries { /** Null until a metadata refresh has stamped it (#160, #177). */ metadata_refreshed_at: string | null; status: SeriesStatus; - wanted_episodes: number; + total_episodes: number; available_episodes: number; } @@ -397,19 +397,27 @@ export async function seriesMetadata(seriesId: number): Promise