feat(downloads): derive queue and library view model

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent bc29f24eb2
commit d6e1ee5d0c
8 files changed
+1290 -5

No files matched your search

@@ -0,0 +1,21 @@
export default {
displayName: 'portal-downloads-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/downloads/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/downloads/feature/jest.config.ts",
"tsConfig": "libs/portal/downloads/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -0,0 +1,201 @@
import type { DownloadItem } from '@iptvnator/services';
export interface DownloadLibraryRow {
readonly item: DownloadItem;
readonly episodeLabel: string;
readonly sourceName: string;
}
interface DownloadLibraryEntityBase {
readonly key: string;
readonly newestTimestamp: number;
readonly sourceName: string;
readonly trackedBytes: number;
}
interface DownloadItemLibraryEntityBase extends DownloadLibraryEntityBase {
readonly item: DownloadItem;
}
export interface DownloadMovieCardViewModel extends DownloadItemLibraryEntityBase {
readonly kind: 'movie';
}
export interface DownloadEpisodeCardViewModel extends DownloadItemLibraryEntityBase {
readonly kind: 'episode';
readonly episodeLabel: string;
}
export interface DownloadSeriesCardViewModel extends DownloadLibraryEntityBase {
readonly kind: 'series';
readonly representative: DownloadItem;
readonly members: readonly DownloadItem[];
readonly seriesXtreamId: number;
readonly title: string;
readonly posterUrl?: string;
readonly firstSeason?: number;
readonly lastSeason?: number;
}
export type DownloadLibraryEntity =
| DownloadMovieCardViewModel
| DownloadEpisodeCardViewModel
| DownloadSeriesCardViewModel;
const STANDARD_EPISODE_TITLE = /^(.+?) - S\d{2}E\d{2} - /;
export function normalizedDownloadTimestamp(value: string | undefined): number {
const timestamp = Date.parse(value ?? '');
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function isUsableDownloadOrderNumber(
value: number | undefined
): value is number {
return Number.isInteger(value) && (value ?? -1) >= 0;
}
export function deriveStandardizedSeriesTitle(
item: DownloadItem
): string | undefined {
if (item.contentType !== 'episode') {
return undefined;
}
return STANDARD_EPISODE_TITLE.exec(item.title)?.[1];
}
function compareOrderNumber(
left: number | undefined,
right: number | undefined
): number {
const leftUsable = isUsableDownloadOrderNumber(left);
const rightUsable = isUsableDownloadOrderNumber(right);
if (leftUsable && rightUsable) {
return left - right;
}
if (leftUsable) {
return -1;
}
return rightUsable ? 1 : 0;
}
function compareMembers(left: DownloadItem, right: DownloadItem): number {
return (
compareOrderNumber(left.seasonNumber, right.seasonNumber) ||
compareOrderNumber(left.episodeNumber, right.episodeNumber) ||
normalizedDownloadTimestamp(left.createdAt) -
normalizedDownloadTimestamp(right.createdAt) ||
left.id - right.id
);
}
function compareNewest(left: DownloadItem, right: DownloadItem): number {
return (
normalizedDownloadTimestamp(right.createdAt) -
normalizedDownloadTimestamp(left.createdAt) || right.id - left.id
);
}
function isValidSeriesId(value: number | undefined): value is number {
return Number.isSafeInteger(value) && (value ?? 0) > 0;
}
function createSeriesEntity(
rows: readonly DownloadLibraryRow[],
seriesXtreamId: number
): DownloadSeriesCardViewModel {
const members = rows.map(({ item }) => item).sort(compareMembers);
const newestFirst = [...members].sort(compareNewest);
const representative = newestFirst[0];
const posterUrl = newestFirst.find(
(item) => (item.posterUrl?.trim().length ?? 0) > 0
)?.posterUrl;
const standardizedTitle = members
.map(deriveStandardizedSeriesTitle)
.find((title) => title !== undefined);
const legacyTitle =
members.find((item) => item.title.trim().length > 0)?.title ?? '';
const seasons = members
.map(({ seasonNumber }) => seasonNumber)
.filter(isUsableDownloadOrderNumber);
return {
kind: 'series',
key: `series:${representative.playlistId}:${seriesXtreamId}`,
representative,
members,
seriesXtreamId,
title: standardizedTitle ?? legacyTitle,
...(posterUrl === undefined ? {} : { posterUrl }),
newestTimestamp: normalizedDownloadTimestamp(representative.createdAt),
sourceName: rows[0].sourceName,
trackedBytes: members.reduce(
(total, item) => total + (item.bytesDownloaded ?? 0),
0
),
...(seasons.length === 0
? {}
: {
firstSeason: Math.min(...seasons),
lastSeason: Math.max(...seasons),
}),
};
}
export function buildDownloadLibrary(
rows: readonly DownloadLibraryRow[]
): DownloadLibraryEntity[] {
const entities: DownloadLibraryEntity[] = [];
const groups = new Map<
string,
{
readonly seriesXtreamId: number;
readonly rows: DownloadLibraryRow[];
}
>();
for (const row of rows) {
const { item } = row;
if (item.contentType === 'vod') {
entities.push({
kind: 'movie',
key: `movie:${item.id}`,
item,
newestTimestamp: normalizedDownloadTimestamp(item.createdAt),
sourceName: row.sourceName,
trackedBytes: item.bytesDownloaded ?? 0,
});
} else if (isValidSeriesId(item.seriesXtreamId)) {
const key = `${item.playlistId}:${item.seriesXtreamId}`;
const group = groups.get(key);
if (group) {
group.rows.push(row);
} else {
groups.set(key, {
seriesXtreamId: item.seriesXtreamId,
rows: [row],
});
}
} else {
entities.push({
kind: 'episode',
key: `episode:${item.id}`,
item,
newestTimestamp: normalizedDownloadTimestamp(item.createdAt),
sourceName: row.sourceName,
trackedBytes: item.bytesDownloaded ?? 0,
episodeLabel: row.episodeLabel,
});
}
}
for (const { rows: members, seriesXtreamId } of groups.values()) {
entities.push(createSeriesEntity(members, seriesXtreamId));
}
return entities.sort(
(left, right) =>
right.newestTimestamp - left.newestTimestamp ||
(left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
);
}
@@ -0,0 +1,807 @@
import type { DownloadItem } from '@iptvnator/services';
import type { Playlist } from '@iptvnator/shared/interfaces';
import {
buildDownloadManagerViewModel,
normalizeDownloadFilter,
type DownloadEpisodeCardViewModel,
type DownloadLibraryEntity,
type DownloadManagerViewModel,
type DownloadMovieCardViewModel,
type DownloadSeriesCardViewModel,
} from './download-manager.viewmodel';
type PlaylistSummary = Pick<Playlist, '_id' | 'title'>;
type ExpectedDownloadLibraryEntity =
| DownloadMovieCardViewModel
| DownloadEpisodeCardViewModel
| DownloadSeriesCardViewModel;
const PLAYLISTS: readonly PlaylistSummary[] = [
{ _id: 'playlist-a', title: 'Alpha Source' },
{ _id: 'playlist-b', title: 'Beta Source' },
];
function download(
id: number,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
playlistId: 'playlist-a',
xtreamId: id * 10,
contentType: 'vod',
title: `Download ${id}`,
url: `https://example.test/${id}`,
status: 'completed',
createdAt: `2026-01-${String(id).padStart(2, '0')}T00:00:00Z`,
...overrides,
};
}
function build(
downloads: readonly DownloadItem[],
options: {
playlists?: readonly PlaylistSummary[];
scopePlaylistId?: string;
filter?: string;
searchTerm?: string;
} = {}
): DownloadManagerViewModel {
return buildDownloadManagerViewModel({
downloads,
playlists: options.playlists ?? PLAYLISTS,
scopePlaylistId: options.scopePlaylistId,
filter: options.filter ?? 'all',
searchTerm: options.searchTerm,
});
}
function rowIds(
rows:
| DownloadManagerViewModel['active']
| DownloadManagerViewModel['attention']
): number[] {
return rows.map(({ item }) => item.id);
}
function libraryItemIds(entities: readonly DownloadLibraryEntity[]): number[] {
const typedEntities: readonly ExpectedDownloadLibraryEntity[] = entities;
return typedEntities.flatMap((entity) =>
entity.kind === 'series'
? entity.members.map(({ id }) => id)
: [entity.item.id]
);
}
describe('normalizeDownloadFilter', () => {
it.each(['all', 'movie', 'series', 'in-progress'] as const)(
'keeps the known %s filter',
(filter) => {
expect(normalizeDownloadFilter(filter)).toBe(filter);
}
);
it.each([null, undefined, '', 'unknown', 'MOVIE'])(
'normalizes %p to all',
(filter) => {
expect(normalizeDownloadFilter(filter)).toBe('all');
}
);
});
describe('buildDownloadManagerViewModel', () => {
it('supports global and playlist-scoped views with resolved source labels', () => {
const downloads = [
download(1, { status: 'queued' }),
download(2, {
playlistId: 'playlist-b',
status: 'failed',
}),
download(3, {
playlistId: 'missing-playlist',
status: 'completed',
}),
];
const global = build(downloads);
const scoped = build(downloads, { scopePlaylistId: 'playlist-a' });
expect(global.scopedItems.map(({ id }) => id)).toEqual([1, 2, 3]);
expect(global.active[0].sourceName).toBe('Alpha Source');
expect(global.attention[0].sourceName).toBe('Beta Source');
expect(global.library[0].sourceName).toBe('');
expect(scoped.scopedItems.map(({ id }) => id)).toEqual([1]);
expect(rowIds(scoped.active)).toEqual([1]);
expect(scoped.attention).toEqual([]);
expect(scoped.library).toEqual([]);
});
it('applies all four display filters across active, attention, and library partitions', () => {
const downloads = [
download(1, { status: 'queued' }),
download(2, {
contentType: 'episode',
status: 'failed',
seasonNumber: 1,
episodeNumber: 2,
}),
download(3),
download(4, {
contentType: 'episode',
seriesXtreamId: 44,
seasonNumber: 1,
episodeNumber: 1,
}),
];
const all = build(downloads);
const movies = build(downloads, { filter: 'movie' });
const series = build(downloads, { filter: 'series' });
const inProgress = build(downloads, { filter: 'in-progress' });
expect(rowIds(all.active)).toEqual([1]);
expect(rowIds(all.attention)).toEqual([2]);
expect(all.library.map(({ kind }) => kind)).toEqual([
'series',
'movie',
]);
expect(rowIds(movies.active)).toEqual([1]);
expect(movies.attention).toEqual([]);
expect(movies.library.map(({ kind }) => kind)).toEqual(['movie']);
expect(series.active).toEqual([]);
expect(rowIds(series.attention)).toEqual([2]);
expect(series.library.map(({ kind }) => kind)).toEqual(['series']);
expect(rowIds(inProgress.active)).toEqual([1]);
expect(inProgress.attention).toEqual([]);
expect(inProgress.library).toEqual([]);
});
it('keeps scoped counts stable when search hides every displayed item', () => {
const downloads = [
download(1, {
status: 'queued',
bytesDownloaded: 1,
}),
download(2, {
contentType: 'episode',
status: 'downloading',
bytesDownloaded: 2,
}),
download(3, {
status: 'paused',
bytesDownloaded: 3,
}),
download(4, {
contentType: 'episode',
status: 'failed',
bytesDownloaded: 4,
}),
download(5, {
status: 'canceled',
bytesDownloaded: 5,
}),
download(6, { bytesDownloaded: 6 }),
download(7, {
contentType: 'episode',
seriesXtreamId: 70,
bytesDownloaded: 7,
}),
download(8, {
contentType: 'episode',
seriesXtreamId: 70,
bytesDownloaded: 8,
}),
download(9, {
contentType: 'episode',
bytesDownloaded: 9,
}),
download(10, {
playlistId: 'playlist-b',
status: 'queued',
bytesDownloaded: 100,
}),
];
const visible = build(downloads, { scopePlaylistId: 'playlist-a' });
const hidden = build(downloads, {
scopePlaylistId: 'playlist-a',
searchTerm: 'no result can match this',
});
expect(visible.counts).toEqual({
all: 8,
movie: 4,
series: 4,
inProgress: 3,
});
expect(hidden.counts).toEqual(visible.counts);
expect(hidden.scopedItems).toHaveLength(9);
expect(hidden.active).toEqual([]);
expect(hidden.attention).toEqual([]);
expect(hidden.library).toEqual([]);
expect(hidden.activeCount).toBe(2);
expect(hidden.trackedBytes).toBe(45);
expect(hidden.hasClearable).toBe(true);
});
it('searches stored title, derived series title, source, episode label, and error message', () => {
const downloads = [
download(1, {
title: 'Solar Feature',
status: 'queued',
}),
download(2, {
contentType: 'episode',
title: 'Archive Show - S01E02 - The Pilot',
seasonNumber: 1,
episodeNumber: 2,
status: 'paused',
}),
download(3, {
playlistId: 'playlist-b',
title: 'Unrelated',
status: 'failed',
}),
download(4, {
contentType: 'episode',
title: 'A legacy chapter',
seasonNumber: 7,
episodeNumber: 8,
}),
download(5, {
title: 'Another unrelated item',
status: 'canceled',
errorMessage: 'Checksum mismatch',
}),
];
expect(
libraryItemIds(
build(downloads, {
searchTerm: ' A LEGACY CHAPTER ',
}).library
)
).toEqual([4]);
expect(
rowIds(
build(downloads, {
searchTerm: 'archive show',
}).active
)
).toEqual([2]);
expect(
rowIds(
build(downloads, {
searchTerm: 'beta source',
}).attention
)
).toEqual([3]);
expect(
libraryItemIds(
build(downloads, {
searchTerm: 's07e08',
}).library
)
).toEqual([4]);
expect(
rowIds(
build(downloads, {
searchTerm: 'checksum mismatch',
}).attention
)
).toEqual([5]);
const episode = build(downloads).active.find(
({ item }) => item.id === 2
);
expect(episode).toMatchObject({
episodeLabel: 'S01E02',
seriesTitle: 'Archive Show',
});
});
it('derives labels only from usable episode numbers and preserves legacy titles', () => {
const downloads = [
download(1, {
contentType: 'episode',
title: 'Modern - S03E09 - A title',
seasonNumber: 3,
episodeNumber: 9,
status: 'queued',
}),
download(2, {
contentType: 'episode',
title: 'Legacy episode title',
seasonNumber: Number.NaN,
episodeNumber: 2,
status: 'queued',
}),
download(3, {
contentType: 'episode',
title: 'Negative episode',
seasonNumber: 1,
episodeNumber: -1,
status: 'queued',
}),
download(4, {
title: 'Movie title',
seasonNumber: 1,
episodeNumber: 2,
status: 'queued',
}),
];
expect(
build(downloads).active.map(
({ item, episodeLabel, seriesTitle }) => ({
id: item.id,
episodeLabel,
seriesTitle,
})
)
).toEqual([
{
id: 1,
episodeLabel: 'S03E09',
seriesTitle: 'Modern',
},
{
id: 2,
episodeLabel: '',
seriesTitle: 'Legacy episode title',
},
{
id: 3,
episodeLabel: '',
seriesTitle: 'Negative episode',
},
{ id: 4, episodeLabel: '', seriesTitle: '' },
]);
});
it('partitions every supported status without leaking items between sections', () => {
const downloads = [
download(1, { status: 'queued' }),
download(2, { status: 'downloading' }),
download(3, { status: 'paused' }),
download(4, { status: 'failed' }),
download(5, { status: 'canceled' }),
download(6, { status: 'completed' }),
];
const result = build(downloads);
expect(rowIds(result.active)).toEqual([1, 2, 3]);
expect(rowIds(result.attention)).toEqual([4, 5]);
expect(libraryItemIds(result.library)).toEqual([6]);
});
it('excludes paused rows from activeCount independently of filter and search', () => {
const result = build(
[
download(1, { status: 'queued' }),
download(2, { status: 'downloading' }),
download(3, { status: 'paused' }),
download(4, {
playlistId: 'playlist-b',
status: 'downloading',
}),
],
{
scopePlaylistId: 'playlist-a',
filter: 'movie',
searchTerm: 'nothing',
}
);
expect(result.activeCount).toBe(2);
expect(result.active).toEqual([]);
});
it('sums scoped tracked bytes and reports whether a row can be cleared', () => {
const clearable = build(
[
download(1, {
status: 'queued',
bytesDownloaded: undefined,
}),
download(2, {
status: 'completed',
bytesDownloaded: 25,
}),
download(3, {
playlistId: 'playlist-b',
status: 'failed',
bytesDownloaded: 100,
}),
],
{ scopePlaylistId: 'playlist-a' }
);
const activeOnly = build([
download(4, { status: 'paused', bytesDownloaded: 4 }),
]);
expect(clearable.trackedBytes).toBe(25);
expect(clearable.hasClearable).toBe(true);
expect(activeOnly.trackedBytes).toBe(4);
expect(activeOnly.hasClearable).toBe(false);
});
it('sorts active and attention rows by normalized timestamp then numeric id', () => {
const downloads = [
download(9, { status: 'queued', createdAt: undefined }),
download(7, {
status: 'downloading',
createdAt: '2026-01-01T00:00:00Z',
}),
download(3, {
status: 'paused',
createdAt: '2026-01-01T00:00:00Z',
}),
download(10, {
status: 'queued',
createdAt: '2025-01-01T00:00:00Z',
}),
download(2, {
status: 'queued',
createdAt: 'invalid',
}),
download(15, {
status: 'failed',
createdAt: '2026-02-01T00:00:00Z',
}),
download(12, {
status: 'canceled',
createdAt: '2026-02-01T00:00:00Z',
}),
download(11, {
status: 'failed',
createdAt: undefined,
}),
];
const result = build(downloads);
expect(rowIds(result.active)).toEqual([2, 9, 10, 3, 7]);
expect(rowIds(result.attention)).toEqual([11, 12, 15]);
});
it('sorts completed library entities by newest timestamp then key', () => {
const result = build([
download(3, { createdAt: '2025-01-01T00:00:00Z' }),
download(20, { createdAt: '2025-01-01T00:00:00Z' }),
download(4, { createdAt: 'invalid' }),
download(5, { createdAt: '2026-01-01T00:00:00Z' }),
]);
expect(result.library.map(({ key }) => key)).toEqual([
'movie:5',
'movie:20',
'movie:3',
'movie:4',
]);
});
it('groups completed episodes only for a positive safe series id', () => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 70,
seasonNumber: 1,
episodeNumber: 1,
}),
download(2, {
contentType: 'episode',
seriesXtreamId: 70,
seasonNumber: 1,
episodeNumber: 2,
}),
]);
expect(result.library).toHaveLength(1);
expect(result.library[0]).toMatchObject({
kind: 'series',
key: 'series:playlist-a:70',
seriesXtreamId: 70,
});
});
it('keeps identical series ids from different playlists in separate groups', () => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 70,
}),
download(2, {
playlistId: 'playlist-b',
contentType: 'episode',
seriesXtreamId: 70,
}),
]);
expect(result.library.map(({ key }) => key).sort()).toEqual([
'series:playlist-a:70',
'series:playlist-b:70',
]);
});
it.each([
['invalid', Number.NaN],
['zero', 0],
['negative', -1],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
['missing', undefined],
])(
'keeps an episode with a %s series id as an individual card',
(_description, seriesXtreamId) => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId,
seasonNumber: 1,
episodeNumber: 4,
}),
]);
expect(result.library).toEqual([
expect.objectContaining({
kind: 'episode',
key: 'episode:1',
episodeLabel: 'S01E04',
}),
]);
expect('seriesXtreamId' in result.library[0]).toBe(false);
}
);
it('prefers a standardized series prefix and otherwise keeps the first non-empty legacy title', () => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 1,
title: 'Legacy chapter',
}),
download(2, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 2,
title: 'Canonical Show - S01E02 - Second',
}),
download(3, {
contentType: 'episode',
seriesXtreamId: 20,
seasonNumber: 1,
episodeNumber: 1,
title: ' ',
}),
download(4, {
contentType: 'episode',
seriesXtreamId: 20,
seasonNumber: 1,
episodeNumber: 2,
title: 'Original legacy title',
}),
]);
const series = result.library.filter(
(entity) => entity.kind === 'series'
);
expect(
series.find(({ seriesXtreamId }) => seriesXtreamId === 10)?.title
).toBe('Canonical Show');
expect(
series.find(({ seriesXtreamId }) => seriesXtreamId === 20)?.title
).toBe('Original legacy title');
});
it('uses the newest member as representative', () => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-02-01T00:00:00Z',
}),
download(9, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-03-01T00:00:00Z',
}),
download(8, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-03-01T00:00:00Z',
}),
]);
const [series] = result.library;
expect(series.kind).toBe('series');
if (series.kind === 'series') {
expect(series.representative.id).toBe(9);
expect(series.newestTimestamp).toBe(
Date.parse('2026-03-01T00:00:00Z')
);
}
});
it('uses the newest member with a non-empty poster for series artwork', () => {
const result = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-01-01T00:00:00Z',
posterUrl: 'https://image.test/old.jpg',
}),
download(2, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-02-01T00:00:00Z',
posterUrl: 'https://image.test/new.jpg',
}),
download(3, {
contentType: 'episode',
seriesXtreamId: 10,
createdAt: '2026-03-01T00:00:00Z',
posterUrl: ' ',
}),
]);
expect(result.library[0]).toMatchObject({
kind: 'series',
posterUrl: 'https://image.test/new.jpg',
});
});
it('orders series members by season, episode, timestamp, then id with invalid numbers last', () => {
const episodes = [
download(9, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: undefined,
episodeNumber: 1,
}),
download(8, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 2,
episodeNumber: 1,
}),
download(7, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: undefined,
}),
download(6, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 1,
createdAt: '2026-01-01T00:00:00Z',
}),
download(5, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 1,
createdAt: '2025-01-01T00:00:00Z',
}),
download(4, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 1,
createdAt: '2025-01-01T00:00:00Z',
}),
download(3, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
episodeNumber: 2,
}),
];
const [series] = build(episodes).library;
expect(series.kind).toBe('series');
if (series.kind === 'series') {
expect(series.members.map(({ id }) => id)).toEqual([
4, 5, 6, 3, 7, 8, 9,
]);
}
});
it('derives the usable season range for a series', () => {
const [series] = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 4,
}),
download(2, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: Number.POSITIVE_INFINITY,
}),
download(3, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
}),
download(4, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: -1,
}),
]).library;
expect(series).toMatchObject({
kind: 'series',
firstSeason: 1,
lastSeason: 4,
});
});
it('aggregates tracked bytes across every member of a series', () => {
const [series] = build([
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
bytesDownloaded: 30,
}),
download(2, {
contentType: 'episode',
seriesXtreamId: 10,
bytesDownloaded: undefined,
}),
download(3, {
contentType: 'episode',
seriesXtreamId: 10,
bytesDownloaded: 12,
}),
]).library;
expect(series).toMatchObject({
kind: 'series',
trackedBytes: 42,
});
});
it('does not mutate input arrays or objects while sorting and grouping', () => {
const first = Object.freeze(
download(2, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 2,
})
);
const second = Object.freeze(
download(1, {
contentType: 'episode',
seriesXtreamId: 10,
seasonNumber: 1,
})
);
const downloads = Object.freeze([first, second]);
const playlist = Object.freeze({
_id: 'playlist-a',
title: 'Alpha Source',
});
const playlists = Object.freeze([playlist]);
const result = build(downloads, { playlists });
expect(downloads).toEqual([first, second]);
expect(playlists).toEqual([playlist]);
expect(result.scopedItems[0]).toBe(first);
const [series] = result.library;
expect(series.kind).toBe('series');
if (series.kind === 'series') {
expect(series.members.map(({ id }) => id)).toEqual([1, 2]);
}
});
});
@@ -0,0 +1,223 @@
import type { DownloadItem } from '@iptvnator/services';
import type { Playlist } from '@iptvnator/shared/interfaces';
import {
buildDownloadLibrary,
deriveStandardizedSeriesTitle,
isUsableDownloadOrderNumber,
normalizedDownloadTimestamp,
type DownloadLibraryEntity,
type DownloadLibraryRow,
} from './download-library.viewmodel';
export type {
DownloadEpisodeCardViewModel,
DownloadLibraryEntity,
DownloadMovieCardViewModel,
DownloadSeriesCardViewModel,
} from './download-library.viewmodel';
export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress';
export interface DownloadListItemViewModel extends DownloadLibraryRow {
readonly seriesTitle: string;
}
export interface DownloadManagerCounts {
readonly all: number;
readonly movie: number;
readonly series: number;
readonly inProgress: number;
}
export interface DownloadManagerViewModel {
readonly scopedItems: readonly DownloadItem[];
readonly active: readonly DownloadListItemViewModel[];
readonly attention: readonly DownloadListItemViewModel[];
readonly library: readonly DownloadLibraryEntity[];
readonly counts: DownloadManagerCounts;
readonly activeCount: number;
readonly trackedBytes: number;
readonly hasClearable: boolean;
}
export interface BuildDownloadManagerViewModelInput {
readonly downloads: readonly DownloadItem[];
readonly playlists: readonly Pick<Playlist, '_id' | 'title'>[];
readonly scopePlaylistId?: string;
readonly filter: string | null | undefined;
readonly searchTerm?: string;
}
export function normalizeDownloadFilter(
value: string | null | undefined
): DownloadFilterId {
switch (value) {
case 'movie':
case 'series':
case 'in-progress':
return value;
default:
return 'all';
}
}
function episodeLabel(item: DownloadItem): string {
if (
item.contentType !== 'episode' ||
!isUsableDownloadOrderNumber(item.seasonNumber) ||
!isUsableDownloadOrderNumber(item.episodeNumber)
) {
return '';
}
const season = String(item.seasonNumber).padStart(2, '0');
const episode = String(item.episodeNumber).padStart(2, '0');
return `S${season}E${episode}`;
}
function seriesTitle(item: DownloadItem): string {
if (item.contentType !== 'episode') {
return '';
}
return deriveStandardizedSeriesTitle(item) ?? item.title;
}
function isActive(item: DownloadItem): boolean {
return (
item.status === 'queued' ||
item.status === 'downloading' ||
item.status === 'paused'
);
}
function needsAttention(item: DownloadItem): boolean {
return item.status === 'failed' || item.status === 'canceled';
}
function compareQueued(
left: DownloadListItemViewModel,
right: DownloadListItemViewModel
): number {
return (
normalizedDownloadTimestamp(left.item.createdAt) -
normalizedDownloadTimestamp(right.item.createdAt) ||
left.item.id - right.item.id
);
}
function matchesSearch(
row: DownloadListItemViewModel,
searchTerm: string
): boolean {
const query = searchTerm.trim().toLowerCase();
return (
query.length === 0 ||
[
row.item.title,
row.seriesTitle,
row.sourceName,
row.episodeLabel,
row.item.errorMessage ?? '',
].some((value) => value.toLowerCase().includes(query))
);
}
function matchesFilter(
row: DownloadListItemViewModel,
filter: DownloadFilterId
): boolean {
if (filter === 'in-progress') {
return isActive(row.item);
}
if (filter === 'movie') {
return row.item.contentType === 'vod';
}
if (filter === 'series') {
return row.item.contentType === 'episode';
}
return true;
}
function countContentType(
rows: readonly DownloadListItemViewModel[],
contentType: DownloadItem['contentType']
): number {
return rows.filter((row) => row.item.contentType === contentType).length;
}
export function buildDownloadManagerViewModel({
downloads,
playlists,
scopePlaylistId,
filter,
searchTerm = '',
}: BuildDownloadManagerViewModelInput): DownloadManagerViewModel {
const sourceNames = new Map(
playlists.map(({ _id, title }) => [_id, title] as const)
);
const scopedItems = downloads.filter(
(item) =>
scopePlaylistId === undefined || item.playlistId === scopePlaylistId
);
const scopedRows = scopedItems.map((item) => ({
item,
episodeLabel: episodeLabel(item),
seriesTitle: seriesTitle(item),
sourceName: sourceNames.get(item.playlistId) ?? '',
}));
const scopedActive = scopedRows.filter(({ item }) => isActive(item));
const scopedAttention = scopedRows.filter(({ item }) =>
needsAttention(item)
);
const scopedLibrary = buildDownloadLibrary(
scopedRows.filter(({ item }) => item.status === 'completed')
);
const activeFilter = normalizeDownloadFilter(filter);
const displayed = scopedRows
.filter((row) => matchesSearch(row, searchTerm))
.filter((row) => matchesFilter(row, activeFilter));
const active = displayed
.filter(({ item }) => isActive(item))
.sort(compareQueued);
const attention = displayed
.filter(({ item }) => needsAttention(item))
.sort(compareQueued);
const library = buildDownloadLibrary(
displayed.filter(({ item }) => item.status === 'completed')
);
return {
scopedItems,
active,
attention,
library,
counts: {
all:
scopedActive.length +
scopedAttention.length +
scopedLibrary.length,
movie:
countContentType(scopedActive, 'vod') +
countContentType(scopedAttention, 'vod') +
scopedLibrary.filter(({ kind }) => kind === 'movie').length,
series:
countContentType(scopedActive, 'episode') +
countContentType(scopedAttention, 'episode') +
scopedLibrary.filter(({ kind }) => kind !== 'movie').length,
inProgress: scopedActive.length,
},
activeCount: scopedItems.filter(
(item) => item.status === 'queued' || item.status === 'downloading'
).length,
trackedBytes: scopedItems.reduce(
(total, item) => total + (item.bytesDownloaded ?? 0),
0
),
hasClearable: scopedItems.some(
(item) =>
item.status === 'completed' ||
item.status === 'failed' ||
item.status === 'canceled'
),
};
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+7 -5
View File
@@ -124,6 +124,13 @@
"validationCommand": "pnpm nx test portal-catalog-feature",
"e2eTags": ["@xtream", "@stalker"]
},
{
"name": "portal-downloads-feature",
"root": "libs/portal/downloads/feature",
"sourceRoot": "libs/portal/downloads/feature/src",
"validationCommand": "pnpm nx test portal-downloads-feature",
"e2eTags": ["@downloads", "@electron", "@persistence"]
},
{
"name": "portal-shared-data-access",
"root": "libs/portal/shared/data-access",
@@ -330,11 +337,6 @@
"root": "libs/shared/testing",
"reason": "Test helper library."
},
{
"name": "portal-downloads-feature",
"root": "libs/portal/downloads/feature",
"reason": "No test target yet; add focused tests when feature work changes this area."
},
{
"name": "remote-control",
"root": "libs/ui/remote-control",