From d6e1ee5d0ce9fa86325118931f6281a202432c4f Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 01:03:19 +0200 Subject: [PATCH] feat(downloads): derive queue and library view model --- libs/portal/downloads/feature/jest.config.ts | 21 + libs/portal/downloads/feature/project.json | 8 + .../src/lib/download-library.viewmodel.ts | 201 +++++ .../lib/download-manager.viewmodel.spec.ts | 807 ++++++++++++++++++ .../src/lib/download-manager.viewmodel.ts | 223 +++++ .../downloads/feature/src/test-setup.ts | 6 + .../downloads/feature/tsconfig.spec.json | 17 + tools/coverage/coverage-policy.json | 12 +- 8 files changed, 1290 insertions(+), 5 deletions(-) create mode 100644 libs/portal/downloads/feature/jest.config.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts create mode 100644 libs/portal/downloads/feature/src/test-setup.ts create mode 100644 libs/portal/downloads/feature/tsconfig.spec.json diff --git a/libs/portal/downloads/feature/jest.config.ts b/libs/portal/downloads/feature/jest.config.ts new file mode 100644 index 000000000..88cd5d852 --- /dev/null +++ b/libs/portal/downloads/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-downloads-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/downloads/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/portal/downloads/feature/project.json b/libs/portal/downloads/feature/project.json index 305cabdde..5c8325819 100644 --- a/libs/portal/downloads/feature/project.json +++ b/libs/portal/downloads/feature/project.json @@ -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" } diff --git a/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts new file mode 100644 index 000000000..3a7fcaddd --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-library.viewmodel.ts @@ -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) + ); +} diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts new file mode 100644 index 000000000..0898789ad --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -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; +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 { + 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]); + } + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts new file mode 100644 index 000000000..663cff1b9 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -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[]; + 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' + ), + }; +} diff --git a/libs/portal/downloads/feature/src/test-setup.ts b/libs/portal/downloads/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/downloads/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/downloads/feature/tsconfig.spec.json b/libs/portal/downloads/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/downloads/feature/tsconfig.spec.json @@ -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" + ] +} diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index d9320dd7b..b2c2b69ba 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -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",