feat(downloads): queue season episode downloads (#1357)

* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
This commit is contained in:
4gray authored and GitHub committed 2026-08-03 08:53:44 +02:00
1 parent d3cc18dc72
commit 96facd6f49
79 files changed
+8352 -1255

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: downloads
---
Series downloads now let you queue several episodes in a row or add every available episode from the selected season. Already queued, paused, or downloaded episodes are skipped without duplicates, and the app reports the batch result.
+58
View File
@@ -941,6 +941,64 @@ engine` (restart required) or
Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series
cards use the global Small/Medium/Large cover-grid tokens; missing completed
files move to Needs attention instead of remaining in Ready to watch.
- Series details route individual and selected-season episode downloads through
the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode
pending identities synchronously, submits season candidates sequentially and
best-effort through the existing `DOWNLOADS_START` path, performs one final
authoritative refresh after added or stable duplicate submissions, and
reports added, skipped, and failed counts. Xtream and Stalker adapters remain
responsible for provider URLs, headers, and metadata; the backend still runs
one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole
start IPC. A reserved completed-missing match triggers one authoritative
preflight refresh before provider preparation. Download-list loads are
serialized as one active IPC plus one coalesced trailing refresh; a preflight
assigned to that trailing refresh cannot be starved by later progress
broadcasts. A restored Stalker file can therefore become a stable skip
without a portal request. The IPC's stable
`reason: 'already-in-progress'` and `reason: 'already-downloaded'`
results are counted as skipped, and no batch IPC is introduced. The latter
comes from an asynchronous main-process filesystem recheck before a
completed-missing row can be reset, so a file restored after the renderer
snapshot is not orphaned or downloaded again. The recheck has a one-second
caller deadline that starts before shared-slot acquisition; timeout or probe
failure leaves the row untouched and reports a failed submission so the
season loop can continue. Completed-file list callers use the same deadline
and report a timeout as missing for that snapshot. The underlying filesystem
operation remains coalesced and charged against the four-probe cap until it
settles, so later callers have independent bounded waits without duplicating
stalled native work. Only `ENOENT` and `ENOTDIR` prove absence; permission,
I/O, and other filesystem errors remain unknown and cannot clear a completed
row. Before a completed-missing, failed, or canceled row clears its retained
path, the start IPC asynchronously removes any `.part` through a separate,
same-path-coalesced, four-operation cap. A one-second admission deadline
rejects queued work before unlink starts; started work is awaited so it cannot
mutate after a failure response. Non-absence errors keep the row's ownership
intact; `ENOENT` and `ENOTDIR` safely proceed. Episode and season download
actions require an authoritative global list. A
successful snapshot remains authoritative while a later background refresh
is in flight; a latest refresh failure leaves
loading/empty-state resolution intact but disables starts until another
snapshot succeeds. Overlapping download-list callers join one serialized
trailing refresh, so responses commit in request order and frequent progress
events cannot perpetually postpone a waiting series action.
- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`
for both providers; Stalker playback identifiers only resolve the URL. Exact
`(playlistId, contentType, xtreamId)` matches are authoritative, while
complete playlist/series/season/episode coordinates are a fail-closed legacy
fallback that migrates reusable rows to the canonical id. Numeric season
zero, including fallback key `"0"`, remains a valid Specials coordinate for
both providers. Stalker persists
`episode_identity_scope` separately for regular `/series`, embedded VOD
`series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not
match; a pre-scope coordinate row is ambiguous and blocked, while an exact
canonical legacy row remains authoritative. Renderer lookup preserves that
ambiguity or conflicting ownership as a distinct ineligible state, so
neither the episode action nor the season count treats it as a row-less
download. SQLite `null` and optional `undefined` coordinates both mean an
incomplete canonical legacy row, matching the backend resolver. Pending and
active rows plus completed available/unknown rows are skipped; failed,
canceled, completed-missing, and unambiguous row-less episodes remain
eligible.
- Ready movie and grouped-series cards open a focused local detail. Movies play
the finalized local file; series list only locally available episode rows and
every episode action targets its own downloaded file. Focused routes disable
@@ -0,0 +1,191 @@
import { mkdirSync } from 'fs';
import { join } from 'path';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamImportToFinish,
} from './electron-test-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
const downloadQueueCredentials = {
password: 'downloadqueue',
username: 'downloadqueue',
};
const expectedEpisodeIds = [80_000, 80_001, 80_002, 80_003];
type EpisodeQueueRow = {
episodeNumber: number | null;
seriesXtreamId: number | null;
status: string;
xtreamId: number;
};
const expectedEpisodeQueue: EpisodeQueueRow[] = [
{
episodeNumber: 1,
seriesXtreamId: 30_000,
status: 'downloading',
xtreamId: expectedEpisodeIds[0],
},
{
episodeNumber: 2,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[1],
},
{
episodeNumber: 3,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[2],
},
{
episodeNumber: 4,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[3],
},
];
type SeriesFixture = Awaited<ReturnType<typeof fetchXtreamSeriesFixture>>;
function waitForQueueStabilityInterval(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_500));
}
function readFirstSeriesTitle(fixture: SeriesFixture): string {
const firstSeries = fixture.items[0];
const title = firstSeries ? getXtreamTitle(firstSeries) : '';
if (
!title ||
Number(firstSeries?.series_id) !== 30_000 ||
fixture.items.length !== 4
) {
throw new Error('Series download queue fixture is invalid.');
}
return title;
}
async function readEpisodeQueue(page: Page): Promise<EpisodeQueueRow[]> {
return page.evaluate(async () => {
const downloads = (await window.electron?.downloadsGetList?.()) ?? [];
return downloads
.filter((download) => download.contentType === 'episode')
.map((download) => ({
episodeNumber: download.episodeNumber ?? null,
seriesXtreamId: download.seriesXtreamId ?? null,
status: download.status,
xtreamId: download.xtreamId,
}));
});
}
test.describe('Electron Series Download Queue', () => {
test('@downloads @xtream @electron queues individual and season episode downloads without duplicates', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
downloadQueueCredentials
);
const firstSeriesTitle = readFirstSeriesTitle(seriesFixture);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...downloadQueueCredentials,
name: 'Series Download Queue',
});
await waitForXtreamImportToFinish(app.mainWindow);
await app.mainWindow
.getByRole('button', { name: 'Open downloads' })
.click();
await app.mainWindow.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
const downloadsDir = join(dataDir, 'series-downloads');
mkdirSync(downloadsDir, { recursive: true });
await app.electronApp.evaluate(({ dialog }, folder) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [folder],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, downloadsDir);
await app.mainWindow
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.getByTestId('downloads-folder')
).toContainText('series-downloads');
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, firstSeriesTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
);
const firstEpisodeAction = app.mainWindow.getByTestId(
`episode-download-${expectedEpisodeIds[0]}`
);
const secondEpisodeAction = app.mainWindow.getByTestId(
`episode-download-${expectedEpisodeIds[1]}`
);
await expect(firstEpisodeAction).toBeEnabled({ timeout: 20_000 });
await expect(secondEpisodeAction).toBeEnabled();
await firstEpisodeAction.click();
await secondEpisodeAction.click();
await expect
.poll(() => readEpisodeQueue(app.mainWindow), {
timeout: 20_000,
})
.toEqual(expectedEpisodeQueue.slice(0, 2));
const seasonAction = app.mainWindow.getByTestId('download-season');
await expect(seasonAction).toBeVisible();
await expect(seasonAction).toContainText('Download season (2)');
await expect(seasonAction).toBeEnabled();
await seasonAction.click();
await expect(
app.mainWindow.getByText('Added 2 · Skipped 2', {
exact: true,
})
).toBeVisible({ timeout: 20_000 });
await expect
.poll(() => readEpisodeQueue(app.mainWindow), {
timeout: 20_000,
})
.toEqual(expectedEpisodeQueue);
await expect(seasonAction).toBeDisabled();
await expect(seasonAction).toContainText('Download season (0)');
await waitForQueueStabilityInterval();
expect(await readEpisodeQueue(app.mainWindow)).toEqual(
expectedEpisodeQueue
);
} finally {
await closeElectronApp(app);
}
});
});
@@ -1,6 +1,8 @@
import {
createDownloadFileAvailabilityProbe,
decorateDownloadItem,
getDownloadFileAvailability,
getDownloadFileAvailabilityWithTimeoutAsync,
isAvailableDownloadFile,
type DownloadLstat,
} from './download-file-availability';
@@ -16,6 +18,54 @@ function lstatResult(options: {
}
describe('download file availability', () => {
it.each([
['EACCES', 'unknown'],
['EIO', 'unknown'],
['ENOENT', 'missing'],
['ENOTDIR', 'missing'],
] as const)(
'classifies an %s filesystem probe without risking a completed row',
async (code, expected) => {
const error = Object.assign(new Error(code), { code });
const probe = createDownloadFileAvailabilityProbe(async () => {
throw error;
});
await expect(
getDownloadFileAvailabilityWithTimeoutAsync(
{
filePath: '/downloads/episode.mp4',
status: 'completed',
},
25,
probe
)
).resolves.toBe(expected);
}
);
it('bounds a restored-file probe and returns unknown on timeout', async () => {
jest.useFakeTimers();
try {
const probe = jest.fn(() => new Promise<boolean>(() => undefined));
const result = getDownloadFileAvailabilityWithTimeoutAsync(
{
filePath: '/downloads/unresponsive/episode.mp4',
status: 'completed',
},
25,
probe
);
await jest.advanceTimersByTimeAsync(25);
await expect(result).resolves.toBe('unknown');
expect(probe).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
it('marks only a completed regular non-symbolic-link file available', () => {
const lstat = lstatResult({ isFile: true });
@@ -21,21 +21,40 @@ type DownloadAsyncLstat = (
filePath: string
) => Promise<Pick<Stats, 'isFile' | 'isSymbolicLink'>>;
type DownloadFileAvailabilityProbeResult = boolean | 'unknown';
type DownloadFileAvailabilityProbe = (
filePath: string
) => Promise<boolean>;
) => Promise<DownloadFileAvailabilityProbeResult>;
const DEFAULT_MAX_CONCURRENT_FILE_PROBES = 4;
const DEFAULT_FILE_PROBE_TIMEOUT_MS = 1_000;
function createDownloadFileAvailabilityProbe(
export type BoundedDownloadFileAvailability =
ElectronDownloadFileAvailability | 'unknown';
function isMissingFileSystemError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('code' in error)) {
return false;
}
const code = (error as { code?: unknown }).code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
export function createDownloadFileAvailabilityProbe(
asyncLstat: DownloadAsyncLstat = lstat,
maxConcurrent = DEFAULT_MAX_CONCURRENT_FILE_PROBES
): DownloadFileAvailabilityProbe {
const concurrency = Math.max(1, Math.floor(maxConcurrent));
const pending: Array<() => void> = [];
// Coalesce only active probes. Completed results are discarded so an
// externally removed file is visible on the next list refresh.
const inFlight = new Map<string, Promise<boolean>>();
// Coalesce unfinished probes, including work waiting for a slot. Caller
// deadlines never evict this raw operation, so a stalled lstat remains
// charged to the concurrency cap instead of being duplicated by refreshes.
// Completed results are discarded so later filesystem changes stay visible.
const inFlight = new Map<
string,
Promise<DownloadFileAvailabilityProbeResult>
>();
let active = 0;
const acquire = (): Promise<void> => {
@@ -55,13 +74,15 @@ function createDownloadFileAvailabilityProbe(
}
};
const inspect = async (filePath: string): Promise<boolean> => {
const inspect = async (
filePath: string
): Promise<DownloadFileAvailabilityProbeResult> => {
await acquire();
try {
const stats = await asyncLstat(filePath);
return stats.isFile() && !stats.isSymbolicLink();
} catch {
return false;
} catch (error) {
return isMissingFileSystemError(error) ? false : 'unknown';
} finally {
release();
}
@@ -87,6 +108,40 @@ function createDownloadFileAvailabilityProbe(
const probeDownloadFileAvailability = createDownloadFileAvailabilityProbe();
function toBoundedDownloadFileAvailability(
available: DownloadFileAvailabilityProbeResult
): BoundedDownloadFileAvailability {
if (available === 'unknown') {
return available;
}
return available ? 'available' : 'missing';
}
async function probeDownloadFileAvailabilityWithTimeout(
filePath: string,
timeoutMs: number,
probe: DownloadFileAvailabilityProbe
): Promise<DownloadFileAvailabilityProbeResult> {
const boundedTimeoutMs =
Number.isFinite(timeoutMs) && timeoutMs >= 0
? timeoutMs
: DEFAULT_FILE_PROBE_TIMEOUT_MS;
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<'unknown'>((resolve) => {
timeout = setTimeout(() => resolve('unknown'), boundedTimeoutMs);
});
try {
return await Promise.race([probe(filePath), timedOut]);
} catch {
return 'unknown';
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
export function isAvailableDownloadFile(
filePath: string | null | undefined,
lstat: DownloadLstat = lstatSync
@@ -128,7 +183,34 @@ export async function getDownloadFileAvailabilityAsync(
return 'missing';
}
return (await probe(download.filePath)) ? 'available' : 'missing';
const available = await probeDownloadFileAvailabilityWithTimeout(
download.filePath,
DEFAULT_FILE_PROBE_TIMEOUT_MS,
probe
);
return available === true ? 'available' : 'missing';
}
export async function getDownloadFileAvailabilityWithTimeoutAsync(
download: DownloadFileRow,
timeoutMs = DEFAULT_FILE_PROBE_TIMEOUT_MS,
probe: DownloadFileAvailabilityProbe = probeDownloadFileAvailability
): Promise<BoundedDownloadFileAvailability> {
if (download.status !== 'completed') {
return 'not-applicable';
}
if (!download.filePath) {
return 'missing';
}
return toBoundedDownloadFileAvailability(
await probeDownloadFileAvailabilityWithTimeout(
download.filePath,
timeoutMs,
probe
)
);
}
export function decorateDownloadItem<T extends DownloadFileRow>(
@@ -0,0 +1,120 @@
import {
createPartialDownloadCleanup,
removePartialDownloadFileAsync,
} from './download-partial-cleanup';
describe('bounded partial download cleanup', () => {
it.each([
['EACCES', 'unknown'],
['EIO', 'unknown'],
['ENOENT', 'missing'],
['ENOTDIR', 'missing'],
] as const)(
'classifies an %s unlink result as %s',
async (code, expected) => {
const error = Object.assign(new Error(code), { code });
const unlink = jest.fn(async () => {
throw error;
});
const cleanup = createPartialDownloadCleanup(unlink);
await expect(
removePartialDownloadFileAsync(
'/downloads/episode.mp4',
25,
cleanup
)
).resolves.toBe(expected);
expect(unlink).toHaveBeenCalledWith('/downloads/episode.mp4.part');
}
);
it('removes the partial asynchronously', async () => {
const unlink = jest.fn(async () => undefined);
const cleanup = createPartialDownloadCleanup(unlink);
await expect(
removePartialDownloadFileAsync(
'/downloads/episode.mp4',
25,
cleanup
)
).resolves.toBe('removed');
expect(unlink).toHaveBeenCalledWith('/downloads/episode.mp4.part');
});
it('waits for a started unlink and coalesces same-path callers', async () => {
jest.useFakeTimers();
try {
let finish: (() => void) | undefined;
const unlink = jest.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve;
})
);
const cleanup = createPartialDownloadCleanup(unlink, 1);
const first = removePartialDownloadFileAsync(
'/downloads/episode.mp4',
25,
cleanup
);
const second = removePartialDownloadFileAsync(
'/downloads/episode.mp4',
50,
cleanup
);
let firstSettled = false;
void first.then(() => {
firstSettled = true;
});
await jest.advanceTimersByTimeAsync(25);
expect(firstSettled).toBe(false);
expect(unlink).toHaveBeenCalledTimes(1);
finish?.();
await expect(first).resolves.toBe('removed');
await expect(second).resolves.toBe('removed');
expect(unlink).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
it('times out queued cleanup without unlinking it later', async () => {
jest.useFakeTimers();
try {
let finishFirst: (() => void) | undefined;
const unlink = jest.fn((partialPath: string) =>
partialPath.includes('first')
? new Promise<void>((resolve) => {
finishFirst = resolve;
})
: Promise.resolve()
);
const cleanup = createPartialDownloadCleanup(unlink, 1);
const first = removePartialDownloadFileAsync(
'/downloads/first.mp4',
25,
cleanup
);
const queued = removePartialDownloadFileAsync(
'/downloads/queued.mp4',
25,
cleanup
);
await jest.advanceTimersByTimeAsync(25);
await expect(queued).resolves.toBe('unknown');
expect(unlink).toHaveBeenCalledTimes(1);
finishFirst?.();
await expect(first).resolves.toBe('removed');
await Promise.resolve();
expect(unlink).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,133 @@
import { unlink } from 'node:fs/promises';
import { getPartialDownloadPath } from './download-file-path';
export type DownloadPartialCleanupResult = 'removed' | 'missing' | 'unknown';
export type DownloadAsyncUnlink = (filePath: string) => Promise<void>;
export type DownloadPartialCleanup = (
filePath: string,
admissionTimeoutMs?: number
) => Promise<DownloadPartialCleanupResult>;
const DEFAULT_MAX_CONCURRENT_PARTIAL_CLEANUPS = 4;
const DEFAULT_PARTIAL_CLEANUP_TIMEOUT_MS = 1_000;
function isMissingFileSystemError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('code' in error)) {
return false;
}
const code = (error as { code?: unknown }).code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
export function createPartialDownloadCleanup(
asyncUnlink: DownloadAsyncUnlink = unlink,
maxConcurrent = DEFAULT_MAX_CONCURRENT_PARTIAL_CLEANUPS
): DownloadPartialCleanup {
const concurrency = Math.max(1, Math.floor(maxConcurrent));
const pending: Array<() => void> = [];
// Only admission can time out. Once unlink starts, every coalesced caller
// awaits its authoritative result so no late deletion can race a retry.
const inFlight = new Map<string, Promise<DownloadPartialCleanupResult>>();
let active = 0;
const acquire = (timeoutMs: number): Promise<boolean> => {
if (active < concurrency) {
active += 1;
return Promise.resolve(true);
}
return new Promise((resolve) => {
let settled = false;
const start = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(true);
};
const timeout = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const index = pending.indexOf(start);
if (index >= 0) {
pending.splice(index, 1);
}
resolve(false);
}, timeoutMs);
pending.push(start);
});
};
const release = (): void => {
const next = pending.shift();
if (next) {
next();
} else {
active -= 1;
}
};
const remove = async (
filePath: string,
admissionTimeoutMs: number
): Promise<DownloadPartialCleanupResult> => {
if (!(await acquire(admissionTimeoutMs))) {
return 'unknown';
}
try {
await asyncUnlink(getPartialDownloadPath(filePath));
return 'removed';
} catch (error) {
return isMissingFileSystemError(error) ? 'missing' : 'unknown';
} finally {
release();
}
};
return (
filePath: string,
admissionTimeoutMs = DEFAULT_PARTIAL_CLEANUP_TIMEOUT_MS
) => {
const existing = inFlight.get(filePath);
if (existing) {
return existing;
}
const cleanup = remove(filePath, admissionTimeoutMs);
inFlight.set(filePath, cleanup);
const clear = () => {
if (inFlight.get(filePath) === cleanup) {
inFlight.delete(filePath);
}
};
void cleanup.then(clear, clear);
return cleanup;
};
}
const cleanupPartialDownloadFile = createPartialDownloadCleanup();
export async function removePartialDownloadFileAsync(
filePath: string | null | undefined,
admissionTimeoutMs = DEFAULT_PARTIAL_CLEANUP_TIMEOUT_MS,
cleanup: DownloadPartialCleanup = cleanupPartialDownloadFile
): Promise<DownloadPartialCleanupResult> {
if (!filePath) {
return 'missing';
}
const boundedAdmissionTimeoutMs =
Number.isFinite(admissionTimeoutMs) && admissionTimeoutMs >= 0
? admissionTimeoutMs
: DEFAULT_PARTIAL_CLEANUP_TIMEOUT_MS;
try {
return await cleanup(filePath, boundedAdmissionTimeoutMs);
} catch {
return 'unknown';
}
}
@@ -0,0 +1,272 @@
import * as schema from '../../database/schema';
import type { DownloadsDatabase } from './download-task';
import {
type ExistingDownloadIdentityResolution,
resolveExistingDownloadIdentity,
} from './download-request-identity';
type DownloadRow = typeof schema.downloads.$inferSelect;
const episodeRequest = {
contentType: 'episode' as const,
episodeNumber: 3,
playlistId: 'playlist-1',
seasonNumber: 2,
seriesXtreamId: 100,
xtreamId: 700,
};
function createDownloadRow(overrides: Partial<DownloadRow> = {}): DownloadRow {
return {
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
episodeNumber: 3,
episodeIdentityScope: null,
errorMessage: null,
fileName: 'episode.mp4',
filePath: null,
id: 42,
metadataSnapshot: null,
playlistId: 'playlist-1',
posterUrl: null,
requestHeaders: null,
resumeValidator: null,
seasonNumber: 2,
seriesXtreamId: 100,
status: 'canceled',
title: 'Episode 3',
totalBytes: null,
updatedAt: '2026-08-02 10:00:00',
url: 'https://example.test/episode.mp4',
xtreamId: 700,
...overrides,
};
}
function createQueryHarness(
canonicalRows: DownloadRow[],
coordinateRows: DownloadRow[] = []
) {
let queryIndex = 0;
const limit = jest.fn((value: number) => {
const rows = queryIndex === 0 ? canonicalRows : coordinateRows;
queryIndex += 1;
return Promise.resolve(rows.slice(0, value));
});
const where = jest.fn(() => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
return {
db: { select } as unknown as DownloadsDatabase,
limit,
select,
};
}
describe('resolveExistingDownloadIdentity', () => {
it('ignores a coordinate row owned by another explicit episode identity scope', async () => {
const otherModeRow = createDownloadRow({
xtreamId: 77,
episodeIdentityScope: 'stalker-lazy-vod',
});
const harness = createQueryHarness([], [otherModeRow]);
const scopedRequest = {
...episodeRequest,
episodeIdentityScope: 'stalker-regular-series' as const,
};
await expect(
resolveExistingDownloadIdentity(harness.db, scopedRequest)
).resolves.toEqual({ kind: 'none' });
});
it('fails closed for an unscoped coordinate row when the request has an explicit scope', async () => {
const legacyRow = createDownloadRow({ xtreamId: 77 });
const harness = createQueryHarness([], [legacyRow]);
const scopedRequest = {
...episodeRequest,
episodeIdentityScope: 'stalker-regular-series' as const,
};
await expect(
resolveExistingDownloadIdentity(harness.db, scopedRequest)
).resolves.toEqual({ kind: 'conflict' });
});
it('fails closed for an incomplete canonical row owned by another explicit scope', async () => {
const canonicalRow = createDownloadRow({
episodeIdentityScope: 'stalker-lazy-vod',
seriesXtreamId: null,
});
const harness = createQueryHarness([canonicalRow], []);
await expect(
resolveExistingDownloadIdentity(harness.db, {
...episodeRequest,
episodeIdentityScope: 'stalker-regular-series',
})
).resolves.toEqual({ kind: 'conflict' });
});
it('returns a canonical match without migration', async () => {
const row = createDownloadRow();
const harness = createQueryHarness([row], []);
const resolution: ExistingDownloadIdentityResolution =
await resolveExistingDownloadIdentity(harness.db, episodeRequest);
expect(resolution).toEqual({
item: row,
kind: 'match',
migrateCanonicalId: false,
});
expect(harness.limit).toHaveBeenNthCalledWith(1, 1);
expect(harness.limit).toHaveBeenNthCalledWith(2, 2);
});
it('returns a legacy coordinate match with canonical migration', async () => {
const row = createDownloadRow({ xtreamId: 77 });
const harness = createQueryHarness([], [row]);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({
item: row,
kind: 'match',
migrateCanonicalId: true,
});
});
it('fails closed when canonical and coordinate lookups find different rows', async () => {
const harness = createQueryHarness(
[createDownloadRow()],
[createDownloadRow({ id: 43, xtreamId: 77 })]
);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({ kind: 'conflict' });
});
it('fails closed when the canonical row has conflicting complete coordinates', async () => {
const harness = createQueryHarness(
[createDownloadRow({ episodeNumber: 4 })],
[]
);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({ kind: 'conflict' });
});
it.each([
['series id', { seriesXtreamId: Number.MAX_SAFE_INTEGER + 1 }],
['season number', { seasonNumber: 2.5 }],
['episode number', { episodeNumber: Number.NaN }],
])(
'fails closed when the canonical row has a present but unsafe %s',
async (_label, override) => {
const harness = createQueryHarness(
[createDownloadRow(override)],
[]
);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({ kind: 'conflict' });
}
);
it('fails closed when multiple rows share the legacy coordinates', async () => {
const harness = createQueryHarness(
[],
[
createDownloadRow({ id: 42, xtreamId: 77 }),
createDownloadRow({ id: 43, xtreamId: 78 }),
]
);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({ kind: 'conflict' });
});
it('performs only the canonical lookup for VOD', async () => {
const row = createDownloadRow({
contentType: 'vod',
episodeNumber: null,
seasonNumber: null,
seriesXtreamId: null,
});
const harness = createQueryHarness([row]);
await expect(
resolveExistingDownloadIdentity(harness.db, {
contentType: 'vod',
playlistId: episodeRequest.playlistId,
xtreamId: episodeRequest.xtreamId,
})
).resolves.toEqual({
item: row,
kind: 'match',
migrateCanonicalId: false,
});
expect(harness.limit).toHaveBeenCalledTimes(1);
expect(harness.limit).toHaveBeenCalledWith(1);
});
it.each([
['series id', { seriesXtreamId: undefined }],
['season number', { seasonNumber: undefined }],
['episode number', { episodeNumber: undefined }],
['unsafe series id', { seriesXtreamId: Number.MAX_SAFE_INTEGER + 1 }],
['unsafe season number', { seasonNumber: Number.NaN }],
['unsafe episode number', { episodeNumber: 1.5 }],
])(
'performs only the canonical lookup when an episode has an incomplete or unsafe %s',
async (_label, override) => {
const row = createDownloadRow({
episodeNumber: null,
seasonNumber: null,
seriesXtreamId: null,
});
const harness = createQueryHarness([row]);
await expect(
resolveExistingDownloadIdentity(harness.db, {
...episodeRequest,
...override,
})
).resolves.toEqual({
item: row,
kind: 'match',
migrateCanonicalId: false,
});
expect(harness.limit).toHaveBeenCalledTimes(1);
expect(harness.limit).toHaveBeenCalledWith(1);
}
);
it('lets the canonical match win when both lookups find the same row', async () => {
const row = createDownloadRow();
const harness = createQueryHarness([row], [row]);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({
item: row,
kind: 'match',
migrateCanonicalId: false,
});
});
it('returns none when neither lookup finds a row', async () => {
const harness = createQueryHarness([], []);
await expect(
resolveExistingDownloadIdentity(harness.db, episodeRequest)
).resolves.toEqual({ kind: 'none' });
});
});
@@ -0,0 +1,196 @@
import { and, eq, isNull, or } from 'drizzle-orm';
import type { ElectronBridgeEpisodeIdentityScope } from '@iptvnator/shared/interfaces';
import * as schema from '../../database/schema';
import type { DownloadsDatabase } from './download-task';
type DownloadRow = typeof schema.downloads.$inferSelect;
export const DOWNLOAD_IDENTITY_KIND = {
CONFLICT: 'conflict',
MATCH: 'match',
NONE: 'none',
} as const;
interface DownloadIdentityNone {
readonly kind: typeof DOWNLOAD_IDENTITY_KIND.NONE;
}
interface DownloadIdentityConflict {
readonly kind: typeof DOWNLOAD_IDENTITY_KIND.CONFLICT;
}
interface DownloadIdentityMatch {
readonly item: DownloadRow;
readonly kind: typeof DOWNLOAD_IDENTITY_KIND.MATCH;
readonly migrateCanonicalId: boolean;
}
export type ExistingDownloadIdentityResolution =
DownloadIdentityNone | DownloadIdentityConflict | DownloadIdentityMatch;
export interface DownloadIdentityRequest {
contentType: DownloadRow['contentType'];
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistId: string;
seasonNumber?: number;
seriesXtreamId?: number;
xtreamId: number;
}
function isSafeInteger(value: number | null | undefined): value is number {
return Number.isSafeInteger(value);
}
function rowHasConflictingCoordinates(
row: DownloadRow,
request: Required<
Pick<
DownloadIdentityRequest,
'episodeNumber' | 'seasonNumber' | 'seriesXtreamId'
>
> &
Pick<DownloadIdentityRequest, 'episodeIdentityScope'>
): boolean {
const { episodeNumber, seasonNumber, seriesXtreamId } = row;
if (
request.episodeIdentityScope !== undefined &&
row.episodeIdentityScope !== null &&
row.episodeIdentityScope !== request.episodeIdentityScope
) {
return true;
}
if (
seriesXtreamId === null ||
seriesXtreamId === undefined ||
seasonNumber === null ||
seasonNumber === undefined ||
episodeNumber === null ||
episodeNumber === undefined
) {
return false;
}
return (
!isSafeInteger(seriesXtreamId) ||
!isSafeInteger(seasonNumber) ||
!isSafeInteger(episodeNumber) ||
seriesXtreamId !== request.seriesXtreamId ||
seasonNumber !== request.seasonNumber ||
episodeNumber !== request.episodeNumber
);
}
export async function resolveExistingDownloadIdentity(
db: DownloadsDatabase,
request: DownloadIdentityRequest
): Promise<ExistingDownloadIdentityResolution> {
const canonicalRows = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, request.playlistId),
eq(schema.downloads.contentType, request.contentType),
eq(schema.downloads.xtreamId, request.xtreamId)
)
)
.limit(1);
const canonicalRow = canonicalRows[0];
const seriesXtreamId = request.seriesXtreamId;
const seasonNumber = request.seasonNumber;
const episodeNumber = request.episodeNumber;
const episodeIdentityScope = request.episodeIdentityScope;
if (
request.contentType !== 'episode' ||
!isSafeInteger(seriesXtreamId) ||
!isSafeInteger(seasonNumber) ||
!isSafeInteger(episodeNumber)
) {
return canonicalRow
? {
item: canonicalRow,
kind: DOWNLOAD_IDENTITY_KIND.MATCH,
migrateCanonicalId: false,
}
: { kind: DOWNLOAD_IDENTITY_KIND.NONE };
}
const coordinateRows = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, request.playlistId),
eq(schema.downloads.contentType, 'episode'),
eq(schema.downloads.seriesXtreamId, seriesXtreamId),
eq(schema.downloads.seasonNumber, seasonNumber),
eq(schema.downloads.episodeNumber, episodeNumber),
episodeIdentityScope === undefined
? isNull(schema.downloads.episodeIdentityScope)
: or(
eq(
schema.downloads.episodeIdentityScope,
episodeIdentityScope
),
isNull(schema.downloads.episodeIdentityScope)
)
)
)
.limit(2);
const unscopedCoordinateRows = coordinateRows.filter(
(row) => row.episodeIdentityScope == null
);
if (
episodeIdentityScope !== undefined &&
unscopedCoordinateRows.some((row) => row.id !== canonicalRow?.id)
) {
return { kind: DOWNLOAD_IDENTITY_KIND.CONFLICT };
}
const compatibleCoordinateRows = coordinateRows.filter((row) =>
episodeIdentityScope === undefined
? row.episodeIdentityScope == null
: row.episodeIdentityScope === episodeIdentityScope
);
if (compatibleCoordinateRows.length > 1) {
return { kind: DOWNLOAD_IDENTITY_KIND.CONFLICT };
}
const coordinateRow = compatibleCoordinateRows[0];
if (canonicalRow && coordinateRow && canonicalRow.id !== coordinateRow.id) {
return { kind: DOWNLOAD_IDENTITY_KIND.CONFLICT };
}
if (
canonicalRow &&
rowHasConflictingCoordinates(canonicalRow, {
episodeNumber,
episodeIdentityScope,
seasonNumber,
seriesXtreamId,
})
) {
return { kind: DOWNLOAD_IDENTITY_KIND.CONFLICT };
}
if (canonicalRow) {
return {
item: canonicalRow,
kind: DOWNLOAD_IDENTITY_KIND.MATCH,
migrateCanonicalId: false,
};
}
if (coordinateRow) {
return {
item: coordinateRow,
kind: DOWNLOAD_IDENTITY_KIND.MATCH,
migrateCanonicalId: true,
};
}
return { kind: DOWNLOAD_IDENTITY_KIND.NONE };
}
@@ -1,4 +1,5 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { Download } from '../../database/schema';
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
const metadataSnapshot: DownloadMetadataSnapshot = {
@@ -9,14 +10,18 @@ const metadataSnapshot: DownloadMetadataSnapshot = {
};
async function setupStartMetadataRequest(
existing: Record<string, unknown> | undefined
existing: Record<string, unknown> | undefined,
coordinateRows: Record<string, unknown>[] = [],
completedFileAvailability: 'available' | 'missing' | 'unknown' = 'missing'
) {
jest.resetModules();
const schema = await import('../../database/schema');
const playlistLimit = jest.fn().mockResolvedValue([{ id: 'playlist-1' }]);
const downloadLimit = jest
.fn()
.mockResolvedValue(existing ? [existing] : []);
const downloadLimit = jest.fn((limit: number) =>
Promise.resolve(
limit === 2 ? coordinateRows : existing ? [existing] : []
)
);
const from = jest.fn((table: unknown) => ({
where: jest.fn(() => ({
limit: table === schema.playlists ? playlistLimit : downloadLimit,
@@ -36,6 +41,15 @@ async function setupStartMetadataRequest(
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const getDownloadFileAvailabilityAsync = jest.fn(
async () => completedFileAvailability
);
const getDownloadFileAvailabilityWithTimeoutAsync = jest.fn(
async () => completedFileAvailability
);
const isAvailableDownloadFile = jest.fn(
() => completedFileAvailability === 'available'
);
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
@@ -49,18 +63,55 @@ async function setupStartMetadataRequest(
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
jest.doMock('./download-file-availability', () => ({
getDownloadFileAvailabilityAsync,
getDownloadFileAvailabilityWithTimeoutAsync,
isAvailableDownloadFile,
}));
const { startDownloadRequest } = await import('./download-requests');
return {
authorizer,
db,
downloadLimit,
enqueueDownload,
getDownloadFileAvailabilityAsync,
getDownloadFileAvailabilityWithTimeoutAsync,
insertValues,
isAvailableDownloadFile,
set,
startDownloadRequest,
};
}
function createStartDownloadRow(overrides: Partial<Download> = {}): Download {
return {
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
episodeNumber: 3,
episodeIdentityScope: null,
errorMessage: null,
fileName: 'episode.mp4',
filePath: null,
id: 42,
metadataSnapshot: null,
playlistId: 'playlist-1',
posterUrl: null,
requestHeaders: null,
resumeValidator: null,
seasonNumber: 2,
seriesXtreamId: 100,
status: 'canceled',
title: 'Episode 3',
totalBytes: null,
updatedAt: '2026-08-02 10:00:00',
url: 'https://example.test/episode.mp4',
xtreamId: 77,
...overrides,
};
}
function startPayload(
snapshot?: DownloadMetadataSnapshot,
contentType: 'vod' | 'episode' = 'vod'
@@ -76,6 +127,18 @@ function startPayload(
};
}
function episodeStartPayload() {
return {
...startPayload(undefined, 'episode'),
episodeNumber: 3,
seasonNumber: 2,
seriesXtreamId: 100,
title: 'Episode 3',
url: 'https://example.test/episode.mp4',
xtreamId: 700,
};
}
describe('download request metadata snapshots', () => {
it('persists an encoded snapshot for a new download', async () => {
const request = await setupStartMetadataRequest(undefined);
@@ -115,6 +178,7 @@ describe('download request metadata snapshots', () => {
expect(request.set.mock.calls[0][0]).not.toHaveProperty(
'metadataSnapshot'
);
expect(request.set.mock.calls[0][0]).not.toHaveProperty('xtreamId');
});
it('replaces stored metadata when a restart supplies a snapshot', async () => {
@@ -383,6 +447,199 @@ describe('download request metadata snapshots', () => {
});
});
describe('download request identity resolution', () => {
it('skips a completed download whose file became available after the list snapshot', async () => {
const completedRow = createStartDownloadRow({
filePath: '/downloads/restored-episode.mp4',
status: 'completed',
xtreamId: 700,
});
const request = await setupStartMetadataRequest(
completedRow,
[completedRow],
'available'
);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({
error: 'Download already completed',
id: completedRow.id,
reason: 'already-downloaded',
success: false,
});
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.db.update).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
expect(
request.getDownloadFileAvailabilityWithTimeoutAsync
).toHaveBeenCalledWith(completedRow);
expect(request.getDownloadFileAvailabilityAsync).not.toHaveBeenCalled();
expect(request.isAvailableDownloadFile).not.toHaveBeenCalled();
});
it('fails closed when a completed-file recheck times out', async () => {
const completedRow = createStartDownloadRow({
filePath: '/downloads/unresponsive/episode.mp4',
status: 'completed',
xtreamId: 700,
});
const request = await setupStartMetadataRequest(
completedRow,
[completedRow],
'unknown'
);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({
error: 'Could not verify the completed download file',
id: completedRow.id,
success: false,
});
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.db.update).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
});
it.each(['queued', 'downloading', 'paused'] as const)(
'returns the stable duplicate result for an active legacy-coordinate %s row',
async (status) => {
const legacyRow = createStartDownloadRow({ status });
const request = await setupStartMetadataRequest(undefined, [
legacyRow,
]);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({
error: 'Download already in progress',
id: legacyRow.id,
reason: 'already-in-progress',
success: false,
});
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.db.update).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
}
);
it('keeps the stable duplicate reason for an exact active row', async () => {
const exactRow = createStartDownloadRow({
status: 'queued',
xtreamId: 700,
});
const request = await setupStartMetadataRequest(exactRow, [exactRow]);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({
error: 'Download already in progress',
id: exactRow.id,
reason: 'already-in-progress',
success: false,
});
expect(request.enqueueDownload).not.toHaveBeenCalled();
});
it.each(['failed', 'canceled', 'completed'] as const)(
'reuses and migrates a legacy-coordinate %s row before restart',
async (status) => {
const legacyRow = createStartDownloadRow({ status });
const request = await setupStartMetadataRequest(undefined, [
legacyRow,
]);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({ id: legacyRow.id, success: true });
expect(request.set).toHaveBeenCalledTimes(1);
expect(request.set).toHaveBeenCalledWith(
expect.objectContaining({
status: 'queued',
xtreamId: episodeStartPayload().xtreamId,
})
);
expect(request.enqueueDownload).toHaveBeenCalledWith(
expect.objectContaining({ id: legacyRow.id })
);
}
);
it('rejects conflicting canonical and coordinate rows without enqueueing', async () => {
const canonicalRow = createStartDownloadRow({
episodeNumber: null,
seasonNumber: null,
seriesXtreamId: null,
xtreamId: 700,
});
const coordinateRow = createStartDownloadRow({ id: 43 });
const request = await setupStartMetadataRequest(canonicalRow, [
coordinateRow,
]);
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).resolves.toEqual({
error: 'Download identity conflict',
success: false,
});
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.db.update).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
});
it('propagates a rejected canonical-id migration before enqueueing', async () => {
const migrationError = new Error(
'UNIQUE constraint failed: downloads.xtream_id'
);
const legacyRow = createStartDownloadRow({ status: 'canceled' });
const request = await setupStartMetadataRequest(undefined, [legacyRow]);
request.set.mockReturnValueOnce({
where: jest.fn().mockRejectedValue(migrationError),
});
await expect(
request.startDownloadRequest(
episodeStartPayload(),
request.authorizer
)
).rejects.toBe(migrationError);
expect(request.set).toHaveBeenCalledWith(
expect.objectContaining({
xtreamId: episodeStartPayload().xtreamId,
})
);
expect(request.db.insert).not.toHaveBeenCalled();
expect(request.enqueueDownload).not.toHaveBeenCalled();
});
});
describe('download requests resume', () => {
it('adds the Xtream fallback User-Agent to a legacy paused row', async () => {
jest.resetModules();
@@ -630,82 +887,91 @@ describe('download requests resume', () => {
});
});
it('deletes the retained partial before re-downloading a failed row from scratch', async () => {
jest.resetModules();
it.each(['failed', 'canceled'] as const)(
'deletes the retained partial asynchronously before re-downloading a %s row from scratch',
async (status) => {
jest.resetModules();
const failedRow = {
contentType: 'vod',
filePath: '/downloads/movie.mp4',
id: 42,
playlistId: 'playlist-1',
status: 'failed',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
};
const limit = jest
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce([failedRow]);
const set = jest.fn<{ where: jest.Mock }, [Record<string, unknown>]>(
() => ({
const terminalRow = {
contentType: 'vod',
filePath: '/downloads/movie.mp4',
id: 42,
playlistId: 'playlist-1',
status,
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
};
const limit = jest
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce([terminalRow]);
const set = jest.fn<
{ where: jest.Mock },
[Record<string, unknown>]
>(() => ({
where: jest.fn().mockResolvedValue(undefined),
})
);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
})),
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const removePartialDownloadFile = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const removePartialDownloadFileAsync = jest.fn(
async () => 'removed' as const
);
const authorizer = {
requireAuthorized: jest.fn(
async (directory: string) => directory
),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-partial-cleanup', () => ({
removePartialDownloadFileAsync,
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { startDownloadRequest } = await import('./download-requests');
const { startDownloadRequest } =
await import('./download-requests');
await expect(
startDownloadRequest(
{
contentType: 'vod',
downloadFolder: '/downloads',
playlistId: 'playlist-1',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
},
authorizer
)
).resolves.toEqual({ id: 42, success: true });
await expect(
startDownloadRequest(
{
contentType: 'vod',
downloadFolder: '/downloads',
playlistId: 'playlist-1',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
},
authorizer
)
).resolves.toEqual({ id: 42, success: true });
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: null,
resumeValidator: null,
status: 'queued',
})
);
});
expect(removePartialDownloadFileAsync).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: null,
resumeValidator: null,
status: 'queued',
})
);
}
);
it('fails the re-download when the retained partial cannot be deleted', async () => {
jest.resetModules();
@@ -736,9 +1002,9 @@ describe('download requests resume', () => {
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
});
const removePartialDownloadFileAsync = jest.fn(
async () => 'unknown' as const
);
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
@@ -749,8 +1015,8 @@ describe('download requests resume', () => {
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile,
jest.doMock('./download-partial-cleanup', () => ({
removePartialDownloadFileAsync,
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
@@ -1,11 +1,18 @@
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type {
DownloadMetadataSnapshot,
ElectronBridgeEpisodeIdentityScope,
ElectronBridgeDownloadStartResult,
} from '@iptvnator/shared/interfaces';
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
import { and, eq, sql } from 'drizzle-orm';
import { basename, dirname, extname } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { removePartialDownloadFile } from './download-file-path';
import { getDownloadFileAvailabilityWithTimeoutAsync } from './download-file-availability';
import { removePartialDownloadFileAsync } from './download-partial-cleanup';
import { resolveExistingDownloadIdentity } from './download-request-identity';
import { resolveStoredDownloadHeaders } from './download-request-headers';
import {
assertDownloadMetadataArtworkDiffersFromStream,
@@ -28,6 +35,7 @@ export interface StartDownloadRequest {
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
@@ -84,7 +92,7 @@ function serializeHeaders(
export async function startDownloadRequest(
data: StartDownloadRequest,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string; id?: number }> {
): Promise<ElectronBridgeDownloadStartResult> {
const encodedMetadataSnapshot =
data.metadataSnapshot === undefined
? undefined
@@ -113,22 +121,18 @@ export async function startDownloadRequest(
.from(schema.playlists)
.where(eq(schema.playlists.id, data.playlistId))
.limit(1);
const existing = await db
.select()
.from(schema.downloads)
.where(
and(
eq(schema.downloads.playlistId, data.playlistId),
eq(schema.downloads.xtreamId, data.xtreamId),
eq(schema.downloads.contentType, data.contentType)
)
)
.limit(1);
const identity = await resolveExistingDownloadIdentity(db, data);
if (identity.kind === 'conflict') {
return {
error: 'Download identity conflict',
success: false,
};
}
const fileName = createFileName(data.title, data.url);
const headers = createHeaders(data.headers);
if (existing.length > 0) {
const item = existing[0];
if (identity.kind === 'match') {
const item = identity.item;
if (normalizedMetadataSnapshot) {
assertDownloadMetadataMatchesContentType(
normalizedMetadataSnapshot,
@@ -143,25 +147,45 @@ export async function startDownloadRequest(
data.url
);
}
if (item.contentType === 'episode' && item.status === 'completed') {
const completedFileAvailability =
await getDownloadFileAvailabilityWithTimeoutAsync(item);
if (completedFileAvailability === 'unknown') {
return {
error: 'Could not verify the completed download file',
id: item.id,
success: false,
};
}
if (completedFileAvailability === 'available') {
return {
error: 'Download already completed',
id: item.id,
reason: ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyDownloaded,
success: false,
};
}
}
if (!['completed', 'failed', 'canceled'].includes(item.status)) {
return {
error: 'Download already in progress',
id: item.id,
reason: 'already-in-progress',
success: false,
};
}
if (item.status === 'failed' && item.filePath) {
// A failed row can still reference a retained .part; delete it
if (
['completed', 'failed', 'canceled'].includes(item.status) &&
item.filePath
) {
// A terminal row can still reference a retained .part; delete it
// before the restart clears filePath, or the file is orphaned.
// A locked .part must keep its database owner, so fail the
// restart instead of proceeding without the cleanup.
try {
removePartialDownloadFile(item.filePath);
} catch (error) {
// An unavailable or slow .part must keep its database owner.
const cleanup = await removePartialDownloadFileAsync(item.filePath);
if (cleanup === 'unknown') {
console.error(
'[Downloads] Failed to delete retained partial before re-download:',
error
'[Downloads] Could not verify retained partial cleanup'
);
return {
error: 'Could not delete the previous partial file',
@@ -187,6 +211,12 @@ export async function startDownloadRequest(
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
url: data.url,
...(identity.migrateCanonicalId
? { xtreamId: data.xtreamId }
: {}),
...(data.episodeIdentityScope === undefined
? {}
: { episodeIdentityScope: data.episodeIdentityScope }),
})
.where(eq(schema.downloads.id, item.id));
enqueueDownload({
@@ -227,6 +257,7 @@ export async function startDownloadRequest(
const result = await db.insert(schema.downloads).values({
contentType: data.contentType,
episodeNumber: data.episodeNumber,
episodeIdentityScope: data.episodeIdentityScope,
fileName,
metadataSnapshot: encodedMetadataSnapshot,
playlistId: data.playlistId,
@@ -77,6 +77,109 @@ describe('downloads events: file availability', () => {
await expect(response).resolves.toHaveLength(2);
});
it('bounds each refresh without duplicating an unresponsive filesystem probe', async () => {
jest.useFakeTimers();
try {
const row = {
filePath: '/downloads/unresponsive/movie.mp4',
id: 1,
status: 'completed',
};
const orderBy = jest.fn().mockResolvedValue([row]);
mockGetDatabase.mockResolvedValue({
select: jest.fn(() => ({
from: jest.fn(() => ({ orderBy })),
})),
});
let finishFirstProbe!: (
value: ReturnType<typeof regularFile>
) => void;
mockLstat
.mockReturnValueOnce(
new Promise((resolve) => {
finishFirstProbe = resolve;
})
)
.mockResolvedValueOnce(regularFile());
const timedOutRefresh = getHandler('DOWNLOADS_GET_LIST')(null);
await jest.advanceTimersByTimeAsync(1_000);
await expect(timedOutRefresh).resolves.toEqual([
{
...row,
metadataSnapshot: undefined,
fileAvailability: 'missing',
},
]);
const secondTimedOutRefresh =
getHandler('DOWNLOADS_GET_LIST')(null);
await jest.advanceTimersByTimeAsync(1_000);
await expect(secondTimedOutRefresh).resolves.toEqual([
{
...row,
metadataSnapshot: undefined,
fileAvailability: 'missing',
},
]);
expect(mockLstat).toHaveBeenCalledTimes(1);
finishFirstProbe(regularFile());
await jest.advanceTimersByTimeAsync(0);
await expect(
getHandler('DOWNLOADS_GET_LIST')(null)
).resolves.toEqual([
{
...row,
metadataSnapshot: undefined,
fileAvailability: 'available',
},
]);
expect(mockLstat).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
}, 500);
it('starts the list deadline before a completed-file probe waits for a slot', async () => {
jest.useFakeTimers();
try {
const rows = Array.from({ length: 5 }, (_, index) => ({
filePath: `/downloads/offline/movie-${index}.mp4`,
id: index + 1,
status: 'completed',
}));
const orderBy = jest.fn().mockResolvedValue(rows);
mockGetDatabase.mockResolvedValue({
select: jest.fn(() => ({
from: jest.fn(() => ({ orderBy })),
})),
});
mockLstat.mockImplementation(
() =>
new Promise(() => {
// Keep all four shared filesystem slots occupied.
})
);
const refresh = getHandler('DOWNLOADS_GET_LIST')(null);
await jest.advanceTimersByTimeAsync(1_000);
await expect(refresh).resolves.toEqual(
rows.map((row) => ({
...row,
metadataSnapshot: undefined,
fileAvailability: 'missing',
}))
);
expect(mockLstat).toHaveBeenCalledTimes(4);
} finally {
jest.useRealTimers();
}
}, 500);
it('starts at most four completed-file probes concurrently', async () => {
const rows = Array.from({ length: 6 }, (_, index) => ({
filePath: `/downloads/network/movie-${index}.mp4`,
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "التنزيلات",
"DOWNLOAD": "تنزيل",
"PLAY_LOCAL": "تشغيل محلي",
"DOWNLOAD_SEASON": "تنزيل الموسم ({{count}})",
"DOWNLOAD_SEASON_ARIA": "تنزيل الموسم، تتوفر {{count}} حلقات",
"ADDING_TO_QUEUE": "جارٍ الإضافة إلى قائمة الانتظار…",
"EPISODE_DOWNLOAD_ARIA": "تنزيل {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} موجود بالفعل في قائمة انتظار التنزيل",
"EPISODE_DOWNLOAD_FAILED": "تعذّرت إضافة الحلقة إلى التنزيلات.",
"SEASON_QUEUE_RESULT": "تمت الإضافة: {{added}} · تم التخطي: {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "تمت الإضافة: {{added}} · تم التخطي: {{skipped}} · فشل: {{failed}}",
"EMPTY": "لا توجد تنزيلات بعد",
"EMPTY_HINT": "نزّل الأفلام والحلقات للمشاهدة دون اتصال",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "التحميلات",
"DOWNLOAD": "تحميل",
"PLAY_LOCAL": "شغل محلياً",
"DOWNLOAD_SEASON": "تحميل الموسم ({{count}})",
"DOWNLOAD_SEASON_ARIA": "تحميل الموسم، كاينين {{count}} حلقات متوفرين",
"ADDING_TO_QUEUE": "كيتزادو للطابور…",
"EPISODE_DOWNLOAD_ARIA": "تحميل {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} راه ديجا فطابور التحميل",
"EPISODE_DOWNLOAD_FAILED": "ما قدرناش نزيدو الحلقة للتحميلات.",
"SEASON_QUEUE_RESULT": "تزادو {{added}} · تخطّاو {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "تزادو {{added}} · تخطّاو {{skipped}} · فشلو {{failed}}",
"EMPTY": "مازال ما كاينش تحميلات",
"EMPTY_HINT": "حمل أفلام وحلقات باش تشاف بدون أنترنت",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Спампаванні",
"DOWNLOAD": "Спампаваць",
"PLAY_LOCAL": "Прайграць лакальна",
"DOWNLOAD_SEASON": "Спампаваць сезон ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Спампаваць сезон, даступна эпізодаў: {{count}}",
"ADDING_TO_QUEUE": "Дадаванне ў чаргу…",
"EPISODE_DOWNLOAD_ARIA": "Спампаваць {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} ужо ў чарзе спампаванняў",
"EPISODE_DOWNLOAD_FAILED": "Не ўдалося дадаць эпізод да спампаванняў.",
"SEASON_QUEUE_RESULT": "Дададзена: {{added}} · Прапушчана: {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Дададзена: {{added}} · Прапушчана: {{skipped}} · Памылак: {{failed}}",
"EMPTY": "Пакуль няма спампаванняў",
"EMPTY_HINT": "Спампоўвайце фільмы і эпізоды, каб глядзець іх без падключэння",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Downloads",
"DOWNLOAD": "Herunterladen",
"PLAY_LOCAL": "Lokal abspielen",
"DOWNLOAD_SEASON": "Staffel herunterladen ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Staffel herunterladen, {{count}} Episoden verfügbar",
"ADDING_TO_QUEUE": "Wird zur Warteschlange hinzugefügt…",
"EPISODE_DOWNLOAD_ARIA": "{{title}} herunterladen",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} befindet sich bereits in der Download-Warteschlange",
"EPISODE_DOWNLOAD_FAILED": "Die Episode konnte nicht zu den Downloads hinzugefügt werden.",
"SEASON_QUEUE_RESULT": "Hinzugefügt {{added}} · Übersprungen {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Hinzugefügt {{added}} · Übersprungen {{skipped}} · Fehlgeschlagen {{failed}}",
"EMPTY": "Noch keine Downloads",
"EMPTY_HINT": "Filme und Episoden zum Offline-Ansehen herunterladen",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Λήψεις",
"DOWNLOAD": "Λήψη",
"PLAY_LOCAL": "Τοπική αναπαραγωγή",
"DOWNLOAD_SEASON": "Λήψη σεζόν ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Λήψη σεζόν, {{count}} διαθέσιμα επεισόδια",
"ADDING_TO_QUEUE": "Προσθήκη στην ουρά…",
"EPISODE_DOWNLOAD_ARIA": "Λήψη {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "Το {{title}} βρίσκεται ήδη στην ουρά λήψεων",
"EPISODE_DOWNLOAD_FAILED": "Δεν ήταν δυνατή η προσθήκη του επεισοδίου στις λήψεις.",
"SEASON_QUEUE_RESULT": "Προστέθηκαν {{added}} · Παραλείφθηκαν {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Προστέθηκαν {{added}} · Παραλείφθηκαν {{skipped}} · Απέτυχαν {{failed}}",
"EMPTY": "Δεν υπάρχουν ακόμα λήψεις",
"EMPTY_HINT": "Κατεβάστε ταινίες και επεισόδια για παρακολούθηση εκτός σύνδεσης",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Downloads",
"DOWNLOAD": "Download",
"PLAY_LOCAL": "Play Local",
"DOWNLOAD_SEASON": "Download season ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Download season, {{count}} episodes available",
"ADDING_TO_QUEUE": "Adding to queue…",
"EPISODE_DOWNLOAD_ARIA": "Download {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} is already in the download queue",
"EPISODE_DOWNLOAD_FAILED": "The episode could not be added to downloads.",
"SEASON_QUEUE_RESULT": "Added {{added}} · Skipped {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Added {{added}} · Skipped {{skipped}} · Failed {{failed}}",
"EMPTY": "No downloads yet",
"EMPTY_HINT": "Download movies and episodes to watch offline",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Descargas",
"DOWNLOAD": "Descargar",
"PLAY_LOCAL": "Reproducir local",
"DOWNLOAD_SEASON": "Descargar temporada ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Descargar temporada, {{count}} episodios disponibles",
"ADDING_TO_QUEUE": "Añadiendo a la cola…",
"EPISODE_DOWNLOAD_ARIA": "Descargar {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} ya está en la cola de descargas",
"EPISODE_DOWNLOAD_FAILED": "No se pudo añadir el episodio a las descargas.",
"SEASON_QUEUE_RESULT": "Añadidos {{added}} · Omitidos {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Añadidos {{added}} · Omitidos {{skipped}} · Fallidos {{failed}}",
"EMPTY": "Sin descargas aún",
"EMPTY_HINT": "Descarga películas y episodios para ver sin conexión",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Téléchargements",
"DOWNLOAD": "Télécharger",
"PLAY_LOCAL": "Lire local",
"DOWNLOAD_SEASON": "Télécharger la saison ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Télécharger la saison, {{count}} épisodes disponibles",
"ADDING_TO_QUEUE": "Ajout à la file d’attente…",
"EPISODE_DOWNLOAD_ARIA": "Télécharger {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} est déjà dans la file d’attente des téléchargements",
"EPISODE_DOWNLOAD_FAILED": "L’épisode n’a pas pu être ajouté aux téléchargements.",
"SEASON_QUEUE_RESULT": "Ajoutés {{added}} · Ignorés {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Ajoutés {{added}} · Ignorés {{skipped}} · Échoués {{failed}}",
"EMPTY": "Aucun téléchargement",
"EMPTY_HINT": "Téléchargez des films et épisodes pour les regarder hors ligne",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Letöltések",
"DOWNLOAD": "Letöltés",
"PLAY_LOCAL": "Helyi fájl lejátszása",
"DOWNLOAD_SEASON": "Évad letöltése ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Évad letöltése, {{count}} epizód érhető el",
"ADDING_TO_QUEUE": "Hozzáadás a várólistához…",
"EPISODE_DOWNLOAD_ARIA": "{{title}} letöltése",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} már szerepel a letöltési várólistán",
"EPISODE_DOWNLOAD_FAILED": "Az epizódot nem sikerült hozzáadni a letöltésekhez.",
"SEASON_QUEUE_RESULT": "Hozzáadva: {{added}} · Kihagyva: {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Hozzáadva: {{added}} · Kihagyva: {{skipped}} · Sikertelen: {{failed}}",
"EMPTY": "Még nincs letöltés",
"EMPTY_HINT": "Töltsön le filmeket és epizódokat offline megtekintéshez.",
"NO_PLAYLISTS_TITLE": "A letöltéshez előbb adjon hozzá egy lejátszási listát",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Download",
"DOWNLOAD": "Scarica",
"PLAY_LOCAL": "Riproduci locale",
"DOWNLOAD_SEASON": "Scarica la stagione ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Scarica la stagione, {{count}} episodi disponibili",
"ADDING_TO_QUEUE": "Aggiunta alla coda…",
"EPISODE_DOWNLOAD_ARIA": "Scarica {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} è già nella coda dei download",
"EPISODE_DOWNLOAD_FAILED": "Non è stato possibile aggiungere l'episodio ai download.",
"SEASON_QUEUE_RESULT": "Aggiunti {{added}} · Ignorati {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Aggiunti {{added}} · Ignorati {{skipped}} · Non riusciti {{failed}}",
"EMPTY": "Ancora nessun download",
"EMPTY_HINT": "Scarica film ed episodi per guardarli offline",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "ダウンロード",
"DOWNLOAD": "ダウンロード",
"PLAY_LOCAL": "ローカル再生",
"DOWNLOAD_SEASON": "シーズンをダウンロード({{count}})",
"DOWNLOAD_SEASON_ARIA": "シーズンをダウンロード、ダウンロード可能なエピソードは{{count}}話です",
"ADDING_TO_QUEUE": "キューに追加中…",
"EPISODE_DOWNLOAD_ARIA": "{{title}}をダウンロード",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}}はすでにダウンロードキューに追加されています",
"EPISODE_DOWNLOAD_FAILED": "エピソードをダウンロード一覧に追加できませんでした。",
"SEASON_QUEUE_RESULT": "追加 {{added}} · スキップ {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "追加 {{added}} · スキップ {{skipped}} · 失敗 {{failed}}",
"EMPTY": "ダウンロードはまだありません",
"EMPTY_HINT": "映画やエピソードをダウンロードしてオフラインで視聴できます",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "다운로드",
"DOWNLOAD": "다운로드",
"PLAY_LOCAL": "로컬 재생",
"DOWNLOAD_SEASON": "시즌 다운로드 ({{count}})",
"DOWNLOAD_SEASON_ARIA": "시즌 다운로드, 다운로드 가능한 에피소드 {{count}}개",
"ADDING_TO_QUEUE": "대기열에 추가 중…",
"EPISODE_DOWNLOAD_ARIA": "{{title}} 다운로드",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} 항목은 이미 다운로드 대기열에 있습니다",
"EPISODE_DOWNLOAD_FAILED": "에피소드를 다운로드 목록에 추가하지 못했습니다.",
"SEASON_QUEUE_RESULT": "추가 {{added}} · 건너뜀 {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "추가 {{added}} · 건너뜀 {{skipped}} · 실패 {{failed}}",
"EMPTY": "아직 다운로드한 항목이 없습니다",
"EMPTY_HINT": "오프라인 시청을 위해 영화와 에피소드를 다운로드하세요",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Downloads",
"DOWNLOAD": "Downloaden",
"PLAY_LOCAL": "Lokaal afspelen",
"DOWNLOAD_SEASON": "Seizoen downloaden ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Seizoen downloaden, {{count}} afleveringen beschikbaar",
"ADDING_TO_QUEUE": "Toevoegen aan wachtrij…",
"EPISODE_DOWNLOAD_ARIA": "{{title}} downloaden",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} staat al in de downloadwachtrij",
"EPISODE_DOWNLOAD_FAILED": "De aflevering kon niet aan de downloads worden toegevoegd.",
"SEASON_QUEUE_RESULT": "Toegevoegd {{added}} · Overgeslagen {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Toegevoegd {{added}} · Overgeslagen {{skipped}} · Mislukt {{failed}}",
"EMPTY": "Nog geen downloads",
"EMPTY_HINT": "Download films en afleveringen om offline te bekijken",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Pobrane",
"DOWNLOAD": "Pobierz",
"PLAY_LOCAL": "Odtwórz lokalnie",
"DOWNLOAD_SEASON": "Pobierz sezon ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Pobierz sezon, dostępnych odcinków: {{count}}",
"ADDING_TO_QUEUE": "Dodawanie do kolejki…",
"EPISODE_DOWNLOAD_ARIA": "Pobierz {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} jest już w kolejce pobierania",
"EPISODE_DOWNLOAD_FAILED": "Nie udało się dodać odcinka do pobrań.",
"SEASON_QUEUE_RESULT": "Dodano {{added}} · Pominięto {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Dodano {{added}} · Pominięto {{skipped}} · Nieudane {{failed}}",
"EMPTY": "Brak pobranych",
"EMPTY_HINT": "Pobierz filmy i odcinki, aby oglądać offline",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Downloads",
"DOWNLOAD": "Baixar",
"PLAY_LOCAL": "Reproduzir local",
"DOWNLOAD_SEASON": "Baixar temporada ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Baixar temporada, {{count}} episódios disponíveis",
"ADDING_TO_QUEUE": "Adicionando à fila…",
"EPISODE_DOWNLOAD_ARIA": "Baixar {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} já está na fila de downloads",
"EPISODE_DOWNLOAD_FAILED": "Não foi possível adicionar o episódio aos downloads.",
"SEASON_QUEUE_RESULT": "Adicionados {{added}} · Ignorados {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Adicionados {{added}} · Ignorados {{skipped}} · Falhas {{failed}}",
"EMPTY": "Nenhum download ainda",
"EMPTY_HINT": "Baixe filmes e episódios para assistir offline",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "Загрузки",
"DOWNLOAD": "Скачать",
"PLAY_LOCAL": "Воспроизвести локально",
"DOWNLOAD_SEASON": "Скачать сезон ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Скачать сезон, доступно эпизодов: {{count}}",
"ADDING_TO_QUEUE": "Добавление в очередь…",
"EPISODE_DOWNLOAD_ARIA": "Скачать {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} уже в очереди загрузок",
"EPISODE_DOWNLOAD_FAILED": "Не удалось добавить эпизод в загрузки.",
"SEASON_QUEUE_RESULT": "Добавлено: {{added}} · Пропущено: {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Добавлено: {{added}} · Пропущено: {{skipped}} · Ошибок: {{failed}}",
"EMPTY": "Пока нет загрузок",
"EMPTY_HINT": "Скачивайте фильмы и эпизоды для просмотра офлайн",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "İndirilenler",
"DOWNLOAD": "İndir",
"PLAY_LOCAL": "Yerel Oynat",
"DOWNLOAD_SEASON": "Sezonu indir ({{count}})",
"DOWNLOAD_SEASON_ARIA": "Sezonu indir, indirilebilir {{count}} bölüm var",
"ADDING_TO_QUEUE": "Sıraya ekleniyor…",
"EPISODE_DOWNLOAD_ARIA": "{{title}} indir",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} zaten indirme kuyruğunda",
"EPISODE_DOWNLOAD_FAILED": "Bölüm indirilenlere eklenemedi.",
"SEASON_QUEUE_RESULT": "Eklendi {{added}} · Atlandı {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "Eklendi {{added}} · Atlandı {{skipped}} · Başarısız {{failed}}",
"EMPTY": "Henüz indirme yok",
"EMPTY_HINT": "Çevrimdışı izlemek için film ve dizileri indirin",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "下载",
"DOWNLOAD": "下载",
"PLAY_LOCAL": "播放本地",
"DOWNLOAD_SEASON": "下载整季({{count}})",
"DOWNLOAD_SEASON_ARIA": "下载整季,有 {{count}} 集可用",
"ADDING_TO_QUEUE": "正在添加到队列…",
"EPISODE_DOWNLOAD_ARIA": "下载 {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} 已在下载队列中",
"EPISODE_DOWNLOAD_FAILED": "无法添加该剧集的下载任务。",
"SEASON_QUEUE_RESULT": "已添加 {{added}} · 已跳过 {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "已添加 {{added}} · 已跳过 {{skipped}} · 失败 {{failed}}",
"EMPTY": "暂无下载",
"EMPTY_HINT": "下载电影和剧集以离线观看",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
+8
View File
@@ -1054,6 +1054,14 @@
"TITLE": "下載",
"DOWNLOAD": "下載",
"PLAY_LOCAL": "播放本機檔案",
"DOWNLOAD_SEASON": "下載整季({{count}})",
"DOWNLOAD_SEASON_ARIA": "下載整季,有 {{count}} 集可用",
"ADDING_TO_QUEUE": "正在加入佇列…",
"EPISODE_DOWNLOAD_ARIA": "下載 {{title}}",
"EPISODE_DOWNLOAD_PENDING_ARIA": "{{title}} 已在下載佇列中",
"EPISODE_DOWNLOAD_FAILED": "無法加入此集的下載項目。",
"SEASON_QUEUE_RESULT": "已加入 {{added}} · 已略過 {{skipped}}",
"SEASON_QUEUE_RESULT_WITH_FAILURES": "已加入 {{added}} · 已略過 {{skipped}} · 失敗 {{failed}}",
"EMPTY": "尚無下載項目",
"EMPTY_HINT": "下載電影與集數以離線觀看",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
@@ -21,6 +21,8 @@ export interface ScenarioConfig {
performanceFixture?: 'catalog-100k';
/** Build series details on demand instead of during portal initialization. */
deferSeriesDetails?: true;
/** Optional local stream fixture for deterministic download queue tests. */
downloadStreamFixture?: 'slow-series';
}
/**
@@ -99,6 +101,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'downloadqueue:downloadqueue': {
name: 'download-queue',
description:
'Download queue — 1 series category, 4 series with 4 episodes each',
seed: 8080,
categoryCount: { live: 0, vod: 0, series: 1 },
itemsPerCategory: 4,
seasonsPerSeries: 1,
episodesPerSeason: 4,
accountStatus: 'Active',
expiryDate: '2099-12-31',
downloadStreamFixture: 'slow-series',
},
'epg:epg': {
name: 'epg-fixture',
description:
@@ -1,9 +1,14 @@
import { connect } from 'node:net';
import express from 'express';
import { resetAll } from './data-store.js';
import {
createXtreamMockApp,
parseXtreamMockServerEnvironment,
} from './server.js';
import {
type SlowSeriesDownloadOptions,
streamSlowSeriesDownload,
} from './slow-series-download.js';
import { startLoopbackServer } from './testing/http-server.fixture.js';
jest.mock('@faker-js/faker', () => {
@@ -117,6 +122,38 @@ describe('Xtream mock server factory', () => {
}
});
it('serves the download queue series fixture locally without changing ordinary series redirects', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
);
try {
const localSeries = await fetch(
`${running.origin}/series/downloadqueue/downloadqueue/80000.mkv`,
{ redirect: 'manual' }
);
const ordinarySeries = await fetch(
`${running.origin}/series/user1/pass1/80000.mkv`,
{ redirect: 'manual' }
);
expect(localSeries.status).toBe(200);
expect(localSeries.headers.get('content-type')).toContain(
'video/mp4'
);
expect(
Number(localSeries.headers.get('content-length'))
).toBeGreaterThan(1024 * 1024);
await localSeries.body?.cancel();
expect(ordinarySeries.status).toBe(302);
expect(ordinarySeries.headers.get('location')).toBe(
'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'
);
} finally {
await running.close();
}
});
it('keeps a non-EPG timezone stream empty across repeated short-EPG requests', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
@@ -192,6 +229,64 @@ describe('Xtream mock server factory', () => {
});
});
describe('Slow series download stream', () => {
it('completes the configured byte count over a loopback response', async () => {
const options = {
chunkSize: 1_024,
intervalMs: 1,
totalBytes: 10 * 1_024 + 7,
} satisfies SlowSeriesDownloadOptions;
const running = await startLoopbackServer(
createSlowSeriesDownloadApp(options)
);
let closed = false;
try {
const response = await fetch(`${running.origin}/slow-series`);
const body = await response.arrayBuffer();
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('video/mp4');
expect(Number(response.headers.get('content-length'))).toBe(
options.totalBytes
);
expect(body.byteLength).toBe(options.totalBytes);
await within(running.close(), 1_000);
closed = true;
} finally {
if (!closed) await running.close().catch(() => undefined);
}
});
it('stops a longer recursive stream after the response body is cancelled', async () => {
const options = {
chunkSize: 1_024,
intervalMs: 25,
totalBytes: 64 * 1_024,
} satisfies SlowSeriesDownloadOptions;
const running = await startLoopbackServer(
createSlowSeriesDownloadApp(options)
);
let closed = false;
try {
const response = await fetch(`${running.origin}/slow-series`);
const reader = response.body?.getReader();
if (!reader) throw new Error('Expected a streaming response body.');
const firstChunk = await within(reader.read(), 1_000);
expect(firstChunk.done).toBe(false);
expect(firstChunk.value?.byteLength).toBeGreaterThan(0);
await within(reader.cancel(), 1_000);
await within(running.close(), 1_000);
closed = true;
} finally {
if (!closed) await running.close().catch(() => undefined);
}
});
});
describe('Xtream mock environment parsing', () => {
it('uses safe defaults and enables control only for the exact flag', () => {
expect(parseXtreamMockServerEnvironment({})).toEqual({
@@ -271,3 +366,28 @@ async function rawLoopbackGet(
});
});
}
function createSlowSeriesDownloadApp(
options: SlowSeriesDownloadOptions
): express.Express {
const app = express();
app.get('/slow-series', (request, response) => {
streamSlowSeriesDownload(request, response, options);
});
return app;
}
async function within<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Operation exceeded ${timeoutMs}ms.`)),
timeoutMs
);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
+22 -1
View File
@@ -14,6 +14,8 @@ import {
XtreamPerformanceController,
} from './performance-control.js';
import { dispatchAction } from './routes/dispatch.js';
import { getScenario } from './scenarios.js';
import { streamSlowSeriesDownload } from './slow-series-download.js';
export { createXtreamMockServerShutdown } from './server-lifecycle.js';
@@ -251,10 +253,21 @@ function installStreamRoutes(
}
response.redirect(HLS_STUB);
};
const seriesResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
if (isSlowSeriesDownloadRequest(request)) {
streamSlowSeriesDownload(request, response);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
app.get('/live/:username/:password/:streamId.ts', streamResponse);
app.get('/movie/:username/:password/:streamId.:ext', streamResponse);
app.get('/series/:username/:password/:streamId.:ext', streamResponse);
app.get('/series/:username/:password/:streamId.:ext', seriesResponse);
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
streamResponse
@@ -262,6 +275,14 @@ function installStreamRoutes(
app.all('/streaming/timeshift.php', streamResponse);
}
function isSlowSeriesDownloadRequest(request: Request): boolean {
const username = String(request.params['username'] ?? '');
const password = String(request.params['password'] ?? '');
return (
getScenario(username, password).downloadStreamFixture === 'slow-series'
);
}
function isPerformanceMediaRequest(
request: Request,
controlEnabled: boolean
@@ -0,0 +1,93 @@
import type { Request, Response } from 'express';
export interface SlowSeriesDownloadOptions {
readonly chunkSize: number;
readonly intervalMs: number;
readonly totalBytes: number;
}
export const DEFAULT_SLOW_SERIES_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
chunkSize: 32 * 1024,
intervalMs: 100,
totalBytes: 8 * 1024 * 1024,
};
export function streamSlowSeriesDownload(
request: Request,
response: Response,
options: SlowSeriesDownloadOptions = DEFAULT_SLOW_SERIES_DOWNLOAD_OPTIONS
): void {
validateOptions(options);
const chunkBuffer = Buffer.alloc(options.chunkSize);
let sentBytes = 0;
let stopped = false;
let timer: NodeJS.Timeout | undefined;
function stop(): void {
if (stopped) return;
stopped = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
request.off('close', stop);
response.off('close', stop);
response.off('finish', stop);
response.off('drain', scheduleChunk);
}
function scheduleChunk(): void {
if (!stopped && !timer) {
timer = setTimeout(writeChunk, options.intervalMs);
}
}
function writeChunk(): void {
timer = undefined;
if (stopped || response.destroyed || response.writableEnded) {
stop();
return;
}
const remainingBytes = options.totalBytes - sentBytes;
const chunk =
remainingBytes >= chunkBuffer.length
? chunkBuffer
: chunkBuffer.subarray(0, remainingBytes);
sentBytes += chunk.length;
const canContinue = response.write(chunk);
if (sentBytes >= options.totalBytes) {
response.end();
} else if (canContinue) {
scheduleChunk();
} else {
response.once('drain', scheduleChunk);
}
}
request.once('close', stop);
response.once('close', stop);
response.once('finish', stop);
response
.status(200)
.type('video/mp4')
.set('Content-Length', String(options.totalBytes))
.set('Cache-Control', 'no-store')
.flushHeaders();
scheduleChunk();
}
function validateOptions(options: SlowSeriesDownloadOptions): void {
if (!Number.isSafeInteger(options.totalBytes) || options.totalBytes <= 0) {
throw new Error('Slow download totalBytes must be a positive integer.');
}
if (!Number.isSafeInteger(options.chunkSize) || options.chunkSize <= 0) {
throw new Error('Slow download chunkSize must be a positive integer.');
}
if (!Number.isSafeInteger(options.intervalMs) || options.intervalMs < 0) {
throw new Error(
'Slow download intervalMs must be a non-negative integer.'
);
}
}
+75 -5
View File
@@ -54,11 +54,81 @@ variants, contextual buttons, and theme-aware styling.
`DownloadsService.downloads` is the renderer's authoritative **global** list.
`loadDownloads()` therefore always invokes the Electron list IPC without its
legacy optional playlist scope. Route scope, category, and search must never
replace or narrow that signal. Overlapping loads are request-ordered so a
late response cannot replace a newer snapshot. Before each fresh download or
resume the service asks the main process for an authorized folder and calls
the corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a
new global load.
replace or narrow that signal. Loads are serialized: at most one list IPC is
active, and callers arriving during it coalesce behind one trailing refresh.
Each response therefore commits in request order. A caller assigned to the
trailing refresh resolves after that refresh settles even when later
broadcasts queue the following refresh, preventing both stale continuation
and starvation during frequent progress updates.
`hasLoadedDownloads` records that the latest attempt completed, including an
error, while
`hasAuthoritativeDownloadList` becomes true after a successful request,
remains true while a later refresh is in flight, and clears only if the
latest request fails. Series download actions require both loaded and
authoritative state so a failed refresh cannot restart rows missing from a
stale or empty renderer snapshot. Before each fresh download or resume the
service asks the main process for an authorized folder and calls the
corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a new
global load.
- **Series season queueing**
`SeasonDownloadCoordinator` owns synchronous, per-identity pending
reservations and submits an individual episode or selected-season snapshot
through `DownloadsService.startDownload()`. Season batches are sequential
and best-effort: one candidate failing does not stop later candidates. After
added or stable duplicate submissions, one authoritative list refresh closes
the pending-to-queued/downloaded handoff, and the coordinator returns
`added`, `skipped`, and `failed` counts. Xtream and Stalker adapters own
provider URL, request header, and metadata preparation; the coordinator owns
only provider-neutral orchestration. When a reserved candidate matches a
completed-missing row, the coordinator performs one authoritative preflight
refresh before any provider preparation. If another list request is active,
the preflight joins the single trailing refresh; later download-update
broadcasts cannot delay that assigned refresh. Restored files therefore
become stable skips without requiring a Stalker URL/network request. Both
providers use normalized `episode.id` as the canonical
episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only
in URL resolution. Provider adapters preserve numeric season zero, including
a fallback season key of `"0"`, so Specials keep distinct `S00` coordinates.
The exact `(playlistId, contentType, xtreamId)` identity is authoritative.
Complete `(playlistId, seriesXtreamId, seasonNumber, episodeNumber)`
coordinates are a legacy episode-compatibility fallback. Stalker also stores
an `episode_identity_scope` for regular `/series`, embedded VOD `series[]`,
and lazy Ministra VOD `is_series` origins. A known different scope is a
different episode owner; an older coordinate row without a provable scope
fails closed instead of being migrated across modes. Exact canonical legacy
rows remain authoritative. Other ambiguous or conflicting matches resolve to
the same explicit renderer conflict state rather than masquerading as a
missing row, so both the episode action and season count fail closed.
SQLite `null` and optional `undefined` coordinates both mean that a canonical
legacy row is incomplete, matching the backend resolver. Renderer-pending,
queued, downloading, and paused episodes are skipped, as are completed rows
whose file is available or whose availability is still unknown. Failed,
canceled, completed-missing, and unambiguous row-less episodes are eligible;
a completed-missing row is restarted as a fresh download. Before resetting
such a completed row, `DOWNLOADS_START` asynchronously rechecks its retained
path in the main process. A restored file returns stable
`reason: 'already-downloaded'` without mutation; active matches return
`reason: 'already-in-progress'`. The recheck has a one-second caller deadline
that starts before shared-slot acquisition; timeout or probe failure leaves
the row untouched and returns a failed submission, allowing the sequential
season loop to continue. Completed-file list callers have the same deadline
and report a timeout as missing for that snapshot. The underlying filesystem
operation remains coalesced and charged against the four-probe cap until it
actually settles, so later callers get independent bounded waits without
duplicating stalled native work. Only `ENOENT` and `ENOTDIR` are authoritative
absence; permission, I/O, and other probe errors remain unknown, so
`DOWNLOADS_START` leaves the completed row and file path untouched. Before a
completed-missing, failed, or canceled row clears its path, the same start IPC
asynchronously removes any retained `.part`. Cleanup coalesces same-path work
and allows at most four underlying unlinks. A one-second admission deadline
rejects queued work before it can mutate the filesystem; once an unlink
starts, the request awaits its authoritative result so no late side effect can
race a retry. Permission and I/O failures keep the row's ownership intact,
while `ENOENT` and `ENOTDIR` safely proceed. The coordinator counts both stable
duplicate reasons as skipped. There is no batch IPC,
parallel transfer, or queue reordering: destination authorization, persisted
header handling, and the backend's one-active-transfer FIFO semantics remain
unchanged.
- **Pure manager model**
(`download-manager.viewmodel.ts` and `download-library.viewmodel.ts`)
derives the current route scope, search/category filtering, queue partitions,
+6
View File
@@ -538,6 +538,12 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string
Series inline playback behavior is shared across all three modes:
- Episode downloads preserve those three origins separately through the
persisted `episode_identity_scope`. Coordinate compatibility may reuse only
a row with the same proven scope. A pre-scope coordinate row is ambiguous and
blocks the action rather than binding regular, embedded, or lazy VOD content
to another mode; an exact canonical episode id still wins.
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
- Embedded players receive previous/next episode state for the current season only.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,502 @@
# Season Download Queue Design
**Date:** 2026-08-01
**Status:** Approved
**Scope:** Xtream and Stalker series details in the Electron application
## Summary
IPTVnator will let a user enqueue several episode downloads one after another
and enqueue every eligible episode in the currently selected season. The
existing Electron transfer model remains unchanged: one active transfer and a
FIFO queue behind it.
Download availability becomes episode-specific. A pending, queued,
downloading, paused, or locally available completed episode cannot be enqueued
again, while unrelated episodes remain actionable. Failed, canceled, and
completed-but-missing episodes are eligible for a fresh download. Season batch
processing is best-effort and reports how many episodes were added, skipped,
and failed.
The shared season UI owns presentation and interaction state. A
provider-neutral coordinator owns pending identities, eligibility, sequential
submission, and result aggregation. Xtream and Stalker adapters remain
responsible for producing provider-specific download requests. No batch IPC,
transfer concurrency, or database schema change is introduced.
## Context and Confirmed Findings
- `download-runtime.ts` already processes one active transfer at a time and
appends other tasks to an in-memory FIFO queue.
- The downloads table already has a unique index on
`(xtream_id, playlist_id, content_type)`, and the runtime also rejects a
duplicate managed row id.
- `DownloadsService.downloads` is the renderer's authoritative global list and
is refreshed through the existing download update broadcast.
- `SeasonContainerComponent` currently owns the episode download controls. It
builds Xtream requests itself but emits an event for Stalker, which leaves
the two providers with different orchestration paths.
- Stalker adapters already create a stable per-episode tracking id in
`episode.id`. The current download path instead derives `xtreamId` from
`originalId` or `originalCmd`; those playback fields can be shared by
multiple episodes. That collision makes one managed download appear to own
several episode buttons.
- The backend already allows `failed`, `canceled`, and `completed` rows to be
restarted through `DOWNLOADS_START`. A completed row is eligible in this
feature only when its derived `fileAvailability` is exactly `missing`.
The work starts from `760099358` (`feat(downloads): redesign download manager
(#1313)`) on a dedicated worktree and branch.
## Goals
1. Keep download blocking and pending state scoped to one episode identity.
2. Allow several individual episode Download actions to be accepted in quick
succession.
3. Add `Download season (N)` for the currently selected, loaded season.
4. Make season enqueue idempotent across local pending state, authoritative
renderer state, backend duplicate checks, and legacy Stalker identities.
5. Continue after per-episode errors and report `added`, `skipped`, and
`failed` counts truthfully.
6. Preserve the existing FIFO transfer queue, destination authorization,
partial-file, retry, and resume semantics.
7. Give Xtream and Stalker the same shared orchestration contract while
keeping provider request construction in their respective feature areas.
## Non-goals
- Parallel transfers or configurable transfer concurrency.
- Full-series download across every season.
- Selecting or enqueueing several seasons in one action.
- Queue reordering or priority controls.
- Live recording.
- Bulk pause, resume, cancel, retry, or removal.
- A visual redesign of series details, episode cards, or the download manager.
- A new batch IPC endpoint.
## Architecture and Ownership
### Shared season presentation
`SeasonContainerComponent` remains the reusable UI owner for season selection,
episode rows/cards, and download controls. It will no longer construct an
Xtream URL or branch on Stalker markers. Instead it receives a typed
provider adapter through a signal input.
The component is responsible for:
- rendering `Download season (N)` in the existing section header;
- projecting coordinator state into individual episode buttons;
- snapshotting the selected season's visible episode order on batch click;
- invoking the coordinator for one episode or a season snapshot;
- showing a localized aggregate snackbar after batch completion.
The provider adapter contract returns a candidate for an episode. A candidate
contains a canonical episode identity and an async request factory. The
factory may resolve immediately for Xtream or perform Stalker link resolution
before returning `DownloadStartInput`.
Conceptually:
```ts
interface EpisodeDownloadIdentity {
playlistId: string;
xtreamId: number;
contentType: 'episode';
seriesXtreamId: number;
seasonNumber: number;
episodeNumber: number;
}
interface EpisodeDownloadCandidate {
identity: EpisodeDownloadIdentity;
prepare: () => Promise<DownloadStartInput>;
}
interface SeasonEpisodeDownloadAdapter {
createCandidate(
episode: XtreamSerieEpisode,
fallbackSeasonKey: string | undefined
): EpisodeDownloadCandidate | null;
}
```
The exact exported names may follow existing repository naming conventions,
but the boundary and responsibilities are fixed by this design.
### Provider-neutral orchestration
A coordinator in `libs/portal/shared/data-access` injects the existing
`DownloadsService`. This location owns provider-neutral stateful orchestration
under the repository's Nx placement rules.
The coordinator owns:
- a signal-backed set of pending episode identity keys;
- pure eligibility evaluation against the global download list;
- synchronous reservation of every eligible batch identity before the first
asynchronous request begins;
- sequential invocation of each candidate's request factory and
`DownloadsService.startDownload()`;
- best-effort continuation and aggregate result counts;
- an authoritative list refresh before successful pending state is released.
Pure identity matching and eligibility helpers belong in
`libs/portal/shared/util`. Reusable visual work stays in
`libs/ui/components`. No new project or boundary exception is required.
### Provider adapters
Xtream request construction moves out of the shared season component and next
to `SerialDetailsComponent`. It continues to use the loaded playlist
credentials, series metadata, episode extension, and metadata snapshot.
Stalker request construction moves out of the component method into a focused
adapter next to `StalkerSeriesViewComponent`. It continues to use
`fetchLinkToPlay`, portal headers, the current playlist, and the existing
metadata snapshot builder.
Both adapters use the already-normalized `episode.id` as the canonical
`xtreamId`. Stalker `originalId` and `originalCmd` remain inputs to provider
link resolution only and never determine download ownership.
### Electron backend compatibility guard
The backend keeps the current `DOWNLOADS_START` handler and transfer runtime.
The existing-row lookup gains a compatibility fallback for episode requests
that contain safe integer series, season, and episode coordinates:
1. Look up the canonical `(playlistId, xtreamId, contentType)` identity.
2. Look up an episode row with the same
`(playlistId, contentType, seriesXtreamId, seasonNumber, episodeNumber)`.
3. Use the one row found, or the same row when both lookups agree.
4. If the lookups resolve to different rows, or a canonical row carries
conflicting complete episode coordinates, fail closed as an identity
conflict without mutating either row.
5. Apply the existing status rules to the matched row.
6. When an eligible coordinate-matched legacy row is restarted, update its
`xtreamId` to the canonical value as part of the existing queued reset.
The fallback does not run for VOD rows or coordinate-incomplete legacy rows.
It does not merge or delete ambiguous data. A uniqueness conflict fails closed
without enqueueing a second runtime task.
The existing start result gains an optional machine-readable
`already-in-progress` reason for a queued, downloading, or paused match. The
human-readable error remains for compatibility. The coordinator uses the
reason rather than comparing localized or free-form error strings, so a stale
renderer snapshot is counted as `skipped`. This extends the current result
contract without adding an IPC method.
This is a duplicate-ownership compatibility fix, not a schema migration. The
unique index, download destination authorization, partial ownership, and
single-transfer queue remain unchanged.
## Eligibility and Identity Rules
An episode identity is matched against a download row by canonical identity,
then by the legacy episode coordinates described above. Matching includes the
playlist, so identical provider ids in different sources never share state.
Eligibility is evaluated as follows:
| State | Season batch | Individual episode action |
| ------------------------- | ------------ | ------------------------------- |
| Local pending | Skip | Disabled |
| `queued` | Skip | Disabled |
| `downloading` | Skip | Disabled |
| `paused` | Skip | Show existing Resume action |
| `completed` + `available` | Skip | Show existing Play local action |
| `completed` + `missing` | Eligible | Show Download again action |
| `failed` | Eligible | Show Download action |
| `canceled` | Eligible | Show Download action |
| No row | Eligible | Show Download action |
For safety, a completed row whose file availability is not yet known is
treated as blocked, not as missing. Season batch is disabled until the first
authoritative download-list load has completed. Later background refreshes do
not globally disable unrelated episode controls.
If the provider adapter cannot produce a valid candidate for an otherwise
visible episode, the episode is not included in the eligible count and is
counted as `skipped` when a batch with other eligible episodes runs. An
operation that creates a candidate but fails during provider preparation is
counted as `failed`.
## UX Specification
### Season action
The visible text button `Download season (N)` appears in the season section
header immediately before the existing Grid/List toggle. It is present only
when all of the following are true:
- the runtime supports downloads;
- download presentation is enabled for the current detail view;
- a provider adapter is available.
It is absent in the Web application and provider-only details. It is disabled
when the selected season is loading, empty, has zero eligible episodes, the
initial download list is not ready, or a season batch is already running in
that component.
`N` is the current eligible count for the selected season. It excludes every
item that would be skipped at click time. During execution the button shows a
spinner and `Adding to queue…`. The clicked season key and episode array are
snapshotted, so changing tabs does not alter the in-flight batch.
Only one season batch is initiated by a season component at a time. Individual
actions for unrelated episodes remain available and may be accepted while a
batch prepares Stalker links. The Electron backend's actual receive order
remains the FIFO order; no renderer-side transfer scheduler is added.
### Per-episode controls
The coordinator reserves an episode identity synchronously before awaiting
provider or IPC work. The corresponding Download button immediately becomes a
non-actionable pending/downloading presentation. Every unrelated episode keeps
its own state and remains clickable.
The same coordinator path handles single-item and season actions, preventing
different pending or error behavior between them. A failed operation releases
only that episode's pending key. A successful operation keeps its key pending
until the global list has been refreshed, avoiding a re-enabled gap between
the IPC acknowledgement and the authoritative queued row.
An individual success relies on the visible queued/downloading state and does
not add a redundant snackbar. An individual provider or start failure shows a
generic localized download-failed snackbar; raw backend or provider text is
not displayed.
Existing Resume and Play local actions remain unchanged. The grid and list
views expose equivalent states and actions.
### Result feedback
After every season batch, one Material snackbar announces:
- `Added N · Skipped M` when `failed` is zero;
- `Added N · Skipped M · Failed K` when at least one candidate fails.
The three counts partition every episode in the snapshotted selected season:
- `added`: `DOWNLOADS_START` accepted the item;
- `skipped`: the item had no valid provider candidate, was already blocked
locally or authoritatively, or the backend returned the stable
`already-in-progress` reason;
- `failed`: provider preparation, URL resolution, authorization, or IPC failed.
Processing never stops at the first failure. No raw URL, credential, backend
message, or exception text is rendered in the snackbar. Technical diagnostics
use the existing redacted logger.
## Data Flow
### Individual episode
1. The season component asks the adapter for a candidate.
2. The coordinator evaluates current eligibility and reserves its identity.
3. The adapter prepares the provider-specific request.
4. `DownloadsService.startDownload()` obtains the current authorized folder
and invokes the existing bridge method.
5. The main process validates the folder and remote URL, resolves canonical or
legacy ownership, persists `queued`, and calls the existing
`enqueueDownload()`.
6. The renderer refreshes the global list and hands UI ownership from pending
state to the authoritative row.
### Selected season
1. The component snapshots the selected season and asks the adapter for
candidates in the displayed episode order.
2. The coordinator classifies the full snapshot and reserves every eligible
identity before awaiting work.
3. Invalid candidates and already-blocked items are counted without invoking
provider preparation.
4. Eligible candidates are prepared and submitted one at a time in snapshot
order.
5. A preparation or start failure increments `failed`, releases that identity,
and processing continues.
6. The global list is refreshed after processing. Successful pending keys are
released only after that refresh attempt.
7. The component shows one aggregate snackbar and recomputes `N` from the new
authoritative state.
The coordinator does not mutate `DownloadsService.downloads` optimistically.
The existing Electron broadcast remains authoritative, with an explicit final
refresh closing the acknowledgement-to-list timing gap.
## Error and Race Semantics
- Two rapid clicks for the same episode race only against the synchronous
pending reservation; the second is skipped without calling the provider.
- Separate episode clicks reserve separate keys and both reach the existing
backend queue.
- Repeating `Download season` after completion skips rows now reported as
queued, downloading, paused, or available completed.
- A backend `already-in-progress` response caused by a stale renderer snapshot
counts as skipped rather than failed.
- Stalker link resolution failures affect only their candidate.
- A missing or unauthorized download folder produces failed results through
the current service/backend path; destination selection is not bypassed.
- A failed row with a retained partial follows today's `DOWNLOADS_START`
restart behavior, including its existing safe partial cleanup.
- A paused row is never restarted by the season action, preserving Resume and
retained-partial semantics.
- A missing completed row follows today's detail-page fresh-download behavior
and targets the currently authorized download folder.
- If the final renderer refresh fails, pending state is released after the
attempt so controls cannot become permanently stuck. The backend uniqueness
and runtime-id guards remain the final duplicate defense.
## Accessibility, Responsive Layout, and Themes
- The season action uses a real Material button with visible localized text,
a keyboard focus indicator, and an accessible name that includes the
eligible count.
- The progress spinner is decorative; the visible `Adding to queue…` text
communicates the state without relying on animation or color.
- Disabled controls retain the existing native/Material disabled semantics.
- Material snackbar supplies its existing live-region announcement for the
aggregate result.
- The section header may wrap at narrow widths rather than clipping the label
or shrinking the touch target.
- Styling reuses existing `--app-*` and Material system tokens. Light and dark
themes must preserve contrast, hover, focus, pending, and disabled states.
- Grid and list presentations must expose the same action names and state
semantics.
## Test Strategy
Implementation follows test-driven development. Regression tests are written
and observed failing before production changes for each behavior slice.
### Pure and service tests
- Canonical identity and legacy coordinate matching.
- Unique Stalker identities when episodes share `originalId` or
`originalCmd`.
- Eligibility for every download status and completed file-availability value.
- Unknown completed availability fails closed.
- Per-item pending reservation and release.
- Two rapid requests for one identity dispatch once.
- Requests for distinct identities both dispatch.
- Batch order, deduplication, skip counts, missing-completed behavior, and
best-effort continuation.
- Provider preparation and IPC failures produce `failed` without aborting the
batch.
- Invalid provider candidates contribute to `skipped`, so the three result
counts always total the snapshotted season size.
- A stale backend duplicate is classified as `skipped`.
- Final authoritative refresh and pending handoff behavior.
### Component and provider tests
- `Download season (N)` visibility, count, loading, empty, initial-load, and
zero-eligible states.
- Batch pending presentation and per-episode isolation in both grid and list.
- Web and provider-only views omit download presentation.
- Accessible names and native disabled state.
- Xtream URL, title, identity, coordinates, poster, and metadata snapshot.
- Stalker link resolution, headers, metadata, canonical identity, and isolated
error handling.
- The regression test demonstrates that two Stalker episodes sharing playback
identifiers no longer share download state.
### Backend tests
- Canonical exact-match behavior remains unchanged.
- A coordinate-matched legacy active row is rejected as already in progress.
- Queued, downloading, and paused duplicate results carry the stable
`already-in-progress` reason.
- Eligible legacy failed, canceled, and completed rows are reused and upgraded
to the canonical id.
- Coordinate fallback does not apply to VOD or incomplete episode requests.
- A uniqueness conflict fails without mutating or enqueueing another task.
- Existing destination authorization and runtime FIFO tests stay green.
### Electron E2E and manual verification
Extend the closest existing Electron series/download coverage to prove:
- two consecutive episode clicks produce one downloading and one queued row;
- `Download season` submits eligible episodes in display order;
- a repeated action does not create duplicate managed rows;
- the aggregate snackbar shows correct counts;
- only one transfer is active at a time.
Use the existing Xtream mock flow for the complete UI-to-queue journey. Add a
Stalker UI E2E when the existing deterministic mock exposes downloadable
episode streams without expanding fixture scope; otherwise retain Stalker
collision coverage at adapter, component, and backend integration levels and
record that reason in the final summary.
Web E2E does not require a new download workflow because the feature is
Electron-only. Component coverage explicitly verifies that Web does not render
the action.
Perform CDP/manual checks in light and dark themes at desktop and narrow
content widths, including keyboard focus, button names, disabled states, and
snackbar announcement text.
## Documentation and Release Note
Implementation updates:
- `docs/architecture/download-manager.md` with season orchestration, canonical
episode identity, legacy Stalker compatibility, and per-item pending rules;
- the relevant Download Manager description in `CLAUDE.md` so repository
bootstrap guidance remains current;
- one user-facing `.changes/downloads-<short-slug>.md` feature note following
`.changes/README.md`.
Translation keys for the season action, in-progress label, accessible name,
and aggregate result are added to every locale file using the repository's
current localization convention.
## Validation
The implementation plan must name exact targets after Nx discovery. The
expected validation ladder is:
1. focused tests for shared util/data-access, `components`, Xtream feature,
Stalker feature, `services`, and Electron backend;
2. full affected-project tests;
3. affected-project lint;
4. the atomized Electron E2E target covering the new series download flow;
5. `pnpm run release:notes:validate`;
6. light/dark and keyboard/CDP verification.
The fresh-worktree baseline already passes the nearest existing suites:
- `components`: 128 tests;
- `portal-xtream-feature` serial details: 14 tests;
- `portal-stalker-feature` series view: 16 tests;
- `services` downloads: 21 tests;
- Electron backend download requests: 20 tests.
## Acceptance Criteria
1. Starting one episode immediately blocks only that episode's Download
action; a different episode can be clicked and enqueued before the first
transfer completes.
2. The backend runs one transfer and preserves FIFO ordering for queued tasks.
3. `Download season (N)` applies only to the selected loaded season and uses
the displayed episode order.
4. Pending, queued, downloading, paused, and available completed episodes are
skipped without duplicate provider or IPC work.
5. Failed, canceled, and missing-completed episodes are eligible.
6. Batch failures do not stop later episodes and the final counts partition
the entire snapshotted season truthfully.
7. Xtream and Stalker use the same shared orchestration path while retaining
provider-specific request preparation.
8. Stalker episodes with shared playback identifiers have independent
canonical download identities.
9. Legacy Stalker download rows remain recognized by episode coordinates and
are reused rather than duplicated when eligible.
10. The season action is absent from Web and provider-only details.
11. Grid/List, light/dark, keyboard focus, and accessible names remain usable.
12. No batch IPC, transfer concurrency, queue reordering, full-series action,
or multi-season selection is introduced.
@@ -1,2 +1,3 @@
export * from './lib/collection';
export * from './lib/downloads';
export * from './lib/multi-source';
@@ -0,0 +1,2 @@
export * from './season-download.models';
export * from './season-download-coordinator.service';
@@ -0,0 +1,507 @@
import { signal, type WritableSignal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
DownloadsService,
type DownloadItem,
type DownloadStartInput,
} from '@iptvnator/services';
import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces';
import type { EpisodeDownloadIdentity } from '@iptvnator/portal/shared/util';
import type { EpisodeDownloadCandidate } from './season-download.models';
import { SeasonDownloadCoordinator } from './season-download-coordinator.service';
interface DownloadsServiceStub {
readonly downloads: WritableSignal<DownloadItem[]>;
readonly hasAuthoritativeDownloadList: WritableSignal<boolean>;
readonly hasLoadedDownloads: WritableSignal<boolean>;
readonly isAvailable: WritableSignal<boolean>;
readonly startDownload: jest.MockedFunction<
DownloadsService['startDownload']
>;
readonly loadDownloads: jest.MockedFunction<
DownloadsService['loadDownloads']
>;
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason?: unknown) => void;
}
const FIRST_IDENTITY: EpisodeDownloadIdentity = {
playlistId: 'playlist-1',
contentType: 'episode',
xtreamId: 101,
seriesXtreamId: 20,
seasonNumber: 1,
episodeNumber: 1,
};
describe('SeasonDownloadCoordinator', () => {
let downloadsService: DownloadsServiceStub;
let coordinator: SeasonDownloadCoordinator;
beforeEach(() => {
downloadsService = {
downloads: signal<DownloadItem[]>([]),
hasAuthoritativeDownloadList: signal(true),
hasLoadedDownloads: signal(true),
isAvailable: signal(true),
startDownload: jest.fn().mockResolvedValue({ success: true }),
loadDownloads: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
SeasonDownloadCoordinator,
{ provide: DownloadsService, useValue: downloadsService },
],
});
coordinator = TestBed.inject(SeasonDownloadCoordinator);
});
it('reserves one identity synchronously without blocking a distinct episode', async () => {
const firstPrepare = deferred<DownloadStartInput>();
const first = candidate(FIRST_IDENTITY, () => firstPrepare.promise);
const second = candidate(identity(102, 2));
const submission = coordinator.enqueueOne(first);
expect(coordinator.isPending(first.identity)).toBe(true);
expect(coordinator.isEligible(first)).toBe(false);
expect(coordinator.isPending(second.identity)).toBe(false);
expect(coordinator.isEligible(second)).toBe(true);
firstPrepare.resolve(request(first.identity));
await expect(submission).resolves.toBe('added');
});
it('starts only one download for two rapid submissions of the same identity', async () => {
const prepare = deferred<DownloadStartInput>();
const first = candidate(FIRST_IDENTITY, () => prepare.promise);
const duplicate = candidate(FIRST_IDENTITY);
const firstSubmission = coordinator.enqueueOne(first);
const secondSubmission = coordinator.enqueueOne(duplicate);
await expect(secondSubmission).resolves.toBe('skipped');
expect(duplicate.prepare).not.toHaveBeenCalled();
prepare.resolve(request(first.identity));
await expect(firstSubmission).resolves.toBe('added');
expect(downloadsService.startDownload).toHaveBeenCalledTimes(1);
});
it('submits a season sequentially in display order', async () => {
const firstStart = deferred<ElectronBridgeDownloadStartResult>();
const first = candidate(FIRST_IDENTITY);
const second = candidate(identity(102, 2));
downloadsService.startDownload
.mockImplementationOnce(() => firstStart.promise)
.mockResolvedValueOnce({ success: true });
const submission = coordinator.enqueueSeason([first, second]);
await flushMicrotasks();
expect(first.prepare).toHaveBeenCalledTimes(1);
expect(second.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).toHaveBeenCalledTimes(1);
firstStart.resolve({ success: true });
await flushMicrotasks();
expect(second.prepare).toHaveBeenCalledTimes(1);
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
2,
request(second.identity)
);
await expect(submission).resolves.toEqual({
added: 2,
skipped: 0,
failed: 0,
});
});
it('partitions added, invalid, paused, stable skip, and unsuccessful candidates', async () => {
const added = candidate(FIRST_IDENTITY);
const paused = candidate(identity(102, 2));
const stable = candidate(identity(103, 3));
const unsuccessful = candidate(identity(104, 4));
downloadsService.downloads.set([
download(paused.identity, { status: 'paused' }),
]);
downloadsService.startDownload
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({
success: false,
reason: 'already-in-progress',
})
.mockResolvedValueOnce({
success: false,
error: 'rejected',
});
await expect(
coordinator.enqueueSeason([
added,
null,
paused,
stable,
unsuccessful,
])
).resolves.toEqual({ added: 1, skipped: 3, failed: 1 });
expect(added.prepare).toHaveBeenCalledTimes(1);
expect(paused.prepare).not.toHaveBeenCalled();
expect(stable.prepare).toHaveBeenCalledTimes(1);
expect(unsuccessful.prepare).toHaveBeenCalledTimes(1);
expect(downloadsService.startDownload).toHaveBeenCalledTimes(3);
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
1,
request(added.identity)
);
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
2,
request(stable.identity)
);
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
3,
request(unsuccessful.identity)
);
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
});
it('classifies a backend filesystem recheck as a stable skip', async () => {
const restored = candidate(FIRST_IDENTITY);
downloadsService.startDownload.mockResolvedValueOnce({
success: false,
reason: 'already-downloaded' as never,
});
await expect(coordinator.enqueueOne(restored)).resolves.toBe('skipped');
expect(restored.prepare).toHaveBeenCalledTimes(1);
expect(downloadsService.startDownload).toHaveBeenCalledTimes(1);
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
});
it('refreshes once after a season contains only submitted stable skips', async () => {
const first = candidate(FIRST_IDENTITY);
const second = candidate(identity(102, 2));
const refresh = deferred<void>();
const refreshStarted = deferred<void>();
downloadsService.startDownload
.mockResolvedValueOnce({
success: false,
reason: 'already-downloaded' as never,
})
.mockResolvedValueOnce({
success: false,
reason: 'already-in-progress',
});
downloadsService.loadDownloads.mockImplementation(() => {
refreshStarted.resolve(undefined);
return refresh.promise;
});
const submission = coordinator.enqueueSeason([first, second]);
await refreshStarted.promise;
expect(downloadsService.startDownload).toHaveBeenCalledTimes(2);
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
expect(coordinator.isPending(first.identity)).toBe(true);
expect(coordinator.isPending(second.identity)).toBe(true);
refresh.resolve(undefined);
await expect(submission).resolves.toEqual({
added: 0,
skipped: 2,
failed: 0,
});
expect(coordinator.isPending(first.identity)).toBe(false);
expect(coordinator.isPending(second.identity)).toBe(false);
});
it('continues after preparation rejects and refreshes once after successes', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
/* captured */
});
const rejected = candidate(FIRST_IDENTITY, () =>
Promise.reject(
new Error(
'Request failed: https://portal.invalid?username=alice&password=hunter2'
)
)
);
const ipcFailure = candidate(identity(102, 2));
const firstSuccess = candidate(identity(103, 3));
const secondSuccess = candidate(identity(104, 4));
downloadsService.startDownload.mockRejectedValueOnce(
new Error(
'IPC failed: https://portal.invalid?username=alice&password=hunter2'
)
);
try {
await expect(
coordinator.enqueueSeason([
rejected,
ipcFailure,
firstSuccess,
secondSuccess,
])
).resolves.toEqual({ added: 2, skipped: 0, failed: 2 });
expect(downloadsService.startDownload).toHaveBeenCalledTimes(3);
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
expect(loggedText(warnSpy)).not.toContain('hunter2');
expect(loggedText(warnSpy)).not.toContain('alice');
} finally {
warnSpy.mockRestore();
}
});
it('blocks eligibility and reservation without an authoritative download list', async () => {
downloadsService.hasAuthoritativeDownloadList.set(false);
const item = candidate(FIRST_IDENTITY);
expect(downloadsService.hasLoadedDownloads()).toBe(true);
expect(coordinator.isEligible(item)).toBe(false);
await expect(coordinator.enqueueOne(item)).resolves.toBe('skipped');
expect(item.prepare).not.toHaveBeenCalled();
downloadsService.hasAuthoritativeDownloadList.set(true);
downloadsService.hasLoadedDownloads.set(false);
expect(coordinator.isEligible(item)).toBe(false);
await expect(coordinator.enqueueOne(item)).resolves.toBe('skipped');
expect(item.prepare).not.toHaveBeenCalled();
downloadsService.hasLoadedDownloads.set(true);
downloadsService.isAvailable.set(false);
expect(coordinator.isEligible(item)).toBe(false);
await expect(coordinator.enqueueOne(item)).resolves.toBe('skipped');
});
it('allows a completed missing episode and skips a completed available one', async () => {
const missing = candidate(FIRST_IDENTITY);
const available = candidate(identity(102, 2));
downloadsService.downloads.set([
download(missing.identity, {
status: 'completed',
fileAvailability: 'missing',
}),
download(available.identity, {
status: 'completed',
fileAvailability: 'available',
}),
]);
expect(coordinator.resolveDownload(missing.identity)).toMatchObject({
kind: 'match',
download: { fileAvailability: 'missing' },
});
expect(coordinator.isEligible(missing)).toBe(true);
expect(coordinator.isEligible(available)).toBe(false);
await expect(coordinator.enqueueOne(available)).resolves.toBe(
'skipped'
);
expect(available.prepare).not.toHaveBeenCalled();
});
it('refreshes a completed-missing episode before provider preparation', async () => {
const restored = candidate(FIRST_IDENTITY, () =>
Promise.reject(new Error('portal unavailable'))
);
downloadsService.downloads.set([
download(restored.identity, {
status: 'completed',
fileAvailability: 'missing',
}),
]);
downloadsService.loadDownloads.mockImplementation(async () => {
downloadsService.downloads.set([
download(restored.identity, {
status: 'completed',
fileAvailability: 'available',
}),
]);
});
await expect(coordinator.enqueueOne(restored)).resolves.toBe('skipped');
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
expect(restored.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).not.toHaveBeenCalled();
expect(coordinator.isPending(restored.identity)).toBe(false);
});
it('refreshes completed-missing season candidates once before provider preparation', async () => {
const first = candidate(FIRST_IDENTITY, () =>
Promise.reject(new Error('portal unavailable'))
);
const second = candidate(identity(102, 2), () =>
Promise.reject(new Error('portal unavailable'))
);
downloadsService.downloads.set([
download(first.identity, {
status: 'completed',
fileAvailability: 'missing',
}),
download(second.identity, {
status: 'completed',
fileAvailability: 'missing',
}),
]);
downloadsService.loadDownloads.mockImplementation(async () => {
downloadsService.downloads.set([
download(first.identity, {
status: 'completed',
fileAvailability: 'available',
}),
download(second.identity, {
status: 'completed',
fileAvailability: 'available',
}),
]);
});
await expect(
coordinator.enqueueSeason([first, second])
).resolves.toEqual({ added: 0, skipped: 2, failed: 0 });
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
expect(first.prepare).not.toHaveBeenCalled();
expect(second.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).not.toHaveBeenCalled();
});
it('skips an episode when multiple managed rows claim its coordinates', async () => {
const item = candidate(FIRST_IDENTITY);
downloadsService.downloads.set([
download(item.identity, { id: 71, xtreamId: 9001 }),
download(item.identity, { id: 72, xtreamId: 9002 }),
]);
expect(coordinator.isEligible(item)).toBe(false);
await expect(coordinator.enqueueSeason([item])).resolves.toEqual({
added: 0,
skipped: 1,
failed: 0,
});
expect(item.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).not.toHaveBeenCalled();
});
it('prepares a duplicate identity only once within one batch', async () => {
const first = candidate(FIRST_IDENTITY);
const duplicate = candidate(FIRST_IDENTITY);
await expect(
coordinator.enqueueSeason([first, duplicate])
).resolves.toEqual({ added: 1, skipped: 1, failed: 0 });
expect(first.prepare).toHaveBeenCalledTimes(1);
expect(duplicate.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).toHaveBeenCalledTimes(1);
});
it('keeps successful keys pending and releases them when refresh rejects', async () => {
const refresh = deferred<void>();
const refreshStarted = deferred<void>();
const first = candidate(FIRST_IDENTITY);
const second = candidate(identity(102, 2));
downloadsService.loadDownloads.mockImplementation(() => {
refreshStarted.resolve(undefined);
return refresh.promise;
});
const submission = coordinator.enqueueSeason([first, second]);
await refreshStarted.promise;
expect(coordinator.isPending(first.identity)).toBe(true);
expect(coordinator.isPending(second.identity)).toBe(true);
refresh.reject(new Error('Refresh failed'));
await expect(submission).rejects.toThrow('Refresh failed');
expect(coordinator.isPending(first.identity)).toBe(false);
expect(coordinator.isPending(second.identity)).toBe(false);
});
});
function identity(
xtreamId: number,
episodeNumber: number
): EpisodeDownloadIdentity {
return { ...FIRST_IDENTITY, xtreamId, episodeNumber };
}
function request(value: EpisodeDownloadIdentity): DownloadStartInput {
return {
playlistId: value.playlistId,
xtreamId: value.xtreamId,
contentType: value.contentType,
title: `Episode ${value.episodeNumber}`,
url: `https://stream.invalid/${value.xtreamId}`,
seriesXtreamId: value.seriesXtreamId,
seasonNumber: value.seasonNumber,
episodeNumber: value.episodeNumber,
};
}
function candidate(
value: EpisodeDownloadIdentity,
prepare: () => Promise<DownloadStartInput> = () =>
Promise.resolve(request(value))
): EpisodeDownloadCandidate & { readonly prepare: jest.Mock } {
return { identity: value, prepare: jest.fn(prepare) };
}
function download(
value: EpisodeDownloadIdentity,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id: value.xtreamId,
playlistId: value.playlistId,
xtreamId: value.xtreamId,
contentType: value.contentType,
seriesXtreamId: value.seriesXtreamId,
seasonNumber: value.seasonNumber,
episodeNumber: value.episodeNumber,
title: `Episode ${value.episodeNumber}`,
url: 'https://stream.invalid/download',
status: 'queued',
...overrides,
};
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function loggedText(spy: jest.SpyInstance): string {
return spy.mock.calls
.flat()
.map((value) =>
value instanceof Error
? `${value.message}\n${value.stack ?? ''}`
: typeof value === 'string'
? value
: JSON.stringify(value)
)
.join('\n');
}
@@ -0,0 +1,228 @@
import { inject, Injectable, signal } from '@angular/core';
import {
DownloadsService,
type DownloadItem,
type DownloadStartInput,
} from '@iptvnator/services';
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
import {
createEpisodeDownloadIdentityKey,
createLogger,
isEpisodeDownloadEligible,
resolveEpisodeDownload,
type EpisodeDownloadIdentity,
type EpisodeDownloadResolution,
} from '@iptvnator/portal/shared/util';
import {
EPISODE_DOWNLOAD_SUBMISSIONS,
type EpisodeDownloadCandidate,
type EpisodeDownloadSubmission,
type SeasonDownloadResult,
} from './season-download.models';
interface EpisodeDownloadPreflight {
readonly ready: EpisodeDownloadCandidate[];
readonly skipped: EpisodeDownloadCandidate[];
readonly failed: EpisodeDownloadCandidate[];
}
@Injectable({ providedIn: 'root' })
export class SeasonDownloadCoordinator {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonDownloadCoordinator');
private readonly pending = signal<ReadonlySet<string>>(new Set());
isPending(identity: EpisodeDownloadIdentity): boolean {
return this.pending().has(createEpisodeDownloadIdentityKey(identity));
}
resolveDownload(
identity: EpisodeDownloadIdentity
): EpisodeDownloadResolution<DownloadItem> {
return resolveEpisodeDownload(
identity,
this.downloadsService.downloads()
);
}
isEligible(candidate: EpisodeDownloadCandidate): boolean {
return (
this.downloadsService.isAvailable() &&
this.downloadsService.hasAuthoritativeDownloadList() &&
this.downloadsService.hasLoadedDownloads() &&
!this.isPending(candidate.identity) &&
isEpisodeDownloadEligible(this.resolveDownload(candidate.identity))
);
}
async enqueueOne(
candidate: EpisodeDownloadCandidate
): Promise<EpisodeDownloadSubmission> {
if (!this.reserve(candidate)) {
return EPISODE_DOWNLOAD_SUBMISSIONS.Skipped;
}
const preflight = await this.preflightCompletedMissing([candidate]);
if (preflight.ready.length === 0) {
this.release(candidate.identity);
return preflight.skipped.length > 0
? EPISODE_DOWNLOAD_SUBMISSIONS.Skipped
: EPISODE_DOWNLOAD_SUBMISSIONS.Failed;
}
const submission = await this.submit(candidate);
if (submission === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
this.release(candidate.identity);
return submission;
}
try {
await this.downloadsService.loadDownloads();
return submission;
} finally {
this.release(candidate.identity);
}
}
async enqueueSeason(
candidates: readonly (EpisodeDownloadCandidate | null)[]
): Promise<SeasonDownloadResult> {
const result = { added: 0, skipped: 0, failed: 0 };
const reserved: EpisodeDownloadCandidate[] = [];
for (const candidate of candidates) {
if (!candidate || !this.reserve(candidate)) {
result.skipped += 1;
continue;
}
reserved.push(candidate);
}
const preflight = await this.preflightCompletedMissing(reserved);
result.skipped += preflight.skipped.length;
result.failed += preflight.failed.length;
this.releaseAll(
[...preflight.skipped, ...preflight.failed].map(
({ identity }) => identity
)
);
const refreshPending: EpisodeDownloadIdentity[] = [];
for (const candidate of preflight.ready) {
const submission = await this.submit(candidate);
result[submission] += 1;
if (submission !== EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
refreshPending.push(candidate.identity);
} else {
this.release(candidate.identity);
}
}
if (refreshPending.length > 0) {
try {
await this.downloadsService.loadDownloads();
} finally {
this.releaseAll(refreshPending);
}
}
return result;
}
private async preflightCompletedMissing(
candidates: readonly EpisodeDownloadCandidate[]
): Promise<EpisodeDownloadPreflight> {
if (
!candidates.some(({ identity }) =>
this.isCompletedMissing(identity)
)
) {
return { ready: [...candidates], skipped: [], failed: [] };
}
try {
await this.downloadsService.loadDownloads();
} catch {
this.logger.warn('Completed episode file recheck failed');
return { ready: [], skipped: [], failed: [...candidates] };
}
if (
!this.downloadsService.isAvailable() ||
!this.downloadsService.hasAuthoritativeDownloadList() ||
!this.downloadsService.hasLoadedDownloads()
) {
return { ready: [], skipped: [], failed: [...candidates] };
}
const ready: EpisodeDownloadCandidate[] = [];
const skipped: EpisodeDownloadCandidate[] = [];
for (const candidate of candidates) {
const resolution = this.resolveDownload(candidate.identity);
(isEpisodeDownloadEligible(resolution) ? ready : skipped).push(
candidate
);
}
return { ready, skipped, failed: [] };
}
private isCompletedMissing(identity: EpisodeDownloadIdentity): boolean {
const resolution = this.resolveDownload(identity);
return (
resolution.kind === 'match' &&
resolution.download.status === 'completed' &&
resolution.download.fileAvailability === 'missing'
);
}
private reserve(candidate: EpisodeDownloadCandidate): boolean {
if (!this.isEligible(candidate)) {
return false;
}
const key = createEpisodeDownloadIdentityKey(candidate.identity);
const next = new Set(this.pending());
next.add(key);
this.pending.set(next);
return true;
}
private async submit(
candidate: EpisodeDownloadCandidate
): Promise<EpisodeDownloadSubmission> {
try {
const request: DownloadStartInput = await candidate.prepare();
const result = await this.downloadsService.startDownload(request);
if (result.success) {
return EPISODE_DOWNLOAD_SUBMISSIONS.Added;
}
if (
result.reason ===
ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyInProgress ||
result.reason ===
ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyDownloaded
) {
return EPISODE_DOWNLOAD_SUBMISSIONS.Skipped;
}
return EPISODE_DOWNLOAD_SUBMISSIONS.Failed;
} catch {
this.logger.warn('Episode download submission failed');
return EPISODE_DOWNLOAD_SUBMISSIONS.Failed;
}
}
private release(identity: EpisodeDownloadIdentity): void {
const key = createEpisodeDownloadIdentityKey(identity);
const next = new Set(this.pending());
next.delete(key);
this.pending.set(next);
}
private releaseAll(identities: readonly EpisodeDownloadIdentity[]): void {
const next = new Set(this.pending());
for (const identity of identities) {
next.delete(createEpisodeDownloadIdentityKey(identity));
}
this.pending.set(next);
}
}
@@ -0,0 +1,30 @@
import type { DownloadStartInput } from '@iptvnator/services';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import type { EpisodeDownloadIdentity } from '@iptvnator/portal/shared/util';
export const EPISODE_DOWNLOAD_SUBMISSIONS = {
Added: 'added',
Skipped: 'skipped',
Failed: 'failed',
} as const;
export type EpisodeDownloadSubmission =
(typeof EPISODE_DOWNLOAD_SUBMISSIONS)[keyof typeof EPISODE_DOWNLOAD_SUBMISSIONS];
export interface EpisodeDownloadCandidate {
readonly identity: EpisodeDownloadIdentity;
readonly prepare: () => Promise<DownloadStartInput>;
}
export interface SeasonEpisodeDownloadAdapter {
createCandidate(
episode: XtreamSerieEpisode,
fallbackSeasonKey: string | undefined
): EpisodeDownloadCandidate | null;
}
export interface SeasonDownloadResult {
readonly added: number;
readonly skipped: number;
readonly failed: number;
}
+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,232 @@
import {
createEpisodeDownloadIdentityKey,
isEpisodeDownloadEligible,
resolveEpisodeDownload,
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 {
id: 1,
playlistId: identity.playlistId,
contentType: identity.contentType,
xtreamId: identity.xtreamId,
seriesXtreamId: identity.seriesXtreamId,
seasonNumber: identity.seasonNumber,
episodeNumber: identity.episodeNumber,
status: 'queued',
filePath: '/downloads/episode-3.mp4',
...overrides,
};
}
describe('episode download identity', () => {
it('does not match coordinates owned by another explicit identity scope', () => {
const scopedIdentity = {
...identity,
episodeIdentityScope: 'stalker-regular-series',
} as EpisodeDownloadIdentity;
const otherMode = row({
xtreamId: 999,
episodeIdentityScope: 'stalker-lazy-vod',
} as Partial<EpisodeDownloadRecord>);
expect(resolveEpisodeDownload(scopedIdentity, [otherMode])).toEqual({
kind: 'missing',
});
});
it('fails closed for unscoped coordinate ownership when the identity is scoped', () => {
const scopedIdentity = {
...identity,
episodeIdentityScope: 'stalker-regular-series',
} as EpisodeDownloadIdentity;
const legacyRow = row({ xtreamId: 999 });
expect(resolveEpisodeDownload(scopedIdentity, [legacyRow])).toEqual({
kind: 'conflict',
});
});
it('fails closed for an incomplete canonical row owned by another explicit scope', () => {
const scopedIdentity = {
...identity,
episodeIdentityScope: 'stalker-regular-series',
} as EpisodeDownloadIdentity;
const otherMode = row({
episodeIdentityScope: 'stalker-lazy-vod',
seriesXtreamId: null,
} as Partial<EpisodeDownloadRecord>);
expect(resolveEpisodeDownload(scopedIdentity, [otherMode])).toEqual({
kind: 'conflict',
});
});
it('returns a canonical row when the same row also owns the coordinates', () => {
const canonical = row();
expect(resolveEpisodeDownload(identity, [canonical])).toEqual({
kind: 'match',
download: canonical,
});
});
it.each([
['series id', { seriesXtreamId: null }],
['season number', { seasonNumber: null }],
['episode number', { episodeNumber: null }],
])(
'treats a null legacy %s as an incomplete canonical row',
(_label, override) => {
const canonical = row(override);
expect(resolveEpisodeDownload(identity, [canonical])).toEqual({
kind: 'match',
download: canonical,
});
}
);
it('fails closed when canonical and coordinate lookups find different rows', () => {
const coordinateFallback = row({ xtreamId: 999 });
const canonical = row();
expect(
resolveEpisodeDownload(identity, [coordinateFallback, canonical])
).toEqual({ kind: 'conflict' });
});
it('fails closed when an incomplete canonical row conflicts with a coordinate row', () => {
const coordinateFallback = row({ id: 2, xtreamId: 999 });
const incompleteCanonical = row({ seriesXtreamId: undefined });
expect(
resolveEpisodeDownload(identity, [
coordinateFallback,
incompleteCanonical,
])
).toEqual({ kind: 'conflict' });
});
it('fails closed when a canonical match has conflicting complete coordinates', () => {
const coordinateFallback = row({ xtreamId: 999 });
const conflictingCanonical = row({
seriesXtreamId: 50,
seasonNumber: 4,
episodeNumber: 8,
});
expect(
resolveEpisodeDownload(identity, [
coordinateFallback,
conflictingCanonical,
])
).toEqual({ kind: 'conflict' });
});
it('falls back to complete matching episode coordinates', () => {
const coordinateFallback = row({ xtreamId: 999 });
expect(resolveEpisodeDownload(identity, [coordinateFallback])).toEqual({
kind: 'match',
download: coordinateFallback,
});
});
it('fails closed when multiple rows share the fallback coordinates', () => {
const first = row({ id: 2, xtreamId: 999 });
const second = row({ id: 3, xtreamId: 998 });
const resolution = resolveEpisodeDownload(identity, [first, second]);
expect(resolution).toEqual({ kind: 'conflict' });
expect(isEpisodeDownloadEligible(resolution)).toBe(false);
});
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(resolveEpisodeDownload(identity, candidates)).toEqual({
kind: 'missing',
});
});
it('allows an episode with no existing download', () => {
expect(
isEpisodeDownloadEligible(resolveEpisodeDownload(identity, []))
).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({
kind: 'match',
download: 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,
})
);
});
it('creates different keys for different episode identity scopes', () => {
expect(
createEpisodeDownloadIdentityKey({
...identity,
episodeIdentityScope: 'stalker-regular-series',
})
).not.toBe(
createEpisodeDownloadIdentityKey({
...identity,
episodeIdentityScope: 'stalker-lazy-vod',
})
);
});
});
@@ -0,0 +1,154 @@
import type {
ElectronBridgeDownloadStatus,
ElectronBridgeEpisodeIdentityScope,
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;
readonly episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
}
export interface EpisodeDownloadRecord {
readonly id: number;
readonly playlistId: string;
readonly contentType: ElectronBridgePlaybackContentType;
readonly xtreamId: number;
readonly seriesXtreamId?: number | null;
readonly seasonNumber?: number | null;
readonly episodeNumber?: number | null;
readonly episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope | null;
readonly status: ElectronBridgeDownloadStatus;
readonly fileAvailability?: ElectronDownloadFileAvailability;
readonly filePath?: string;
}
export type EpisodeDownloadResolution<T extends EpisodeDownloadRecord> =
| { readonly kind: 'match'; readonly download: T }
| { readonly kind: 'missing' }
| { readonly kind: 'conflict' };
function hasConflictingCompleteCoordinates(
download: EpisodeDownloadRecord,
identity: EpisodeDownloadIdentity
): boolean {
const { seriesXtreamId, seasonNumber, episodeNumber } = download;
if (
identity.episodeIdentityScope !== undefined &&
download.episodeIdentityScope != null &&
download.episodeIdentityScope !== identity.episodeIdentityScope
) {
return true;
}
if (
seriesXtreamId === null ||
seriesXtreamId === undefined ||
seasonNumber === null ||
seasonNumber === undefined ||
episodeNumber === null ||
episodeNumber === undefined
) {
return false;
}
return (
!Number.isSafeInteger(seriesXtreamId) ||
!Number.isSafeInteger(seasonNumber) ||
!Number.isSafeInteger(episodeNumber) ||
seriesXtreamId !== identity.seriesXtreamId ||
seasonNumber !== identity.seasonNumber ||
episodeNumber !== identity.episodeNumber
);
}
export function resolveEpisodeDownload<T extends EpisodeDownloadRecord>(
identity: EpisodeDownloadIdentity,
downloads: readonly T[]
): EpisodeDownloadResolution<T> {
const canonicalMatch = downloads.find(
(download) =>
download.playlistId === identity.playlistId &&
download.contentType === identity.contentType &&
download.xtreamId === identity.xtreamId
);
const coordinateOwners = downloads.filter(
(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
);
const ambiguousUnscopedOwner =
identity.episodeIdentityScope !== undefined &&
coordinateOwners.some(
(download) =>
download.episodeIdentityScope == null &&
download.id !== canonicalMatch?.id
);
if (ambiguousUnscopedOwner) {
return { kind: 'conflict' };
}
const coordinateMatches = coordinateOwners.filter((download) =>
identity.episodeIdentityScope === undefined
? download.episodeIdentityScope == null
: download.episodeIdentityScope === identity.episodeIdentityScope
);
if (coordinateMatches.length > 1) {
return { kind: 'conflict' };
}
const coordinateMatch = coordinateMatches[0];
if (canonicalMatch) {
if (
hasConflictingCompleteCoordinates(canonicalMatch, identity) ||
(coordinateMatch && coordinateMatch.id !== canonicalMatch.id)
) {
return { kind: 'conflict' };
}
return { kind: 'match', download: canonicalMatch };
}
return coordinateMatch
? { kind: 'match', download: coordinateMatch }
: { kind: 'missing' };
}
export function isEpisodeDownloadEligible(
resolution: EpisodeDownloadResolution<EpisodeDownloadRecord>
): boolean {
if (resolution.kind === 'missing') {
return true;
}
if (resolution.kind === 'conflict') {
return false;
}
const { download } = resolution;
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,
episodeIdentityScope: identity.episodeIdentityScope,
});
}
@@ -82,6 +82,31 @@ describe('stalker-series.adapters', () => {
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('preserves the Specials season zero through VOD episode mapping', () => {
const mapped = mapVodSeriesEpisodes(
[
{
id: 'specials',
video_id: 'v1',
name: 'Specials',
season_number: '0',
episodes: [
{
id: 'special-1',
series_number: 1,
name: 'Special episode',
},
],
isLoading: false,
isExpanded: false,
},
],
{ parentSeriesId: 100 }
);
expect(mapped['0'][0].season).toBe(0);
});
it('generates deterministic VOD-series episode IDs for the same parent and provider episode', () => {
const seasons = [
{
@@ -74,10 +74,7 @@ function generateVodEpisodeId(options: {
);
}
function generateRegularEpisodeId(
seed: string,
episodeNum: number
): number {
function generateRegularEpisodeId(seed: string, episodeNum: number): number {
return hashString(`${seed}_ep_${episodeNum}`);
}
@@ -269,8 +266,13 @@ export function getVodSeriesSeasonNumber(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const parsedSeasonNumber = Number(season.season_number);
if (Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0) {
const normalizedSeasonNumber = season.season_number.trim();
const parsedSeasonNumber = Number(normalizedSeasonNumber);
if (
normalizedSeasonNumber !== '' &&
Number.isInteger(parsedSeasonNumber) &&
parsedSeasonNumber >= 0
) {
return parsedSeasonNumber;
}
@@ -0,0 +1,394 @@
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { createStalkerSeriesDownloadAdapter } from './stalker-series-download.adapter';
const EPISODE_POSTER_URL = 'https://images.example.test/stills/the-call.jpg';
const SERIES_POSTER_URL =
'https://images.example.test/posters/signal-house.jpg';
const PLAYLIST = {
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
userAgent: 'Stalker Client/1.0',
referrer: 'https://stalker.example.test/c/',
origin: 'https://stalker.example.test',
} as const;
const ITEM = {
id: '50001',
cmd: '/media/file_50001.mpg',
category_id: '18',
info: {
name: 'Signal House',
o_name: 'La casa de la señal',
description: 'Parent series plot',
movie_image: SERIES_POSTER_URL,
actors: 'Sienna Wave',
director: 'Cora Bell',
releasedate: '2025-03-01',
genre: 'Drama, Mystery',
rating_imdb: '8.4',
rating_kinopoisk: '8.0',
tmdb_id: 88001,
tmdb_status: 'returning-series',
},
} as const;
function episode(
overrides: Partial<XtreamSerieEpisode> & {
originalCmd?: string;
originalId?: string;
} = {}
): XtreamSerieEpisode {
return {
id: '61001',
episode_num: 3,
title: 'The Call',
container_extension: 'mpg',
info: {
plot: 'Episode-specific plot',
movie_image: EPISODE_POSTER_URL,
},
custom_sid: 'regular-series',
added: '',
season: 2,
direct_source: '',
originalCmd: '/media/file_777.mpg',
...overrides,
} as XtreamSerieEpisode;
}
function options(
resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg')
) {
return {
playlist: PLAYLIST,
item: ITEM,
language: 'en',
seriesId: 50001,
seriesMode: 'regular-series' as const,
resolveUrl,
};
}
describe('createStalkerSeriesDownloadAdapter', () => {
it.each([
['regular-series', 'stalker-regular-series'],
['embedded-vod', 'stalker-embedded-vod'],
['lazy-vod', 'stalker-lazy-vod'],
] as const)(
'scopes %s coordinates independently',
(seriesMode, expectedScope) => {
const adapter = createStalkerSeriesDownloadAdapter({
...options(),
seriesMode,
});
const candidate = adapter.createCandidate(episode(), '2');
expect(
(
candidate?.identity as
{ episodeIdentityScope?: string } | undefined
)?.episodeIdentityScope
).toBe(expectedScope);
}
);
it('keeps regular-series candidate identity distinct when playback ownership collides', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const adapter = createStalkerSeriesDownloadAdapter(options(resolveUrl));
const first = adapter.createCandidate(episode({ id: '61001' }), '2');
const second = adapter.createCandidate(episode({ id: '61002' }), '2');
expect(first?.identity.xtreamId).toBe(61001);
expect(second?.identity.xtreamId).toBe(61002);
expect(first?.identity).not.toEqual(second?.identity);
await expect(first?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 61001 })
);
await expect(second?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 61002 })
);
expect(resolveUrl).toHaveBeenNthCalledWith(1, '/media/file_777.mpg', 3);
expect(resolveUrl).toHaveBeenNthCalledWith(2, '/media/file_777.mpg', 3);
});
it('keeps VOD-series candidate identity distinct when originalId ownership collides', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const adapter = createStalkerSeriesDownloadAdapter(options(resolveUrl));
const first = adapter.createCandidate(
episode({
id: '62001',
custom_sid: 'vod-series',
originalCmd: undefined,
originalId: '777',
}),
'2'
);
const second = adapter.createCandidate(
episode({
id: '62002',
custom_sid: 'vod-series',
originalCmd: undefined,
originalId: '777',
}),
'2'
);
expect(first?.identity.xtreamId).toBe(62001);
expect(second?.identity.xtreamId).toBe(62002);
expect(first?.identity).not.toEqual(second?.identity);
await expect(first?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 62001 })
);
await expect(second?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 62002 })
);
expect(resolveUrl).toHaveBeenNthCalledWith(1, '/media/file_777.mpg', 3);
expect(resolveUrl).toHaveBeenNthCalledWith(2, '/media/file_777.mpg', 3);
});
it('prepares the complete Stalker episode request and versioned snapshot', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const candidate = createStalkerSeriesDownloadAdapter(
options(resolveUrl)
).createCandidate(episode(), '1');
expect(candidate?.identity).toEqual({
playlistId: 'stalker-1',
contentType: 'episode',
xtreamId: 61001,
seriesXtreamId: 50001,
seasonNumber: 2,
episodeNumber: 3,
episodeIdentityScope: 'stalker-regular-series',
});
if (!candidate) {
throw new Error('expected a valid Stalker episode candidate');
}
const request = await candidate.prepare();
const { metadataSnapshot, ...requestWithoutSnapshot } = request;
expect(requestWithoutSnapshot).toEqual({
playlistId: 'stalker-1',
contentType: 'episode',
xtreamId: 61001,
seriesXtreamId: 50001,
seasonNumber: 2,
episodeNumber: 3,
episodeIdentityScope: 'stalker-regular-series',
title: 'Signal House - S02E03 - The Call',
url: 'https://cdn.example.test/episode.mpg',
posterUrl: EPISODE_POSTER_URL,
headers: {
userAgent: 'Stalker Client/1.0',
referer: 'https://stalker.example.test/c/',
origin: 'https://stalker.example.test',
},
playlistName: 'Living Room Portal',
playlistType: 'stalker',
portalUrl: 'https://stalker.example.test/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
});
expect(metadataSnapshot).toEqual({
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Signal House',
originalTitle: 'La casa de la señal',
plot: 'Parent series plot',
releaseDate: '2025-03-01',
year: 2025,
genres: ['Drama', 'Mystery'],
rating: 8.4,
status: 'returning-series',
posterUrl: SERIES_POSTER_URL,
tmdbId: 88001,
providerCategoryId: '18',
cast: [{ name: 'Sienna Wave' }],
creators: [{ name: 'Cora Bell' }],
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The Call',
plot: 'Episode-specific plot',
stillUrl: EPISODE_POSTER_URL,
},
enrichedAt: expect.any(String),
});
expect(resolveUrl).toHaveBeenCalledWith('/media/file_777.mpg', 3);
});
it.each([
['regular series', 'regular-series', '/media/file_301.mpg', undefined],
[
'embedded VOD series',
'regular-series',
'/media/file_401.mpg',
undefined,
],
['lazy VOD series', 'vod-series', undefined, '501'],
] as const)(
'preserves %s playback ownership solely for URL resolution',
async (_label, customSid, originalCmd, originalId) => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const candidate = createStalkerSeriesDownloadAdapter(
options(resolveUrl)
).createCandidate(
episode({
id: '63001',
custom_sid: customSid,
originalCmd,
originalId,
episode_num: 7,
}),
'4'
);
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 63001 })
);
expect(resolveUrl).toHaveBeenCalledWith(
customSid === 'vod-series'
? '/media/file_501.mpg'
: originalCmd,
7
);
}
);
it('preserves specials season zero and the existing fallback rules', async () => {
const adapter = createStalkerSeriesDownloadAdapter(options());
const specials = adapter.createCandidate(
episode({ season: 0, episode_num: 0 }),
'4'
);
expect(specials?.identity).toEqual(
expect.objectContaining({ seasonNumber: 0, episodeNumber: 1 })
);
expect(
adapter.createCandidate(episode({ season: Number.NaN }), '0')
?.identity
).toEqual(
expect.objectContaining({ seasonNumber: 0, episodeNumber: 3 })
);
expect(
adapter.createCandidate(
episode({ season: Number.NaN, episode_num: Number.NaN }),
undefined
)?.identity
).toEqual(
expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 })
);
await expect(specials?.prepare()).resolves.toEqual(
expect.objectContaining({
title: 'Signal House - S00E01 - The Call',
})
);
});
it('uses the Stalker Portal title fallback', async () => {
const candidate = createStalkerSeriesDownloadAdapter({
...options(),
playlist: { ...PLAYLIST, title: '' },
}).createCandidate(episode(), '2');
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({ playlistName: 'Stalker Portal' })
);
});
it('rejects preparation when URL resolution returns an empty URL', async () => {
const candidate = createStalkerSeriesDownloadAdapter(
options(jest.fn().mockResolvedValue(' '))
).createCandidate(episode(), '2');
await expect(candidate?.prepare()).rejects.toThrow(Error);
});
it('propagates a rejected URL resolver promise', async () => {
const providerFailure = new Error('provider failure');
const candidate = createStalkerSeriesDownloadAdapter(
options(jest.fn().mockRejectedValue(providerFailure))
).createCandidate(episode(), '2');
await expect(candidate?.prepare()).rejects.toBe(providerFailure);
});
it.each([
['missing playlist', { playlist: null }, {}, '2'],
[
'missing playlist id',
{ playlist: { ...PLAYLIST, _id: '' } },
{},
'2',
],
[
'missing portal URL',
{ playlist: { ...PLAYLIST, portalUrl: undefined } },
{},
'2',
],
[
'missing MAC address',
{ playlist: { ...PLAYLIST, macAddress: '' } },
{},
'2',
],
['missing item', { item: null }, {}, '2'],
['missing language', { language: ' ' }, {}, '2'],
[
'unsafe episode id',
{},
{ id: String(Number.MAX_SAFE_INTEGER + 1) },
'2',
],
['non-numeric episode id', {}, { id: 'episode-1' }, '2'],
['non-positive episode id', {}, { id: '0' }, '2'],
[
'unsafe series id',
{ seriesId: Number.MAX_SAFE_INTEGER + 1 },
{},
'2',
],
['non-positive series id', { seriesId: 0 }, {}, '2'],
['unsafe season', {}, { season: 1.5 }, '2'],
['non-positive season', {}, { season: -1 }, '2'],
['unsafe episode number', {}, { episode_num: 1.5 }, '2'],
['non-positive episode number', {}, { episode_num: -1 }, '2'],
['non-positive fallback season', {}, { season: Number.NaN }, '-2'],
[
'unsafe fallback season',
{},
{ season: Number.NaN },
String(Number.MAX_SAFE_INTEGER + 1),
],
] as const)(
'returns null for %s',
(_label, optionOverrides, episodeOverrides, fallbackSeasonKey) => {
const adapter = createStalkerSeriesDownloadAdapter({
...options(),
...optionOverrides,
} as never);
expect(
adapter.createCandidate(
episode(episodeOverrides),
fallbackSeasonKey
)
).toBeNull();
}
);
});
@@ -0,0 +1,181 @@
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import type {
StalkerMappedEpisode,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
import type {
Playlist,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES } from '@iptvnator/shared/interfaces';
import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata';
type StalkerDownloadPlaylist = Pick<
Playlist,
| '_id'
| 'title'
| 'portalUrl'
| 'macAddress'
| 'userAgent'
| 'referrer'
| 'origin'
>;
export interface StalkerSeriesDownloadAdapterOptions {
readonly playlist: StalkerDownloadPlaylist | null | undefined;
readonly item: StalkerSelectedVodItem | null | undefined;
readonly language: string;
readonly seriesId: number;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly resolveUrl: (
command: string,
episodeNumber: number
) => Promise<string>;
}
export const STALKER_SERIES_DOWNLOAD_MODES = {
EmbeddedVod: 'embedded-vod',
LazyVod: 'lazy-vod',
RegularSeries: 'regular-series',
} as const;
export type StalkerSeriesDownloadMode =
(typeof STALKER_SERIES_DOWNLOAD_MODES)[keyof typeof STALKER_SERIES_DOWNLOAD_MODES];
const IDENTITY_SCOPE_BY_MODE = {
[STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerEmbeddedVod,
[STALKER_SERIES_DOWNLOAD_MODES.LazyVod]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerLazyVod,
[STALKER_SERIES_DOWNLOAD_MODES.RegularSeries]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerRegularSeries,
} as const;
function isPresent(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function positiveSafeInteger(value: unknown): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function downloadSeasonNumber(
value: unknown,
fallbackSeasonKey: string | undefined
): number | null {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) {
return parsed >= 0 ? parsed : null;
}
if (value != null && !(typeof value === 'number' && Number.isNaN(value))) {
return null;
}
const normalizedFallback = fallbackSeasonKey?.trim();
const fallback = Number(normalizedFallback || 1);
return Number.isSafeInteger(fallback) && fallback >= 0 ? fallback : null;
}
function episodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
return Array.isArray(episode.info) ? undefined : episode.info;
}
function playbackCommand(episode: XtreamSerieEpisode): string {
const mappedEpisode = episode as StalkerMappedEpisode;
return mappedEpisode.custom_sid === 'vod-series'
? `/media/file_${mappedEpisode.originalId}.mpg`
: (mappedEpisode.originalCmd ?? '');
}
export function createStalkerSeriesDownloadAdapter(
options: StalkerSeriesDownloadAdapterOptions
): SeasonEpisodeDownloadAdapter {
return {
createCandidate(episode, fallbackSeasonKey) {
const playlist = options.playlist;
const item = options.item;
const xtreamId = positiveSafeInteger(episode.id);
const seriesXtreamId = positiveSafeInteger(options.seriesId);
const seasonNumber = downloadSeasonNumber(
episode.season,
fallbackSeasonKey
);
const episodeNumber = positiveSafeInteger(episode.episode_num || 1);
const episodeIdentityScope =
IDENTITY_SCOPE_BY_MODE[options.seriesMode];
if (
!playlist ||
!item ||
!isPresent(playlist._id) ||
!isPresent(playlist.portalUrl) ||
!isPresent(playlist.macAddress) ||
!isPresent(options.language) ||
xtreamId === null ||
seriesXtreamId === null ||
seasonNumber === null ||
episodeNumber === null
) {
return null;
}
const identity = {
playlistId: playlist._id,
contentType: 'episode' as const,
xtreamId,
seriesXtreamId,
seasonNumber,
episodeNumber,
episodeIdentityScope,
};
return {
identity,
prepare: async () => {
const url = await options.resolveUrl(
playbackCommand(episode),
episodeNumber
);
if (!isPresent(url)) {
throw new Error('Unable to prepare episode download');
}
const info = episodeInfo(episode);
const seriesTitle = item.info?.name || 'Series';
const episodeCode = `S${String(seasonNumber).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')}`;
return {
...identity,
title: `${seriesTitle} - ${episodeCode} - ${episode.title}`,
url,
posterUrl: info?.movie_image,
metadataSnapshot: createStalkerSeriesDownloadSnapshot({
item,
episode,
language: options.language,
seriesTitle,
seasonNumber,
episodeNumber,
}),
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
};
},
};
},
};
}
@@ -202,13 +202,13 @@
"
[seasonDescriptions]="seasonDescriptions()"
[downloadsEnabled]="!providerOnly()"
[downloadAdapter]="episodeDownloadAdapter()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequestedFromUi($event)
"
@@ -6,6 +6,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -17,7 +18,7 @@ import {
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService, TmdbEnrichmentService } from '@iptvnator/services';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { EMPTY, of } from 'rxjs';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
@@ -39,9 +40,9 @@ class StubSeasonContainerComponent {
readonly seasonDescriptions = input<unknown>(null);
readonly isLoading = input(false);
readonly downloadsEnabled = input(true);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly seasonSelected = output<string>();
readonly episodeClicked = output<unknown>();
readonly episodeDownloadRequested = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
readonly selectedSeason = signal<string | undefined>(undefined);
}
@@ -90,14 +91,19 @@ describe('StalkerSeriesViewComponent', () => {
const openResolvedPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
const tmdbGetSeason = jest.fn();
const startDownload = jest.fn();
const fetchLinkToPlay = jest.fn();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
@@ -155,12 +161,12 @@ describe('StalkerSeriesViewComponent', () => {
overview: 'Season overview from TMDB',
episodes: [],
});
startDownload.mockReset().mockResolvedValue({ success: true });
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
@@ -212,12 +218,6 @@ describe('StalkerSeriesViewComponent', () => {
navigateByUrl: jest.fn(),
},
},
{
provide: DownloadsService,
useValue: {
startDownload,
},
},
{
provide: TmdbEnrichmentService,
useValue: {
@@ -290,8 +290,7 @@ describe('StalkerSeriesViewComponent', () => {
});
it('renders quick start for regular series and starts the first episode', async () => {
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
@@ -327,8 +326,7 @@ describe('StalkerSeriesViewComponent', () => {
it('keeps provider episodes playable while hiding download presentation', async () => {
fixture.componentRef.setInput('providerOnly', true);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
@@ -346,65 +344,100 @@ describe('StalkerSeriesViewComponent', () => {
).not.toBeNull();
});
it('captures enriched parent and episode metadata when an episode download starts', async () => {
it('binds a Stalker adapter that prepares canonical episode metadata', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
category_id: '18',
info: {
name: 'Signal House',
description: 'Parent series plot',
movie_image:
'https://images.example.test/posters/signal-house.jpg',
actors: 'Sienna Wave',
director: 'Cora Bell',
tmdb_cast: [],
tmdb_directors: [],
},
info: { name: 'Signal House' },
});
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
await fixture.componentInstance.downloadEpisode({
episode_num: '0',
title: 'The Call',
info: {
plot: 'Episode-specific plot',
movie_image: 'https://images.example.test/stills/the-call.jpg',
},
custom_sid: 'vod-series',
season: '0',
originalId: '502',
} as never);
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
const initialAdapter = seasonContainer.downloadAdapter();
const candidate = initialAdapter?.createCandidate(
{
id: '61001',
episode_num: 3,
title: 'The Call',
custom_sid: 'vod-series',
season: 2,
originalId: '502',
} as never,
'2'
);
expect(startDownload).toHaveBeenCalledWith(
const request = await candidate?.prepare();
expect(fetchLinkToPlay).toHaveBeenCalledWith(
'https://stalker.example.test',
'00:1A:79:12:34:56',
'/media/file_502.mpg',
3
);
expect(request).toEqual(
expect.objectContaining({
episodeIdentityScope: 'stalker-lazy-vod',
playlistId: 'stalker-1',
playlistType: 'stalker',
seriesXtreamId: 50001,
seasonNumber: 0,
episodeNumber: '0',
title: 'Signal House - S00E00 - The Call',
xtreamId: 61001,
title: 'Signal House - S02E03 - The Call',
})
);
currentPlaylist.set({
_id: 'stalker-2',
title: 'Bedroom Portal',
portalUrl: 'https://bedroom.example.test',
macAddress: '00:1A:79:65:43:21',
});
selectedItem.set({
id: '40002:season-slice',
category_id: '22',
info: { name: 'Second Signal' },
});
await stabilize();
const updatedAdapter = seasonContainer.downloadAdapter();
expect(updatedAdapter).not.toBe(initialAdapter);
const updatedCandidate = updatedAdapter?.createCandidate(
{
id: '62001',
episode_num: 1,
title: 'Pilot',
custom_sid: 'regular-series',
season: 4,
originalCmd: '/media/file_888.mpg',
} as never,
'4'
);
if (!updatedCandidate) {
throw new Error('expected a refreshed Stalker download adapter');
}
const updatedRequest = await updatedCandidate.prepare();
expect(fetchLinkToPlay).toHaveBeenLastCalledWith(
'https://bedroom.example.test',
'00:1A:79:65:43:21',
'/media/file_888.mpg',
1
);
expect(updatedRequest).toEqual(
expect.objectContaining({
episodeIdentityScope: 'stalker-regular-series',
playlistId: 'stalker-2',
seriesXtreamId: 40002,
xtreamId: 62001,
title: 'Second Signal - S04E01 - Pilot',
portalUrl: 'https://bedroom.example.test',
macAddress: '00:1A:79:65:43:21',
metadataSnapshot: expect.objectContaining({
language: 'en',
mediaKind: 'series',
title: 'Signal House',
plot: 'Parent series plot',
posterUrl:
'https://images.example.test/posters/signal-house.jpg',
providerCategoryId: '18',
cast: [{ name: 'Sienna Wave' }],
creators: [{ name: 'Cora Bell' }],
episode: {
seasonNumber: 0,
episodeNumber: 0,
title: 'The Call',
plot: 'Episode-specific plot',
stillUrl:
'https://images.example.test/stills/the-call.jpg',
},
title: 'Second Signal',
providerCategoryId: '22',
}),
})
);
@@ -424,8 +457,7 @@ describe('StalkerSeriesViewComponent', () => {
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([]);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
@@ -505,8 +537,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
@@ -577,8 +608,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
@@ -677,8 +707,7 @@ describe('StalkerSeriesViewComponent', () => {
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
@@ -820,8 +849,7 @@ describe('StalkerSeriesViewComponent', () => {
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
@@ -855,8 +883,7 @@ describe('StalkerSeriesViewComponent', () => {
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
@@ -904,8 +931,7 @@ describe('StalkerSeriesViewComponent', () => {
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
@@ -938,8 +964,7 @@ describe('StalkerSeriesViewComponent', () => {
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
@@ -947,8 +972,7 @@ describe('StalkerSeriesViewComponent', () => {
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
@@ -982,8 +1006,7 @@ describe('StalkerSeriesViewComponent', () => {
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
@@ -1014,8 +1037,7 @@ describe('StalkerSeriesViewComponent', () => {
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
@@ -1027,8 +1049,7 @@ describe('StalkerSeriesViewComponent', () => {
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
@@ -1079,8 +1100,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.vodSeriesSeasons.set([
{
@@ -1151,8 +1171,7 @@ describe('StalkerSeriesViewComponent', () => {
});
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.detectChanges();
// Season tabs auto-select immediately — usually before the async
@@ -1161,8 +1180,7 @@ describe('StalkerSeriesViewComponent', () => {
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
seasonContainer.seasonSelected.emit('1');
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
// The TMDB match lands afterwards — the fetch must run now.
@@ -1176,8 +1194,7 @@ describe('StalkerSeriesViewComponent', () => {
tmdb_id: 777,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
});
@@ -1194,12 +1211,10 @@ describe('StalkerSeriesViewComponent', () => {
tmdb_id: 777,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// Season resource still loading — fetching now would pass a zero
// season count, suppress the title-marker override and cache the
@@ -1214,8 +1229,7 @@ describe('StalkerSeriesViewComponent', () => {
series: [1, 2],
},
]);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// Single-season slice whose provider season is renumbered to 1:
// the title marker names the real TMDB season.
@@ -1223,12 +1237,10 @@ describe('StalkerSeriesViewComponent', () => {
});
it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => {
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// No show-level TMDB match yet — nothing fetched for the first item
expect(tmdbGetSeason).not.toHaveBeenCalled();
@@ -1246,8 +1258,7 @@ describe('StalkerSeriesViewComponent', () => {
tmdb_id: 888,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// The new tmdb_id must NOT pair with the previous series' season
// context while the resource reloads.
@@ -1266,17 +1277,14 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
isSerialSeasonsLoading.set(false);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
it('enriches after equal-id navigation once the season resource settles', async () => {
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
// Distinct items can reuse a provider id; the loading gate (not an
// id comparison) keeps the stale map from being used.
@@ -1291,13 +1299,11 @@ describe('StalkerSeriesViewComponent', () => {
tmdb_id: 999,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
@@ -1313,12 +1319,10 @@ describe('StalkerSeriesViewComponent', () => {
tmdb_id: 777,
},
} as never);
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
});
@@ -29,7 +29,6 @@ import {
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieEpisode,
type XtreamSerieEpisodeInfo,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
@@ -69,7 +68,6 @@ import {
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
PlaybackPositionRuntimeBridgeService,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
@@ -83,7 +81,10 @@ import {
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata';
import {
createStalkerSeriesDownloadAdapter,
STALKER_SERIES_DOWNLOAD_MODES,
} from './stalker-series-download.adapter';
interface SeriesPositionContext {
readonly generation: number;
@@ -188,7 +189,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
@@ -534,6 +534,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
readonly episodeDownloadAdapter = computed(() => {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
return createStalkerSeriesDownloadAdapter({
playlist,
item,
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
seriesId: this.toSeriesId(item?.id ?? 0),
seriesMode: this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
: this.vodWithSeries()
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
resolveUrl: (command, episodeNumber) =>
this.stalkerStore.fetchLinkToPlay(
playlist?.portalUrl ?? '',
playlist?.macAddress ?? '',
command,
episodeNumber
),
});
});
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
@@ -1072,81 +1098,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
if (!playlist || !playlist.portalUrl || !playlist.macAddress || !item) {
return;
}
const customSid = (episode as { custom_sid?: string }).custom_sid;
const cmd =
customSid === 'vod-series'
? `/media/file_${(episode as { originalId?: string }).originalId}.mpg`
: ((episode as { originalCmd?: string }).originalCmd ?? '');
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle =
item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
)}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
metadataSnapshot: createStalkerSeriesDownloadSnapshot({
item,
episode,
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
seriesTitle,
seasonNumber: seasonNum,
episodeNumber: episodeNum,
}),
seriesXtreamId: this.toSeriesId(item.id),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
private async loadSeriesPositions(
context: SeriesPositionContext
): Promise<void> {
@@ -1508,53 +1459,4 @@ export class StalkerSeriesViewComponent implements OnDestroy {
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
private getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (!episode.info || Array.isArray(episode.info)) {
return undefined;
}
return episode.info;
}
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return this.hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? this.hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId)
? this.hashString(String(episode.id))
: numericId;
}
private hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
}
@@ -205,8 +205,7 @@
[playlistId]="currentPlaylistId()"
[seriesTitle]="info.name"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[downloadMetadataContext]="downloadMetadataContext()"
[downloadAdapter]="episodeDownloadAdapter()"
[downloadsEnabled]="!providerOnly()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
@@ -17,6 +17,7 @@ import {
PORTAL_PLAYER,
SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
@@ -37,8 +38,7 @@ class StubSeasonContainerComponent {
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly xtreamDownloadContext = input<unknown>(null);
readonly downloadMetadataContext = input<unknown>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
@@ -88,9 +88,9 @@ describe('SerialDetailsComponent', () => {
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
userAgent: 'Provider Player/1.0',
referrer: 'https://provider.test/player',
origin: 'https://provider.test',
userAgent: 'ProtectedProvider/2.0',
referrer: 'https://referrer.example/series',
origin: 'https://origin.example',
});
const fetchSerialDetailsWithMetadata = jest.fn();
const cancelDetailsRequest = jest.fn();
@@ -359,30 +359,57 @@ describe('SerialDetailsComponent', () => {
],
});
expect(seasonContainer?.downloadsEnabled()).toBe(true);
expect(seasonContainer?.xtreamDownloadContext()).toEqual({
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
userAgent: 'Provider Player/1.0',
referrer: 'https://provider.test/player',
origin: 'https://provider.test',
const adapter = seasonContainer?.downloadAdapter();
const candidate = adapter?.createCandidate(
(
seasonContainer?.seasons() as Record<
string,
Array<Record<string, unknown>>
>
)['1'][0] as never,
'1'
);
expect(candidate?.identity).toEqual({
playlistId: 'xtream-1',
contentType: 'episode',
xtreamId: 1001,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
expect(seasonContainer?.downloadMetadataContext()).toEqual(
expect.objectContaining({
await expect(candidate?.prepare()).resolves.toEqual({
playlistId: 'xtream-1',
xtreamId: 1001,
contentType: 'episode',
title: 'Series One - S01E01 - Episode 1',
url: 'http://xtream.example/series/user/pass/1001.mp4',
posterUrl: undefined,
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
headers: {
userAgent: 'ProtectedProvider/2.0',
referer: 'https://referrer.example/series',
origin: 'https://origin.example',
},
metadataSnapshot: {
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Series One',
plot: 'Series plot',
genres: ['Drama'],
providerCategoryId: '3',
tmdbId: 901,
cast: expect.arrayContaining([
expect.objectContaining({
name: 'Sienna Wave',
role: 'Mara',
}),
]),
})
);
providerCategoryId: '3',
cast: [{ name: 'Sienna Wave', role: 'Mara' }],
episode: {
seasonNumber: 1,
episodeNumber: 1,
title: 'Episode 1',
},
enrichedAt: expect.any(String),
},
});
});
it('keeps every provider episode but disables download presentation in provider-only mode', async () => {
@@ -21,9 +21,8 @@ import {
PortalDetailShellComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
type SeasonContainerDownloadMetadataContext,
SeasonContainerXtreamDownloadContext,
} from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
buildUpNextRailItems,
@@ -53,6 +52,7 @@ import {
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
@Component({
selector: 'app-serial-details',
@@ -102,19 +102,31 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
readonly currentPlaylistId = signal('');
readonly xtreamDownloadContext =
signal<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
computed<SeasonContainerDownloadMetadataContext | null>(() => {
const info = this.selectedItem()?.info;
return info
? createXtreamSeriesDownloadMetadataContext(
info,
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en'
)
: null;
readonly episodeDownloadAdapter =
computed<SeasonEpisodeDownloadAdapter | null>(() => {
const playlist = this.xtreamStore.currentPlaylist();
const item = this.selectedItem();
if (!playlist || !item) {
return null;
}
return createXtreamSeriesDownloadAdapter({
playlistId: playlist.id,
seriesId: Number(item.series_id),
title: item.info.name,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
metadataContext: createXtreamSeriesDownloadMetadataContext(
item.info,
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en'
),
});
});
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
@@ -254,18 +266,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
this.currentPlaylistId.set(playlist?.id ?? '');
this.xtreamDownloadContext.set(
playlist
? {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
}
: null
);
});
// Initializes on first render and RE-initializes when the route
@@ -1,4 +1,4 @@
import type { SeasonContainerDownloadMetadataContext } from '@iptvnator/ui/components';
import type { DownloadMovieSnapshotInput } from '@iptvnator/portal/shared/util';
import type {
DownloadMetadataPerson,
TmdbEnrichedCastMember,
@@ -41,7 +41,7 @@ function people(
export function createXtreamSeriesDownloadMetadataContext(
info: XtreamSerieInfo,
language: string
): SeasonContainerDownloadMetadataContext {
): DownloadMovieSnapshotInput {
const runtimeMinutes = finiteNumber(info.episode_run_time);
return {
language,
@@ -0,0 +1,228 @@
import {
XTREAM_CLIENT_USER_AGENT,
type XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
const POSTER_URL = 'https://images.test/stills/the-one.jpg';
const OPTIONS = {
playlistId: 'playlist-1',
seriesId: 900,
title: 'Signal House',
serverUrl: 'http://host/',
username: 'user',
password: 'pass',
userAgent: 'ProtectedProvider/1.0',
referrer: 'https://referrer.test/series',
origin: 'https://origin.test',
metadataContext: {
language: 'en',
title: 'Signal House',
plot: 'Series plot',
},
} as const;
function episode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '55',
episode_num: 3,
title: 'The One',
container_extension: 'mkv',
info: {
plot: 'Episode plot',
movie_image: POSTER_URL,
},
custom_sid: '',
added: '',
season: 2,
direct_source: '',
...overrides,
};
}
describe('createXtreamSeriesDownloadAdapter', () => {
it('creates a canonical candidate and prepares the complete Xtream request', async () => {
const adapter = createXtreamSeriesDownloadAdapter(OPTIONS);
const candidate = adapter.createCandidate(episode(), '1');
expect(candidate?.identity).toEqual({
playlistId: 'playlist-1',
contentType: 'episode',
xtreamId: 55,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
});
if (!candidate) {
throw new Error('expected a valid Xtream episode candidate');
}
const request = await candidate.prepare();
const { metadataSnapshot, ...requestWithoutSnapshot } = request;
expect(requestWithoutSnapshot).toEqual({
playlistId: 'playlist-1',
xtreamId: 55,
contentType: 'episode',
title: 'Signal House - S02E03 - The One',
url: 'http://host/series/user/pass/55.mkv',
posterUrl: POSTER_URL,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
headers: {
userAgent: 'ProtectedProvider/1.0',
referer: 'https://referrer.test/series',
origin: 'https://origin.test',
},
});
expect(metadataSnapshot).toEqual({
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Signal House',
plot: 'Series plot',
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The One',
plot: 'Episode plot',
stillUrl: POSTER_URL,
},
enrichedAt: expect.any(String),
});
});
it('keeps the Xtream client user-agent fallback for episode downloads', async () => {
const adapter = createXtreamSeriesDownloadAdapter({
...OPTIONS,
userAgent: ' ',
referrer: undefined,
origin: undefined,
});
const candidate = adapter.createCandidate(episode(), '1');
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({
headers: {
userAgent: XTREAM_CLIENT_USER_AGENT,
referer: undefined,
origin: undefined,
},
})
);
});
it('preserves specials season zero and the existing fallback rules', async () => {
const adapter = createXtreamSeriesDownloadAdapter(OPTIONS);
const specials = adapter.createCandidate(
episode({ season: 0, episode_num: 0 }),
'4'
);
const stringSpecials = adapter.createCandidate(
episode({ season: '0' as unknown as number }),
'4'
);
const fromSeasonKey = adapter.createCandidate(
episode({ season: Number.NaN }),
'0'
);
const defaults = adapter.createCandidate(
episode({ season: Number.NaN, episode_num: Number.NaN }),
undefined
);
expect(specials?.identity).toEqual(
expect.objectContaining({ seasonNumber: 0, episodeNumber: 1 })
);
expect(stringSpecials?.identity).toEqual(
expect.objectContaining({ seasonNumber: 0, episodeNumber: 3 })
);
expect(fromSeasonKey?.identity).toEqual(
expect.objectContaining({ seasonNumber: 0, episodeNumber: 3 })
);
expect(defaults?.identity).toEqual(
expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 })
);
await expect(specials?.prepare()).resolves.toEqual(
expect.objectContaining({
title: 'Signal House - S00E01 - The One',
})
);
});
it.each([null, '', ' '] as const)(
'uses the season key for a missing runtime season value %p',
(season) => {
const adapter = createXtreamSeriesDownloadAdapter(OPTIONS);
expect(
adapter.createCandidate(
episode({ season: season as unknown as number }),
'2'
)?.identity
).toEqual(expect.objectContaining({ seasonNumber: 2 }));
}
);
it('preserves raw Xtream path concatenation and the mp4 extension fallback', async () => {
const adapter = createXtreamSeriesDownloadAdapter({
...OPTIONS,
serverUrl: 'http://host/base//',
username: 'u@ser',
password: 'p/a',
});
const candidate = adapter.createCandidate(
episode({ container_extension: '' }),
undefined
);
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({
url: 'http://host/base//series/u@ser/p/a/55.mp4',
})
);
});
it.each([
['missing server URL', { serverUrl: undefined }, {}, undefined],
['empty server URL', { serverUrl: ' ' }, {}, undefined],
['missing username', { username: undefined }, {}, undefined],
['empty username', { username: '' }, {}, undefined],
['missing password', { password: undefined }, {}, undefined],
['empty password', { password: ' ' }, {}, undefined],
['missing playlist id', { playlistId: undefined }, {}, undefined],
['empty playlist id', { playlistId: ' ' }, {}, undefined],
['unsafe episode id', {}, { id: 'not-an-id' }, undefined],
['non-positive episode id', {}, { id: '0' }, undefined],
[
'unsafe series id',
{ seriesId: Number.MAX_SAFE_INTEGER + 1 },
{},
undefined,
],
['non-positive series id', { seriesId: 0 }, {}, undefined],
['unsafe season', {}, { season: 1.5 }, undefined],
['unsafe episode number', {}, { episode_num: -1 }, undefined],
['unsafe fallback season', {}, { season: Number.NaN }, '-2'],
] as const)(
'returns null for %s',
(_label, optionOverrides, episodeOverrides, fallbackSeasonKey) => {
const adapter = createXtreamSeriesDownloadAdapter({
...OPTIONS,
...optionOverrides,
});
expect(
adapter.createCandidate(
episode(episodeOverrides),
fallbackSeasonKey
)
).toBeNull();
}
);
});
@@ -0,0 +1,136 @@
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createSeriesEpisodeDownloadSnapshot,
type DownloadMovieSnapshotInput,
} from '@iptvnator/portal/shared/util';
import {
XTREAM_CLIENT_USER_AGENT,
type XtreamSerieEpisode,
type XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
export interface XtreamSeriesDownloadAdapterOptions {
readonly playlistId?: string;
readonly seriesId: number;
readonly title: string;
readonly serverUrl?: string;
readonly username?: string;
readonly password?: string;
readonly userAgent?: string;
readonly referrer?: string;
readonly origin?: string;
readonly metadataContext: DownloadMovieSnapshotInput;
}
function isPresent(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function positiveSafeInteger(value: unknown): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function downloadSeasonNumber(
value: unknown,
fallbackSeasonKey: string | undefined
): number | null {
const isMissing =
value == null ||
(typeof value === 'string' && value.trim() === '') ||
(typeof value === 'number' && Number.isNaN(value));
if (!isMissing) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
const normalizedFallback = fallbackSeasonKey?.trim();
const fallback = Number(normalizedFallback || 1);
return Number.isSafeInteger(fallback) && fallback >= 0 ? fallback : null;
}
function episodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
return Array.isArray(episode.info) ? undefined : episode.info;
}
export function createXtreamSeriesDownloadAdapter(
options: XtreamSeriesDownloadAdapterOptions
): SeasonEpisodeDownloadAdapter {
return {
createCandidate(episode, fallbackSeasonKey) {
const playlistId = options.playlistId;
const serverUrl = options.serverUrl;
const username = options.username;
const password = options.password;
const xtreamId = positiveSafeInteger(episode.id);
const seriesXtreamId = positiveSafeInteger(options.seriesId);
const seasonNumber = downloadSeasonNumber(
episode.season,
fallbackSeasonKey
);
const episodeNumber = positiveSafeInteger(episode.episode_num || 1);
if (
!isPresent(playlistId) ||
!isPresent(serverUrl) ||
!isPresent(username) ||
!isPresent(password) ||
!options.metadataContext ||
xtreamId === null ||
seriesXtreamId === null ||
seasonNumber === null ||
episodeNumber === null
) {
return null;
}
const identity = {
playlistId,
contentType: 'episode' as const,
xtreamId,
seriesXtreamId,
seasonNumber,
episodeNumber,
};
return {
identity,
prepare: async () => {
const info = episodeInfo(episode);
const extension = episode.container_extension || 'mp4';
const normalizedServerUrl = serverUrl.replace(/\/$/, '');
const episodeCode = `S${String(seasonNumber).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')}`;
return {
...identity,
title: `${options.title || 'Series'} - ${episodeCode} - ${episode.title}`,
url: `${normalizedServerUrl}/series/${username}/${password}/${episode.id}.${extension}`,
posterUrl: info?.movie_image,
headers: {
userAgent:
options.userAgent?.trim() ||
XTREAM_CLIENT_USER_AGENT,
referer: options.referrer,
origin: options.origin,
},
metadataSnapshot: createSeriesEpisodeDownloadSnapshot({
...options.metadataContext,
episode: {
seasonNumber,
episodeNumber,
title: episode.title,
plot: info?.plot,
stillUrl: info?.movie_image,
},
}),
};
},
};
},
};
}
@@ -0,0 +1,81 @@
import { signal } from '@angular/core';
interface QueuedDownloadListLoad {
readonly operation: () => Promise<void>;
readonly promise: Promise<void>;
readonly resolve: () => void;
readonly reject: (reason?: unknown) => void;
}
export class DownloadListLoadState {
private activeLoad?: Promise<void>;
private queuedLoad?: QueuedDownloadListLoad;
private readonly loading = signal(false);
private readonly loaded = signal(false);
private readonly authoritative = signal(false);
readonly isLoading = this.loading.asReadonly();
readonly hasLoaded = this.loaded.asReadonly();
readonly hasAuthoritativeList = this.authoritative.asReadonly();
markSucceeded(): void {
this.authoritative.set(true);
this.loaded.set(true);
}
markFailed(): void {
this.authoritative.set(false);
this.loaded.set(true);
}
run(operation: () => Promise<void>): Promise<void> {
if (!this.activeLoad) {
return this.start(operation);
}
if (!this.queuedLoad) {
this.queuedLoad = this.createQueuedLoad(operation);
}
return this.queuedLoad.promise;
}
private start(operation: () => Promise<void>): Promise<void> {
this.loading.set(true);
const activeLoad = operation();
this.activeLoad = activeLoad;
void activeLoad.then(
() => this.finish(activeLoad),
() => this.finish(activeLoad)
);
return activeLoad;
}
private finish(activeLoad: Promise<void>): void {
if (this.activeLoad !== activeLoad) {
return;
}
this.activeLoad = undefined;
const queuedLoad = this.queuedLoad;
this.queuedLoad = undefined;
if (!queuedLoad) {
this.loading.set(false);
return;
}
const nextLoad = this.start(queuedLoad.operation);
void nextLoad.then(queuedLoad.resolve, queuedLoad.reject);
}
private createQueuedLoad(
operation: () => Promise<void>
): QueuedDownloadListLoad {
let resolve!: () => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<void>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { operation, promise, resolve, reject };
}
}
+4 -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,
@@ -20,6 +20,8 @@ export interface DownloadItem {
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?:
ElectronBridgeDownloadStartPayload['episodeIdentityScope'] | null;
title: string;
url: string;
fileName?: string;
+113 -25
View File
@@ -10,7 +10,9 @@ import {
import type {
DownloadMetadataSnapshot,
ElectronBridgeDownloadStartPayload,
ElectronBridgeDownloadStartResult,
} from '@iptvnator/shared/interfaces';
import { DownloadListLoadState } from './download-list-load-state';
import { DownloadItem, DownloadsService } from './downloads.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
@@ -19,6 +21,7 @@ type TestDownloadsService = {
downloadFolder: WritableSignal<string>;
isAvailable: () => boolean;
isLoadingDownloads: Signal<boolean>;
hasAuthoritativeDownloadList: Signal<boolean>;
hasLoadedDownloads: Signal<boolean>;
getDownload: DownloadsService['getDownload'];
loadDownloads: DownloadsService['loadDownloads'];
@@ -29,15 +32,13 @@ type TestDownloadsService = {
selectFolder: DownloadsService['selectFolder'];
startDownload: DownloadsService['startDownload'];
updateMetadata: DownloadsService['updateMetadata'];
_isLoadingDownloads: WritableSignal<boolean>;
_hasLoadedDownloads: WritableSignal<boolean>;
loadDownloadsRequestId: number;
downloadListLoadState: DownloadListLoadState;
};
type DownloadsElectronStub = {
downloadsGetDefaultFolder?: jest.Mock<Promise<string>, []>;
downloadsStart?: jest.Mock<
Promise<{ success: boolean; id?: number; error?: string }>,
Promise<ElectronBridgeDownloadStartResult>,
[ElectronBridgeDownloadStartPayload]
>;
downloadsUpdateMetadata?: jest.Mock<
@@ -116,8 +117,7 @@ describe('DownloadsService', () => {
function createService(initialDownloads: DownloadItem[] = []) {
const downloads = signal(initialDownloads);
const isLoadingDownloads = signal(false);
const hasLoadedDownloads = signal(false);
const downloadListLoadState = new DownloadListLoadState();
const downloadFolder = signal('');
const service = Object.create(
DownloadsService.prototype
@@ -127,11 +127,11 @@ describe('DownloadsService', () => {
downloads,
downloadFolder,
isAvailable: () => true,
_isLoadingDownloads: isLoadingDownloads,
isLoadingDownloads: isLoadingDownloads.asReadonly(),
_hasLoadedDownloads: hasLoadedDownloads,
hasLoadedDownloads: hasLoadedDownloads.asReadonly(),
loadDownloadsRequestId: 0,
downloadListLoadState,
isLoadingDownloads: downloadListLoadState.isLoading,
hasAuthoritativeDownloadList:
downloadListLoadState.hasAuthoritativeList,
hasLoadedDownloads: downloadListLoadState.hasLoaded,
});
return service;
@@ -182,6 +182,7 @@ describe('DownloadsService', () => {
const request = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
expect(service.hasAuthoritativeDownloadList()).toBe(false);
expect(service.hasLoadedDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenCalledWith();
@@ -190,6 +191,7 @@ describe('DownloadsService', () => {
expect(service.downloads()).toEqual([item]);
expect(service.isLoadingDownloads()).toBe(false);
expect(service.hasAuthoritativeDownloadList()).toBe(true);
expect(service.hasLoadedDownloads()).toBe(true);
});
@@ -245,6 +247,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'),
@@ -465,21 +499,26 @@ describe('DownloadsService', () => {
);
});
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
it('marks a failed request complete while making the preserved list non-authoritative', async () => {
const existing = createDownload(1);
const error = new Error('download query failed');
const pending = createDeferred<DownloadItem[]>();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
testWindow.electron = {
downloadsGetList: jest.fn(async () => {
throw error;
}),
downloadsGetList: jest.fn(() => pending.promise),
};
const service = createService([existing]);
service.downloadListLoadState.markSucceeded();
await service.loadDownloads();
const request = service.loadDownloads();
expect(service.hasAuthoritativeDownloadList()).toBe(true);
pending.reject(error);
await request;
expect(service.downloads()).toEqual([existing]);
expect(service.isLoadingDownloads()).toBe(false);
expect(service.hasAuthoritativeDownloadList()).toBe(false);
expect(service.hasLoadedDownloads()).toBe(true);
expect(console.error).toHaveBeenCalledWith(
'[DownloadsService] Error loading downloads:',
@@ -487,8 +526,8 @@ describe('DownloadsService', () => {
);
});
it('keeps only the latest overlapping download list request result', async () => {
const staleItem = createDownload(1, 'playlist-old');
it('serializes overlapping requests and finishes with the trailing snapshot', async () => {
const initialItem = createDownload(1, 'playlist-initial');
const latestItem = createDownload(2, 'playlist-new');
const first = createDeferred<DownloadItem[]>();
const second = createDeferred<DownloadItem[]>();
@@ -505,23 +544,72 @@ describe('DownloadsService', () => {
const secondRequest = service.loadDownloads();
expect(service.isLoadingDownloads()).toBe(true);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
first.resolve([initialItem]);
await firstRequest;
expect(service.downloads()).toEqual([initialItem]);
expect(service.isLoadingDownloads()).toBe(true);
expect(service.hasLoadedDownloads()).toBe(true);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(2);
second.resolve([latestItem]);
await secondRequest;
expect(service.downloads()).toEqual([latestItem]);
expect(service.isLoadingDownloads()).toBe(false);
expect(service.hasLoadedDownloads()).toBe(true);
first.resolve([staleItem]);
await firstRequest;
expect(service.downloads()).toEqual([latestItem]);
expect(service.isLoadingDownloads()).toBe(false);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(1);
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2);
});
it('coalesces update storms without starving a queued refresh caller', async () => {
const initialItem = createDownload(1, 'playlist-initial');
const preflightItem = createDownload(2, 'playlist-preflight');
const broadcastItem = createDownload(3, 'playlist-broadcast');
const first = createDeferred<DownloadItem[]>();
const second = createDeferred<DownloadItem[]>();
const third = createDeferred<DownloadItem[]>();
const electron = {
downloadsGetList: jest
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise),
};
testWindow.electron = electron;
const service = createService();
let preflightSettled = false;
const firstRequest = service.loadDownloads();
const preflightRequest = service.loadDownloads().finally(() => {
preflightSettled = true;
});
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
first.resolve([initialItem]);
await firstRequest;
expect(electron.downloadsGetList).toHaveBeenCalledTimes(2);
expect(preflightSettled).toBe(false);
const broadcastRequest = service.loadDownloads();
expect(electron.downloadsGetList).toHaveBeenCalledTimes(2);
second.resolve([preflightItem]);
await preflightRequest;
expect(preflightSettled).toBe(true);
expect(service.downloads()).toEqual([preflightItem]);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(3);
third.resolve([broadcastItem]);
await broadcastRequest;
expect(service.downloads()).toEqual([broadcastItem]);
});
it('reports paused content and resumes it by content identity', async () => {
const pausedItem = {
...createDownload(7),
+28 -26
View File
@@ -1,29 +1,36 @@
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces';
import { DownloadListLoadState } from './download-list-load-state';
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 {
private readonly runtime = inject(RuntimeCapabilitiesService);
private unsubscribe?: () => void;
private loadDownloadsRequestId = 0;
private readonly _isLoadingDownloads = signal(false);
private readonly _hasLoadedDownloads = signal(false);
private readonly downloadListLoadState = new DownloadListLoadState();
/** Signal for the list of downloads */
readonly downloads = signal<DownloadItem[]>([]);
/** Whether the download list is currently being loaded */
readonly isLoadingDownloads = this._isLoadingDownloads.asReadonly();
readonly isLoadingDownloads = this.downloadListLoadState.isLoading;
/** Whether the first download list request has completed */
readonly hasLoadedDownloads = this._hasLoadedDownloads.asReadonly();
readonly hasLoadedDownloads = this.downloadListLoadState.hasLoaded;
/** Whether the latest download list request completed successfully */
readonly hasAuthoritativeDownloadList =
this.downloadListLoadState.hasAuthoritativeList;
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => this.runtime.supportsDownloads);
@@ -81,30 +88,25 @@ export class DownloadsService implements OnDestroy {
}
/**
* Load downloads from the backend
* Load downloads from the backend. Overlapping callers coalesce behind one
* serialized trailing refresh.
*/
async loadDownloads(): Promise<void> {
if (!this.isAvailable()) return;
const requestId = ++this.loadDownloadsRequestId;
this._isLoadingDownloads.set(true);
try {
const list = await window.electron.downloadsGetList();
if (requestId === this.loadDownloadsRequestId) {
return this.downloadListLoadState.run(async () => {
try {
const list = await window.electron.downloadsGetList();
this.downloads.set(list);
this._hasLoadedDownloads.set(true);
this.downloadListLoadState.markSucceeded();
} catch (error) {
console.error(
'[DownloadsService] Error loading downloads:',
error
);
this.downloadListLoadState.markFailed();
}
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
if (requestId === this.loadDownloadsRequestId) {
this._hasLoadedDownloads.set(true);
}
} finally {
if (requestId === this.loadDownloadsRequestId) {
this._isLoadingDownloads.set(false);
}
}
});
}
/**
@@ -135,7 +137,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' };
}
@@ -25,6 +25,7 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
}
function rebuildDownloadsInElectron(metadataSnapshot: string): {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -51,6 +52,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -84,20 +86,22 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
content_type,
title,
url,
metadata_snapshot
) VALUES (?, ?, ?, ?, ?, ?)
metadata_snapshot,
episode_identity_scope
) VALUES (?, ?, ?, ?, ?, ?, ?)
\`).run(
'playlist-1',
42,
'vod',
'Offline title',
'https://example.com/movie',
metadataSnapshot
metadataSnapshot,
'stalker-lazy-vod'
);
console.log = () => undefined;
__databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite);
const row = sqlite.prepare(
'SELECT metadata_snapshot FROM downloads WHERE id = 1'
'SELECT metadata_snapshot, episode_identity_scope FROM downloads WHERE id = 1'
).get();
const table = sqlite.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
@@ -109,6 +113,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
).get());
sqlite.close();
process.stdout.write(JSON.stringify({
episodeIdentityScope: row.episode_identity_scope,
metadataSnapshot: row.metadata_snapshot,
retainedAfterPlaylistDelete,
schemaSql: table.sql,
@@ -129,6 +134,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
);
return JSON.parse(output) as {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -240,6 +246,8 @@ describe('database schema statements', () => {
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain('metadata_snapshot TEXT');
expect(downloadColumns).toContain('metadata_snapshot');
expect(schemaSql).toContain('episode_identity_scope TEXT');
expect(downloadColumns).toContain('episode_identity_scope');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
@@ -288,6 +296,9 @@ describe('database schema statements', () => {
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT'
);
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT'
);
});
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
@@ -658,6 +669,7 @@ describe('database schema statements', () => {
expect(rebuilt.schemaSql).toContain(`'paused'`);
expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists');
expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot);
expect(rebuilt.episodeIdentityScope).toBe('stalker-lazy-vod');
expect(rebuilt.retainedAfterPlaylistDelete).toBe(true);
});
@@ -98,6 +98,7 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -381,6 +382,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
// Offline details: provider-neutral display metadata captured at download time
`ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`,
// Series queue: scope coordinate compatibility across provider series modes
`ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -1010,6 +1013,12 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
const legacyResumeValidatorSelect = hasResumeValidator
? 'resume_validator'
: 'NULL AS resume_validator';
const hasEpisodeIdentityScope = row.sql.includes(
'episode_identity_scope'
);
const legacyEpisodeIdentityScopeSelect = hasEpisodeIdentityScope
? 'episode_identity_scope'
: 'NULL AS episode_identity_scope';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
@@ -1036,6 +1045,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
episode_identity_scope,
title,
url,
file_name,
@@ -1059,6 +1069,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
${legacyEpisodeIdentityScopeSelect},
title,
url,
file_name,
+1
View File
@@ -323,6 +323,7 @@ export const downloads = sqliteTable(
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
episodeIdentityScope: text('episode_identity_scope'),
// Download metadata
title: text('title').notNull(),
url: text('url').notNull(),
@@ -138,6 +138,23 @@ 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 = {
AlreadyDownloaded: 'already-downloaded',
AlreadyInProgress: 'already-in-progress',
} as const;
export type ElectronBridgeDownloadStartReason =
(typeof ELECTRON_BRIDGE_DOWNLOAD_START_REASONS)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_START_REASONS];
export const ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES = {
StalkerEmbeddedVod: 'stalker-embedded-vod',
StalkerLazyVod: 'stalker-lazy-vod',
StalkerRegularSeries: 'stalker-regular-series',
} as const;
export type ElectronBridgeEpisodeIdentityScope =
(typeof ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES)[keyof typeof ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES];
export type ElectronDownloadFileAvailability =
'available' | 'missing' | 'not-applicable';
@@ -534,6 +551,7 @@ export interface ElectronBridgeDownloadStartPayload {
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope;
playlistName?: string;
playlistType?: ElectronBridgePlaylistType;
serverUrl?: string;
@@ -543,6 +561,7 @@ export interface ElectronBridgeDownloadStartPayload {
export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorResult {
id?: number;
reason?: ElectronBridgeDownloadStartReason;
}
export interface ElectronBridgeDownloadRedownloadResult extends ElectronBridgeErrorResult {
@@ -557,6 +576,7 @@ export interface ElectronDownloadItem {
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope | null;
title: string;
url: string;
fileName?: string;
@@ -1,178 +0,0 @@
import {
DownloadMetadataSnapshot,
XTREAM_CLIENT_USER_AGENT,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import {
createMovieDownloadSnapshot,
createSeriesEpisodeDownloadSnapshot,
type DownloadMovieSnapshotInput,
} from '@iptvnator/portal/shared/util';
/**
* Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the
* download-manager content ids and download requests used by the season
* container.
*/
export interface XtreamEpisodeDownloadContext {
serverUrl?: string;
username?: string;
password?: string;
userAgent?: string;
referrer?: string;
origin?: string;
}
/** Provider-neutral metadata already loaded by the detail host. */
export type SeasonContainerDownloadMetadataContext = DownloadMovieSnapshotInput;
export interface XtreamEpisodeDownloadRequest {
playlistId: string;
xtreamId: number;
contentType: 'episode';
title: string;
url: string;
posterUrl?: string;
seriesXtreamId: number;
seasonNumber: number;
episodeNumber: number;
headers: {
userAgent: string;
referer?: string;
origin?: string;
};
metadataSnapshot?: DownloadMetadataSnapshot;
}
function rowCoordinate(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
function buildMetadataSnapshot(
metadata: SeasonContainerDownloadMetadataContext,
episode: XtreamSerieEpisode,
seasonNumber: unknown,
episodeNumber: unknown
): DownloadMetadataSnapshot {
const normalizedSeason = rowCoordinate(seasonNumber);
const normalizedEpisode = rowCoordinate(episodeNumber);
if (normalizedSeason === undefined || normalizedEpisode === undefined) {
return {
...createMovieDownloadSnapshot(metadata),
mediaKind: 'series',
};
}
const info = Array.isArray(episode.info)
? undefined
: (episode.info as XtreamSerieEpisodeInfo);
return createSeriesEpisodeDownloadSnapshot({
...metadata,
episode: {
seasonNumber: normalizedSeason,
episodeNumber: normalizedEpisode,
title: episode.title,
plot: info?.plot,
stillUrl: info?.movie_image,
},
});
}
export function hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
export function isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
const customSid = (episode as { custom_sid?: string }).custom_sid;
return customSid === 'vod-series' || customSid === 'regular-series';
}
export function getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId) ? hashString(String(episode.id)) : numericId;
}
export function buildXtreamEpisodeDownloadRequest(options: {
episode: XtreamSerieEpisode;
context: XtreamEpisodeDownloadContext;
playlistId: string;
seriesId: number;
seriesTitle: string;
fallbackSeasonKey: string | undefined;
posterUrl?: string;
metadataContext?: SeasonContainerDownloadMetadataContext;
}): XtreamEpisodeDownloadRequest {
const { episode, context, playlistId, seriesId, seriesTitle } = options;
const serverUrl = context.serverUrl?.replace(/\/$/, '') || '';
const username = context.username || '';
const password = context.password || '';
const extension = episode.container_extension || 'mp4';
const seasonNumber =
episode.season || Number(options.fallbackSeasonKey) || 1;
const episodeNumber = episode.episode_num || 1;
const title = `${seriesTitle || 'Series'} - S${String(
seasonNumber
).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`;
return {
playlistId,
xtreamId: Number(episode.id),
contentType: 'episode',
title,
url: `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`,
posterUrl: options.posterUrl,
seriesXtreamId: seriesId,
seasonNumber,
episodeNumber,
headers: {
userAgent: context.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT,
referer: context.referrer,
origin: context.origin,
},
...(options.metadataContext
? {
metadataSnapshot: buildMetadataSnapshot(
options.metadataContext,
episode,
seasonNumber,
episodeNumber
),
}
: {}),
};
}
@@ -1,190 +1,8 @@
import {
XTREAM_CLIENT_USER_AGENT,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import {
buildXtreamEpisodeDownloadRequest,
getEpisodeDownloadId,
hashString,
isStalkerEpisode,
} from './episode-download.util';
import {
formatEpisodePositionText,
parseDuration,
} from './episode-progress.util';
const episode = (overrides: Record<string, unknown>): XtreamSerieEpisode =>
({ id: '10', episode_num: 1, title: 'Ep', ...overrides }) as never;
describe('episode-download.util', () => {
it('detects stalker-mapped episodes', () => {
expect(isStalkerEpisode(episode({ custom_sid: 'vod-series' }))).toBe(
true
);
expect(
isStalkerEpisode(episode({ custom_sid: 'regular-series' }))
).toBe(true);
expect(isStalkerEpisode(episode({}))).toBe(false);
});
it('resolves download ids per episode source', () => {
expect(getEpisodeDownloadId(episode({}))).toBe(10);
expect(
getEpisodeDownloadId(
episode({
custom_sid: 'regular-series',
originalCmd: '/media/file_777.mpg',
})
)
).toBe(777);
expect(
getEpisodeDownloadId(
episode({ custom_sid: 'vod-series', originalId: '42' })
)
).toBe(42);
expect(
getEpisodeDownloadId(
episode({ custom_sid: 'vod-series', originalId: 'abc' })
)
).toBe(hashString('abc'));
});
it('builds an Xtream episode download request', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({
id: '55',
episode_num: 3,
season: 2,
container_extension: 'mkv',
title: 'The One',
}),
context: {
serverUrl: 'http://host/',
username: 'u',
password: 'p',
},
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: '1',
});
expect(request).toEqual({
playlistId: 'pl-1',
xtreamId: 55,
contentType: 'episode',
title: 'Show - S02E03 - The One',
url: 'http://host/series/u/p/55.mkv',
posterUrl: undefined,
seriesXtreamId: 900,
seasonNumber: 2,
episodeNumber: 3,
headers: {
userAgent: XTREAM_CLIENT_USER_AGENT,
referer: undefined,
origin: undefined,
},
});
});
it('forwards playlist request headers for the first episode transfer', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({ id: '55' }),
context: {
serverUrl: 'http://host',
username: 'u',
password: 'p',
userAgent: 'Provider Player/1.0',
referrer: 'https://provider.test/player',
origin: 'https://provider.test',
},
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: '1',
});
expect(request.headers).toEqual({
userAgent: 'Provider Player/1.0',
referer: 'https://provider.test/player',
origin: 'https://provider.test',
});
});
it('adds the loaded series and episode metadata when provided', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({
id: '55',
episode_num: 3,
season: 2,
title: 'The One',
info: {
plot: 'Episode plot',
movie_image: 'https://images.test/stills/one.jpg',
},
}),
context: { serverUrl: 'http://host' },
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: '1',
metadataContext: {
language: 'de',
title: 'Show',
plot: 'Series plot',
genres: ['Drama'],
posterUrl: 'poster.jpg',
providerCategoryId: '7',
cast: [{ name: 'Actor' }],
},
});
expect(request.metadataSnapshot).toEqual(
expect.objectContaining({
language: 'de',
mediaKind: 'series',
title: 'Show',
plot: 'Series plot',
genres: ['Drama'],
providerCategoryId: '7',
cast: [{ name: 'Actor' }],
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The One',
plot: 'Episode plot',
stillUrl: 'https://images.test/stills/one.jpg',
},
})
);
});
it('creates a valid sparse snapshot without provider enrichment', () => {
const request = buildXtreamEpisodeDownloadRequest({
episode: episode({ season: 1, info: [] }),
context: {},
playlistId: 'pl-1',
seriesId: 900,
seriesTitle: 'Show',
fallbackSeasonKey: undefined,
metadataContext: { language: 'en', title: 'Show' },
});
expect(request.metadataSnapshot).toEqual(
expect.objectContaining({
language: 'en',
mediaKind: 'series',
title: 'Show',
episode: {
seasonNumber: 1,
episodeNumber: 1,
title: 'Ep',
},
})
);
});
});
describe('episode-progress.util', () => {
it('parses duration strings', () => {
expect(parseDuration('01:00:30')).toBe(3630);
@@ -1,33 +1,14 @@
<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
<app-season-header
[downloadVisible]="downloadPresenter.presentationVisible()"
[batchRunning]="downloadPresenter.batchRunning()"
[seasonDisabled]="downloadPresenter.seasonDisabled()"
[eligibleEpisodeCount]="downloadPresenter.eligibleEpisodeCount()"
[viewToggleVisible]="hasSeasons() && !showSeasonEmptyState()"
[viewMode]="viewMode()"
(downloadSeason)="downloadPresenter.enqueueSeason()"
(viewModeChange)="setViewMode($event)"
/>
@if (hasSeasons()) {
<app-season-tabs
@@ -155,73 +136,65 @@
}}</mat-icon>
</button>
<div class="episode-card__actions">
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="download-btn download-btn--paused"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME'
| translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
}
@if (
downloadPresenter.presentationVisible()
) {
@let downloadRow =
downloadPresenter.rowFor(episode);
@let downloadState =
downloadRow.presentation;
<button
mat-icon-button
class="download-btn"
[class.download-btn--completed]="
downloadState.action === 'play'
"
[class.download-btn--paused]="
downloadState.action ===
'resume'
"
[class.download-btn--downloading]="
downloadState.disabled
"
[disabled]="downloadState.disabled"
[attr.data-test-id]="
'episode-download-' + episode.id
"
[attr.aria-label]="
downloadState.action === 'play'
? ('DOWNLOADS.PLAY_LOCAL'
| translate) +
': ' +
episode.title
: (downloadState.ariaKey
| translate
: {
title: episode.title,
})
"
[matTooltip]="
downloadState.action === 'play'
? ('DOWNLOADS.PLAY_LOCAL'
| translate) +
': ' +
episode.title
: (downloadState.ariaKey
| translate
: {
title: episode.title,
})
"
(click)="
downloadPresenter.runEpisodeAction(
$event,
episode
)
"
>
<mat-icon>{{
downloadState.icon
}}</mat-icon>
</button>
}
</div>
<div class="episode-card__overlay">
@@ -412,63 +385,56 @@
}}</mat-icon>
}
</button>
@if (isElectron() && downloadsEnabled()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodePaused(episode)) {
<button
mat-icon-button
class="action-btn"
(click)="
resumeEpisodeDownload(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.RESUME' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
}
@if (downloadPresenter.presentationVisible()) {
@let downloadRow =
downloadPresenter.rowFor(episode);
@let downloadState =
downloadRow.presentation;
<button
mat-icon-button
class="action-btn"
[class.action-btn--success]="
downloadState.action === 'play'
"
[disabled]="downloadState.disabled"
[attr.data-test-id]="
'episode-download-' + episode.id
"
[attr.aria-label]="
downloadState.action === 'play'
? ('DOWNLOADS.PLAY_LOCAL'
| translate) +
': ' +
episode.title
: (downloadState.ariaKey
| translate
: {
title: episode.title,
})
"
[matTooltip]="
downloadState.action === 'play'
? ('DOWNLOADS.PLAY_LOCAL'
| translate) +
': ' +
episode.title
: (downloadState.ariaKey
| translate
: {
title: episode.title,
})
"
(click)="
downloadPresenter.runEpisodeAction(
$event,
episode
)
"
>
<mat-icon>{{
downloadState.icon
}}</mat-icon>
</button>
}
@if (episodeInfo?.plot) {
<button
@@ -3,80 +3,6 @@
margin-top: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
mat-icon {
color: var(--accent-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
}
}
}
}
.loading-container {
display: flex;
justify-content: center;
@@ -1,22 +1,63 @@
import { signal } from '@angular/core';
import { signal, type WritableSignal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import {
type InterpolatableTranslationObject,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
SeasonDownloadCoordinator,
type EpisodeDownloadCandidate,
type SeasonEpisodeDownloadAdapter,
} from '@iptvnator/portal/shared/data-access';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import {
DownloadsService,
type DownloadItem,
type DownloadStartInput,
} from '@iptvnator/services';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { of } from 'rxjs';
import { EPISODE_INFO_PLAY } from './episode-info-dialog.component';
import { SeasonContainerComponent } from './season-container.component';
const downloadsServiceStub = {
isAvailable: signal(false),
downloads: () => [],
startDownload: jest.fn().mockResolvedValue(undefined),
isDownloaded: () => false,
isDownloading: () => false,
getDownloadedFilePath: () => '',
playDownload: async () => undefined,
interface DownloadsServiceStub {
readonly isAvailable: WritableSignal<boolean>;
readonly hasAuthoritativeDownloadList: WritableSignal<boolean>;
readonly hasLoadedDownloads: WritableSignal<boolean>;
readonly downloads: WritableSignal<DownloadItem[]>;
readonly startDownload: jest.MockedFunction<
DownloadsService['startDownload']
>;
readonly loadDownloads: jest.MockedFunction<
DownloadsService['loadDownloads']
>;
readonly resumeDownload: jest.MockedFunction<
DownloadsService['resumeDownload']
>;
readonly playDownload: jest.MockedFunction<
DownloadsService['playDownload']
>;
}
interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
}
const EN_TRANSLATIONS = JSON.parse(
readFileSync(
resolve(process.cwd(), 'apps/web/src/assets/i18n/en.json'),
'utf8'
)
) as InterpolatableTranslationObject;
const DOWNLOAD_TRANSLATIONS = {
DOWNLOADS: EN_TRANSLATIONS['DOWNLOADS'],
};
function createEpisode(
@@ -40,26 +81,126 @@ function createEpisode(
};
}
function createDownloadAdapter(): SeasonEpisodeDownloadAdapter {
return {
createCandidate(
episode: XtreamSerieEpisode,
fallbackSeasonKey: string | undefined
): EpisodeDownloadCandidate | null {
const xtreamId = Number(episode.id);
const seasonNumber = Number(
episode.season || fallbackSeasonKey || 1
);
const episodeNumber = Number(episode.episode_num);
if (
!Number.isSafeInteger(xtreamId) ||
xtreamId <= 0 ||
!Number.isSafeInteger(seasonNumber) ||
seasonNumber <= 0 ||
!Number.isSafeInteger(episodeNumber) ||
episodeNumber <= 0
) {
return null;
}
const identity = {
playlistId: 'playlist-1',
contentType: 'episode' as const,
xtreamId,
seriesXtreamId: 20,
seasonNumber,
episodeNumber,
};
return {
identity,
prepare: async (): Promise<DownloadStartInput> => ({
...identity,
title: episode.title,
url: `https://stream.test/${episode.id}`,
}),
};
},
};
}
function createDownload(
episode: XtreamSerieEpisode,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id: Number(episode.id),
playlistId: 'playlist-1',
xtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 20,
seasonNumber: Number(episode.season || 1),
episodeNumber: Number(episode.episode_num),
title: episode.title,
url: `https://stream.test/${episode.id}`,
status: 'queued',
...overrides,
};
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
const dialogOpen = jest.fn();
describe('SeasonContainerComponent', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
let downloadsServiceStub: DownloadsServiceStub;
let downloadAdapter: SeasonEpisodeDownloadAdapter;
let emittedSeasons: string[];
const snackBarOpen = jest.fn();
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
isLoading = false
) => {
fixture.componentRef.setInput('seasons', seasons);
fixture.componentRef.setInput('seriesId', 1);
fixture.componentRef.setInput('seriesId', 20);
fixture.componentRef.setInput('playlistId', 'playlist-1');
fixture.componentRef.setInput('isLoading', isLoading);
};
const enableDownloads = (
adapter: SeasonEpisodeDownloadAdapter | null = downloadAdapter
) => {
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasAuthoritativeDownloadList.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
fixture.componentRef.setInput('downloadAdapter', adapter);
};
const episodeAction = (episodeId: string | number): HTMLButtonElement => {
const button = fixture.nativeElement.querySelector(
`[data-test-id="episode-download-${episodeId}"]`
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
return button as HTMLButtonElement;
};
beforeEach(async () => {
downloadsServiceStub.isAvailable.set(false);
downloadsServiceStub.startDownload.mockClear();
localStorage.removeItem('iptvnator_episode_view_mode');
downloadsServiceStub = {
isAvailable: signal(false),
hasAuthoritativeDownloadList: signal(false),
hasLoadedDownloads: signal(false),
downloads: signal<DownloadItem[]>([]),
startDownload: jest.fn().mockResolvedValue({ success: true }),
loadDownloads: jest.fn().mockResolvedValue(undefined),
resumeDownload: jest.fn().mockResolvedValue({ success: true }),
playDownload: jest.fn().mockResolvedValue({ success: true }),
};
downloadAdapter = createDownloadAdapter();
snackBarOpen.mockReset();
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
@@ -75,9 +216,19 @@ describe('SeasonContainerComponent', () => {
provide: MatDialog,
useValue: { open: dialogOpen },
},
SeasonDownloadCoordinator,
{
provide: MatSnackBar,
useValue: { open: snackBarOpen },
},
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', DOWNLOAD_TRANSLATIONS);
translate.setDefaultLang('en');
translate.use('en');
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
emittedSeasons = [];
@@ -88,6 +239,10 @@ describe('SeasonContainerComponent', () => {
);
});
afterEach(() => {
localStorage.removeItem('iptvnator_episode_view_mode');
});
it('renders the series-level placeholder when no seasons are available', () => {
setRequiredInputs({});
fixture.detectChanges();
@@ -145,53 +300,12 @@ describe('SeasonContainerComponent', () => {
fixture.nativeElement.querySelectorAll('.episode-card').length
).toBe(1);
expect(
fixture.nativeElement.querySelectorAll('.download-btn').length
fixture.nativeElement.querySelectorAll(
'[data-test-id^="episode-download-"]'
).length
).toBe(0);
});
it('forwards the optional series metadata into the Xtream request', async () => {
const target = createEpisode();
setRequiredInputs({ '1': [target] });
fixture.componentRef.setInput('seriesTitle', 'Signal House');
fixture.componentRef.setInput('xtreamDownloadContext', {
serverUrl: 'http://host',
username: 'u',
password: 'p',
userAgent: 'Provider Player/1.0',
referrer: 'https://provider.test/player',
origin: 'https://provider.test',
});
fixture.componentRef.setInput('downloadMetadataContext', {
language: 'en',
title: 'Signal House',
plot: 'Series plot',
});
fixture.detectChanges();
await component.downloadEpisode(new Event('click'), target);
expect(downloadsServiceStub.startDownload).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Signal House - S01E01 - Pilot',
headers: {
userAgent: 'Provider Player/1.0',
referer: 'https://provider.test/player',
origin: 'https://provider.test',
},
metadataSnapshot: expect.objectContaining({
mediaKind: 'series',
title: 'Signal House',
plot: 'Series plot',
episode: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
title: 'Pilot',
}),
}),
})
);
});
it('renders the season-level placeholder when the selected season has no episodes', () => {
setRequiredInputs({ '1': [] });
fixture.detectChanges();
@@ -430,4 +544,591 @@ describe('SeasonContainerComponent', () => {
)?.textContent
).toContain('Season one overview');
});
it('marks only the submitted episode pending synchronously', async () => {
const first = createEpisode();
const second = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [first, second] });
fixture.detectChanges();
episodeAction(101).click();
fixture.detectChanges();
expect(episodeAction(101).disabled).toBe(true);
expect(episodeAction(101).getAttribute('aria-label')).toBe(
'Pilot is already in the download queue'
);
expect(episodeAction(102).disabled).toBe(false);
expect(episodeAction(102).getAttribute('aria-label')).toBe(
'Download Second'
);
start.resolve({ success: true });
await Promise.resolve();
await Promise.resolve();
await fixture.whenStable();
fixture.detectChanges();
expect(episodeAction(101).disabled).toBe(false);
});
it('shows the eligible season count before the view toggle and reports skipped rows truthfully', async () => {
const paused = createEpisode();
const eligible = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
downloadsServiceStub.downloads.set([
createDownload(paused, { id: 41, status: 'paused' }),
]);
enableDownloads();
setRequiredInputs({ '1': [paused, eligible] });
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(button.textContent).toContain('Download season (1)');
expect(button.getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
expect(
button.querySelector('.season-download-button__label')?.textContent
).toContain('Download season (1)');
expect(button.nextElementSibling?.classList).toContain('view-toggle');
button.click();
await fixture.whenStable();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
expect(snackBarOpen).toHaveBeenCalledWith(
'Added 1 · Skipped 1',
undefined,
{ duration: 5000 }
);
});
it('blocks episode and season actions when managed rows claim an ambiguous identity', () => {
const episode = createEpisode();
downloadsServiceStub.downloads.set([
createDownload(episode, { id: 71, xtreamId: 9001 }),
createDownload(episode, { id: 72, xtreamId: 9002 }),
]);
enableDownloads();
setRequiredInputs({ '1': [episode] });
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(true);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'block'
);
expect(seasonButton.disabled).toBe(true);
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
});
it('keeps a failed canonical legacy row with null coordinates downloadable', () => {
const episode = createEpisode();
const legacy = {
...createDownload(episode, { status: 'failed' }),
seriesXtreamId: null,
seasonNumber: null,
episodeNumber: null,
} as unknown as DownloadItem;
downloadsServiceStub.downloads.set([legacy]);
enableDownloads();
setRequiredInputs({ '1': [episode] });
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(false);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'download'
);
expect(seasonButton.disabled).toBe(false);
expect(seasonButton.textContent).toContain('Download season (1)');
});
it('gives the grid and list view radios localized accessible names', () => {
TestBed.inject(TranslateService).setTranslation(
'en',
{
PORTALS: {
GRID_VIEW: 'Grid view',
LIST_VIEW: 'List view',
},
},
true
);
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const viewModeRadios = Array.from(
fixture.nativeElement.querySelectorAll('button[role="radio"]')
) as HTMLButtonElement[];
expect(
viewModeRadios.map((radio) => radio.getAttribute('aria-label'))
).toEqual(['Grid view', 'List view']);
});
it('disables the season action until authoritative eligible work exists and while work is in flight', async () => {
const first = createEpisode();
enableDownloads();
downloadsServiceStub.hasAuthoritativeDownloadList.set(false);
setRequiredInputs({ '1': [first] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(seasonButton().disabled).toBe(true);
expect(downloadsServiceStub.hasLoadedDownloads()).toBe(true);
expect(episodeAction(first.id).disabled).toBe(true);
downloadsServiceStub.hasAuthoritativeDownloadList.set(true);
fixture.componentRef.setInput('isLoading', true);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
fixture.componentRef.setInput('isLoading', false);
fixture.componentRef.setInput('seasons', { '1': [] });
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
fixture.componentRef.setInput('seasons', { '1': [first] });
downloadsServiceStub.downloads.set([
createDownload(first, { status: 'paused' }),
]);
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
downloadsServiceStub.downloads.set([]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
fixture.detectChanges();
seasonButton().click();
fixture.detectChanges();
expect(seasonButton().disabled).toBe(true);
start.resolve({ success: true });
await fixture.whenStable();
});
it('announces the season queue action as busy until submission refresh settles', async () => {
const start = deferred<{ success: boolean }>();
const refresh = deferred<void>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
downloadsServiceStub.loadDownloads.mockReturnValue(refresh.promise);
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = () =>
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton().click();
fixture.detectChanges();
expect(seasonButton().textContent).toContain('Adding to queue…');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Adding to queue…'
);
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
start.resolve({ success: true });
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
refresh.resolve(undefined);
await fixture.whenStable();
fixture.detectChanges();
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
expect(seasonButton().textContent).toContain('Download season (1)');
expect(seasonButton().getAttribute('aria-label')).toBe(
'Download season, 1 episodes available'
);
});
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
const first = createEpisode();
setRequiredInputs({ '1': [first] });
fixture.componentRef.setInput('downloadAdapter', downloadAdapter);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
downloadsServiceStub.isAvailable.set(true);
downloadsServiceStub.hasLoadedDownloads.set(true);
fixture.componentRef.setInput('downloadsEnabled', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id^="episode-download-"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
fixture.componentRef.setInput('downloadsEnabled', true);
fixture.componentRef.setInput('downloadAdapter', null);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id^="episode-download-"]'
)
).toBeNull();
});
it.each(['grid', 'list'] as const)(
'maps every managed state to the same %s action contract',
async (viewMode) => {
const episodes = [
createEpisode(),
createEpisode({ id: '102', episode_num: 2, title: 'Queued' }),
createEpisode({ id: '103', episode_num: 3, title: 'Active' }),
createEpisode({ id: '104', episode_num: 4, title: 'Paused' }),
createEpisode({ id: '105', episode_num: 5, title: 'Local' }),
createEpisode({ id: '106', episode_num: 6, title: 'Unknown' }),
createEpisode({ id: '107', episode_num: 7, title: 'N/A' }),
createEpisode({ id: '108', episode_num: 8, title: 'Failed' }),
createEpisode({ id: '109', episode_num: 9, title: 'Canceled' }),
createEpisode({ id: '110', episode_num: 10, title: 'Missing' }),
createEpisode({ id: '111', episode_num: 11, title: 'New' }),
createEpisode({
id: 'invalid',
episode_num: 12,
title: 'Invalid',
}),
];
downloadsServiceStub.downloads.set([
createDownload(episodes[1], { status: 'queued' }),
createDownload(episodes[2], { status: 'downloading' }),
createDownload(episodes[3], { status: 'paused' }),
createDownload(episodes[4], {
status: 'completed',
fileAvailability: 'available',
filePath: '/downloads/local.mp4',
}),
createDownload(episodes[5], { status: 'completed' }),
createDownload(episodes[6], {
status: 'completed',
fileAvailability: 'not-applicable',
}),
createDownload(episodes[7], { status: 'failed' }),
createDownload(episodes[8], { status: 'canceled' }),
createDownload(episodes[9], {
status: 'completed',
fileAvailability: 'missing',
}),
]);
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': episodes });
component.setViewMode(viewMode);
fixture.detectChanges();
episodeAction(101).click();
fixture.detectChanges();
const expected = [
[
101,
true,
'Pilot is already in the download queue',
'downloading',
],
[
102,
true,
'Queued is already in the download queue',
'downloading',
],
[
103,
true,
'Active is already in the download queue',
'downloading',
],
[104, false, 'Resume Paused', 'play_arrow'],
[105, false, 'Play Local: Local', 'folder_open'],
[106, true, 'Download Unknown', 'block'],
[107, true, 'Download N/A', 'block'],
[108, false, 'Download Failed', 'download'],
[109, false, 'Download Canceled', 'download'],
[110, false, 'Download Missing', 'download'],
[111, false, 'Download New', 'download'],
['invalid', true, 'Download Invalid', 'block'],
] as const;
for (const [id, disabled, aria, icon] of expected) {
const action = episodeAction(id);
expect(action.disabled).toBe(disabled);
expect(action.getAttribute('aria-label')).toBe(aria);
expect(action.querySelector('mat-icon')?.textContent).toContain(
icon
);
}
start.resolve({ success: true });
await fixture.whenStable();
}
);
it('reuses the selected-season row model across rendering, resume, and local play', async () => {
const paused = createEpisode();
const local = createEpisode({
id: '102',
episode_num: 2,
title: 'Local',
});
downloadsServiceStub.downloads.set([
createDownload(paused, { id: 71, status: 'paused' }),
createDownload(local, {
id: 72,
status: 'completed',
fileAvailability: 'available',
filePath: '/authorized/downloads/local.mp4',
}),
]);
const candidateSpy = jest.spyOn(downloadAdapter, 'createCandidate');
const coordinator = TestBed.inject(SeasonDownloadCoordinator);
const resolveDownloadSpy = jest.spyOn(coordinator, 'resolveDownload');
enableDownloads();
setRequiredInputs({ '1': [paused, local] });
fixture.detectChanges();
fixture.detectChanges();
expect(candidateSpy).toHaveBeenCalledTimes(2);
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
episodeAction(101).click();
episodeAction(102).click();
await fixture.whenStable();
fixture.detectChanges();
expect(candidateSpy).toHaveBeenCalledTimes(2);
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
});
it('resumes by managed row id and plays the coordinate-matched local file', async () => {
const paused = createEpisode();
const local = createEpisode({
id: '102',
episode_num: 2,
title: 'Local',
});
downloadsServiceStub.downloads.set([
createDownload(paused, {
id: 71,
xtreamId: 9001,
status: 'paused',
}),
createDownload(local, {
id: 72,
xtreamId: 9002,
status: 'completed',
fileAvailability: 'available',
filePath: '/authorized/downloads/local.mp4',
}),
]);
enableDownloads();
setRequiredInputs({ '1': [paused, local] });
fixture.detectChanges();
episodeAction(101).click();
episodeAction(102).click();
await fixture.whenStable();
expect(downloadsServiceStub.resumeDownload).toHaveBeenCalledWith(71);
expect(downloadsServiceStub.playDownload).toHaveBeenCalledWith(
'/authorized/downloads/local.mp4'
);
});
it('dispatches one submission for rapid duplicate episode clicks without success feedback', async () => {
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
episodeAction(101).click();
episodeAction(101).click();
await Promise.resolve();
await Promise.resolve();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
start.resolve({ success: true });
await fixture.whenStable();
expect(snackBarOpen).not.toHaveBeenCalled();
});
it('dispatches the selected season only once for rapid duplicate clicks', async () => {
const start = deferred<{ success: boolean }>();
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
seasonButton.click();
await Promise.resolve();
await Promise.resolve();
expect(downloadsServiceStub.startDownload).toHaveBeenCalledTimes(1);
start.resolve({ success: true });
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledTimes(1);
});
it('shows only the localized generic episode error and no added feedback', async () => {
downloadsServiceStub.startDownload.mockResolvedValueOnce({
success: false,
error: 'https://portal.test?username=alice&password=hunter2',
});
enableDownloads();
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
episodeAction(101).click();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
'The episode could not be added to downloads.',
undefined,
{ duration: 5000 }
);
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain('alice');
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain(
'hunter2'
);
expect(JSON.stringify(snackBarOpen.mock.calls)).not.toContain(
'portal.test'
);
snackBarOpen.mockClear();
downloadsServiceStub.startDownload.mockResolvedValueOnce({
success: true,
});
episodeAction(101).click();
await fixture.whenStable();
expect(snackBarOpen).not.toHaveBeenCalled();
});
it('snapshots the selected season before asynchronous preparation', async () => {
const preparation = deferred<void>();
const first = createEpisode();
const second = createEpisode({
id: '102',
episode_num: 2,
title: 'Second',
});
const delayedAdapter: SeasonEpisodeDownloadAdapter = {
createCandidate(episode, seasonKey) {
const candidate = downloadAdapter.createCandidate(
episode,
seasonKey
);
if (!candidate || episode.id !== '101') {
return candidate;
}
return {
...candidate,
prepare: async () => {
await preparation.promise;
return candidate.prepare();
},
};
},
};
enableDownloads(delayedAdapter);
setRequiredInputs({
'1': [first, second],
'2': [createEpisode({ id: '201', season: 2 })],
});
component.selectSeason('1');
fixture.detectChanges();
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
seasonButton.click();
component.selectSeason('2');
fixture.componentRef.setInput('seasons', {
'1': [createEpisode({ id: '103', title: 'Replacement' })],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
preparation.resolve(undefined);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await fixture.whenStable();
expect(
downloadsServiceStub.startDownload.mock.calls.map(
([request]) => request.xtreamId
)
).toEqual([101, 102]);
});
it('uses the failure aggregate key when any season submission fails', async () => {
downloadsServiceStub.startDownload
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'rejected' });
enableDownloads();
setRequiredInputs({
'1': [
createEpisode(),
createEpisode({ id: '102', episode_num: 2 }),
],
});
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement
).click();
await fixture.whenStable();
expect(snackBarOpen).toHaveBeenCalledWith(
'Added 1 · Skipped 0 · Failed 1',
undefined,
{ duration: 5000 }
);
});
});
@@ -12,12 +12,12 @@ import {
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createLogger,
getPortalPlaybackProgressPercent,
@@ -29,15 +29,7 @@ import {
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
import {
buildXtreamEpisodeDownloadRequest,
getEpisodeDownloadId,
isStalkerEpisode,
type SeasonContainerDownloadMetadataContext,
type XtreamEpisodeDownloadContext,
} from './episode-download.util';
import {
EPISODE_INFO_PLAY,
EpisodeInfoDialogComponent,
@@ -47,15 +39,15 @@ import {
formatEpisodePositionText,
parseDuration,
} from './episode-progress.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import {
type EpisodeViewMode,
SeasonHeaderComponent,
} from './season-header.component';
import { SeasonTabsComponent } from './season-tabs.component';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
export type SeasonContainerXtreamDownloadContext = XtreamEpisodeDownloadContext;
export type { SeasonContainerDownloadMetadataContext } from './episode-download.util';
export interface SeasonContainerPlaybackToggleRequest {
contentXtreamId: number;
nextPosition: PlaybackPositionData | null;
@@ -66,23 +58,24 @@ export interface SeasonContainerPlaybackToggleRequest {
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SeasonDownloadPresenter],
imports: [
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
SeasonHeaderComponent,
SeasonTabsComponent,
TranslateModule,
],
})
export class SeasonContainerComponent implements OnInit {
private readonly downloadsService = inject(DownloadsService);
private readonly dialog = inject(MatDialog);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
readonly downloadPresenter = inject(SeasonDownloadPresenter);
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
@@ -92,10 +85,7 @@ export class SeasonContainerComponent implements OnInit {
readonly playbackPositions = input<Map<number, PlaybackPositionData>>(
new Map()
);
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly downloadMetadataContext =
input<SeasonContainerDownloadMetadataContext | null>(null);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly downloadsEnabled = input(true);
readonly openingEpisodeId = input<number | null>(null);
/** Episode currently playing in an EXTERNAL player session. */
@@ -106,11 +96,9 @@ export class SeasonContainerComponent implements OnInit {
readonly seasonDescriptions = input<Record<string, string> | null>(null);
readonly episodeClicked = output<XtreamSerieEpisode>();
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
readonly playbackToggleRequested =
output<SeasonContainerPlaybackToggleRequest>();
readonly seasonSelected = output<string>();
readonly isElectron = this.downloadsService.isAvailable;
readonly viewMode = signal<EpisodeViewMode>('grid');
readonly sortedSeasonKeys = computed(() =>
@@ -150,6 +138,11 @@ export class SeasonContainerComponent implements OnInit {
*/
readonly selectedSeason = signal<string | undefined>(undefined);
readonly selectedSeasonEpisodes = computed(() => {
const selected = this.selectedSeason();
return selected ? (this.seasons()[selected] ?? []) : [];
});
private readonly autoSelectKey = computed(
() =>
`${this.sortedSeasonKeys().join('|')}::${
@@ -184,6 +177,14 @@ export class SeasonContainerComponent implements OnInit {
});
constructor() {
this.downloadPresenter.connect({
adapter: this.downloadAdapter,
downloadsEnabled: this.downloadsEnabled,
isLoading: this.isLoading,
selectedEpisodes: this.selectedSeasonEpisodes,
selectedSeason: this.selectedSeason,
});
effect(() => {
const key = this.autoSelectKey();
if (key === this.lastAutoSelectKey) {
@@ -234,11 +235,6 @@ export class SeasonContainerComponent implements OnInit {
);
}
selectedSeasonEpisodes(): XtreamSerieEpisode[] {
const selected = this.selectedSeason();
return selected ? (this.seasons()[selected] ?? []) : [];
}
selectSeason(seasonKey: string) {
this.selectedSeason.set(seasonKey);
}
@@ -363,106 +359,6 @@ export class SeasonContainerComponent implements OnInit {
return Number(episode.id);
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.downloadsEnabled()) return;
if (isStalkerEpisode(episode)) {
this.episodeDownloadRequested.emit(episode);
return;
}
const xtreamDownload = this.xtreamDownloadContext();
if (!this.playlistId() || !xtreamDownload) {
return;
}
await this.downloadsService.startDownload(
buildXtreamEpisodeDownloadRequest({
episode,
context: xtreamDownload,
playlistId: this.playlistId(),
seriesId: this.seriesId(),
seriesTitle: this.seriesTitle(),
fallbackSeasonKey: this.selectedSeason(),
posterUrl: this.getEpisodeInfo(episode)?.movie_image,
metadataContext: this.downloadMetadataContext() ?? undefined,
})
);
}
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
isEpisodePaused(episode: XtreamSerieEpisode): boolean {
if (!this.downloadsEnabled() || !this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isPaused(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async resumeEpisodeDownload(
event: Event,
episode: XtreamSerieEpisode
): Promise<void> {
event.stopPropagation();
if (!this.playlistId()) {
return;
}
await this.downloadsService.resumeDownloadByContent(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
return;
}
const filePath = this.downloadsService.getDownloadedFilePath(
getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
private getEpisodePosition(
episode: XtreamSerieEpisode
): PlaybackPositionData | undefined {
@@ -0,0 +1,317 @@
import {
Injectable,
type Signal,
computed,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
EPISODE_DOWNLOAD_SUBMISSIONS,
SeasonDownloadCoordinator,
type EpisodeDownloadCandidate,
type SeasonEpisodeDownloadAdapter,
} from '@iptvnator/portal/shared/data-access';
import { isEpisodeDownloadEligible } from '@iptvnator/portal/shared/util';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DownloadsService, type DownloadItem } from '@iptvnator/services';
const EPISODE_DOWNLOAD_ACTIONS = {
Blocked: 'blocked',
Download: 'download',
Pending: 'pending',
Play: 'play',
Resume: 'resume',
} as const;
type EpisodeDownloadAction =
(typeof EPISODE_DOWNLOAD_ACTIONS)[keyof typeof EPISODE_DOWNLOAD_ACTIONS];
export interface EpisodeDownloadPresentation {
readonly action: EpisodeDownloadAction;
readonly ariaKey: string;
readonly disabled: boolean;
readonly icon: string;
}
export interface SeasonEpisodeDownloadRow {
readonly candidate: EpisodeDownloadCandidate | null;
readonly download: DownloadItem | undefined;
readonly eligible: boolean;
readonly episode: XtreamSerieEpisode;
readonly presentation: EpisodeDownloadPresentation;
}
export interface SeasonDownloadPresenterSources {
readonly adapter: Signal<SeasonEpisodeDownloadAdapter | null>;
readonly downloadsEnabled: Signal<boolean>;
readonly isLoading: Signal<boolean>;
readonly selectedEpisodes: Signal<readonly XtreamSerieEpisode[]>;
readonly selectedSeason: Signal<string | undefined>;
}
const EPISODE_DOWNLOAD_STATES: Record<
EpisodeDownloadAction,
EpisodeDownloadPresentation
> = {
blocked: {
action: EPISODE_DOWNLOAD_ACTIONS.Blocked,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
disabled: true,
icon: 'block',
},
download: {
action: EPISODE_DOWNLOAD_ACTIONS.Download,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
disabled: false,
icon: 'download',
},
pending: {
action: EPISODE_DOWNLOAD_ACTIONS.Pending,
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_PENDING_ARIA',
disabled: true,
icon: 'downloading',
},
play: {
action: EPISODE_DOWNLOAD_ACTIONS.Play,
ariaKey: 'DOWNLOADS.PLAY_LOCAL',
disabled: false,
icon: 'folder_open',
},
resume: {
action: EPISODE_DOWNLOAD_ACTIONS.Resume,
ariaKey: 'DOWNLOADS.ARIA.RESUME',
disabled: false,
icon: 'play_arrow',
},
};
@Injectable()
export class SeasonDownloadPresenter {
private readonly coordinator = inject(SeasonDownloadCoordinator);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly sources = signal<SeasonDownloadPresenterSources | null>(
null
);
private readonly batchInFlight = signal(false);
readonly batchRunning = this.batchInFlight.asReadonly();
readonly presentationVisible = computed(() => {
const sources = this.sources();
return Boolean(
sources &&
this.downloadsService.isAvailable() &&
sources.downloadsEnabled() &&
sources.adapter()
);
});
readonly rows = computed<readonly SeasonEpisodeDownloadRow[]>(() => {
const sources = this.sources();
if (!sources) {
return [];
}
const adapter = sources.adapter();
const seasonKey = sources.selectedSeason();
const downloadsAvailable = this.downloadsService.isAvailable();
const downloadsReady =
this.downloadsService.hasLoadedDownloads() &&
this.downloadsService.hasAuthoritativeDownloadList();
this.downloadsService.downloads();
return sources.selectedEpisodes().map((episode) => {
const candidate =
adapter && seasonKey
? adapter.createCandidate(episode, seasonKey)
: null;
const pending = candidate
? this.coordinator.isPending(candidate.identity)
: false;
const resolution = candidate
? this.coordinator.resolveDownload(candidate.identity)
: { kind: 'missing' as const };
const download =
resolution.kind === 'match' ? resolution.download : undefined;
const identityConflict = resolution.kind === 'conflict';
return {
candidate,
download,
eligible: Boolean(
candidate &&
downloadsAvailable &&
downloadsReady &&
!pending &&
isEpisodeDownloadEligible(resolution)
),
episode,
presentation: this.createPresentation(
candidate,
download,
pending,
downloadsReady,
identityConflict
),
};
});
});
private readonly rowByEpisode = computed(
() => new Map(this.rows().map((row) => [row.episode, row] as const))
);
readonly eligibleEpisodeCount = computed(
() => this.rows().filter((row) => row.eligible).length
);
readonly seasonDisabled = computed(() => {
const sources = this.sources();
return (
!sources ||
sources.isLoading() ||
this.batchRunning() ||
!this.downloadsService.hasLoadedDownloads() ||
!this.downloadsService.hasAuthoritativeDownloadList() ||
!sources.selectedSeason() ||
this.rows().length === 0 ||
this.eligibleEpisodeCount() === 0
);
});
connect(sources: SeasonDownloadPresenterSources): void {
if (this.sources()) {
throw new Error('SeasonDownloadPresenter is already connected');
}
this.sources.set(sources);
}
rowFor(episode: XtreamSerieEpisode): SeasonEpisodeDownloadRow {
const row = this.rowByEpisode().get(episode);
if (!row) {
throw new Error('Episode is outside the selected-season row model');
}
return row;
}
runEpisodeAction(event: Event, episode: XtreamSerieEpisode): void {
event.stopPropagation();
const row = this.rowByEpisode().get(episode);
if (!row || row.presentation.disabled) {
return;
}
if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Resume) {
void this.resume(row);
} else if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Play) {
void this.play(row);
} else if (
row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Download
) {
void this.enqueueOne(episode);
}
}
async enqueueOne(episode: XtreamSerieEpisode): Promise<void> {
if (!this.presentationVisible()) {
return;
}
const row = this.rowByEpisode().get(episode);
if (!row?.candidate || !row.eligible) {
return;
}
const result = await this.coordinator.enqueueOne(row.candidate);
if (result === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
this.notify('DOWNLOADS.EPISODE_DOWNLOAD_FAILED');
}
}
async enqueueSeason(): Promise<void> {
if (
this.batchRunning() ||
this.seasonDisabled() ||
!this.presentationVisible()
) {
return;
}
const candidates = this.rows().map((row) => row.candidate);
this.batchInFlight.set(true);
try {
const result = await this.coordinator.enqueueSeason(candidates);
this.notify(
result.failed > 0
? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES'
: 'DOWNLOADS.SEASON_QUEUE_RESULT',
result
);
} finally {
this.batchInFlight.set(false);
}
}
private async resume(row: SeasonEpisodeDownloadRow): Promise<void> {
const download = row.download;
if (
download?.status === 'paused' &&
Number.isSafeInteger(download.id) &&
download.id > 0
) {
await this.downloadsService.resumeDownload(download.id);
}
}
private async play(row: SeasonEpisodeDownloadRow): Promise<void> {
const download = row.download;
if (
download?.status === 'completed' &&
download.fileAvailability === 'available' &&
download.filePath
) {
await this.downloadsService.playDownload(download.filePath);
}
}
private notify(key: string, params?: object): void {
this.snackBar.open(this.translate.instant(key, params), undefined, {
duration: 5000,
});
}
private createPresentation(
candidate: EpisodeDownloadCandidate | null,
download: DownloadItem | undefined,
pending: boolean,
downloadsReady: boolean,
identityConflict: boolean
): EpisodeDownloadPresentation {
if (!candidate || !downloadsReady || identityConflict) {
return EPISODE_DOWNLOAD_STATES.blocked;
}
if (
pending ||
download?.status === 'queued' ||
download?.status === 'downloading'
) {
return EPISODE_DOWNLOAD_STATES.pending;
}
if (download?.status === 'paused') {
return EPISODE_DOWNLOAD_STATES.resume;
}
if (download?.status === 'completed') {
if (
download.fileAvailability === 'available' &&
download.filePath
) {
return EPISODE_DOWNLOAD_STATES.play;
}
return download.fileAvailability === 'missing'
? EPISODE_DOWNLOAD_STATES.download
: EPISODE_DOWNLOAD_STATES.blocked;
}
return EPISODE_DOWNLOAD_STATES.download;
}
}
@@ -0,0 +1,71 @@
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (downloadVisible() || viewToggleVisible()) {
<div class="section-header__actions">
@if (downloadVisible()) {
<button
mat-stroked-button
class="season-download-button"
data-test-id="download-season"
[disabled]="seasonDisabled()"
[attr.aria-label]="
batchRunning()
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
| translate: { count: eligibleEpisodeCount() })
"
[attr.aria-busy]="batchRunning() ? 'true' : null"
(click)="downloadSeason.emit()"
>
@if (batchRunning()) {
<mat-spinner
diameter="18"
aria-hidden="true"
></mat-spinner>
} @else {
<mat-icon>download</mat-icon>
}
<span class="season-download-button__label">
@if (batchRunning()) {
{{ 'DOWNLOADS.ADDING_TO_QUEUE' | translate }}
} @else {
{{
'DOWNLOADS.DOWNLOAD_SEASON'
| translate
: { count: eligibleEpisodeCount() }
}}
}
</span>
</button>
}
@if (viewToggleVisible()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="viewModeChange.emit($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
value="grid"
[aria-label]="'PORTALS.GRID_VIEW' | translate"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle
value="list"
[aria-label]="'PORTALS.LIST_VIEW' | translate"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
}
</div>
@@ -0,0 +1,106 @@
:host {
display: block;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
&__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
mat-icon {
color: var(--accent-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
}
}
}
}
.season-download-button {
min-height: 36px;
mat-spinner {
display: inline-block;
margin-inline-end: 8px;
}
}
@media (max-width: 560px) {
.section-header {
align-items: flex-start;
flex-wrap: wrap;
&__actions {
width: 100%;
justify-content: space-between;
}
}
}
@@ -0,0 +1,40 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
export type EpisodeViewMode = 'grid' | 'list';
@Component({
selector: 'app-season-header',
templateUrl: './season-header.component.html',
styleUrls: ['./season-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
TranslateModule,
],
})
export class SeasonHeaderComponent {
readonly downloadVisible = input.required<boolean>();
readonly batchRunning = input.required<boolean>();
readonly seasonDisabled = input.required<boolean>();
readonly eligibleEpisodeCount = input.required<number>();
readonly viewToggleVisible = input.required<boolean>();
readonly viewMode = input.required<EpisodeViewMode>();
readonly downloadSeason = output<void>();
readonly viewModeChange = output<EpisodeViewMode>();
}
-1
View File
@@ -64,7 +64,6 @@ export const maxLinesBaseline = [
'libs/shared/interfaces/src/lib/electron-api.interface.ts',
'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts',
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
'libs/ui/components/src/lib/season-container/season-container.component.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',