From dbce23f570e1f79ee38296452a3af8a1c8869200 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 29 Aug 2026 12:38:26 +0100 Subject: [PATCH] fix(web): count on-disk episodes, not just wanted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untracking a season clears `wanted` on every episode (§4.1) and leaves the files where they were, so a season grabbed once and then untracked rendered `0/0 eps` beside a column of green check glyphs. The same counter could also read `10/1`, since the numerator was drawn from the wanted set the denominator had already shrunk. An episode now counts once it is wanted, on disk, or both, and the chip is hidden rather than shown as `0/0` when a season or series accounts for nothing. `Series.wanted_episodes` becomes `total_episodes`. --- crates/arr-api/src/series.rs | 25 +++++++++----- web/src/library.ts | 9 ++--- web/src/main.ts | 64 +++++++++++++++++++++--------------- web/src/search.ts | 2 +- web/src/series.ts | 24 +++++++++----- 5 files changed, 76 insertions(+), 48 deletions(-) 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