From 907e5f5a28080d380b9ff539b9956bead6a1be94 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 10:15:40 +0200 Subject: [PATCH] feat(downloads): define episode queue identity --- libs/portal/shared/util/src/index.ts | 1 + .../episode-download-identity.spec.ts | 107 ++++++++++++++++++ .../downloads/episode-download-identity.ts | 77 +++++++++++++ libs/services/src/lib/downloads.models.ts | 4 +- .../src/lib/downloads.service.spec.ts | 35 +++++- libs/services/src/lib/downloads.service.ts | 9 +- .../src/lib/electron-api.interface.ts | 8 ++ 7 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts create mode 100644 libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index b7490f4cf..2ab7e80d7 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -30,3 +30,4 @@ export * from './lib/collection'; export * from './lib/external-playback-button-state'; export * from './lib/inline-playback-position-writer'; export * from './lib/downloads/download-metadata-snapshot'; +export * from './lib/downloads/episode-download-identity'; diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts new file mode 100644 index 000000000..000785bc3 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts @@ -0,0 +1,107 @@ +import { + createEpisodeDownloadIdentityKey, + findEpisodeDownload, + isEpisodeDownloadEligible, + type EpisodeDownloadIdentity, + type EpisodeDownloadRecord, +} from './episode-download-identity'; + +const identity: EpisodeDownloadIdentity = { + playlistId: 'playlist-1', + contentType: 'episode', + xtreamId: 301, + seriesXtreamId: 40, + seasonNumber: 2, + episodeNumber: 3, +}; + +function row( + overrides: Partial = {} +): EpisodeDownloadRecord { + return { + playlistId: identity.playlistId, + contentType: identity.contentType, + xtreamId: identity.xtreamId, + seriesXtreamId: identity.seriesXtreamId, + seasonNumber: identity.seasonNumber, + episodeNumber: identity.episodeNumber, + status: 'queued', + ...overrides, + }; +} + +describe('episode download identity', () => { + it('prefers a canonical match over an earlier coordinate fallback', () => { + const coordinateFallback = row({ xtreamId: 999 }); + const canonical = row({ + seriesXtreamId: 50, + seasonNumber: 4, + episodeNumber: 8, + }); + + expect( + findEpisodeDownload([coordinateFallback, canonical], identity) + ).toBe(canonical); + }); + + it('falls back to complete matching episode coordinates', () => { + const coordinateFallback = row({ xtreamId: 999 }); + + expect(findEpisodeDownload([coordinateFallback], identity)).toBe( + coordinateFallback + ); + }); + + it('does not fall back across playlists or to incomplete coordinates', () => { + const candidates = [ + row({ playlistId: 'playlist-2' }), + row({ xtreamId: 998, seriesXtreamId: undefined }), + row({ xtreamId: 997, seasonNumber: undefined }), + row({ xtreamId: 996, episodeNumber: undefined }), + row({ contentType: 'vod', xtreamId: 995 }), + ]; + + expect(findEpisodeDownload(candidates, identity)).toBeUndefined(); + }); + + it('allows an episode with no existing download', () => { + expect(isEpisodeDownloadEligible(undefined)).toBe(true); + }); + + it.each([ + ['queued', undefined, false], + ['downloading', undefined, false], + ['paused', undefined, false], + ['completed', 'available', false], + ['completed', undefined, false], + ['completed', 'not-applicable', false], + ['completed', 'missing', true], + ['failed', undefined, true], + ['canceled', undefined, true], + ] as const)( + 'returns %s with availability %s as eligible=%s', + (status, fileAvailability, expected) => { + expect( + isEpisodeDownloadEligible(row({ status, fileAvailability })) + ).toBe(expected); + } + ); + + it('creates different keys for different playlists', () => { + expect(createEpisodeDownloadIdentityKey(identity)).not.toBe( + createEpisodeDownloadIdentityKey({ + ...identity, + playlistId: 'playlist-2', + }) + ); + }); + + it('creates different keys for different episode coordinates', () => { + expect(createEpisodeDownloadIdentityKey(identity)).not.toBe( + createEpisodeDownloadIdentityKey({ + ...identity, + episodeNumber: identity.episodeNumber + 1, + }) + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts new file mode 100644 index 000000000..ebd8074d4 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts @@ -0,0 +1,77 @@ +import type { + ElectronBridgeDownloadStatus, + ElectronBridgePlaybackContentType, + ElectronDownloadFileAvailability, +} from '@iptvnator/shared/interfaces'; + +export interface EpisodeDownloadIdentity { + readonly playlistId: string; + readonly contentType: 'episode'; + readonly xtreamId: number; + readonly seriesXtreamId: number; + readonly seasonNumber: number; + readonly episodeNumber: number; +} + +export interface EpisodeDownloadRecord { + readonly playlistId: string; + readonly contentType: ElectronBridgePlaybackContentType; + readonly xtreamId: number; + readonly seriesXtreamId?: number; + readonly seasonNumber?: number; + readonly episodeNumber?: number; + readonly status: ElectronBridgeDownloadStatus; + readonly fileAvailability?: ElectronDownloadFileAvailability; +} + +export function findEpisodeDownload( + downloads: readonly T[], + identity: EpisodeDownloadIdentity +): T | undefined { + const canonicalMatch = downloads.find( + (download) => + download.playlistId === identity.playlistId && + download.contentType === identity.contentType && + download.xtreamId === identity.xtreamId + ); + if (canonicalMatch) { + return canonicalMatch; + } + + return downloads.find( + (download) => + download.playlistId === identity.playlistId && + download.contentType === identity.contentType && + download.seriesXtreamId !== undefined && + download.seriesXtreamId === identity.seriesXtreamId && + download.seasonNumber !== undefined && + download.seasonNumber === identity.seasonNumber && + download.episodeNumber !== undefined && + download.episodeNumber === identity.episodeNumber + ); +} + +export function isEpisodeDownloadEligible( + download: EpisodeDownloadRecord | undefined +): boolean { + if (!download) { + return true; + } + if (download.status === 'completed') { + return download.fileAvailability === 'missing'; + } + return download.status === 'failed' || download.status === 'canceled'; +} + +export function createEpisodeDownloadIdentityKey( + identity: EpisodeDownloadIdentity +): string { + return JSON.stringify({ + playlistId: identity.playlistId, + contentType: identity.contentType, + xtreamId: identity.xtreamId, + seriesXtreamId: identity.seriesXtreamId, + seasonNumber: identity.seasonNumber, + episodeNumber: identity.episodeNumber, + }); +} diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts index 96aa81596..b12c36fe7 100644 --- a/libs/services/src/lib/downloads.models.ts +++ b/libs/services/src/lib/downloads.models.ts @@ -1,11 +1,11 @@ import type { DownloadMetadataSnapshot, + ElectronBridgeDownloadStatus, ElectronBridgeDownloadStartPayload, ElectronDownloadFileAvailability, } from '@iptvnator/shared/interfaces'; -export type DownloadStatus = - 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; +export type DownloadStatus = ElectronBridgeDownloadStatus; export type DownloadStartInput = Omit< ElectronBridgeDownloadStartPayload, diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 8a77a54c9..a2f57da36 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -10,6 +10,7 @@ import { import type { DownloadMetadataSnapshot, ElectronBridgeDownloadStartPayload, + ElectronBridgeDownloadStartResult, } from '@iptvnator/shared/interfaces'; import { DownloadItem, DownloadsService } from './downloads.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; @@ -37,7 +38,7 @@ type TestDownloadsService = { type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; downloadsStart?: jest.Mock< - Promise<{ success: boolean; id?: number; error?: string }>, + Promise, [ElectronBridgeDownloadStartPayload] >; downloadsUpdateMetadata?: jest.Mock< @@ -245,6 +246,38 @@ describe('DownloadsService', () => { ); }); + it('returns an already-in-progress start result unchanged', async () => { + const bridgeResult: ElectronBridgeDownloadStartResult = { + success: false, + reason: 'already-in-progress', + }; + const electron = { + downloadsGetDefaultFolder: jest.fn(async () => '/downloads'), + downloadsGetList: jest.fn(async () => []), + downloadsStart: jest + .fn< + Promise, + [ElectronBridgeDownloadStartPayload] + >() + .mockResolvedValue(bridgeResult), + }; + testWindow.electron = electron; + const service = createService(); + + await expect( + service.startDownload({ + playlistId: 'playlist-1', + xtreamId: 7, + contentType: 'episode', + seriesXtreamId: 3, + seasonNumber: 1, + episodeNumber: 2, + title: 'Episode 2', + url: 'https://example.com/episode-2.mp4', + }) + ).resolves.toBe(bridgeResult); + }); + it('stores a selected download folder returned by the main process', async () => { const electron = { downloadsSelectFolder: jest.fn(async () => '/selected'), diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index e2c702c7a..fa2a0b215 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,11 +1,16 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; +import type { ElectronBridgeDownloadStartResult } 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 { DownloadItem, DownloadStatus } from './downloads.models'; +export type { + DownloadItem, + DownloadStartInput, + DownloadStatus, +} from './downloads.models'; @Injectable({ providedIn: 'root' }) export class DownloadsService implements OnDestroy { @@ -135,7 +140,7 @@ export class DownloadsService implements OnDestroy { */ async startDownload( data: DownloadStartInput - ): Promise<{ success: boolean; id?: number; error?: string }> { + ): Promise { if (!this.isAvailable()) { return { success: false, error: 'Downloads not available' }; } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 44023a9cc..6c8946ee1 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -138,6 +138,13 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = { export type ElectronBridgeDownloadStatus = (typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES]; +export const ELECTRON_BRIDGE_DOWNLOAD_START_REASONS = { + AlreadyInProgress: 'already-in-progress', +} as const; + +export type ElectronBridgeDownloadStartReason = + (typeof ELECTRON_BRIDGE_DOWNLOAD_START_REASONS)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_START_REASONS]; + export type ElectronDownloadFileAvailability = 'available' | 'missing' | 'not-applicable'; @@ -532,6 +539,7 @@ export interface ElectronBridgeDownloadStartPayload { export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorResult { id?: number; + reason?: ElectronBridgeDownloadStartReason; } export interface ElectronBridgeDownloadRedownloadResult extends ElectronBridgeErrorResult {