fix(web): count on-disk episodes, not just wanted
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`.
This commit is contained in:
+5
-4
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+38
-26
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -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<SeriesMetadataOu
|
||||
|
||||
/* ---- display helpers --------------------------------------------------- */
|
||||
|
||||
/** Wanted / on-disk counts for one season, whatever its number. */
|
||||
export function seasonCounts(season: ApiSeason): { wanted: number; available: number } {
|
||||
let wanted = 0;
|
||||
/**
|
||||
* On-disk / accounted-for counts for one season, whatever its number.
|
||||
*
|
||||
* An episode counts once it is wanted, on disk, or both. Counting intent
|
||||
* alone reads `0/0` on a season that was grabbed and then untracked —
|
||||
* untracking clears `wanted` (§4.1) and leaves every file where it was, so
|
||||
* the chip contradicted a column of green check glyphs.
|
||||
*/
|
||||
export function seasonCounts(season: ApiSeason): { total: number; available: number } {
|
||||
let total = 0;
|
||||
let available = 0;
|
||||
for (const episode of season.episodes) {
|
||||
if (episode.wanted) {
|
||||
wanted += 1;
|
||||
if (episode.state === "available") {
|
||||
const onDisk = episode.state === "available";
|
||||
if (episode.wanted || onDisk) {
|
||||
total += 1;
|
||||
if (onDisk) {
|
||||
available += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wanted, available };
|
||||
return { total, available };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user