mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
d3cc18dc72
commit
96facd6f49
79 files changed
+8352
-1255
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
+103
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
+507
@@ -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');
|
||||
}
|
||||
+228
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+394
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
+181
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
"
|
||||
|
||||
+122
-118
@@ -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);
|
||||
});
|
||||
|
||||
+30
-128
@@ -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()"
|
||||
|
||||
+50
-23
@@ -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,
|
||||
|
||||
+228
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user