fix(downloads): preserve unknown episode coordinates

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent b2efcedda1
commit 95cdf83aa2
2 files changed
+431 -81

No files matched your search

@@ -5,8 +5,10 @@ import type {
} from '@iptvnator/shared/interfaces';
import {
buildDownloadOfflineDetail,
DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
type DeepReadonly,
type DownloadOfflineDetail,
type DownloadOfflineEpisode,
type DownloadOfflineMovieDetail,
} from './download-offline-detail.viewmodel';
const SERIES_ID = 77;
@@ -79,7 +81,34 @@ function expectSeries(
return detail;
}
function assertReadonlyViewModelTypes(
episode: DownloadOfflineEpisode,
movie: DownloadOfflineMovieDetail
): void {
const item: DeepReadonly<DownloadItem> = episode.item;
void item;
// @ts-expect-error View-model source items are recursively readonly.
episode.item.title = 'Mutated title';
if (episode.item.metadataSnapshot?.genres) {
// @ts-expect-error Nested snapshot arrays are recursively readonly.
episode.item.metadataSnapshot.genres.push('Mutation');
}
if (episode.episodeMetadata) {
// @ts-expect-error Per-episode metadata is recursively readonly.
episode.episodeMetadata.title = 'Mutated episode';
}
if (movie.snapshot) {
// @ts-expect-error Detail snapshots are recursively readonly.
movie.snapshot.title = 'Mutated movie';
}
}
describe('buildDownloadOfflineDetail', () => {
it('exposes source data through recursive readonly view-model types', () => {
expect(assertReadonlyViewModelTypes).toBeDefined();
});
it('resolves an available movie by its exact positive integer download id', () => {
const movieSnapshot = snapshot('movie', 'Stored movie');
const selected = download(7, {
@@ -298,59 +327,221 @@ describe('buildDownloadOfflineDetail', () => {
expect(series.representative).toBe(selected);
expect(series.seasons).toEqual([
{
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
seasonNumber: null,
seasonNumberState: 'unknown',
episodes: [
{
item: selected,
seasonNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
episodeNumber: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK,
seasonNumber: null,
seasonNumberState: 'unknown',
episodeNumber: null,
episodeNumberState: 'unknown',
},
],
},
]);
});
it('uses zero as the stable fallback for sparse or invalid coordinates without dropping files', () => {
const sparse = download(23, {
it('distinguishes valid S0E0 specials from unknown coordinates', () => {
const special = download(23, {
seasonNumber: 0,
episodeNumber: 0,
});
const sparse = download(24, {
seasonNumber: undefined,
episodeNumber: undefined,
});
const invalid = download(24, {
const invalid = download(25, {
seasonNumber: -2,
episodeNumber: 1.5,
});
const valid = download(25, {
seasonNumber: 1,
episodeNumber: 3,
});
const downloads = deepFreeze([valid, sparse, invalid]);
const downloads = deepFreeze([invalid, sparse, special]);
const series = expectSeries(build(23, downloads));
expect(DOWNLOAD_OFFLINE_COORDINATE_FALLBACK).toBe(0);
expect(
series.seasons.map(({ seasonNumber, episodes }) => ({
seasonNumber,
episodes: episodes.map(({ item, episodeNumber }) => ({
id: item.id,
episodeNumber,
})),
}))
series.seasons.map(
({ seasonNumber, seasonNumberState, episodes }) => ({
seasonNumber,
seasonNumberState,
episodes: episodes.map(
({
item,
seasonNumber: episodeSeasonNumber,
seasonNumberState: episodeSeasonNumberState,
episodeNumber,
episodeNumberState,
}) => ({
id: item.id,
seasonNumber: episodeSeasonNumber,
seasonNumberState: episodeSeasonNumberState,
episodeNumber,
episodeNumberState,
})
),
})
)
).toEqual([
{
seasonNumber: 0,
seasonNumberState: 'known',
episodes: [
{ id: 23, episodeNumber: 0 },
{ id: 24, episodeNumber: 0 },
{
id: 23,
seasonNumber: 0,
seasonNumberState: 'known',
episodeNumber: 0,
episodeNumberState: 'known',
},
],
},
{
seasonNumber: 1,
episodes: [{ id: 25, episodeNumber: 3 }],
seasonNumber: null,
seasonNumberState: 'unknown',
episodes: [
{
id: 24,
seasonNumber: null,
seasonNumberState: 'unknown',
episodeNumber: null,
episodeNumberState: 'unknown',
},
{
id: 25,
seasonNumber: null,
seasonNumberState: 'unknown',
episodeNumber: null,
episodeNumberState: 'unknown',
},
],
},
]);
});
it('keeps multiple unknown episodes distinct and orders them by createdAt then id', () => {
const downloads = deepFreeze([
download(63, {
seasonNumber: undefined,
episodeNumber: undefined,
createdAt: '2026-01-03T00:00:00.000Z',
}),
download(62, {
seasonNumber: Number.NaN,
episodeNumber: Number.POSITIVE_INFINITY,
createdAt: '2026-01-02T00:00:00.000Z',
}),
download(61, {
seasonNumber: -1,
episodeNumber: Number.MAX_SAFE_INTEGER + 1,
createdAt: '2026-01-02T00:00:00.000Z',
}),
]);
const series = expectSeries(build(63, downloads));
expect(series.seasons).toHaveLength(1);
expect(series.seasons[0]).toEqual(
expect.objectContaining({
seasonNumber: null,
seasonNumberState: 'unknown',
})
);
expect(
series.seasons[0].episodes.map(({ item, episodeNumber }) => ({
id: item.id,
episodeNumber,
}))
).toEqual([
{ id: 61, episodeNumber: null },
{ id: 62, episodeNumber: null },
{ id: 63, episodeNumber: null },
]);
});
it('orders invalid or missing createdAt after valid timestamps and then by id', () => {
const downloads = deepFreeze([
download(73, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: undefined,
}),
download(72, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: 'invalid',
}),
download(71, {
seasonNumber: 1,
episodeNumber: 4,
createdAt: '2026-01-02T00:00:00.000Z',
}),
]);
const series = expectSeries(build(73, downloads));
expect(series.seasons[0].episodes.map(({ item }) => item.id)).toEqual([
71, 72, 73,
]);
});
it('sorts unknown episodes after known episodes within a known season', () => {
const downloads = deepFreeze([
download(28, {
seasonNumber: 1,
episodeNumber: undefined,
}),
download(27, {
seasonNumber: 1,
episodeNumber: 5,
}),
download(26, {
seasonNumber: 1,
episodeNumber: 2,
}),
]);
const series = expectSeries(build(28, downloads));
expect(
series.seasons[0].episodes.map(
({ item, episodeNumber, episodeNumberState }) => ({
id: item.id,
episodeNumber,
episodeNumberState,
})
)
).toEqual([
{ id: 26, episodeNumber: 2, episodeNumberState: 'known' },
{ id: 27, episodeNumber: 5, episodeNumberState: 'known' },
{ id: 28, episodeNumber: null, episodeNumberState: 'unknown' },
]);
});
it('does not collapse known and unknown season groups', () => {
const downloads = deepFreeze([
download(30, {
seasonNumber: undefined,
episodeNumber: 2,
}),
download(29, {
seasonNumber: 0,
episodeNumber: 3,
}),
]);
const series = expectSeries(build(30, downloads));
expect(
series.seasons.map(({ seasonNumber, seasonNumberState }) => ({
seasonNumber,
seasonNumberState,
}))
).toEqual([
{ seasonNumber: 0, seasonNumberState: 'known' },
{ seasonNumber: null, seasonNumberState: 'unknown' },
]);
});
it('sorts seasons and episodes numerically, then duplicate episodes by createdAt and id', () => {
const downloads = deepFreeze([
download(35, {
@@ -462,6 +653,43 @@ describe('buildDownloadOfflineDetail', () => {
expect(reversed.snapshot).toBe(greaterId);
});
it('selects snapshots with entirely invalid timestamps deterministically by id', () => {
const lowerId = snapshot('series', 'Lower id', {
enrichedAt: 'invalid',
});
const middleId = snapshot('series', 'Middle id', {
enrichedAt: 'invalid',
});
const greaterId = snapshot('series', 'Greater id', {
enrichedAt: 'invalid',
});
const downloads = deepFreeze([
download(53, {
metadataSnapshot: lowerId,
updatedAt: 'invalid',
createdAt: 'invalid',
}),
download(54, {
metadataSnapshot: middleId,
updatedAt: undefined,
createdAt: undefined,
}),
download(55, {
metadataSnapshot: greaterId,
updatedAt: 'invalid',
createdAt: undefined,
}),
]);
const forward = expectSeries(build(53, downloads));
const reversed = expectSeries(
build(53, deepFreeze([...downloads].reverse()))
);
expect(forward.snapshot).toBe(greaterId);
expect(reversed.snapshot).toBe(greaterId);
});
it('keeps each episode tied to its own snapshot episode metadata', () => {
const firstEpisode: DownloadEpisodeMetadata = {
title: 'Pilot',
@@ -500,13 +728,17 @@ describe('buildDownloadOfflineDetail', () => {
{
item: first,
seasonNumber: 1,
seasonNumberState: 'known',
episodeNumber: 1,
episodeNumberState: 'known',
episodeMetadata: firstEpisode,
},
{
item: second,
seasonNumber: 1,
seasonNumberState: 'known',
episodeNumber: 2,
episodeNumberState: 'known',
episodeMetadata: secondEpisode,
},
]);
@@ -4,34 +4,59 @@ import type {
DownloadMetadataSnapshot,
} from '@iptvnator/shared/interfaces';
/**
* Missing or invalid season/episode coordinates are presented as specials.
* Zero is stable, numeric, and keeps locally available legacy files visible.
*/
export const DOWNLOAD_OFFLINE_COORDINATE_FALLBACK = 0;
type DeepReadonlyAtomic =
bigint | boolean | null | number | string | symbol | undefined;
export interface DownloadOfflineEpisode {
readonly item: DownloadItem;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly episodeMetadata?: DownloadEpisodeMetadata;
}
export type DeepReadonly<T> = T extends DeepReadonlyAtomic
? T
: T extends (...args: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export interface DownloadOfflineSeason {
readonly seasonNumber: number;
export type DownloadOfflineSeasonCoordinate =
| {
readonly seasonNumber: number;
readonly seasonNumberState: 'known';
}
| {
readonly seasonNumber: null;
readonly seasonNumberState: 'unknown';
};
export type DownloadOfflineEpisodeCoordinate =
| {
readonly episodeNumber: number;
readonly episodeNumberState: 'known';
}
| {
readonly episodeNumber: null;
readonly episodeNumberState: 'unknown';
};
export type DownloadOfflineEpisode = DownloadOfflineSeasonCoordinate &
DownloadOfflineEpisodeCoordinate & {
readonly item: DeepReadonly<DownloadItem>;
readonly episodeMetadata?: DeepReadonly<DownloadEpisodeMetadata>;
};
export type DownloadOfflineSeason = DownloadOfflineSeasonCoordinate & {
readonly episodes: readonly DownloadOfflineEpisode[];
}
};
export interface DownloadOfflineMovieDetail {
readonly kind: 'movie';
readonly item: DownloadItem;
readonly snapshot?: DownloadMetadataSnapshot;
readonly item: DeepReadonly<DownloadItem>;
readonly snapshot?: DeepReadonly<DownloadMetadataSnapshot>;
}
export interface DownloadOfflineSeriesDetail {
readonly kind: 'series';
readonly representative: DownloadItem;
readonly snapshot?: DownloadMetadataSnapshot;
readonly representative: DeepReadonly<DownloadItem>;
readonly snapshot?: DeepReadonly<DownloadMetadataSnapshot>;
readonly seasons: readonly DownloadOfflineSeason[];
}
@@ -40,14 +65,46 @@ export type DownloadOfflineDetail =
export interface BuildDownloadOfflineDetailInput {
readonly downloadId: number;
readonly downloads: readonly DownloadItem[];
readonly downloads: readonly DeepReadonly<DownloadItem>[];
}
type ReadonlyDownloadItem = DeepReadonly<DownloadItem>;
type NormalizedCoordinate =
| {
readonly state: 'known';
readonly value: number;
readonly sortBucket: 0;
}
| {
readonly state: 'unknown';
readonly value: null;
readonly sortBucket: 1;
};
type NormalizedTimestamp =
| {
readonly state: 'known';
readonly value: number;
readonly sortBucket: 0;
}
| {
readonly state: 'unknown';
readonly sortBucket: 1;
};
interface SortableOfflineEpisode {
readonly view: DownloadOfflineEpisode;
readonly season: NormalizedCoordinate;
readonly episode: NormalizedCoordinate;
readonly createdAt: NormalizedTimestamp;
}
function isPositiveSafeInteger(value: number | undefined): value is number {
return Number.isSafeInteger(value) && (value ?? 0) > 0;
}
function isLocallyAvailable(item: DownloadItem): boolean {
function isLocallyAvailable(item: ReadonlyDownloadItem): boolean {
return (
item.status === 'completed' &&
(item.filePath?.trim().length ?? 0) > 0 &&
@@ -60,20 +117,24 @@ function parsedTimestamp(value: string | undefined): number | undefined {
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function normalizedTimestamp(value: string | undefined): number {
return parsedTimestamp(value) ?? 0;
function normalizedCoordinate(value: number | undefined): NormalizedCoordinate {
if (Number.isSafeInteger(value) && (value ?? -1) >= 0) {
return { state: 'known', value: value as number, sortBucket: 0 };
}
return { state: 'unknown', value: null, sortBucket: 1 };
}
function normalizedCoordinate(value: number | undefined): number {
return Number.isSafeInteger(value) && (value ?? -1) >= 0
? (value as number)
: DOWNLOAD_OFFLINE_COORDINATE_FALLBACK;
function normalizedTimestamp(value: string | undefined): NormalizedTimestamp {
const timestamp = parsedTimestamp(value);
return timestamp === undefined
? { state: 'unknown', sortBucket: 1 }
: { state: 'known', value: timestamp, sortBucket: 0 };
}
function snapshotForKind(
item: DownloadItem,
item: ReadonlyDownloadItem,
mediaKind: DownloadMetadataSnapshot['mediaKind']
): DownloadMetadataSnapshot | undefined {
): DeepReadonly<DownloadMetadataSnapshot> | undefined {
const candidate = item.metadataSnapshot;
return candidate?.version === 1 && candidate.mediaKind === mediaKind
? candidate
@@ -87,7 +148,7 @@ function compareIdsNewestFirst(left: number, right: number): number {
return right < left ? -1 : 1;
}
function effectiveSeriesSnapshotTimestamp(item: DownloadItem): number {
function effectiveSeriesSnapshotTimestamp(item: ReadonlyDownloadItem): number {
const snapshot = snapshotForKind(item, 'series');
for (const value of [
snapshot?.enrichedAt,
@@ -103,8 +164,8 @@ function effectiveSeriesSnapshotTimestamp(item: DownloadItem): number {
}
function selectSeriesSnapshot(
members: readonly DownloadItem[]
): DownloadMetadataSnapshot | undefined {
members: readonly ReadonlyDownloadItem[]
): DeepReadonly<DownloadMetadataSnapshot> | undefined {
const candidates = members
.filter((item) => snapshotForKind(item, 'series') !== undefined)
.sort(
@@ -119,56 +180,113 @@ function selectSeriesSnapshot(
: snapshotForKind(candidates[0], 'series');
}
function toOfflineEpisode(item: DownloadItem): DownloadOfflineEpisode {
const seasonNumber = normalizedCoordinate(item.seasonNumber);
const episodeNumber = normalizedCoordinate(item.episodeNumber);
function seasonCoordinateFields(
coordinate: NormalizedCoordinate
): DownloadOfflineSeasonCoordinate {
return coordinate.state === 'known'
? { seasonNumber: coordinate.value, seasonNumberState: 'known' }
: { seasonNumber: null, seasonNumberState: 'unknown' };
}
function episodeCoordinateFields(
coordinate: NormalizedCoordinate
): DownloadOfflineEpisodeCoordinate {
return coordinate.state === 'known'
? { episodeNumber: coordinate.value, episodeNumberState: 'known' }
: { episodeNumber: null, episodeNumberState: 'unknown' };
}
function toSortableOfflineEpisode(
item: ReadonlyDownloadItem
): SortableOfflineEpisode {
const season = normalizedCoordinate(item.seasonNumber);
const episode = normalizedCoordinate(item.episodeNumber);
const episodeMetadata = snapshotForKind(item, 'series')?.episode;
return {
item,
seasonNumber,
episodeNumber,
...(episodeMetadata === undefined ? {} : { episodeMetadata }),
season,
episode,
createdAt: normalizedTimestamp(item.createdAt),
view: {
item,
...seasonCoordinateFields(season),
...episodeCoordinateFields(episode),
...(episodeMetadata === undefined ? {} : { episodeMetadata }),
},
};
}
function compareOfflineEpisodes(
left: DownloadOfflineEpisode,
right: DownloadOfflineEpisode
function compareCoordinates(
left: NormalizedCoordinate,
right: NormalizedCoordinate
): number {
return (
left.seasonNumber - right.seasonNumber ||
left.episodeNumber - right.episodeNumber ||
normalizedTimestamp(left.item.createdAt) -
normalizedTimestamp(right.item.createdAt) ||
left.item.id - right.item.id
left.sortBucket - right.sortBucket ||
(left.state === 'known' && right.state === 'known'
? left.value - right.value
: 0)
);
}
function compareTimestamps(
left: NormalizedTimestamp,
right: NormalizedTimestamp
): number {
return (
left.sortBucket - right.sortBucket ||
(left.state === 'known' && right.state === 'known'
? left.value - right.value
: 0)
);
}
function compareOfflineEpisodes(
left: SortableOfflineEpisode,
right: SortableOfflineEpisode
): number {
return (
compareCoordinates(left.season, right.season) ||
compareCoordinates(left.episode, right.episode) ||
compareTimestamps(left.createdAt, right.createdAt) ||
left.view.item.id - right.view.item.id
);
}
function buildSeasons(
members: readonly DownloadItem[]
members: readonly ReadonlyDownloadItem[]
): readonly DownloadOfflineSeason[] {
const episodes = members.map(toOfflineEpisode).sort(compareOfflineEpisodes);
const grouped = new Map<number, DownloadOfflineEpisode[]>();
const episodes = members
.map(toSortableOfflineEpisode)
.sort(compareOfflineEpisodes);
const grouped = new Map<
number | null,
{
readonly coordinate: NormalizedCoordinate;
readonly episodes: DownloadOfflineEpisode[];
}
>();
for (const episode of episodes) {
const season = grouped.get(episode.seasonNumber);
const season = grouped.get(episode.season.value);
if (season) {
season.push(episode);
season.episodes.push(episode.view);
} else {
grouped.set(episode.seasonNumber, [episode]);
grouped.set(episode.season.value, {
coordinate: episode.season,
episodes: [episode.view],
});
}
}
return [...grouped].map(([seasonNumber, seasonEpisodes]) => ({
seasonNumber,
episodes: seasonEpisodes,
return [...grouped.values()].map(({ coordinate, episodes }) => ({
...seasonCoordinateFields(coordinate),
episodes,
}));
}
function buildSeriesDetail(
representative: DownloadItem,
downloads: readonly DownloadItem[]
representative: ReadonlyDownloadItem,
downloads: readonly ReadonlyDownloadItem[]
): DownloadOfflineSeriesDetail | undefined {
const seriesXtreamId = representative.seriesXtreamId;
if (