feat(downloads): refresh missing local files

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 8a685ebf20
commit 2899091b74
7 files changed
+249 -17

No files matched your search

@@ -6,6 +6,7 @@ export type DownloadItemActionType =
| 'pause'
| 'play'
| 'remove'
| 'redownload'
| 'resume'
| 'retry'
| 'reveal';
@@ -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<DownloadOperationResult>,
onFailure: (error?: string) => void = (error) =>
onFailure: (error?: string) => void | Promise<void> = (error) =>
this.showActionError(error)
): Promise<void> {
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);
@@ -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<void>();
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',
@@ -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<XtreamVodDetails | null>(null);
const downloadsAvailable = signal(false);
const isFavorite = signal(false);
const downloads = signal([]);
const downloads = signal<DownloadItem[]>([]);
const activeSession = signal<unknown>(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<HTMLButtonElement>('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);
@@ -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<boolean>;
@@ -29,6 +30,10 @@ type TestDownloadsService = {
type DownloadsElectronStub = {
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [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),
+38 -2
View File
@@ -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
*/
@@ -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();