From 596f5a8d4d97c0b099d9586cac02de9534b82788 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 22:08:49 +0200 Subject: [PATCH] feat(downloads): separate missing files from ready media --- .../lib/download-manager.viewmodel.spec.ts | 118 ++++++++++++++++++ .../src/lib/download-manager.viewmodel.ts | 28 ++++- 2 files changed, 141 insertions(+), 5 deletions(-) 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 index 2de1ff712..f324798c7 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.spec.ts @@ -489,6 +489,124 @@ describe('buildDownloadManagerViewModel', () => { expect(libraryItemIds(result.library)).toEqual([6]); }); + it('moves missing completed movies to attention instead of the ready library', () => { + const result = build([ + download(1, { fileAvailability: 'available' }), + download(2, { fileAvailability: 'missing' }), + ]); + + expect(rowIds(result.attention)).toEqual([2]); + expect(result.attention[0].attentionReason).toBe('file-missing'); + expect(libraryItemIds(result.library)).toEqual([1]); + }); + + it('keeps available series members grouped and exposes each missing episode', () => { + const seriesEpisode = ( + id: number, + fileAvailability: DownloadItem['fileAvailability'] + ) => + download(id, { + contentType: 'episode', + episodeNumber: id, + fileAvailability, + seasonNumber: 1, + seriesXtreamId: 70, + }); + const result = build([ + seriesEpisode(1, 'available'), + seriesEpisode(2, 'missing'), + seriesEpisode(3, 'available'), + ]); + + expect(rowIds(result.attention)).toEqual([2]); + expect(result.attention[0].attentionReason).toBe('file-missing'); + expect(result.library).toHaveLength(1); + expect(result.library[0].kind).toBe('series'); + if (result.library[0].kind === 'series') { + expect(result.library[0].members.map(({ id }) => id)).toEqual([ + 1, 3, + ]); + } + expect(result.counts).toEqual({ + all: 2, + movie: 0, + series: 2, + inProgress: 0, + }); + }); + + it('keeps a fully missing series out of Ready to watch', () => { + const result = build([ + download(1, { + contentType: 'episode', + fileAvailability: 'missing', + seriesXtreamId: 70, + }), + download(2, { + contentType: 'episode', + fileAvailability: 'missing', + seriesXtreamId: 70, + }), + ]); + + expect(result.library).toEqual([]); + expect(rowIds(result.attention)).toEqual([1, 2]); + expect( + result.attention.map(({ attentionReason }) => attentionReason) + ).toEqual(['file-missing', 'file-missing']); + }); + + it('filters and searches missing rows without exposing source metadata in the library', () => { + const downloads = [ + download(1, { + fileAvailability: 'missing', + playlistId: 'playlist-b', + }), + download(2, { + contentType: 'episode', + fileAvailability: 'missing', + playlistId: 'playlist-b', + }), + ]; + + expect( + rowIds( + build(downloads, { + filter: 'movie', + searchTerm: 'beta source', + }).attention + ) + ).toEqual([1]); + expect( + rowIds( + build(downloads, { + filter: 'series', + searchTerm: 'beta source', + }).attention + ) + ).toEqual([2]); + expect(build(downloads).counts).toEqual({ + all: 2, + movie: 1, + series: 1, + inProgress: 0, + }); + }); + + it('does not mutate missing-file inputs while partitioning them', () => { + const available = Object.freeze( + download(1, { fileAvailability: 'available' }) + ); + const missing = Object.freeze( + download(2, { fileAvailability: 'missing' }) + ); + const downloads = Object.freeze([available, missing]); + + expect(() => build(downloads)).not.toThrow(); + expect(downloads).toEqual([available, missing]); + expect(missing).not.toHaveProperty('attentionReason'); + }); + it('excludes paused rows from activeCount independently of filter and search', () => { const result = build( [ diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 5d9257b08..914c76411 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -19,7 +19,10 @@ export type { export type DownloadFilterId = 'all' | 'movie' | 'series' | 'in-progress'; +export type DownloadAttentionReason = 'file-missing' | 'transfer'; + export interface DownloadListItemViewModel extends DownloadLibraryRow { + readonly attentionReason: DownloadAttentionReason; readonly seriesTitle: string; } @@ -95,6 +98,14 @@ function needsAttention(item: DownloadItem): boolean { return item.status === 'failed' || item.status === 'canceled'; } +function isMissingCompletedFile(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability === 'missing'; +} + +function isReady(item: DownloadItem): boolean { + return item.status === 'completed' && item.fileAvailability !== 'missing'; +} + function compareQueued( left: DownloadListItemViewModel, right: DownloadListItemViewModel @@ -161,17 +172,21 @@ export function buildDownloadManagerViewModel({ scopePlaylistId === undefined || item.playlistId === scopePlaylistId ); const scopedRows = scopedItems.map((item) => ({ + attentionReason: isMissingCompletedFile(item) + ? ('file-missing' as const) + : ('transfer' as const), 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 scopedAttention = scopedRows.filter( + ({ item }) => + needsAttention(item) || isMissingCompletedFile(item) ); const scopedLibrary = buildDownloadLibrary( - scopedRows.filter(({ item }) => item.status === 'completed') + scopedRows.filter(({ item }) => isReady(item)) ); const activeFilter = normalizeDownloadFilter(filter); const displayed = scopedRows @@ -181,10 +196,13 @@ export function buildDownloadManagerViewModel({ .filter(({ item }) => isActive(item)) .sort(compareQueued); const attention = displayed - .filter(({ item }) => needsAttention(item)) + .filter( + ({ item }) => + needsAttention(item) || isMissingCompletedFile(item) + ) .sort(compareQueued); const library = buildDownloadLibrary( - displayed.filter(({ item }) => item.status === 'completed') + displayed.filter(({ item }) => isReady(item)) ); return {