From 2899091b74f868161f90d1fd1ac1bb8db7c35fd6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 22:08:00 +0200 Subject: [PATCH] feat(downloads): refresh missing local files --- .../feature/src/lib/download-actions.ts | 1 + .../lib/download-manager-actions.service.ts | 28 +++++-- .../src/lib/downloads.component.spec.ts | 38 ++++++++++ .../vod-details-route.actions.spec.ts | 75 ++++++++++++++++++- .../src/lib/downloads.service.spec.ts | 47 ++++++++++++ libs/services/src/lib/downloads.service.ts | 40 +++++++++- .../vod-details/vod-details.component.spec.ts | 37 +++++++-- 7 files changed, 249 insertions(+), 17 deletions(-) diff --git a/libs/portal/downloads/feature/src/lib/download-actions.ts b/libs/portal/downloads/feature/src/lib/download-actions.ts index acb6f65e1..5cd0ffa2a 100644 --- a/libs/portal/downloads/feature/src/lib/download-actions.ts +++ b/libs/portal/downloads/feature/src/lib/download-actions.ts @@ -6,6 +6,7 @@ export type DownloadItemActionType = | 'pause' | 'play' | 'remove' + | 'redownload' | 'resume' | 'retry' | 'reveal'; diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts index 657cc155e..0d7d38133 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -40,6 +40,7 @@ export class DownloadManagerActionsService { const operations = { cancel: () => this.downloads.cancelDownload(item.id), pause: () => this.downloads.pauseDownload(item.id), + redownload: () => this.downloads.redownloadMissing(item.id), resume: () => this.downloads.resumeDownload(item.id), retry: () => this.downloads.retryDownload(item.id), }; @@ -128,7 +129,12 @@ export class DownloadManagerActionsService { type === 'play' ? this.downloads.playDownload(filePath) : this.downloads.revealFile(filePath), - (error) => this.showFileActionError(error) + async (error) => { + if (error === 'File not found') { + await this.downloads.loadDownloads(); + } + this.showFileActionError(error); + } ); } @@ -151,7 +157,7 @@ export class DownloadManagerActionsService { private async withPending( itemId: number, operation: () => Promise, - onFailure: (error?: string) => void = (error) => + onFailure: (error?: string) => void | Promise = (error) => this.showActionError(error) ): Promise { if (this.pendingIds().has(itemId)) { @@ -159,12 +165,20 @@ export class DownloadManagerActionsService { } this.pendingIds.update((ids) => new Set(ids).add(itemId)); try { - const result = await operation(); - if (!result.success) { - onFailure(result.error); + let failure: string | undefined; + let failed = false; + try { + const result = await operation(); + failed = !result.success; + failure = result.error; + } catch (error) { + failed = true; + failure = + error instanceof Error ? error.message : String(error); + } + if (failed) { + await onFailure(failure); } - } catch (error) { - onFailure(error instanceof Error ? error.message : String(error)); } finally { this.pendingIds.update((ids) => { const next = new Set(ids); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 55cd8aa32..37599870a 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -107,6 +107,7 @@ describe('DownloadsComponent', () => { loadDownloads: jest.Mock; cancelDownload: jest.Mock; pauseDownload: jest.Mock; + redownloadMissing: jest.Mock; resumeDownload: jest.Mock; retryDownload: jest.Mock; removeDownload: jest.Mock; @@ -182,6 +183,7 @@ describe('DownloadsComponent', () => { loadDownloads: jest.fn(async () => undefined), cancelDownload: jest.fn(success), pauseDownload: jest.fn(success), + redownloadMissing: jest.fn(success), resumeDownload: jest.fn(success), retryDownload: jest.fn(success), removeDownload: jest.fn(success), @@ -557,6 +559,42 @@ describe('DownloadsComponent', () => { ); }); + it('dispatches missing-file recovery by managed id', async () => { + const item = download(16, { fileAvailability: 'missing' }); + + await component.runAction({ type: 'redownload', item }); + + expect(downloadsService.redownloadMissing).toHaveBeenCalledWith(16); + }); + + it('refreshes a newly missing file before clearing pending state', async () => { + const reload = deferred(); + downloadsService.playDownload.mockResolvedValueOnce({ + error: 'File not found', + success: false, + }); + downloadsService.loadDownloads.mockReturnValueOnce(reload.promise); + const item = download(17); + + const action = component.runAction({ type: 'play', item }); + await Promise.resolve(); + await Promise.resolve(); + + expect(downloadsService.loadDownloads).toHaveBeenCalled(); + expect(component.pendingIds().has(17)).toBe(true); + expect(snackBar.open).not.toHaveBeenCalled(); + + reload.resolve(); + await action; + + expect(component.pendingIds().has(17)).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'File not found', + undefined, + expect.objectContaining({ duration: 3000 }) + ); + }); + it('routes dialog episode actions through the shared dispatcher and cleans up', async () => { const item = download(11, { contentType: 'episode', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 14f67a120..65d0d27ce 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -13,6 +13,7 @@ import { import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { CrossPortalSimilarService, + type DownloadItem, DownloadsService, PlaybackPositionRuntimeBridgeService, SettingsStore, @@ -64,7 +65,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { const selectedItem = signal(null); const downloadsAvailable = signal(false); const isFavorite = signal(false); - const downloads = signal([]); + const downloads = signal([]); const activeSession = signal(null); const currentPlaylist = signal({ id: 'playlist-1', @@ -118,6 +119,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { }) as XtreamVodDetails; beforeEach(async () => { selectedItem.set(null); + downloads.set([]); downloadsAvailable.set(false); activeSession.set(null); isFavorite.set(false); @@ -135,8 +137,43 @@ describe('VodDetailsRouteComponent fallback actions', () => { isEmbeddedPlayer.mockReset().mockReturnValue(true); openResolvedPlayback.mockClear(); startDownload.mockClear(); - isDownloaded.mockReset().mockReturnValue(false); - getDownloadedFilePath.mockReset(); + isDownloaded.mockReset().mockImplementation( + ( + xtreamId: number, + playlistId: string, + contentType: 'vod' | 'episode' + ) => { + const item = downloads().find( + (download) => + download.xtreamId === xtreamId && + download.playlistId === playlistId && + download.contentType === contentType + ); + return ( + item?.status === 'completed' && + !!item.filePath && + item.fileAvailability !== 'missing' + ); + } + ); + getDownloadedFilePath.mockReset().mockImplementation( + ( + xtreamId: number, + playlistId: string, + contentType: 'vod' | 'episode' + ) => { + const item = downloads().find( + (download) => + download.xtreamId === xtreamId && + download.playlistId === playlistId && + download.contentType === contentType + ); + return item?.status === 'completed' && + item.fileAvailability !== 'missing' + ? item.filePath + : undefined; + } + ); playDownload.mockClear(); toggleFavorite.mockClear(); await TestBed.configureTestingModule({ @@ -421,6 +458,38 @@ describe('VodDetailsRouteComponent fallback actions', () => { expect(constructVodStreamUrl).not.toHaveBeenCalled(); }); + it('keeps a missing completed file on provider playback', async () => { + selectedItem.set(sparseItem()); + downloadsAvailable.set(true); + downloads.set([ + { + contentType: 'vod', + fileAvailability: 'missing', + filePath: '/downloads/catalog-movie.mp4', + id: 42, + playlistId: 'playlist-1', + status: 'completed', + title: 'Catalog movie', + url: 'https://example.test/catalog-movie.mp4', + xtreamId: 650020, + }, + ]); + + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + const primary = + host.querySelector('button.play-btn'); + expect(host.textContent).not.toContain('DOWNLOADS.OFFLINE'); + expect(primary?.textContent).toContain('XTREAM.PLAY'); + + primary?.click(); + await fixture.whenStable(); + + expect(playDownload).not.toHaveBeenCalled(); + expect(constructVodStreamUrl).toHaveBeenCalled(); + }); + it('renders Offline in rich downloaded details', () => { selectedItem.set(richItem()); downloadsAvailable.set(true); diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 60c72f43e..61cc4731d 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -19,6 +19,7 @@ type TestDownloadsService = { loadDownloads: DownloadsService['loadDownloads']; loadDownloadFolder: DownloadsService['loadDownloadFolder']; pauseDownload: DownloadsService['pauseDownload']; + redownloadMissing: DownloadsService['redownloadMissing']; resumeDownload: DownloadsService['resumeDownload']; selectFolder: DownloadsService['selectFolder']; _isLoadingDownloads: WritableSignal; @@ -29,6 +30,10 @@ type TestDownloadsService = { type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; downloadsPause?: jest.Mock, [number]>; + downloadsRedownloadMissing?: jest.Mock< + Promise<{ success: boolean; recovered?: boolean }>, + [number] + >; downloadsResume?: jest.Mock< Promise<{ success: boolean }>, [number, string] @@ -206,6 +211,22 @@ describe('DownloadsService', () => { expect(electron.downloadsResume).toHaveBeenCalledWith(42, '/downloads'); }); + it('re-downloads a missing completed file by managed id', async () => { + const electron = { + downloadsGetList: jest.fn(async () => []), + downloadsRedownloadMissing: jest.fn(async (_downloadId: number) => ({ + success: true, + })), + }; + testWindow.electron = electron; + const service = createService() as unknown as DownloadsService; + + await expect(service.redownloadMissing(42)).resolves.toEqual({ + success: true, + }); + expect(electron.downloadsRedownloadMissing).toHaveBeenCalledWith(42); + }); + it('marks downloads as loaded after a failed request while preserving existing data', async () => { const existing = createDownload(1); const error = new Error('download query failed'); @@ -285,6 +306,32 @@ describe('DownloadsService', () => { expect(resumeDownload).toHaveBeenCalledWith(7); }); + it('only exposes completed files that are currently available', () => { + const available = { + ...createDownload(20), + fileAvailability: 'available' as const, + filePath: '/downloads/available.mp4', + }; + const missing = { + ...createDownload(21), + fileAvailability: 'missing' as const, + filePath: '/downloads/missing.mp4', + }; + const service = createService([ + available, + missing, + ]) as unknown as DownloadsService; + + expect(service.isDownloaded(20, 'playlist-1', 'vod')).toBe(true); + expect( + service.getDownloadedFilePath(20, 'playlist-1', 'vod') + ).toBe('/downloads/available.mp4'); + expect(service.isDownloaded(21, 'playlist-1', 'vod')).toBe(false); + expect( + service.getDownloadedFilePath(21, 'playlist-1', 'vod') + ).toBeUndefined(); + }); + it('rejects resume-by-content when the download is not paused', async () => { const service = createService([ createDownload(8), diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 9d5a3504e..e68bbd0e2 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,4 +1,5 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; +import type { ElectronDownloadFileAvailability } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; export type DownloadStatus = @@ -16,6 +17,7 @@ export interface DownloadItem { url: string; fileName?: string; filePath?: string; + fileAvailability?: ElectronDownloadFileAvailability; posterUrl?: string; status: DownloadStatus; bytesDownloaded?: number; @@ -291,6 +293,30 @@ export class DownloadsService implements OnDestroy { } } + /** + * Re-download a completed item whose finalized file is unavailable. + */ + async redownloadMissing( + downloadId: number + ): Promise<{ success: boolean; recovered?: boolean; error?: string }> { + if (!this.isAvailable()) { + return { success: false, error: 'Downloads not available' }; + } + + try { + return await window.electron.downloadsRedownloadMissing(downloadId); + } catch (error) { + console.error( + '[DownloadsService] Error re-downloading missing file:', + error + ); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + /** * Remove a download from the list */ @@ -434,7 +460,7 @@ export class DownloadsService implements OnDestroy { playlistId, contentType ); - return download?.status === 'completed' && !!download.filePath; + return this.hasAvailableCompletedFile(download); } /** @@ -505,12 +531,22 @@ export class DownloadsService implements OnDestroy { playlistId, contentType ); - if (download?.status === 'completed') { + if (this.hasAvailableCompletedFile(download)) { return download.filePath; } return undefined; } + private hasAvailableCompletedFile( + item: DownloadItem | undefined + ): item is DownloadItem & { filePath: string } { + return ( + item?.status === 'completed' && + !!item.filePath && + item.fileAvailability !== 'missing' + ); + } + /** * Format bytes to human readable string */ diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts index 6110e5607..171a0791a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts @@ -125,7 +125,9 @@ describe('VodDetailsComponent offline playback', () => { await fixture.whenStable(); }; - const completeDownload = () => { + const completeDownload = ( + fileAvailability: DownloadItem['fileAvailability'] = 'available' + ) => { downloads.set([ { id: 7, @@ -135,6 +137,7 @@ describe('VodDetailsComponent offline playback', () => { title: 'Catalog Movie', url: 'https://portal.example/movie.mp4', filePath: '/downloads/movie.mp4', + fileAvailability, status: 'completed', }, ]); @@ -167,7 +170,12 @@ describe('VodDetailsComponent offline playback', () => { xtreamId, playlistId, contentType - )?.status === 'completed' + )?.status === 'completed' && + matchingDownload( + xtreamId, + playlistId, + contentType + )?.fileAvailability !== 'missing' ), isDownloading: jest.fn(() => false), isPaused: jest.fn(() => false), @@ -176,12 +184,17 @@ describe('VodDetailsComponent offline playback', () => { xtreamId: number, playlistId: string, contentType: 'vod' | 'episode' - ) => - matchingDownload( + ) => { + const item = matchingDownload( xtreamId, playlistId, contentType - )?.filePath + ); + return item?.status === 'completed' && + item.fileAvailability !== 'missing' + ? item.filePath + : undefined; + } ), playDownload, resumeDownloadByContent: jest.fn(), @@ -259,6 +272,20 @@ describe('VodDetailsComponent offline playback', () => { expect(resumeClicked).not.toHaveBeenCalled(); }); + it('keeps a missing completed file on provider playback', async () => { + completeDownload('missing'); + await render(); + + expect(fixture.nativeElement.textContent).not.toContain('Offline'); + expect(buttonText(primaryButton())).toContain('Play'); + + primaryButton().click(); + await fixture.whenStable(); + + expect(playClicked).toHaveBeenCalledWith(STALKER_VOD); + expect(playDownload).not.toHaveBeenCalled(); + }); + it('plays the provider source from the downloaded secondary action', async () => { completeDownload(); await render();