mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(downloads): expose offline metadata updates
This commit is contained in:
1 parent
1d20a36640
commit
15a364ed51
3 files changed
+259
-29
No files matched your search
@@ -1,8 +1,17 @@
|
||||
import type { ElectronDownloadFileAvailability } from '@iptvnator/shared/interfaces';
|
||||
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;
|
||||
@@ -17,6 +26,7 @@ export interface DownloadItem {
|
||||
filePath?: string;
|
||||
fileAvailability?: ElectronDownloadFileAvailability;
|
||||
posterUrl?: string;
|
||||
metadataSnapshot?: DownloadMetadataSnapshot;
|
||||
status: DownloadStatus;
|
||||
bytesDownloaded?: number;
|
||||
totalBytes?: number;
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadItem, DownloadsService } from './downloads.service';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
@@ -16,12 +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;
|
||||
@@ -29,6 +36,14 @@ 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 }>,
|
||||
@@ -42,6 +57,14 @@ type DownloadsElectronStub = {
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, []>;
|
||||
};
|
||||
|
||||
const metadataSnapshot: DownloadMetadataSnapshot = {
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'movie',
|
||||
title: 'Offline Movie',
|
||||
plot: 'Stored for offline details.',
|
||||
};
|
||||
|
||||
describe('DownloadsService', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: DownloadsElectronStub;
|
||||
@@ -79,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);
|
||||
@@ -102,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(
|
||||
[
|
||||
@@ -162,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'),
|
||||
@@ -227,6 +310,134 @@ describe('DownloadsService', () => {
|
||||
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,
|
||||
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('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
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
|
||||
import type { DownloadItem } from './downloads.models';
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { DownloadItem, DownloadStartInput } from './downloads.models';
|
||||
import { formatDownloadBytes } from './downloads.utils';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
export type { DownloadItem, DownloadStatus } from './downloads.models';
|
||||
|
||||
const ACTIVE: DownloadItem['status'][] = ['queued', 'downloading'];
|
||||
const FAILED: DownloadItem['status'][] = ['failed', 'canceled'];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DownloadsService implements OnDestroy {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -31,10 +35,7 @@ export class DownloadsService implements OnDestroy {
|
||||
|
||||
/** Active downloads count */
|
||||
readonly activeCount = computed(
|
||||
() =>
|
||||
this.downloads().filter(
|
||||
(d) => d.status === 'queued' || d.status === 'downloading'
|
||||
).length
|
||||
() => this.downloads().filter((d) => ACTIVE.includes(d.status)).length
|
||||
);
|
||||
|
||||
/** Completed downloads count */
|
||||
@@ -44,10 +45,7 @@ export class DownloadsService implements OnDestroy {
|
||||
|
||||
/** Failed downloads count */
|
||||
readonly failedCount = computed(
|
||||
() =>
|
||||
this.downloads().filter(
|
||||
(d) => d.status === 'failed' || d.status === 'canceled'
|
||||
).length
|
||||
() => this.downloads().filter((d) => FAILED.includes(d.status)).length
|
||||
);
|
||||
|
||||
/** Current download folder */
|
||||
@@ -131,25 +129,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' };
|
||||
}
|
||||
@@ -316,6 +298,26 @@ 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 }> {
|
||||
if (!this.isAvailable()) return { success: false };
|
||||
|
||||
try {
|
||||
const bridge = window.electron;
|
||||
const result = await bridge.downloadsUpdateMetadata(id, snapshot);
|
||||
if (result.success) await this.loadDownloads();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Metadata update error:', error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a downloaded file
|
||||
*/
|
||||
@@ -409,6 +411,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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user