feat(downloads): expose offline metadata updates

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 1d20a36640
commit 15a364ed51
3 files changed
+259 -29

No files matched your search

+11 -1
View File
@@ -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');
+37 -28
View File
@@ -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
*/