mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(downloads): stabilize malformed view model data
This commit is contained in:
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user