mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(downloads): refresh missing local files
This commit is contained in:
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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user