diff --git a/.changes/downloads-season-queue.md b/.changes/downloads-season-queue.md new file mode 100644 index 000000000..b6658cd92 --- /dev/null +++ b/.changes/downloads-season-queue.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index cb66a3e82..cce81de06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend-e2e/src/series-download-queue.e2e.ts b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts new file mode 100644 index 000000000..8236bdcbd --- /dev/null +++ b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts @@ -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>; + +function waitForQueueStabilityInterval(): Promise { + 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 { + 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>; + }, 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); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts index d23b32f22..579412990 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts @@ -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(() => 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 }); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts index a269c11b0..b46672655 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -21,21 +21,40 @@ type DownloadAsyncLstat = ( filePath: string ) => Promise>; +type DownloadFileAvailabilityProbeResult = boolean | 'unknown'; + type DownloadFileAvailabilityProbe = ( filePath: string -) => Promise; +) => Promise; 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>(); + // 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 + >(); let active = 0; const acquire = (): Promise => { @@ -55,13 +74,15 @@ function createDownloadFileAvailabilityProbe( } }; - const inspect = async (filePath: string): Promise => { + const inspect = async ( + filePath: string + ): Promise => { 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 { + const boundedTimeoutMs = + Number.isFinite(timeoutMs) && timeoutMs >= 0 + ? timeoutMs + : DEFAULT_FILE_PROBE_TIMEOUT_MS; + let timeout: ReturnType | 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 { + if (download.status !== 'completed') { + return 'not-applicable'; + } + + if (!download.filePath) { + return 'missing'; + } + + return toBoundedDownloadFileAvailability( + await probeDownloadFileAvailabilityWithTimeout( + download.filePath, + timeoutMs, + probe + ) + ); } export function decorateDownloadItem( diff --git a/apps/electron-backend/src/app/events/database/download-partial-cleanup.spec.ts b/apps/electron-backend/src/app/events/database/download-partial-cleanup.spec.ts new file mode 100644 index 000000000..4a9f7994b --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-partial-cleanup.spec.ts @@ -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((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((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(); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-partial-cleanup.ts b/apps/electron-backend/src/app/events/database/download-partial-cleanup.ts new file mode 100644 index 000000000..ea7af5846 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-partial-cleanup.ts @@ -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; + +export type DownloadPartialCleanup = ( + filePath: string, + admissionTimeoutMs?: number +) => Promise; + +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>(); + let active = 0; + + const acquire = (timeoutMs: number): Promise => { + 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 => { + 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 { + 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'; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts b/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts new file mode 100644 index 000000000..044d3f4cf --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-identity.spec.ts @@ -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 { + 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' }); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/download-request-identity.ts b/apps/electron-backend/src/app/events/database/download-request-identity.ts new file mode 100644 index 000000000..d78c43926 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/download-request-identity.ts @@ -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 +): 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 { + 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 }; +} diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 8e4af8d69..6a24e7481 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -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 | undefined + existing: Record | undefined, + coordinateRows: Record[] = [], + 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 { + 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]>( - () => ({ + 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] + >(() => ({ 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, diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index 5acc8d25a..0ac39c23d 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -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 { 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, diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts index f6f0ba8d1..484995afd 100644 --- a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -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 + ) => 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`, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d3a03fefd..d384fc23e 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e39c28aec..f686a3dab 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 42c74c3fb..7e31c2f4f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f06d9772f..39e022ce4 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a6d2eb7c8..a2107398a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 1deffc51a..39110d498 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 78154682c..dca10e3d1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d6748278f..7199e0e6f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 5f742718c..363518a0f 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 548a96245..802e180a9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 97939b47c..2f3b08d41 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ffc761341..59b734ff5 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ede809d6a..3927b34cc 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 8180bd95a..2f4d4e800 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 882228c41..cbf5b10dc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 3cea2ebd9..685bf964c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7f7c365e9..99718c5fb 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ee7ef14d4..bf74b355b 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index b75377c9a..866127200 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 81e32ff8d..2452860c6 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { 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: diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 0080c5ec0..4a67b8194 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -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(promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`Operation exceeded ${timeoutMs}ms.`)), + timeoutMs + ); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index 576e805cb..aac33a82d 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -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 diff --git a/apps/xtream-mock-server/src/app/slow-series-download.ts b/apps/xtream-mock-server/src/app/slow-series-download.ts new file mode 100644 index 000000000..2a74b0cf9 --- /dev/null +++ b/apps/xtream-mock-server/src/app/slow-series-download.ts @@ -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.' + ); + } +} diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 1ccd77cbd..74e38c1cf 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -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, diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 4b5b9d0a9..c53a1c0d5 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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. diff --git a/docs/superpowers/plans/2026-08-01-season-download-queue.md b/docs/superpowers/plans/2026-08-01-season-download-queue.md new file mode 100644 index 000000000..ff086ce8c --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-season-download-queue.md @@ -0,0 +1,1678 @@ +# Season Download Queue Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Electron users enqueue several series episodes independently and +enqueue every eligible episode in the selected season while preserving the +existing single-transfer FIFO queue. + +**Architecture:** Keep `SeasonContainerComponent` as the shared presentation +owner, move provider-neutral pending/eligibility/batch orchestration into +`portal-shared-data-access`, keep pure identity rules in +`portal-shared-util`, and construct requests in focused Xtream and Stalker +adapters. Extend the existing `DOWNLOADS_START` path with canonical/legacy +episode identity resolution and a stable duplicate reason; do not add batch +IPC, schema changes, or renderer-side transfer scheduling. + +**Tech Stack:** Angular 21 standalone components and signals, TypeScript 5.9, +Angular Material, ngx-translate, Jest through Nx, Electron IPC, Drizzle/SQLite, +Playwright Electron E2E, Markdown architecture/release documentation. + +--- + +### Task 0: Preserve The Approved Baseline And Load Required Skills + +**Files:** + +- Verify: `docs/superpowers/specs/2026-08-01-season-download-queue-design.md` +- Verify: `package.json` +- Verify: `pnpm-lock.yaml` + +- [x] **Step 1: Create the isolated branch from current master** + +The linked worktree was created at commit `760099358`, the merge of PR #1313, +and the branch is: + +```bash +git switch -c agent/download-season-queue +``` + +Expected: `git branch --show-current` prints +`agent/download-season-queue`. + +- [x] **Step 2: Bootstrap and verify Nx discovery** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +``` + +Expected: both commands exit 0 and the project list includes `components`, +`portal-shared-util`, `portal-shared-data-access`, `portal-xtream-feature`, +`portal-stalker-feature`, `services`, `electron-backend`, +`xtream-mock-server`, and `electron-backend-e2e`. + +- [x] **Step 3: Record the focused baseline** + +The following suites already pass before implementation: + +```bash +pnpm nx test components --runInBand \ + --testPathPatterns=season-container.component.spec.ts \ + --testPathPatterns=episode-utils.spec.ts +pnpm nx test portal-xtream-feature --runInBand \ + --testPathPatterns=serial-details.component.spec.ts +pnpm nx test portal-stalker-feature --runInBand \ + --testPathPatterns=stalker-series-view.component.spec.ts +pnpm nx test services --runInBand \ + --testPathPatterns=downloads.service.spec.ts +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-requests.spec.ts +``` + +Expected baseline: components 128 tests, focused Xtream 14, focused Stalker +16, services downloads 21, and Electron download requests 20 all pass. + +- [ ] **Step 4: Load the implementation skills before changing runtime code** + +Read the full instructions for: + +```text +.codex/skills/iptvnator-ui-design/SKILL.md +.codex/skills/iptvnator-theme-style/SKILL.md +.codex/skills/iptvnator-sqlite-db-worker/SKILL.md +.codex/skills/xtream-electron/SKILL.md +.codex/skills/stalker-portal/SKILL.md +~/.agents/skills/angular-developer/SKILL.md +~/.agents/skills/angular-signals/SKILL.md +~/.agents/skills/angular-testing/SKILL.md +~/.agents/skills/test-driven-development/SKILL.md +~/.agents/skills/playwright/SKILL.md +``` + +Announce each skill-driven action in commentary. Use +`test-driven-development` for every task below and keep the RED test output in +the working notes before writing its production code. + +### Task 1: Define Canonical Episode Identity And Eligibility + +**Files:** + +- Create: + `libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts` +- Create: + `libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts` +- Modify: `libs/portal/shared/util/src/index.ts` +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts` +- Modify: `libs/services/src/lib/downloads.models.ts` +- Modify: `libs/services/src/lib/downloads.service.ts` +- Modify: `libs/services/src/lib/downloads.service.spec.ts` + +- [ ] **Step 1: Write the failing identity and eligibility tests** + +Create `episode-download-identity.spec.ts` with a small row builder and these +assertions: + +```typescript +const identity: EpisodeDownloadIdentity = { + playlistId: 'playlist-1', + xtreamId: 101, + contentType: 'episode', + seriesXtreamId: 900, + seasonNumber: 2, + episodeNumber: 3, +}; + +it('matches canonical identity before legacy coordinates', () => { + const coordinateRow = row({ id: 1, xtreamId: 777 }); + const canonicalRow = row({ id: 2, xtreamId: 101 }); + expect( + findEpisodeDownload(identity, [coordinateRow, canonicalRow])?.id + ).toBe(2); +}); + +it('recognizes a legacy row by complete episode coordinates', () => { + expect(findEpisodeDownload(identity, [row({ xtreamId: 777 })])).toEqual( + expect.objectContaining({ xtreamId: 777 }) + ); +}); + +it('does not coordinate-match another playlist or incomplete row', () => { + expect( + findEpisodeDownload(identity, [ + row({ playlistId: 'playlist-2', xtreamId: 777 }), + row({ episodeNumber: undefined, xtreamId: 778 }), + ]) + ).toBeUndefined(); +}); + +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)( + 'classifies %s/%s eligibility', + (status, fileAvailability, expected) => { + expect( + isEpisodeDownloadEligible(row({ status, fileAvailability })) + ).toBe(expected); + } +); + +it('creates collision-free keys across playlists and coordinates', () => { + expect(createEpisodeDownloadIdentityKey(identity)).not.toBe( + createEpisodeDownloadIdentityKey({ + ...identity, + playlistId: 'playlist-2', + }) + ); + expect(createEpisodeDownloadIdentityKey(identity)).not.toBe( + createEpisodeDownloadIdentityKey({ + ...identity, + episodeNumber: 4, + }) + ); +}); +``` + +Run: + +```bash +pnpm nx test portal-shared-util --runInBand \ + --testPathPatterns=episode-download-identity.spec.ts +``` + +Expected: FAIL because the identity module does not exist. + +- [ ] **Step 2: Implement the pure contract and helpers** + +Add the following provider-neutral shape and behavior: + +```typescript +export interface EpisodeDownloadIdentity { + readonly playlistId: string; + readonly xtreamId: number; + readonly contentType: 'episode'; + readonly seriesXtreamId: number; + readonly seasonNumber: number; + readonly episodeNumber: number; +} + +export interface EpisodeDownloadRecord { + readonly id: number; + readonly playlistId: string; + readonly xtreamId: number; + readonly contentType: 'vod' | 'episode'; + readonly seriesXtreamId?: number; + readonly seasonNumber?: number; + readonly episodeNumber?: number; + readonly status: + | 'queued' + | 'downloading' + | 'paused' + | 'completed' + | 'failed' + | 'canceled'; + readonly fileAvailability?: 'available' | 'missing' | 'not-applicable'; + readonly filePath?: string; +} + +export function createEpisodeDownloadIdentityKey( + identity: EpisodeDownloadIdentity +): string { + return JSON.stringify([ + identity.playlistId, + identity.contentType, + identity.xtreamId, + identity.seriesXtreamId, + identity.seasonNumber, + identity.episodeNumber, + ]); +} + +export function findEpisodeDownload( + identity: EpisodeDownloadIdentity, + downloads: readonly T[] +): T | undefined { + return ( + downloads.find( + (item) => + item.playlistId === identity.playlistId && + item.contentType === 'episode' && + item.xtreamId === identity.xtreamId + ) ?? + downloads.find( + (item) => + item.playlistId === identity.playlistId && + item.contentType === 'episode' && + item.seriesXtreamId === identity.seriesXtreamId && + item.seasonNumber === identity.seasonNumber && + item.episodeNumber === identity.episodeNumber + ) + ); +} + +export function isEpisodeDownloadEligible( + download: EpisodeDownloadRecord | undefined +): boolean { + if (!download) return true; + if (download.status === 'failed' || download.status === 'canceled') { + return true; + } + return ( + download.status === 'completed' && + download.fileAvailability === 'missing' + ); +} +``` + +Export the module from `portal/shared/util/src/index.ts`. Keep the util pure: +it must not import `DownloadsService` or another `type:data-access` library. + +Run the focused util spec again. Expected: PASS. + +- [ ] **Step 3: Extend the start-result contract without changing IPC shape** + +In `electron-api.interface.ts`, add: + +```typescript +export type ElectronBridgeDownloadStartReason = 'already-in-progress'; + +export interface ElectronBridgeDownloadStartResult extends ElectronBridgeErrorResult { + id?: number; + reason?: ElectronBridgeDownloadStartReason; +} +``` + +Export `DownloadStartInput` publicly from the services package: + +```typescript +export type { + DownloadItem, + DownloadStartInput, + DownloadStatus, +} from './downloads.models'; +``` + +Type `DownloadsService.startDownload()` as +`Promise`. Extend its spec so a bridge +result containing `reason: 'already-in-progress'` is returned unchanged. + +Run: + +```bash +pnpm nx test services --runInBand \ + --testPathPatterns=downloads.service.spec.ts +pnpm nx test portal-shared-util --runInBand \ + --testPathPatterns=episode-download-identity.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Commit the identity slice** + +```bash +git add libs/portal/shared/util libs/shared/interfaces/src/lib/electron-api.interface.ts \ + libs/services/src/lib/downloads.models.ts \ + libs/services/src/lib/downloads.service.ts \ + libs/services/src/lib/downloads.service.spec.ts +git commit -m "feat(downloads): define episode queue identity" +``` + +### Task 2: Build The Provider-Neutral Queue Coordinator + +**Files:** + +- Create: + `libs/portal/shared/data-access/src/lib/downloads/season-download.models.ts` +- Create: + `libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts` +- Create: + `libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts` +- Create: `libs/portal/shared/data-access/src/lib/downloads/index.ts` +- Modify: `libs/portal/shared/data-access/src/index.ts` + +- [ ] **Step 1: Write failing pending, deduplication, and batch tests** + +Define candidates with a deferred `prepare` promise and a +`DownloadsService` stub whose `downloads`, `hasLoadedDownloads`, and +`isAvailable` values are signals. Cover all of these cases: + +```typescript +it('reserves one identity synchronously and leaves another actionable', () => { + const first = candidate({ xtreamId: 101, episodeNumber: 1 }); + const second = candidate({ xtreamId: 102, episodeNumber: 2 }); + + const pending = coordinator.enqueueOne(first); + + expect(coordinator.isPending(first.identity)).toBe(true); + expect(coordinator.isEligible(first)).toBe(false); + expect(coordinator.isEligible(second)).toBe(true); + return pending; +}); + +it('dispatches one of two rapid requests for the same identity', async () => { + await Promise.all([ + coordinator.enqueueOne(candidate()), + coordinator.enqueueOne(candidate()), + ]); + expect(downloads.startDownload).toHaveBeenCalledTimes(1); +}); + +it('dispatches distinct candidates sequentially in display order', async () => { + const result = await coordinator.enqueueSeason([ + candidate({ xtreamId: 101, episodeNumber: 1 }), + candidate({ xtreamId: 102, episodeNumber: 2 }), + ]); + expect( + downloads.startDownload.mock.calls.map(([request]) => request.xtreamId) + ).toEqual([101, 102]); + expect(result).toEqual({ added: 2, skipped: 0, failed: 0 }); +}); + +it('partitions invalid, duplicate, blocked, and failed candidates', async () => { + downloads.downloads.set([ + row({ xtreamId: 102, episodeNumber: 2, status: 'paused' }), + ]); + downloads.startDownload + .mockResolvedValueOnce({ success: true, id: 1 }) + .mockResolvedValueOnce({ + success: false, + id: 2, + reason: 'already-in-progress', + error: 'Download already in progress', + }) + .mockResolvedValueOnce({ success: false, error: 'rejected' }); + + const result = await coordinator.enqueueSeason([ + candidate({ xtreamId: 101, episodeNumber: 1 }), + null, + candidate({ xtreamId: 102, episodeNumber: 2 }), + candidate({ xtreamId: 103, episodeNumber: 3 }), + candidate({ xtreamId: 104, episodeNumber: 4 }), + ]); + + expect(result).toEqual({ added: 1, skipped: 3, failed: 1 }); +}); + +it('continues after prepare rejects and refreshes once after successes', async () => { + const broken = candidate({ xtreamId: 101, episodeNumber: 1 }); + broken.prepare.mockRejectedValue(new Error('secret provider failure')); + const good = candidate({ xtreamId: 102, episodeNumber: 2 }); + + await expect(coordinator.enqueueSeason([broken, good])).resolves.toEqual({ + added: 1, + skipped: 0, + failed: 1, + }); + expect(downloads.startDownload).toHaveBeenCalledTimes(1); + expect(downloads.loadDownloads).toHaveBeenCalledTimes(1); +}); +``` + +Also assert initial `hasLoadedDownloads=false` blocks eligibility, a missing +completed row is eligible, an available completed row is skipped, candidate +duplicates inside one batch prepare once, and successful pending keys are kept +until the final `loadDownloads()` promise settles. + +Run: + +```bash +pnpm nx test portal-shared-data-access --runInBand \ + --testPathPatterns=season-download-coordinator.service.spec.ts +``` + +Expected: FAIL because the models and coordinator do not exist. + +- [ ] **Step 2: Add the adapter/candidate/result contracts** + +Create `season-download.models.ts`: + +```typescript +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import type { DownloadStartInput } from '@iptvnator/services'; +import type { EpisodeDownloadIdentity } from '@iptvnator/portal/shared/util'; + +export interface EpisodeDownloadCandidate { + readonly identity: EpisodeDownloadIdentity; + readonly prepare: () => Promise; +} + +export interface SeasonEpisodeDownloadAdapter { + createCandidate( + episode: XtreamSerieEpisode, + fallbackSeasonKey: string | undefined + ): EpisodeDownloadCandidate | null; +} + +export interface SeasonDownloadResult { + readonly added: number; + readonly skipped: number; + readonly failed: number; +} + +export type EpisodeDownloadSubmission = 'added' | 'skipped' | 'failed'; +``` + +- [ ] **Step 3: Implement synchronous reservation and sequential submission** + +Create an injectable root coordinator. The key mechanics must be: + +```typescript +@Injectable({ providedIn: 'root' }) +export class SeasonDownloadCoordinator { + private readonly downloadsService = inject(DownloadsService); + private readonly pendingKeys = signal>(new Set()); + + isPending(identity: EpisodeDownloadIdentity): boolean { + return this.pendingKeys().has( + createEpisodeDownloadIdentityKey(identity) + ); + } + + findDownload(identity: EpisodeDownloadIdentity): DownloadItem | undefined { + return findEpisodeDownload(identity, this.downloadsService.downloads()); + } + + isEligible(candidate: EpisodeDownloadCandidate): boolean { + return ( + this.downloadsService.hasLoadedDownloads() && + !this.isPending(candidate.identity) && + isEpisodeDownloadEligible(this.findDownload(candidate.identity)) + ); + } + + async enqueueOne( + candidate: EpisodeDownloadCandidate + ): Promise { + if (!this.reserve(candidate.identity)) return 'skipped'; + const outcome = await this.submit(candidate); + if (outcome === 'added') { + try { + await this.downloadsService.loadDownloads(); + } finally { + this.release(candidate.identity); + } + } else { + this.release(candidate.identity); + } + return outcome; + } + + async enqueueSeason( + candidates: readonly (EpisodeDownloadCandidate | null)[] + ): Promise { + const result = { added: 0, skipped: 0, failed: 0 }; + const reserved: EpisodeDownloadCandidate[] = []; + + for (const candidate of candidates) { + if (!candidate || !this.reserve(candidate.identity)) { + result.skipped++; + } else { + reserved.push(candidate); + } + } + + const accepted: EpisodeDownloadIdentity[] = []; + for (const candidate of reserved) { + const outcome = await this.submit(candidate); + result[outcome]++; + if (outcome === 'added') accepted.push(candidate.identity); + else this.release(candidate.identity); + } + + if (accepted.length > 0) { + try { + await this.downloadsService.loadDownloads(); + } finally { + this.releaseMany(accepted); + } + } + return result; + } +} +``` + +`reserve()` must clone the set, re-check authoritative eligibility, and add +the key in the same synchronous call. `submit()` must catch provider/IPC +exceptions, log through `createLogger`, return `skipped` only for +`reason === 'already-in-progress'`, and return `failed` for every other +unsuccessful result. Never compare the human-readable `error` string and never +log request URLs or credentials. + +Export the downloads sub-entrypoint from the library root. + +Run the focused coordinator spec. Expected: PASS. + +- [ ] **Step 4: Commit the orchestration slice** + +```bash +git add libs/portal/shared/data-access +git commit -m "feat(downloads): coordinate season queue submissions" +``` + +### Task 3: Add The Backend Legacy Identity Guard + +**Files:** + +- Create: + `apps/electron-backend/src/app/events/database/download-request-identity.ts` +- Create: + `apps/electron-backend/src/app/events/database/download-request-identity.spec.ts` +- Modify: + `apps/electron-backend/src/app/events/database/download-requests.ts` +- Modify: + `apps/electron-backend/src/app/events/database/download-requests.spec.ts` + +- [ ] **Step 1: Write failing resolver tests** + +The focused resolver spec must cover: + +```typescript +expect(await resolveExistingDownloadIdentity(db, episodeRequest())).toEqual({ + kind: 'match', + item: canonicalRow, + migrateCanonicalId: false, +}); + +expect( + await resolveExistingDownloadIdentity( + dbWithRows([], [legacyRow]), + episodeRequest() + ) +).toEqual({ + kind: 'match', + item: legacyRow, + migrateCanonicalId: true, +}); + +expect( + await resolveExistingDownloadIdentity( + dbWithRows([canonicalRow], [differentCoordinateRow]), + episodeRequest() + ) +).toEqual({ kind: 'conflict' }); +``` + +Add explicit cases for a canonical row with conflicting complete coordinates, +two coordinate rows, VOD, and an episode request missing any of +`seriesXtreamId`, `seasonNumber`, or `episodeNumber`. VOD/incomplete requests +must perform only the canonical lookup. + +Run: + +```bash +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-request-identity.spec.ts +``` + +Expected: FAIL because the resolver does not exist. + +- [ ] **Step 2: Implement exact-first, fail-closed resolution** + +Use `DownloadsDatabase` and `schema.downloads.$inferSelect`. The result union +must be: + +```typescript +export type ExistingDownloadIdentityResolution = + | { readonly kind: 'none' } + | { readonly kind: 'conflict' } + | { + readonly kind: 'match'; + readonly item: typeof schema.downloads.$inferSelect; + readonly migrateCanonicalId: boolean; + }; +``` + +Perform a canonical query with `limit(1)`. Only for an `episode` request with +all three safe integer coordinates, perform the coordinate query with +`limit(2)`. Return `conflict` when: + +- more than one coordinate row exists; +- exact and coordinate lookups point at different row ids; or +- the exact row has complete coordinates that disagree with the request. + +Return the exact row before the coordinate row when they agree. Do not merge, +delete, or mutate inside the resolver. + +Run the resolver spec. Expected: PASS. + +- [ ] **Step 3: Drive start-request integration from failing tests** + +Refactor the existing download-request test harness so it can return separate +canonical and coordinate lookup rows. Add tests asserting: + +```typescript +await expect( + startDownloadRequest(activeLegacyPayload, authorizer) +).resolves.toEqual({ + success: false, + id: legacyRow.id, + error: 'Download already in progress', + reason: 'already-in-progress', +}); + +await startDownloadRequest(canceledLegacyPayload, authorizer); +expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'queued', + xtreamId: canceledLegacyPayload.xtreamId, + }) +); + +await expect( + startDownloadRequest(conflictingPayload, authorizer) +).resolves.toEqual({ + success: false, + error: 'Download identity conflict', +}); +expect(enqueueDownload).not.toHaveBeenCalled(); +``` + +Use `it.each` for `queued`, `downloading`, and `paused`; each must carry the +stable reason. Use `it.each` for `failed`, `canceled`, and `completed`; each +eligible coordinate match must reuse the row id and migrate `xtreamId`. +Assert a rejected uniqueness update does not call `enqueueDownload`. + +Run both backend specs. Expected: the new integration assertions FAIL against +the current exact-only implementation. + +- [ ] **Step 4: Integrate the resolver without changing queue semantics** + +Replace only the existing download-row lookup in `startDownloadRequest()`: + +```typescript +const resolution = await resolveExistingDownloadIdentity(db, data); +if (resolution.kind === 'conflict') { + return { success: false, error: 'Download identity conflict' }; +} +const item = resolution.kind === 'match' ? resolution.item : undefined; + +if (item && !['completed', 'failed', 'canceled'].includes(item.status)) { + return { + success: false, + id: item.id, + error: 'Download already in progress', + reason: 'already-in-progress', + }; +} +``` + +In the existing restart update, add `xtreamId: data.xtreamId` only when +`resolution.migrateCanonicalId` is true. Leave directory authorization, +remote URL validation, retained partial cleanup, metadata validation, file +naming, row insertion, `enqueueDownload()`, and `download-runtime.ts` +unchanged. A database uniqueness rejection must propagate before enqueueing, +which is the fail-closed race behavior. + +Run: + +```bash +pnpm nx test electron-backend --runInBand \ + --testPathPatterns=download-request-identity.spec.ts \ + --testPathPatterns=download-requests.spec.ts +``` + +Expected: PASS, including all pre-existing authorization/restart tests. + +- [ ] **Step 5: Commit the backend guard** + +```bash +git add apps/electron-backend/src/app/events/database/download-request-identity.ts \ + apps/electron-backend/src/app/events/database/download-request-identity.spec.ts \ + apps/electron-backend/src/app/events/database/download-requests.ts \ + apps/electron-backend/src/app/events/database/download-requests.spec.ts +git commit -m "fix(downloads): reconcile legacy episode identities" +``` + +### Task 4: Move Xtream Request Construction Behind An Adapter + +**Files:** + +- Create: + `libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts` +- Create: + `libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts` +- Modify: + `libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts` +- Modify: + `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- Modify: + `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html` +- Modify: + `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts` + +- [ ] **Step 1: Write the failing Xtream adapter tests** + +Cover the complete request, not just its URL: + +```typescript +const adapter = createXtreamSeriesDownloadAdapter({ + playlistId: 'playlist-1', + seriesId: 900, + seriesTitle: 'Signal House', + serverUrl: 'http://host/', + username: 'user', + password: 'pass', + metadataContext: { + language: 'en', + title: 'Signal House', + plot: 'Series plot', + }, +}); +const candidate = adapter.createCandidate(episode, '1'); + +expect(candidate?.identity).toEqual({ + playlistId: 'playlist-1', + xtreamId: 55, + contentType: 'episode', + seriesXtreamId: 900, + seasonNumber: 2, + episodeNumber: 3, +}); +await expect(candidate?.prepare()).resolves.toEqual( + expect.objectContaining({ + playlistId: 'playlist-1', + xtreamId: 55, + title: 'Signal House - S02E03 - The One', + url: 'http://host/series/user/pass/55.mkv', + posterUrl: 'https://images.test/episode.jpg', + metadataSnapshot: expect.objectContaining({ + mediaKind: 'series', + episode: expect.objectContaining({ + seasonNumber: 2, + episodeNumber: 3, + }), + }), + }) +); +``` + +Also assert fallback season/episode defaults and `null` candidates for missing +provider credentials, playlist, unsafe episode id, unsafe series id, or unsafe +coordinates. + +Run: + +```bash +pnpm nx test portal-xtream-feature --runInBand \ + --testPathPatterns=xtream-series-download.adapter.spec.ts +``` + +Expected: FAIL because the adapter does not exist. + +- [ ] **Step 2: Implement the Xtream adapter and provider-local metadata type** + +Move `buildXtreamEpisodeDownloadRequest()` and its metadata snapshot assembly +out of `ui/components` into the new adapter. Define the adapter options next to +the provider implementation and return `SeasonEpisodeDownloadAdapter`: + +```typescript +export function createXtreamSeriesDownloadAdapter( + options: XtreamSeriesDownloadAdapterOptions +): SeasonEpisodeDownloadAdapter { + return { + createCandidate(episode, fallbackSeasonKey) { + const identity = createIdentity( + options, + episode, + fallbackSeasonKey + ); + if (!identity || !hasRequiredXtreamContext(options)) return null; + return { + identity, + prepare: async () => buildRequest(options, episode, identity), + }; + }, + }; +} +``` + +Change `createXtreamSeriesDownloadMetadataContext()` to return the pure +`DownloadMovieSnapshotInput` type from `@iptvnator/portal/shared/util`, removing +its dependency on a UI-owned type. Preserve the current raw Xtream URL, +credential, extension, title, poster, coordinate, and metadata semantics. + +Run the adapter spec. Expected: PASS. + +- [ ] **Step 3: Wire the adapter into SerialDetailsComponent** + +Replace the old `xtreamDownloadContext` and `downloadMetadataContext` inputs +with one computed adapter: + +```typescript +readonly episodeDownloadAdapter = computed(() => { + const playlist = this.xtreamStore.currentPlaylist(); + const item = this.selectedItem(); + if (!playlist || !item) return null; + return createXtreamSeriesDownloadAdapter({ + playlistId: playlist.id, + seriesId: Number(item.series_id), + seriesTitle: item.info.name, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + metadataContext: createXtreamSeriesDownloadMetadataContext( + item.info, + this.translateService.currentLang || + this.translateService.defaultLang || + 'en' + ), + }); +}); +``` + +Template binding: + +```html + +``` + +Update the stub component and assert the bound adapter creates canonical +candidate id 1001 and the existing metadata snapshot. Run: + +```bash +pnpm nx test portal-xtream-feature --runInBand \ + --testPathPatterns=xtream-series-download.adapter.spec.ts \ + --testPathPatterns=serial-details.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Commit the Xtream adapter** + +```bash +git add libs/portal/xtream/feature/src/lib/serial-details +git commit -m "refactor(downloads): adapt Xtream episode requests" +``` + +### Task 5: Move Stalker Request Construction Behind The Same Contract + +**Files:** + +- Create: + `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts` +- Create: + `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts` +- Modify: + `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify: + `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify: + `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` + +- [ ] **Step 1: Write the Stalker collision regression first** + +Create two mapped episodes with different normalized `episode.id` values but +the same playback ownership fields: + +```typescript +const first = stalkerEpisode({ + id: '61001', + episode_num: 1, + originalCmd: '/media/file_777.mpg', +}); +const second = stalkerEpisode({ + id: '61002', + episode_num: 2, + originalCmd: '/media/file_777.mpg', +}); + +const firstCandidate = adapter.createCandidate(first, '1'); +const secondCandidate = adapter.createCandidate(second, '1'); + +expect(firstCandidate?.identity.xtreamId).toBe(61001); +expect(secondCandidate?.identity.xtreamId).toBe(61002); +expect(firstCandidate?.identity).not.toEqual(secondCandidate?.identity); +``` + +Repeat the regression for VOD-series episodes sharing `originalId`. Assert +that `originalCmd`/`originalId` is still used to call `resolveUrl`, while the +prepared `DownloadStartInput.xtreamId` remains the normalized `episode.id`. +Also test headers, playlist fields, metadata snapshot, empty resolved URL, and +a rejected resolver promise. + +Run: + +```bash +pnpm nx test portal-stalker-feature --runInBand \ + --testPathPatterns=stalker-series-download.adapter.spec.ts +``` + +Expected: FAIL because the adapter does not exist. + +- [ ] **Step 2: Implement provider preparation without owning orchestration** + +The adapter options must contain only the current playlist/item/language, +canonical series id, and an injected URL resolver callback. Its candidate +shape is: + +```typescript +return { + identity: { + playlistId: playlist._id, + xtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId, + seasonNumber, + episodeNumber, + }, + prepare: async () => { + const url = await resolveUrl(cmd, episodeNumber); + if (!url) throw new Error('Stalker episode URL was not resolved'); + return { + ...identity, + title, + url, + posterUrl, + metadataSnapshot: createStalkerSeriesDownloadSnapshot(...), + headers: { + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + playlistName: playlist.title || 'Stalker Portal', + playlistType: 'stalker', + portalUrl: playlist.portalUrl, + macAddress: playlist.macAddress, + }; + }, +}; +``` + +Validate canonical ids and coordinates before returning the candidate. Do not +inject or call `DownloadsService` from the adapter. Run its focused spec; +expected: PASS. + +- [ ] **Step 3: Wire StalkerSeriesViewComponent and remove legacy hashing** + +Add a computed `episodeDownloadAdapter` that snapshots the current playlist, +display item, language, series id, and wraps +`stalkerStore.fetchLinkToPlay()`. Bind it through `[downloadAdapter]`. + +Delete: + +- `(episodeDownloadRequested)` from the template; +- `downloadEpisode()` from the component; +- the component's `getEpisodeDownloadId()` and `hashString()` methods; +- the now-unused `DownloadsService` injection. + +Update the stub and host tests to assert the adapter binding. Keep Stalker +playback behavior and URL resolution unchanged. + +Run: + +```bash +pnpm nx test portal-stalker-feature --runInBand \ + --testPathPatterns=stalker-series-download.adapter.spec.ts \ + --testPathPatterns=stalker-series-view.component.spec.ts +``` + +Expected: PASS, including the shared-playback-identifier regression. + +- [ ] **Step 4: Commit the Stalker adapter** + +```bash +git add libs/portal/stalker/feature/src/lib/stalker-series-view +git commit -m "fix(downloads): use canonical Stalker episode ids" +``` + +### Task 6: Convert SeasonContainer To Per-Episode And Season Actions + +**Files:** + +- Modify: + `libs/ui/components/src/lib/season-container/season-container.component.ts` +- Modify: + `libs/ui/components/src/lib/season-container/season-container.component.html` +- Modify: + `libs/ui/components/src/lib/season-container/season-container.component.scss` +- Modify: + `libs/ui/components/src/lib/season-container/season-container.component.spec.ts` +- Modify: + `libs/ui/components/src/lib/season-container/episode-utils.spec.ts` +- Delete: + `libs/ui/components/src/lib/season-container/episode-download.util.ts` +- Modify: `tools/eslint/max-lines-baseline.mjs` only if regeneration removes + the season container entry + +- [ ] **Step 1: Replace old request-building tests with failing UI behavior tests** + +Remove the old UI-owned Xtream/Stalker id tests from `episode-utils.spec.ts`; +keep only `episode-progress.util` coverage there. Extend the season component +spec with a real `SeasonDownloadCoordinator`, signal-backed +`DownloadsService` stub, `MatSnackBar` spy, and a simple adapter. + +Add RED tests for: + +```typescript +it('keeps a second episode downloadable while the first is pending', async () => { + const first = createEpisode({ id: '101', episode_num: 1 }); + const second = createEpisode({ id: '102', episode_num: 2 }); + const deferred = createDeferred(); + downloadsServiceStub.startDownload.mockReturnValueOnce(deferred.promise); + setDownloadableSeason([first, second]); + + clickDownload(101); + fixture.detectChanges(); + + expect(downloadButton(101).disabled).toBe(true); + expect(downloadButton(102).disabled).toBe(false); + deferred.resolve({ success: true, id: 1 }); + await fixture.whenStable(); +}); + +it('shows Download season with the eligible count and truthful result', async () => { + downloadsServiceStub.downloads.set([ + row({ xtreamId: 102, episodeNumber: 2, status: 'paused' }), + ]); + setDownloadableSeason([ + createEpisode({ id: '101', episode_num: 1 }), + createEpisode({ id: '102', episode_num: 2 }), + createEpisode({ id: '103', episode_num: 3 }), + ]); + + const button = seasonDownloadButton(); + expect(button.textContent).toContain('DOWNLOADS.DOWNLOAD_SEASON'); + expect(button.getAttribute('aria-label')).toContain('2'); + button.click(); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + expect.stringContaining('added'), + undefined, + expect.any(Object) + ); +}); +``` + +Also cover: + +- initial list not loaded, loading, empty, zero eligible, and batch-running + disabled states; +- no button in Web (`isAvailable=false`), provider-only + (`downloadsEnabled=false`), or missing-adapter views; +- queued/downloading/local-pending disabled, paused Resume, completed available + Play local, completed missing Download, and completed unknown blocked; +- resume by managed row id and local play by the coordinate-aware matched row; +- duplicate rapid clicks dispatch once; +- generic individual failure snackbar without raw backend text; +- equal action names and disabled behavior after switching to list view; +- batch snapshots the selected season before async preparation. + +Run: + +```bash +pnpm nx test components --runInBand \ + --testPathPatterns=season-container.component.spec.ts \ + --testPathPatterns=episode-utils.spec.ts +``` + +Expected: FAIL because the adapter input, per-item state, and season action do +not exist. + +- [ ] **Step 2: Replace provider branching with the shared adapter input** + +In the component remove `xtreamDownloadContext`, +`downloadMetadataContext`, and `episodeDownloadRequested`. Add: + +```typescript +readonly downloadAdapter = + input(null); +readonly batchRunning = signal(false); + +readonly downloadPresentationVisible = computed( + () => + this.downloadsService.isAvailable() && + this.downloadsEnabled() && + this.downloadAdapter() !== null +); + +readonly eligibleEpisodeCount = computed(() => { + const adapter = this.downloadAdapter(); + if (!adapter || !this.downloadsService.hasLoadedDownloads()) return 0; + return this.selectedSeasonEpisodes().filter((episode) => { + const candidate = adapter.createCandidate( + episode, + this.selectedSeason() + ); + return candidate ? this.coordinator.isEligible(candidate) : false; + }).length; +}); + +readonly seasonDownloadDisabled = computed( + () => + this.isLoading() || + this.batchRunning() || + !this.downloadsService.hasLoadedDownloads() || + this.selectedSeasonEpisodes().length === 0 || + this.eligibleEpisodeCount() === 0 +); +``` + +Create focused helpers `candidateFor()`, `downloadFor()`, and +`episodeDownloadState()` so both grid and list branches use the same state. +The presentation-state mapping must be: + +```text +local pending / queued / downloading -> pending disabled action +paused -> Resume using row.id +completed + available -> Play local using row.filePath +completed + unknown/not-applicable -> blocked disabled action +failed/canceled/completed missing/no row -> Download +invalid candidate -> blocked disabled action +``` + +`downloadEpisode()` calls `coordinator.enqueueOne()`. On `failed`, show only +the localized generic error. On `added` or `skipped`, rely on the visible +state and show no individual success snackbar. + +- [ ] **Step 3: Add the season batch action and aggregate snackbar** + +Implement: + +```typescript +async downloadSelectedSeason(): Promise { + if (this.batchRunning()) return; + const adapter = this.downloadAdapter(); + const seasonKey = this.selectedSeason(); + if (!adapter || !seasonKey) return; + + const snapshot = [...this.selectedSeasonEpisodes()]; + const candidates = snapshot.map((episode) => + adapter.createCandidate(episode, seasonKey) + ); + this.batchRunning.set(true); + try { + const result = await this.coordinator.enqueueSeason(candidates); + const key = result.failed + ? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES' + : 'DOWNLOADS.SEASON_QUEUE_RESULT'; + this.snackBar.open(this.translate.instant(key, result), undefined, { + duration: 5000, + }); + } finally { + this.batchRunning.set(false); + } +} +``` + +Render the Material stroked button immediately before the existing view +toggle: + +```html +@if (downloadPresentationVisible()) { + +} +``` + +Give every grid/list episode action a localized `aria-label` and stable +`data-test-id="episode-download-"`. Use native/Material `disabled`, not +CSS-only blocking. + +- [ ] **Step 4: Apply responsive, theme-safe styling without redesigning cards** + +Wrap the season action and toggle in `.section-header__actions`. Add only +layout/alignment rules: + +```scss +.section-header__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.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; + } + + .section-header__actions { + width: 100%; + justify-content: space-between; + } +} +``` + +Use existing Material system and `--app-*` tokens. Do not introduce new +hard-coded light/dark foreground or background colors. + +- [ ] **Step 5: Delete obsolete shared provider code and verify the component** + +Delete `episode-download.util.ts` after both adapters own request creation. +Remove all remaining references found by: + +```bash +rg -n "episode-download\.util|getEpisodeDownloadId|isStalkerEpisode|SeasonContainerXtreamDownloadContext|SeasonContainerDownloadMetadataContext|episodeDownloadRequested" \ + libs/ui/components libs/portal/xtream libs/portal/stalker +``` + +Expected: no matches except intentional historical text in specs, which should +also be removed. + +Run: + +```bash +pnpm nx test components --runInBand \ + --testPathPatterns=season-container.component.spec.ts \ + --testPathPatterns=episode-utils.spec.ts +pnpm nx lint components +node tools/eslint/generate-max-lines-baseline.mjs +git diff -- tools/eslint/max-lines-baseline.mjs +``` + +Expected: component tests and lint PASS. The regenerated max-lines baseline +must not gain any file. Commit a removed season-container entry only if the +refactored production TypeScript is now under the enforced 400-line count; +otherwise restore the unchanged generated file. + +- [ ] **Step 6: Commit the shared UI behavior** + +```bash +git add libs/ui/components/src/lib/season-container \ + tools/eslint/max-lines-baseline.mjs +git commit -m "feat(downloads): add selected season queue action" +``` + +Omit `tools/eslint/max-lines-baseline.mjs` from `git add` when it is unchanged. + +### Task 7: Localize The New Actions And Feedback + +**Files:** + +- Modify: `apps/web/src/assets/i18n/ar.json` +- Modify: `apps/web/src/assets/i18n/ary.json` +- Modify: `apps/web/src/assets/i18n/by.json` +- Modify: `apps/web/src/assets/i18n/de.json` +- Modify: `apps/web/src/assets/i18n/el.json` +- Modify: `apps/web/src/assets/i18n/en.json` +- Modify: `apps/web/src/assets/i18n/es.json` +- Modify: `apps/web/src/assets/i18n/fr.json` +- Modify: `apps/web/src/assets/i18n/hu.json` +- Modify: `apps/web/src/assets/i18n/it.json` +- Modify: `apps/web/src/assets/i18n/ja.json` +- Modify: `apps/web/src/assets/i18n/ko.json` +- Modify: `apps/web/src/assets/i18n/nl.json` +- Modify: `apps/web/src/assets/i18n/pl.json` +- Modify: `apps/web/src/assets/i18n/pt.json` +- Modify: `apps/web/src/assets/i18n/ru.json` +- Modify: `apps/web/src/assets/i18n/tr.json` +- Modify: `apps/web/src/assets/i18n/zh.json` +- Modify: `apps/web/src/assets/i18n/zhtw.json` + +- [ ] **Step 1: Add the English keys and observe the drift gate fail** + +Add these string keys under `DOWNLOADS` in `en.json`: + +```text +DOWNLOAD_SEASON +DOWNLOAD_SEASON_ARIA +ADDING_TO_QUEUE +EPISODE_DOWNLOAD_ARIA +EPISODE_DOWNLOAD_PENDING_ARIA +EPISODE_DOWNLOAD_FAILED +SEASON_QUEUE_RESULT +SEASON_QUEUE_RESULT_WITH_FAILURES +``` + +Use these exact source values: + +```json +"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}}" +``` + +Run: + +```bash +pnpm run i18n:check +``` + +Expected: FAIL with all 18 non-English locales reporting the eight missing +keys. This is the localization RED gate. + +- [ ] **Step 2: Add all locale values** + +Translate the eight English values naturally in every other listed locale. +Preserve interpolation tokens exactly. Run `pnpm run i18n:check` again. +Expected: PASS with zero missing and zero extra keys in every locale. + +- [ ] **Step 3: Re-run the component tests with real English translations** + +Load `en.json` into the component spec's translation service for the focused +accessible-name/result cases. Run: + +```bash +pnpm nx test components --runInBand \ + --testPathPatterns=season-container.component.spec.ts +``` + +Expected: PASS with visible English labels and no raw translation keys. + +- [ ] **Step 4: Commit localization** + +```bash +git add apps/web/src/assets/i18n libs/ui/components/src/lib/season-container/season-container.component.spec.ts +git commit -m "feat(downloads): localize season queue feedback" +``` + +### Task 8: Add A Deterministic Xtream Series Download E2E + +**Files:** + +- Modify: `apps/xtream-mock-server/src/app/scenarios.ts` +- Modify: `apps/xtream-mock-server/src/app/server.ts` +- Modify: `apps/xtream-mock-server/src/app/server.spec.ts` +- Create: + `apps/electron-backend-e2e/src/series-download-queue.e2e.ts` + +- [ ] **Step 1: Drive a local slow-series fixture from a failing mock test** + +Add a scenario option: + +```typescript +/** Optional local media response reserved for download queue E2E. */ +downloadStreamFixture?: 'slow-series'; +``` + +Add a `downloadqueue:downloadqueue` scenario with one series category, four +items, one season, and four episodes per season. The server test must request a +series URL with those credentials and assert: + +```typescript +expect(response.status).toBe(200); +expect(response.headers.get('content-type')).toContain('video/mp4'); +expect(Number(response.headers.get('content-length'))).toBeGreaterThan( + 1024 * 1024 +); +await response.body?.cancel(); +``` + +Keep the existing test proving ordinary credentials still return the same 302 +redirect. + +Run: + +```bash +pnpm nx test xtream-mock-server --runInBand \ + --testPathPatterns=server.spec.ts +``` + +Expected: FAIL because the local download stream is not implemented. + +- [ ] **Step 2: Implement the bounded, cancellable slow response** + +In `installStreamRoutes()`, inspect `getScenario(username, password)`. For +only `downloadStreamFixture === 'slow-series'` and only `/series/...`, return a +fixed-size local `video/mp4` response in small timed chunks. Clear the timer on +`request.close` and `response.close`; do not allocate the complete payload at +once and do not affect performance-control or ordinary redirect behavior. + +Run the mock-server spec. Expected: PASS and no outbound request. + +- [ ] **Step 3: Write the Electron UI-to-queue E2E** + +The E2E must: + +1. reset the Xtream mock; +2. fetch the deterministic series/category title with + `fetchXtreamSeriesFixture(request, downloadQueueCredentials)`; +3. add the `downloadqueue` portal and wait for the workspace; +4. authorize `join(dataDir, 'series-downloads')` through the existing native + dialog UI flow; +5. open Series, the deterministic category, and the first series detail; +6. click episode 1 and episode 2 download actions consecutively; +7. poll `window.electron.downloadsGetList()` until the rows show exactly one + `downloading` and one `queued` episode; +8. assert the season button shows `(2)`, click it, and assert the aggregate + snackbar reports `Added 2 · Skipped 2`; +9. poll until four unique episode rows exist in displayed episode order; +10. assert at most one row is `downloading`, all others are `queued`, the + season button is disabled with count zero, and an additional wait does not + increase the row count. + +Use the new `data-test-id="episode-download-"` and +`data-test-id="download-season"` selectors. Always close Electron in `finally`; +the slow server's connection cleanup must make shutdown deterministic. + +Run: + +```bash +pnpm nx show project electron-backend-e2e | rg "series-download-queue" +pnpm nx run electron-backend-e2e:e2e-ci--src/series-download-queue.e2e.ts +``` + +Expected: the inferred atomized target exists and the E2E passes. No Web E2E +is added because component coverage proves the Electron-only action is absent. +No Stalker UI E2E is added because its current mock resolves downloadable +episodes through the general fast playback transport; adapter, component, and +backend tests cover the Stalker identity regression without broadening that +mock. + +- [ ] **Step 4: Commit mock and E2E coverage** + +```bash +git add apps/xtream-mock-server/src/app \ + apps/electron-backend-e2e/src/series-download-queue.e2e.ts +git commit -m "test(downloads): cover series batch queue flow" +``` + +### Task 9: Document The Contract And Add The Release Note + +**Files:** + +- Modify: `docs/architecture/download-manager.md` +- Modify: `CLAUDE.md` +- Create: `.changes/downloads-season-queue.md` + +- [ ] **Step 1: Load and follow the release-notes skill** + +Read `.codex/skills/release-notes/SKILL.md` fully before creating the note. +The change is user-visible, so it must not use `type: internal` and must not be +skipped. + +- [ ] **Step 2: Update canonical architecture documentation** + +Add a focused “Series season queueing” subsection to +`docs/architecture/download-manager.md` covering: + +```text +- one active transfer plus FIFO remains the backend invariant; +- SeasonDownloadCoordinator owns renderer pending and best-effort batching; +- adapters own provider URL/request preparation; +- episode.id is canonical for Xtream and normalized Stalker episodes; +- exact identity precedes legacy coordinate compatibility; +- active/paused/available rows skip, failed/canceled/missing-completed retry; +- DOWNLOADS_START remains the only start IPC and returns the optional stable + already-in-progress reason. +``` + +Update the Download Manager section of `CLAUDE.md` with the same ownership and +identity boundary. Do not modify `AGENTS.md`: it does not currently enumerate +series download behavior, so no mirrored process or path claim changes. + +- [ ] **Step 3: Add and validate the user-facing note** + +Create: + +```markdown +--- +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. +``` + +The body is under 400 characters and contains no implementation jargon. + +Run: + +```bash +pnpm run release:notes:validate +pnpm exec prettier --check \ + docs/architecture/download-manager.md \ + CLAUDE.md \ + .changes/downloads-season-queue.md +``` + +Expected: PASS. + +- [ ] **Step 4: Commit docs and release note** + +```bash +git add docs/architecture/download-manager.md CLAUDE.md \ + .changes/downloads-season-queue.md +git commit -m "docs(downloads): describe season queueing" +``` + +### Task 10: Run The Full Verification Ladder + +**Files:** + +- Verify all files changed by Tasks 1–9 + +- [ ] **Step 1: Load verification-before-completion** + +Read `~/.agents/skills/verification-before-completion/SKILL.md` fully and +follow its evidence requirements. Do not claim success from cached or earlier +test output. + +- [ ] **Step 2: Run focused and full affected tests** + +Run fresh: + +```bash +pnpm nx test portal-shared-util +pnpm nx test portal-shared-data-access +pnpm nx test components +pnpm nx test portal-xtream-feature +pnpm nx test portal-stalker-feature +pnpm nx test services +pnpm nx test electron-backend +pnpm nx test xtream-mock-server +pnpm nx run electron-backend-e2e:e2e-ci--src/series-download-queue.e2e.ts +``` + +Expected: every command exits 0. The E2E proves one active transfer and FIFO +queued rows through the real renderer bridge. + +- [ ] **Step 3: Run affected lint and type/build validation** + +Run: + +```bash +pnpm nx run-many --target=lint --projects=portal-shared-util,portal-shared-data-access,components,portal-xtream-feature,portal-stalker-feature,services,electron-backend,xtream-mock-server,electron-backend-e2e +pnpm nx build electron-backend +pnpm run release:notes:validate +pnpm exec prettier --check \ + docs/superpowers/specs/2026-08-01-season-download-queue-design.md \ + docs/superpowers/plans/2026-08-01-season-download-queue.md \ + docs/architecture/download-manager.md \ + CLAUDE.md \ + .changes/downloads-season-queue.md +``` + +Expected: PASS. Inspect `git diff --check` and confirm max-lines baseline gained +no entries. + +- [ ] **Step 4: Perform the manual accessibility and theme pass** + +Launch the Electron development app against the deterministic Xtream mock and +use CDP at `127.0.0.1:9222`. Check: + +```text +- light and dark themes; +- desktop width and a narrow content width around 520 px; +- season action wrapping without clipping; +- keyboard focus order and visible focus rings; +- accessible names for season and grid/list episode actions; +- native disabled state for pending and zero-eligible actions; +- live-region snackbar text for success and partial failure; +- first episode pending does not disable another episode. +``` + +Capture screenshots only into `/tmp`; do not publish or add them to the repo. +If manual/CDP verification is not possible, record the exact blocker and rely +on the component accessibility assertions without claiming the manual pass. + +- [ ] **Step 5: Self-review scope and repository state** + +Run: + +```bash +git diff master...HEAD --stat +git diff master...HEAD --check +git status --short +rg -n "TODO|TBD|placeholder" \ + libs/portal/shared/util/src/lib/downloads \ + libs/portal/shared/data-access/src/lib/downloads \ + libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts \ + libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts \ + apps/electron-backend/src/app/events/database/download-request-identity.ts +``` + +Confirm the diff contains no batch IPC, concurrency setting, queue reordering, +schema migration, full-series action, multi-season selection, GitHub mutation, +or opportunistic redesign. + +- [ ] **Step 6: Present completion without publishing** + +Summarize: + +- per-episode and season behavior delivered; +- files and architectural boundary changed; +- tests added/updated and every fresh validation result; +- light/dark/accessibility outcome; +- `docs/architecture/download-manager.md` and `CLAUDE.md` updates; +- `.changes/downloads-season-queue.md` addition; +- Stalker E2E omission reason from Task 8. + +Do not push, create a PR, or edit GitHub issues. Offer those actions only if +the user explicitly authorizes them in a later message. diff --git a/docs/superpowers/specs/2026-08-01-season-download-queue-design.md b/docs/superpowers/specs/2026-08-01-season-download-queue-design.md new file mode 100644 index 000000000..84e2f483b --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-season-download-queue-design.md @@ -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; +} + +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-.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. diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index 4f245dfcb..0887a2743 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/collection'; +export * from './lib/downloads'; export * from './lib/multi-source'; diff --git a/libs/portal/shared/data-access/src/lib/downloads/index.ts b/libs/portal/shared/data-access/src/lib/downloads/index.ts new file mode 100644 index 000000000..7b97d720d --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/downloads/index.ts @@ -0,0 +1,2 @@ +export * from './season-download.models'; +export * from './season-download-coordinator.service'; diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts new file mode 100644 index 000000000..81e318f04 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts @@ -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; + readonly hasAuthoritativeDownloadList: WritableSignal; + readonly hasLoadedDownloads: WritableSignal; + readonly isAvailable: WritableSignal; + readonly startDownload: jest.MockedFunction< + DownloadsService['startDownload'] + >; + readonly loadDownloads: jest.MockedFunction< + DownloadsService['loadDownloads'] + >; +} + +interface Deferred { + readonly promise: Promise; + 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([]), + 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(); + 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(); + 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(); + 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(); + const refreshStarted = deferred(); + 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(); + const refreshStarted = deferred(); + 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 = () => + Promise.resolve(request(value)) +): EpisodeDownloadCandidate & { readonly prepare: jest.Mock } { + return { identity: value, prepare: jest.fn(prepare) }; +} + +function download( + value: EpisodeDownloadIdentity, + overrides: Partial = {} +): 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(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + 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'); +} diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts new file mode 100644 index 000000000..1d5bb7cdc --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts @@ -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>(new Set()); + + isPending(identity: EpisodeDownloadIdentity): boolean { + return this.pending().has(createEpisodeDownloadIdentityKey(identity)); + } + + resolveDownload( + identity: EpisodeDownloadIdentity + ): EpisodeDownloadResolution { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download.models.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download.models.ts new file mode 100644 index 000000000..e53013969 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download.models.ts @@ -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; +} + +export interface SeasonEpisodeDownloadAdapter { + createCandidate( + episode: XtreamSerieEpisode, + fallbackSeasonKey: string | undefined + ): EpisodeDownloadCandidate | null; +} + +export interface SeasonDownloadResult { + readonly added: number; + readonly skipped: number; + readonly failed: number; +} diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index b7490f4cf..2ab7e80d7 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -30,3 +30,4 @@ export * from './lib/collection'; export * from './lib/external-playback-button-state'; export * from './lib/inline-playback-position-writer'; export * from './lib/downloads/download-metadata-snapshot'; +export * from './lib/downloads/episode-download-identity'; diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts new file mode 100644 index 000000000..54c6671a4 --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts @@ -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 { + 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); + + 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); + + 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', + }) + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts new file mode 100644 index 000000000..217de539c --- /dev/null +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts @@ -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 = + | { 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( + identity: EpisodeDownloadIdentity, + downloads: readonly T[] +): EpisodeDownloadResolution { + 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 +): 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, + }); +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts index df4f64560..181f31a3d 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts @@ -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 = [ { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts index 17ec5dcec..49aa8df8a 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts @@ -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 ): 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; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts new file mode 100644 index 000000000..8efbd13cc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts @@ -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 & { + 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(); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts new file mode 100644 index 000000000..f0fbce720 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts @@ -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; +} + +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, + }; + }, + }; + }, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 0256b9952..1542d6c88 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -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) " diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index f256c1bd3..b2ecc881c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -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(null); readonly isLoading = input(false); readonly downloadsEnabled = input(true); + readonly downloadAdapter = input(null); readonly seasonSelected = output(); readonly episodeClicked = output(); - readonly episodeDownloadRequested = output(); readonly playbackToggleRequested = output(); readonly selectedSeason = signal(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 { + 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); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 319968b54..3d3d459c8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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(); @@ -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 where string is season number/name. @@ -1072,81 +1098,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - async downloadEpisode(episode: XtreamSerieEpisode): Promise { - 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 { @@ -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); - } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 09d4b6898..cbe1231ba 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -205,8 +205,7 @@ [playlistId]="currentPlaylistId()" [seriesTitle]="info.name" [playbackPositions]="episodePlaybackPositions()" - [xtreamDownloadContext]="xtreamDownloadContext()" - [downloadMetadataContext]="downloadMetadataContext()" + [downloadAdapter]="episodeDownloadAdapter()" [downloadsEnabled]="!providerOnly()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 3b6293254..fc1f0dd0b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -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(undefined); readonly playbackPositions = input(null); - readonly xtreamDownloadContext = input(null); - readonly downloadMetadataContext = input(null); + readonly downloadAdapter = input(null); readonly downloadsEnabled = input(true); readonly openingEpisodeId = input(null); readonly activeEpisodeId = input(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> + > + )['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 () => { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 9c45117a1..f004af19b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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(null); - readonly downloadMetadataContext = - computed(() => { - const info = this.selectedItem()?.info; - return info - ? createXtreamSeriesDownloadMetadataContext( - info, - this.translateService.currentLang || - this.translateService.defaultLang || - 'en' - ) - : null; + readonly episodeDownloadAdapter = + computed(() => { + 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(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 diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts index a532f530e..bb6309d19 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-download-metadata.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts new file mode 100644 index 000000000..5dda2487b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts @@ -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 { + 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(); + } + ); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts new file mode 100644 index 000000000..a041cbf94 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts @@ -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, + }, + }), + }; + }, + }; + }, + }; +} diff --git a/libs/services/src/lib/download-list-load-state.ts b/libs/services/src/lib/download-list-load-state.ts new file mode 100644 index 000000000..0a89ec3b1 --- /dev/null +++ b/libs/services/src/lib/download-list-load-state.ts @@ -0,0 +1,81 @@ +import { signal } from '@angular/core'; + +interface QueuedDownloadListLoad { + readonly operation: () => Promise; + readonly promise: Promise; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; +} + +export class DownloadListLoadState { + private activeLoad?: Promise; + 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): Promise { + if (!this.activeLoad) { + return this.start(operation); + } + + if (!this.queuedLoad) { + this.queuedLoad = this.createQueuedLoad(operation); + } + return this.queuedLoad.promise; + } + + private start(operation: () => Promise): Promise { + 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 { + 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 + ): QueuedDownloadListLoad { + let resolve!: () => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { operation, promise, resolve, reject }; + } +} diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts index 96aa81596..2cd5dc421 100644 --- a/libs/services/src/lib/downloads.models.ts +++ b/libs/services/src/lib/downloads.models.ts @@ -1,11 +1,11 @@ import type { DownloadMetadataSnapshot, + ElectronBridgeDownloadStatus, ElectronBridgeDownloadStartPayload, ElectronDownloadFileAvailability, } from '@iptvnator/shared/interfaces'; -export type DownloadStatus = - 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; +export type DownloadStatus = ElectronBridgeDownloadStatus; export type DownloadStartInput = Omit< ElectronBridgeDownloadStartPayload, @@ -20,6 +20,8 @@ export interface DownloadItem { seriesXtreamId?: number; seasonNumber?: number; episodeNumber?: number; + episodeIdentityScope?: + ElectronBridgeDownloadStartPayload['episodeIdentityScope'] | null; title: string; url: string; fileName?: string; diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 8a77a54c9..8595c4d2f 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -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; isAvailable: () => boolean; isLoadingDownloads: Signal; + hasAuthoritativeDownloadList: Signal; hasLoadedDownloads: Signal; getDownload: DownloadsService['getDownload']; loadDownloads: DownloadsService['loadDownloads']; @@ -29,15 +32,13 @@ type TestDownloadsService = { selectFolder: DownloadsService['selectFolder']; startDownload: DownloadsService['startDownload']; updateMetadata: DownloadsService['updateMetadata']; - _isLoadingDownloads: WritableSignal; - _hasLoadedDownloads: WritableSignal; - loadDownloadsRequestId: number; + downloadListLoadState: DownloadListLoadState; }; type DownloadsElectronStub = { downloadsGetDefaultFolder?: jest.Mock, []>; downloadsStart?: jest.Mock< - Promise<{ success: boolean; id?: number; error?: string }>, + Promise, [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, + [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(); 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(); const second = createDeferred(); @@ -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(); + const second = createDeferred(); + const third = createDeferred(); + 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), diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index e2c702c7a..904ca9890 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -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([]); /** 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 { 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 { if (!this.isAvailable()) { return { success: false, error: 'Downloads not available' }; } diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index cde717a06..740abeb08 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -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); }); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 043222882..db2881ae9 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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, diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index e8b716ec4..2037fbdfc 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -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(), diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 4d96df897..28cfa0707 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts deleted file mode 100644 index 36960d811..000000000 --- a/libs/ui/components/src/lib/season-container/episode-download.util.ts +++ /dev/null @@ -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 - ), - } - : {}), - }; -} diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index a104c5928..c5eb1c7f2 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -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): 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); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index 4285c4ca1..34cbe1f88 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -1,33 +1,14 @@
-
-
-

- {{ 'PORTALS.SEASONS_AND_EPISODES' | translate }} -

-
- @if (hasSeasons() && !showSeasonEmptyState()) { -
- - - grid_view - - - view_list - - -
- } -
+ @if (hasSeasons()) {
- @if (isElectron() && downloadsEnabled()) { - @if (isEpisodeDownloaded(episode)) { - - } @else if (isEpisodePaused(episode)) { - - } @else if ( - isEpisodeDownloading(episode) - ) { - - } @else { - - } + @if ( + downloadPresenter.presentationVisible() + ) { + @let downloadRow = + downloadPresenter.rowFor(episode); + @let downloadState = + downloadRow.presentation; + }
@@ -412,63 +385,56 @@ }} } - @if (isElectron() && downloadsEnabled()) { - @if (isEpisodeDownloaded(episode)) { - - } @else if (isEpisodePaused(episode)) { - - } @else if (isEpisodeDownloading(episode)) { - - } @else { - - } + @if (downloadPresenter.presentationVisible()) { + @let downloadRow = + downloadPresenter.rowFor(episode); + @let downloadState = + downloadRow.presentation; + } @if (episodeInfo?.plot) { + } + @if (viewToggleVisible()) { +
+ + + grid_view + + + view_list + + +
+ } +
+ } + diff --git a/libs/ui/components/src/lib/season-container/season-header.component.scss b/libs/ui/components/src/lib/season-container/season-header.component.scss new file mode 100644 index 000000000..0e7b266b6 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-header.component.scss @@ -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; + } + } +} diff --git a/libs/ui/components/src/lib/season-container/season-header.component.ts b/libs/ui/components/src/lib/season-container/season-header.component.ts new file mode 100644 index 000000000..9093d6995 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-header.component.ts @@ -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(); + readonly batchRunning = input.required(); + readonly seasonDisabled = input.required(); + readonly eligibleEpisodeCount = input.required(); + readonly viewToggleVisible = input.required(); + readonly viewMode = input.required(); + + readonly downloadSeason = output(); + readonly viewModeChange = output(); +} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 937fe4d58..bbdd99343 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',