mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes
This commit is contained in:
1 parent
46c58f4f57
commit
760099358b
188 files changed
+31245
-2281
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
|
||||
interface DownloadMetadataHost {
|
||||
isAvailable(): boolean;
|
||||
loadDownloads(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function updateDownloadMetadata(
|
||||
host: DownloadMetadataHost,
|
||||
downloadId: number,
|
||||
metadataSnapshot: DownloadMetadataSnapshot
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!host.isAvailable()) return { success: false };
|
||||
|
||||
try {
|
||||
const result = await window.electron.downloadsUpdateMetadata(
|
||||
downloadId,
|
||||
metadataSnapshot
|
||||
);
|
||||
if (result.success) await host.loadDownloads();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Metadata update error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
ElectronDownloadFileAvailability,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type DownloadStatus =
|
||||
'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled';
|
||||
|
||||
export type DownloadStartInput = Omit<
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
'downloadFolder'
|
||||
>;
|
||||
|
||||
export interface DownloadItem {
|
||||
id: number;
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
title: string;
|
||||
url: string;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
fileAvailability?: ElectronDownloadFileAvailability;
|
||||
posterUrl?: string;
|
||||
metadataSnapshot?: DownloadMetadataSnapshot;
|
||||
status: DownloadStatus;
|
||||
bytesDownloaded?: number;
|
||||
totalBytes?: number;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
DownloadItem,
|
||||
DownloadsService,
|
||||
} from './downloads.service';
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadItem, DownloadsService } from './downloads.service';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
type TestDownloadsService = {
|
||||
@@ -19,11 +20,15 @@ type TestDownloadsService = {
|
||||
isAvailable: () => boolean;
|
||||
isLoadingDownloads: Signal<boolean>;
|
||||
hasLoadedDownloads: Signal<boolean>;
|
||||
getDownload: DownloadsService['getDownload'];
|
||||
loadDownloads: DownloadsService['loadDownloads'];
|
||||
loadDownloadFolder: DownloadsService['loadDownloadFolder'];
|
||||
pauseDownload: DownloadsService['pauseDownload'];
|
||||
redownloadMissing: DownloadsService['redownloadMissing'];
|
||||
resumeDownload: DownloadsService['resumeDownload'];
|
||||
selectFolder: DownloadsService['selectFolder'];
|
||||
startDownload: DownloadsService['startDownload'];
|
||||
updateMetadata: DownloadsService['updateMetadata'];
|
||||
_isLoadingDownloads: WritableSignal<boolean>;
|
||||
_hasLoadedDownloads: WritableSignal<boolean>;
|
||||
loadDownloadsRequestId: number;
|
||||
@@ -31,13 +36,33 @@ type TestDownloadsService = {
|
||||
|
||||
type DownloadsElectronStub = {
|
||||
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
|
||||
downloadsStart?: jest.Mock<
|
||||
Promise<{ success: boolean; id?: number; error?: string }>,
|
||||
[ElectronBridgeDownloadStartPayload]
|
||||
>;
|
||||
downloadsUpdateMetadata?: jest.Mock<
|
||||
Promise<{ success: boolean; error?: string }>,
|
||||
[number, DownloadMetadataSnapshot]
|
||||
>;
|
||||
downloadsPause?: jest.Mock<Promise<{ success: boolean }>, [number]>;
|
||||
downloadsRedownloadMissing?: jest.Mock<
|
||||
Promise<{ success: boolean; recovered?: boolean }>,
|
||||
[number]
|
||||
>;
|
||||
downloadsResume?: jest.Mock<
|
||||
Promise<{ success: boolean }>,
|
||||
[number, string]
|
||||
>;
|
||||
downloadsSelectFolder?: jest.Mock<Promise<string | null>, []>;
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, []>;
|
||||
};
|
||||
|
||||
const metadataSnapshot: DownloadMetadataSnapshot = {
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'movie',
|
||||
title: 'Offline Movie',
|
||||
plot: 'Stored for offline details.',
|
||||
};
|
||||
|
||||
describe('DownloadsService', () => {
|
||||
@@ -51,7 +76,10 @@ describe('DownloadsService', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem {
|
||||
function createDownload(
|
||||
id: number,
|
||||
playlistId = 'playlist-1'
|
||||
): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
playlistId,
|
||||
@@ -74,6 +102,18 @@ describe('DownloadsService', () => {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createMetadataUpdateMock(result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
return jest
|
||||
.fn<
|
||||
Promise<{ success: boolean; error?: string }>,
|
||||
[number, DownloadMetadataSnapshot]
|
||||
>()
|
||||
.mockResolvedValue(result);
|
||||
}
|
||||
|
||||
function createService(initialDownloads: DownloadItem[] = []) {
|
||||
const downloads = signal(initialDownloads);
|
||||
const isLoadingDownloads = signal(false);
|
||||
@@ -97,6 +137,15 @@ describe('DownloadsService', () => {
|
||||
return service;
|
||||
}
|
||||
|
||||
it('exposes a download metadata snapshot on list items', () => {
|
||||
const item: DownloadItem = {
|
||||
...createDownload(42),
|
||||
metadataSnapshot,
|
||||
};
|
||||
|
||||
expect(item.metadataSnapshot).toBe(metadataSnapshot);
|
||||
});
|
||||
|
||||
it('reports availability through the runtime capability', () => {
|
||||
const injector = createEnvironmentInjector(
|
||||
[
|
||||
@@ -121,7 +170,7 @@ describe('DownloadsService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks loading and loaded state around a successful download list request', async () => {
|
||||
it('loads the global download list without a playlist filter and tracks request state', async () => {
|
||||
const item = createDownload(1);
|
||||
const pending = createDeferred<DownloadItem[]>();
|
||||
const electron = {
|
||||
@@ -130,11 +179,11 @@ describe('DownloadsService', () => {
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
const request = service.loadDownloads('playlist-1');
|
||||
const request = service.loadDownloads();
|
||||
|
||||
expect(service.isLoadingDownloads()).toBe(true);
|
||||
expect(service.hasLoadedDownloads()).toBe(false);
|
||||
expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1');
|
||||
expect(electron.downloadsGetList).toHaveBeenCalledWith();
|
||||
|
||||
pending.resolve([item]);
|
||||
await request;
|
||||
@@ -157,6 +206,45 @@ describe('DownloadsService', () => {
|
||||
expect(electron.downloadsGetDefaultFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards a metadata snapshot unchanged when starting a download', async () => {
|
||||
const electron = {
|
||||
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsStart: jest
|
||||
.fn<
|
||||
Promise<{ id: number; success: boolean }>,
|
||||
[ElectronBridgeDownloadStartPayload]
|
||||
>()
|
||||
.mockResolvedValue({ id: 42, success: true }),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.startDownload({
|
||||
playlistId: 'playlist-1',
|
||||
xtreamId: 7,
|
||||
contentType: 'vod',
|
||||
title: 'Offline Movie',
|
||||
url: 'https://example.com/movie.mp4',
|
||||
metadataSnapshot,
|
||||
})
|
||||
).resolves.toEqual({ id: 42, success: true });
|
||||
|
||||
expect(electron.downloadsStart).toHaveBeenCalledWith({
|
||||
playlistId: 'playlist-1',
|
||||
xtreamId: 7,
|
||||
contentType: 'vod',
|
||||
title: 'Offline Movie',
|
||||
url: 'https://example.com/movie.mp4',
|
||||
metadataSnapshot,
|
||||
downloadFolder: '/downloads',
|
||||
});
|
||||
expect(electron.downloadsStart.mock.calls[0][0].metadataSnapshot).toBe(
|
||||
metadataSnapshot
|
||||
);
|
||||
});
|
||||
|
||||
it('stores a selected download folder returned by the main process', async () => {
|
||||
const electron = {
|
||||
downloadsSelectFolder: jest.fn(async () => '/selected'),
|
||||
@@ -203,9 +291,177 @@ describe('DownloadsService', () => {
|
||||
await expect(service.resumeDownload(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(electron.downloadsResume).toHaveBeenCalledWith(
|
||||
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: downloadId === 42,
|
||||
})),
|
||||
};
|
||||
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('gets a download from the current signal by managed id', () => {
|
||||
const matching = {
|
||||
...createDownload(42),
|
||||
metadataSnapshot,
|
||||
};
|
||||
const service = createService([createDownload(7), matching]);
|
||||
|
||||
expect(service.getDownload(42)).toBe(matching);
|
||||
expect(service.getDownload(99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates metadata by managed id and reloads the authoritative list on success', async () => {
|
||||
const refreshed = {
|
||||
...createDownload(42),
|
||||
metadataSnapshot,
|
||||
};
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => [refreshed]),
|
||||
downloadsUpdateMetadata: createMetadataUpdateMock({
|
||||
success: true,
|
||||
}),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService([createDownload(42)]);
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(electron.downloadsUpdateMetadata).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads'
|
||||
metadataSnapshot
|
||||
);
|
||||
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
|
||||
expect(service.downloads()).toEqual([refreshed]);
|
||||
});
|
||||
|
||||
it('returns a metadata update bridge failure without reloading', async () => {
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsUpdateMetadata: createMetadataUpdateMock({
|
||||
error: 'Metadata rejected',
|
||||
success: false,
|
||||
}),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({
|
||||
error: 'Metadata rejected',
|
||||
success: false,
|
||||
});
|
||||
expect(electron.downloadsGetList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not touch the bridge when metadata updates are unavailable', async () => {
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsUpdateMetadata: createMetadataUpdateMock({
|
||||
success: true,
|
||||
}),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
service.isAvailable = () => false;
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({ success: false });
|
||||
expect(electron.downloadsUpdateMetadata).not.toHaveBeenCalled();
|
||||
expect(electron.downloadsGetList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a safe failure when the metadata update bridge throws', async () => {
|
||||
const error = new Error('metadata update failed');
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsUpdateMetadata: jest
|
||||
.fn<
|
||||
Promise<{ success: boolean; error?: string }>,
|
||||
[number, DownloadMetadataSnapshot]
|
||||
>()
|
||||
.mockRejectedValue(error),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({
|
||||
error: 'metadata update failed',
|
||||
success: false,
|
||||
});
|
||||
expect(electron.downloadsGetList).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[DownloadsService] Metadata update error:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('stringifies a non-Error metadata update failure', async () => {
|
||||
const error = { message: 404 };
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsUpdateMetadata: jest
|
||||
.fn<
|
||||
Promise<{ success: boolean; error?: string }>,
|
||||
[number, DownloadMetadataSnapshot]
|
||||
>()
|
||||
.mockRejectedValue(error),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({
|
||||
error: '[object Object]',
|
||||
success: false,
|
||||
});
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[DownloadsService] Metadata update error:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a safe failure when the metadata reload throws', async () => {
|
||||
const error = new Error('metadata reload failed');
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsUpdateMetadata: createMetadataUpdateMock({
|
||||
success: true,
|
||||
}),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
service.loadDownloads = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.updateMetadata(42, metadataSnapshot)
|
||||
).resolves.toEqual({
|
||||
error: 'metadata reload failed',
|
||||
success: false,
|
||||
});
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[DownloadsService] Metadata update error:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
@@ -245,8 +501,8 @@ describe('DownloadsService', () => {
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
const firstRequest = service.loadDownloads('playlist-old');
|
||||
const secondRequest = service.loadDownloads('playlist-new');
|
||||
const firstRequest = service.loadDownloads();
|
||||
const secondRequest = service.loadDownloads();
|
||||
|
||||
expect(service.isLoadingDownloads()).toBe(true);
|
||||
|
||||
@@ -262,14 +518,8 @@ describe('DownloadsService', () => {
|
||||
|
||||
expect(service.downloads()).toEqual([latestItem]);
|
||||
expect(service.isLoadingDownloads()).toBe(false);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'playlist-old'
|
||||
);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'playlist-new'
|
||||
);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(1);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2);
|
||||
});
|
||||
|
||||
it('reports paused content and resumes it by content identity', async () => {
|
||||
@@ -281,9 +531,7 @@ describe('DownloadsService', () => {
|
||||
pausedItem,
|
||||
createDownload(8),
|
||||
]) as unknown as DownloadsService;
|
||||
const resumeDownload = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true });
|
||||
const resumeDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
(service as unknown as { resumeDownload: jest.Mock }).resumeDownload =
|
||||
resumeDownload;
|
||||
|
||||
@@ -296,6 +544,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,34 +1,11 @@
|
||||
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import { updateDownloadMetadata } from './downloads-metadata-update';
|
||||
import type { DownloadItem, DownloadStartInput } from './downloads.models';
|
||||
import { formatDownloadBytes } from './downloads.utils';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
export type DownloadStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
|
||||
export interface DownloadItem {
|
||||
id: number;
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
title: string;
|
||||
url: string;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
posterUrl?: string;
|
||||
status: DownloadStatus;
|
||||
bytesDownloaded?: number;
|
||||
totalBytes?: number;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
export type { DownloadItem, DownloadStatus } from './downloads.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DownloadsService implements OnDestroy {
|
||||
@@ -106,14 +83,14 @@ export class DownloadsService implements OnDestroy {
|
||||
/**
|
||||
* Load downloads from the backend
|
||||
*/
|
||||
async loadDownloads(playlistId?: string): Promise<void> {
|
||||
async loadDownloads(): Promise<void> {
|
||||
if (!this.isAvailable()) return;
|
||||
|
||||
const requestId = ++this.loadDownloadsRequestId;
|
||||
this._isLoadingDownloads.set(true);
|
||||
|
||||
try {
|
||||
const list = await window.electron.downloadsGetList(playlistId);
|
||||
const list = await window.electron.downloadsGetList();
|
||||
if (requestId === this.loadDownloadsRequestId) {
|
||||
this.downloads.set(list);
|
||||
this._hasLoadedDownloads.set(true);
|
||||
@@ -156,24 +133,9 @@ export class DownloadsService implements OnDestroy {
|
||||
/**
|
||||
* Start a new download
|
||||
*/
|
||||
async startDownload(data: {
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'vod' | 'episode';
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
headers?: { userAgent?: string; referer?: string; origin?: string };
|
||||
seriesXtreamId?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
// Playlist info for auto-creation if needed (Stalker playlists)
|
||||
playlistName?: string;
|
||||
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
|
||||
serverUrl?: string;
|
||||
portalUrl?: string;
|
||||
macAddress?: string;
|
||||
}): Promise<{ success: boolean; id?: number; error?: string }> {
|
||||
async startDownload(
|
||||
data: DownloadStartInput
|
||||
): Promise<{ success: boolean; id?: number; error?: string }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false, error: 'Downloads not available' };
|
||||
}
|
||||
@@ -286,9 +248,30 @@ export class DownloadsService implements OnDestroy {
|
||||
|
||||
try {
|
||||
return await window.electron.downloadsRetry(downloadId, folder);
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error retrying download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 retrying download:',
|
||||
'[DownloadsService] Error re-downloading missing file:',
|
||||
error
|
||||
);
|
||||
return {
|
||||
@@ -311,10 +294,7 @@ export class DownloadsService implements OnDestroy {
|
||||
try {
|
||||
return await window.electron.downloadsRemove(downloadId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[DownloadsService] Error removing download:',
|
||||
error
|
||||
);
|
||||
console.error('[DownloadsService] Error removing download:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -322,6 +302,14 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the offline metadata snapshot for a managed download. */
|
||||
async updateMetadata(
|
||||
id: number,
|
||||
snapshot: DownloadMetadataSnapshot
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return updateDownloadMetadata(this, id, snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a downloaded file
|
||||
*/
|
||||
@@ -379,10 +367,7 @@ export class DownloadsService implements OnDestroy {
|
||||
}
|
||||
return folder;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[DownloadsService] Error selecting folder:',
|
||||
error
|
||||
);
|
||||
console.error('[DownloadsService] Error selecting folder:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -390,9 +375,7 @@ export class DownloadsService implements OnDestroy {
|
||||
/**
|
||||
* Clear completed/failed downloads
|
||||
*/
|
||||
async clearCompleted(
|
||||
playlistId?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
async clearCompleted(playlistId?: string): Promise<{ success: boolean }> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false };
|
||||
}
|
||||
@@ -420,6 +403,13 @@ export class DownloadsService implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a download item by managed id
|
||||
*/
|
||||
getDownload(downloadId: number): DownloadItem | undefined {
|
||||
return this.downloads().find(({ id }) => id === downloadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download item by xtreamId and playlistId
|
||||
*/
|
||||
@@ -444,8 +434,12 @@ export class DownloadsService implements OnDestroy {
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
return download?.status === 'completed' && !!download.filePath;
|
||||
const download = this.getDownloadByContent(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
return this.hasAvailableCompletedFile(download);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,7 +450,11 @@ export class DownloadsService implements OnDestroy {
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
const download = this.getDownloadByContent(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
return (
|
||||
download?.status === 'downloading' ||
|
||||
download?.status === 'queued' ||
|
||||
@@ -472,7 +470,11 @@ export class DownloadsService implements OnDestroy {
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): boolean {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
const download = this.getDownloadByContent(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
return download?.status === 'paused';
|
||||
}
|
||||
|
||||
@@ -484,7 +486,11 @@ export class DownloadsService implements OnDestroy {
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
const download = this.getDownloadByContent(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
if (!download || download.status !== 'paused') {
|
||||
return { success: false, error: 'No paused download found' };
|
||||
}
|
||||
@@ -499,21 +505,31 @@ export class DownloadsService implements OnDestroy {
|
||||
playlistId: string,
|
||||
contentType: 'vod' | 'episode'
|
||||
): string | undefined {
|
||||
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
|
||||
if (download?.status === 'completed') {
|
||||
const download = this.getDownloadByContent(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
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
|
||||
*/
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
return formatDownloadBytes(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function formatDownloadBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const kilobyte = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(kilobyte));
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(kilobyte, index)).toFixed(1)) +
|
||||
' ' +
|
||||
sizes[index]
|
||||
);
|
||||
}
|
||||
@@ -100,6 +100,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
downloadsPause: jest.fn(),
|
||||
downloadsResume: jest.fn(),
|
||||
downloadsRetry: jest.fn(),
|
||||
downloadsRedownloadMissing: jest.fn(),
|
||||
downloadsRemove: jest.fn(),
|
||||
downloadsGetList: jest.fn(),
|
||||
downloadsGet: jest.fn(),
|
||||
@@ -382,6 +383,13 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
onDownloadsUpdate: jest.fn(),
|
||||
};
|
||||
|
||||
expect(service.supportsDownloads).toBe(false);
|
||||
|
||||
testWindow.electron = {
|
||||
...testWindow.electron,
|
||||
downloadsRedownloadMissing: jest.fn(),
|
||||
};
|
||||
|
||||
expect(service.supportsDownloads).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ export class RuntimeCapabilitiesService {
|
||||
'downloadsPause',
|
||||
'downloadsResume',
|
||||
'downloadsRetry',
|
||||
'downloadsRedownloadMissing',
|
||||
'downloadsRemove',
|
||||
'downloadsGetList',
|
||||
'downloadsGet',
|
||||
@@ -278,8 +279,7 @@ export class RuntimeCapabilitiesService {
|
||||
|
||||
private hasElectronMethod(methodName: string): boolean {
|
||||
const bridge = this.electronBridge as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
return typeof bridge?.[methodName] === 'function';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user