From 95cdf83aa242ac84c4ab19f5dfea47168b5594c9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 11:47:00 +0200 Subject: [PATCH] fix(downloads): preserve unknown episode coordinates --- .../download-offline-detail.viewmodel.spec.ts | 280 ++++++++++++++++-- .../download-offline-detail.viewmodel.ts | 232 +++++++++++---- 2 files changed, 431 insertions(+), 81 deletions(-) diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts index 6a877c42c..6012d6980 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.spec.ts @@ -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 = 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, }, ]); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts index c9e9b5e2e..9c43b792c 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.viewmodel.ts @@ -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 extends DeepReadonlyAtomic + ? T + : T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : 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; + readonly episodeMetadata?: DeepReadonly; + }; + +export type DownloadOfflineSeason = DownloadOfflineSeasonCoordinate & { readonly episodes: readonly DownloadOfflineEpisode[]; -} +}; export interface DownloadOfflineMovieDetail { readonly kind: 'movie'; - readonly item: DownloadItem; - readonly snapshot?: DownloadMetadataSnapshot; + readonly item: DeepReadonly; + readonly snapshot?: DeepReadonly; } export interface DownloadOfflineSeriesDetail { readonly kind: 'series'; - readonly representative: DownloadItem; - readonly snapshot?: DownloadMetadataSnapshot; + readonly representative: DeepReadonly; + readonly snapshot?: DeepReadonly; 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[]; +} + +type ReadonlyDownloadItem = DeepReadonly; + +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 | 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 | 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(); + 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 (