From 15a364ed51c7311f0dbd6ff3a76f72e0ed6f950e Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 11:09:04 +0200 Subject: [PATCH] feat(downloads): expose offline metadata updates --- libs/services/src/lib/downloads.models.ts | 12 +- .../src/lib/downloads.service.spec.ts | 211 ++++++++++++++++++ libs/services/src/lib/downloads.service.ts | 65 +++--- 3 files changed, 259 insertions(+), 29 deletions(-) diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts index 339be7098..96aa81596 100644 --- a/libs/services/src/lib/downloads.models.ts +++ b/libs/services/src/lib/downloads.models.ts @@ -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; diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index f065fe273..fe3d7e754 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -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; hasLoadedDownloads: Signal; + 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; _hasLoadedDownloads: WritableSignal; loadDownloadsRequestId: number; @@ -29,6 +36,14 @@ type TestDownloadsService = { type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; + downloadsStart?: jest.Mock< + Promise<{ success: boolean; id?: number; error?: string }>, + [ElectronBridgeDownloadStartPayload] + >; + downloadsUpdateMetadata?: jest.Mock< + Promise<{ success: boolean; error?: string }>, + [number, DownloadMetadataSnapshot] + >; downloadsPause?: jest.Mock, [number]>; downloadsRedownloadMissing?: jest.Mock< Promise<{ success: boolean; recovered?: boolean }>, @@ -42,6 +57,14 @@ type DownloadsElectronStub = { downloadsGetList: jest.Mock, []>; }; +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'); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 3bc8bb3ad..e86dd90fe 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -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 */