mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(downloads): define episode queue identity
This commit is contained in:
1 parent
76cbb055b3
commit
907e5f5a28
7 files changed
+236
-5
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user