fix(downloads): stabilize malformed view model data

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent aae5450f4c
commit bff9ca1491
4 files changed
+374 -4

No files matched your search

@@ -0,0 +1,202 @@
import type { DownloadItem } from '@iptvnator/services';
import {
buildDownloadLibrary,
compareDownloadIds,
deriveStandardizedSeriesTitle,
isUsableDownloadOrderNumber,
type DownloadLibraryRow,
type DownloadSeriesCardViewModel,
} from './download-library.viewmodel';
const CREATED_AT = '2026-01-01T00:00:00Z';
const MALFORMED_ID_ORDER = [
'nan',
'negative-infinity',
'negative',
'zero',
'fractional',
'unsafe',
'positive-infinity',
] as const;
const ID_CASES = [
{ id: 7, title: 'valid-7' },
{ id: Number.NaN, title: 'nan' },
{ id: Number.POSITIVE_INFINITY, title: 'positive-infinity' },
{ id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' },
{ id: -3, title: 'negative' },
{ id: 0, title: 'zero' },
{ id: 42.5, title: 'fractional' },
{ id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' },
{ id: 2, title: 'valid-2' },
] as const;
function download(
id: number,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
playlistId: 'playlist-a',
xtreamId: 1,
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 1,
title: `Episode ${id}`,
url: 'https://example.test/episode',
status: 'completed',
createdAt: CREATED_AT,
...overrides,
};
}
function row(item: DownloadItem): DownloadLibraryRow {
return {
item,
episodeLabel: '',
sourceName: 'Alpha Source',
};
}
function buildSeries(
items: readonly DownloadItem[]
): DownloadSeriesCardViewModel {
const [entity] = buildDownloadLibrary(items.map(row));
expect(entity.kind).toBe('series');
if (entity.kind !== 'series') {
throw new Error('Expected a grouped series entity');
}
return entity;
}
function idEpisodes(
cases: readonly (typeof ID_CASES)[number][]
): DownloadItem[] {
return cases.map(({ id, title }) => download(id, { title }));
}
describe('download library view model helpers', () => {
it('does not derive a series title from a non-episode item', () => {
const movie = download(1, {
contentType: 'vod',
title: 'Movie - S01E02 - Not an episode',
});
expect(deriveStandardizedSeriesTitle(movie)).toBeUndefined();
});
it('derives the standardized prefix from an episode title', () => {
const episode = download(1, {
title: 'Canonical Show - S01E02 - The episode',
});
expect(deriveStandardizedSeriesTitle(episode)).toBe('Canonical Show');
});
it.each([
[0, true],
[1, true],
[Number.MAX_SAFE_INTEGER, true],
[1.5, false],
[Number.MAX_SAFE_INTEGER + 1, false],
[Number.NaN, false],
[Number.POSITIVE_INFINITY, false],
[-1, false],
])('classifies %p as usable=%s', (value, expected) => {
expect(isUsableDownloadOrderNumber(value)).toBe(expected);
});
it('returns finite results for duplicate and zero malformed ids', () => {
expect(compareDownloadIds(3, 3)).toBe(0);
expect(compareDownloadIds(Number.NaN, Number.NaN)).toBe(0);
expect(compareDownloadIds(0, Number.NaN)).toBe(1);
});
it('keeps a fractional series id as an individual episode card', () => {
const entities = buildDownloadLibrary([
row(download(1, { seriesXtreamId: 42.5 })),
]);
expect(entities).toEqual([
expect.objectContaining({
kind: 'episode',
key: 'episode:1',
}),
]);
});
it('orders member ids with valid ascending before the documented malformed fallback', () => {
const expected = ['valid-2', 'valid-7', ...MALFORMED_ID_ORDER];
const forward = buildSeries(idEpisodes(ID_CASES));
const reversed = buildSeries(idEpisodes([...ID_CASES].reverse()));
expect(forward.members.map(({ title }) => title)).toEqual(expected);
expect(reversed.members.map(({ title }) => title)).toEqual(expected);
});
it('chooses the greatest valid id as representative before malformed ids', () => {
const forward = buildSeries(idEpisodes(ID_CASES));
const reversed = buildSeries(idEpisodes([...ID_CASES].reverse()));
expect(forward.representative.title).toBe('valid-7');
expect(reversed.representative.title).toBe('valid-7');
});
it('chooses a malformed representative by canonical fallback independent of input order', () => {
const malformed = ID_CASES.filter(
({ title }) => !title.startsWith('valid')
);
const forward = buildSeries(idEpisodes(malformed));
const reversed = buildSeries(idEpisodes([...malformed].reverse()));
expect(forward.representative.title).toBe('nan');
expect(reversed.representative.title).toBe('nan');
});
it('sorts fractional and unsafe season or episode values last', () => {
const unsafe = Number.MAX_SAFE_INTEGER + 1;
const series = buildSeries([
download(1, { title: 'valid', seasonNumber: 1, episodeNumber: 2 }),
download(2, {
title: 'fractional-season',
seasonNumber: 1.5,
episodeNumber: 1,
}),
download(3, {
title: 'unsafe-season',
seasonNumber: unsafe,
episodeNumber: 1,
}),
download(4, {
title: 'fractional-episode',
seasonNumber: 1,
episodeNumber: 1.5,
}),
download(5, {
title: 'unsafe-episode',
seasonNumber: 1,
episodeNumber: unsafe,
}),
]);
expect(series.members.map(({ title }) => title)).toEqual([
'valid',
'fractional-episode',
'unsafe-episode',
'fractional-season',
'unsafe-season',
]);
});
it('ignores fractional and unsafe seasons when deriving the range', () => {
const unsafe = Number.MAX_SAFE_INTEGER + 1;
const series = buildSeries([
download(1, { seasonNumber: 2 }),
download(2, { seasonNumber: 1.5 }),
download(3, { seasonNumber: unsafe }),
]);
expect(series.firstSeason).toBe(2);
expect(series.lastSeason).toBe(2);
});
});
@@ -43,6 +43,15 @@ export type DownloadLibraryEntity =
| DownloadSeriesCardViewModel;
const STANDARD_EPISODE_TITLE = /^(.+?) - S\d{2}E\d{2} - /;
const MALFORMED_DOWNLOAD_ID_CLASS = {
NAN: '0:nan',
NEGATIVE_INFINITY: '1:negative-infinity',
NEGATIVE: '2:negative',
ZERO: '3:zero',
FRACTIONAL: '4:fractional',
UNSAFE: '5:unsafe',
POSITIVE_INFINITY: '6:positive-infinity',
} as const;
export function normalizedDownloadTimestamp(value: string | undefined): number {
const timestamp = Date.parse(value ?? '');
@@ -52,7 +61,57 @@ export function normalizedDownloadTimestamp(value: string | undefined): number {
export function isUsableDownloadOrderNumber(
value: number | undefined
): value is number {
return Number.isInteger(value) && (value ?? -1) >= 0;
return Number.isSafeInteger(value) && value !== undefined && value >= 0;
}
function malformedDownloadIdKey(value: number): string {
if (Number.isNaN(value)) {
return MALFORMED_DOWNLOAD_ID_CLASS.NAN;
}
if (value === Number.NEGATIVE_INFINITY) {
return MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE_INFINITY;
}
if (value === Number.POSITIVE_INFINITY) {
return MALFORMED_DOWNLOAD_ID_CLASS.POSITIVE_INFINITY;
}
if (value < 0) {
return `${MALFORMED_DOWNLOAD_ID_CLASS.NEGATIVE}:${String(value)}`;
}
if (value === 0) {
return MALFORMED_DOWNLOAD_ID_CLASS.ZERO;
}
if (!Number.isInteger(value)) {
return `${MALFORMED_DOWNLOAD_ID_CLASS.FRACTIONAL}:${String(value)}`;
}
return `${MALFORMED_DOWNLOAD_ID_CLASS.UNSAFE}:${String(value)}`;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
export function compareDownloadIds(
left: number,
right: number,
descending = false
): number {
const leftValid = Number.isSafeInteger(left) && left > 0;
const rightValid = Number.isSafeInteger(right) && right > 0;
if (leftValid && rightValid) {
const first = descending ? right : left;
const second = descending ? left : right;
return first < second ? -1 : first > second ? 1 : 0;
}
if (leftValid) {
return -1;
}
if (rightValid) {
return 1;
}
return compareText(
malformedDownloadIdKey(left),
malformedDownloadIdKey(right)
);
}
export function deriveStandardizedSeriesTitle(
@@ -85,14 +144,15 @@ function compareMembers(left: DownloadItem, right: DownloadItem): number {
compareOrderNumber(left.episodeNumber, right.episodeNumber) ||
normalizedDownloadTimestamp(left.createdAt) -
normalizedDownloadTimestamp(right.createdAt) ||
left.id - right.id
compareDownloadIds(left.id, right.id)
);
}
function compareNewest(left: DownloadItem, right: DownloadItem): number {
return (
normalizedDownloadTimestamp(right.createdAt) -
normalizedDownloadTimestamp(left.createdAt) || right.id - left.id
normalizedDownloadTimestamp(left.createdAt) ||
compareDownloadIds(left.id, right.id, true)
);
}
@@ -244,6 +244,41 @@ describe('buildDownloadManagerViewModel', () => {
expect(hidden.hasClearable).toBe(true);
});
it('keeps counts stable under every display filter', () => {
const downloads = [
download(1, { status: 'queued' }),
download(2, {
contentType: 'episode',
status: 'paused',
}),
download(3, { status: 'failed' }),
download(4),
download(5, {
contentType: 'episode',
seriesXtreamId: 50,
}),
download(6, {
contentType: 'episode',
seriesXtreamId: 50,
}),
];
const expected = {
all: 5,
movie: 3,
series: 2,
inProgress: 2,
};
for (const filter of [
'all',
'movie',
'series',
'in-progress',
] as const) {
expect(build(downloads, { filter }).counts).toEqual(expected);
}
});
it('searches stored title, derived series title, source, episode label, and error message', () => {
const downloads = [
download(1, {
@@ -400,6 +435,43 @@ describe('buildDownloadManagerViewModel', () => {
]);
});
it('leaves episode labels blank for fractional and unsafe season or episode numbers', () => {
const unsafe = Number.MAX_SAFE_INTEGER + 1;
const result = build([
download(1, {
contentType: 'episode',
seasonNumber: 1.5,
episodeNumber: 2,
status: 'queued',
}),
download(2, {
contentType: 'episode',
seasonNumber: unsafe,
episodeNumber: 2,
status: 'queued',
}),
download(3, {
contentType: 'episode',
seasonNumber: 1,
episodeNumber: 2.5,
status: 'queued',
}),
download(4, {
contentType: 'episode',
seasonNumber: 1,
episodeNumber: unsafe,
status: 'queued',
}),
]);
expect(result.active.map(({ episodeLabel }) => episodeLabel)).toEqual([
'',
'',
'',
'',
]);
});
it('partitions every supported status without leaking items between sections', () => {
const downloads = [
download(1, { status: 'queued' }),
@@ -516,6 +588,41 @@ describe('buildDownloadManagerViewModel', () => {
expect(rowIds(result.attention)).toEqual([11, 12, 15]);
});
it('totally orders valid and malformed queue ids independent of input order', () => {
const createdAt = '2026-01-01T00:00:00Z';
const cases = [
{ id: 7, title: 'valid-7' },
{ id: Number.NaN, title: 'nan' },
{ id: Number.POSITIVE_INFINITY, title: 'positive-infinity' },
{ id: Number.NEGATIVE_INFINITY, title: 'negative-infinity' },
{ id: -3, title: 'negative' },
{ id: 0, title: 'zero' },
{ id: 42.5, title: 'fractional' },
{ id: Number.MAX_SAFE_INTEGER + 1, title: 'unsafe' },
{ id: 2, title: 'valid-2' },
];
const expected = [
'valid-2',
'valid-7',
'nan',
'negative-infinity',
'negative',
'zero',
'fractional',
'unsafe',
'positive-infinity',
];
const titlesFor = (input: typeof cases) =>
build(
input.map(({ id, title }) =>
download(id, { title, status: 'queued', createdAt })
)
).active.map(({ item }) => item.title);
expect(titlesFor(cases)).toEqual(expected);
expect(titlesFor([...cases].reverse())).toEqual(expected);
});
it('sorts completed library entities by newest timestamp then key', () => {
const result = build([
download(3, { createdAt: '2025-01-01T00:00:00Z' }),
@@ -2,6 +2,7 @@ import type { DownloadItem } from '@iptvnator/services';
import type { Playlist } from '@iptvnator/shared/interfaces';
import {
buildDownloadLibrary,
compareDownloadIds,
deriveStandardizedSeriesTitle,
isUsableDownloadOrderNumber,
normalizedDownloadTimestamp,
@@ -101,7 +102,7 @@ function compareQueued(
return (
normalizedDownloadTimestamp(left.item.createdAt) -
normalizedDownloadTimestamp(right.item.createdAt) ||
left.item.id - right.item.id
compareDownloadIds(left.item.id, right.item.id)
);
}