feat(downloads): define episode queue identity

This commit is contained in:
4gray committed 2026-08-02 10:15:40 +02:00
1 parent 76cbb055b3
commit 907e5f5a28
7 files changed
+236 -5

No files matched your search

+1
View File
@@ -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';
@@ -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> = {}
): 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,
})
);
});
});
@@ -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<T extends EpisodeDownloadRecord>(
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,
});
}
+2 -2
View File
@@ -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,
@@ -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<Promise<string>, []>;
downloadsStart?: jest.Mock<
Promise<{ success: boolean; id?: number; error?: string }>,
Promise<ElectronBridgeDownloadStartResult>,
[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<ElectronBridgeDownloadStartResult>,
[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'),
+7 -2
View File
@@ -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<ElectronBridgeDownloadStartResult> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
@@ -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 {