From 33ca53e86dc6a210954bea2307885656f6726630 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 20:19:27 +0200 Subject: [PATCH] fix(downloads): preserve scoped episode ownership --- CLAUDE.md | 25 ++++++--- .../download-request-identity.spec.ts | 56 +++++++++++++++++-- .../database/download-request-identity.ts | 56 ++++++++++++++----- .../events/database/download-requests.spec.ts | 40 +++++++++++-- .../app/events/database/download-requests.ts | 20 +++++++ docs/architecture/download-manager.md | 36 ++++++------ docs/architecture/stalker-portal.md | 6 ++ ...eason-download-coordinator.service.spec.ts | 14 +++++ .../season-download-coordinator.service.ts | 4 +- .../episode-download-identity.spec.ts | 56 +++++++++++++++++++ .../downloads/episode-download-identity.ts | 28 +++++++++- .../stalker-series-download.adapter.spec.ts | 25 +++++++++ .../stalker-series-download.adapter.ts | 23 ++++++++ .../stalker-series-view.component.spec.ts | 7 +-- .../stalker-series-view.component.ts | 10 +++- libs/services/src/lib/downloads.models.ts | 2 + .../database/src/lib/connection.spec.ts | 20 +++++-- libs/shared/database/src/lib/connection.ts | 11 ++++ libs/shared/database/src/lib/schema.ts | 1 + .../src/lib/electron-api.interface.ts | 12 ++++ 20 files changed, 395 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac116808a..050dde727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -942,19 +942,26 @@ engine` (restart required) or 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; its - stable `reason: 'already-in-progress'` result is counted as skipped, and no - batch IPC or schema migration is introduced. 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. + stable `reason: 'already-in-progress'` and `reason: 'already-downloaded'` + results are counted as skipped, and no batch IPC is introduced. The latter + comes from a 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. 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. - 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. Renderer lookup - preserves ambiguity or conflicting ownership as a distinct ineligible state, - so neither the episode action nor the season count treats it as a row-less + fallback that migrates reusable rows to the canonical id. 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, 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 index 3e46ca836..044d3f4cf 100644 --- 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 @@ -22,6 +22,7 @@ function createDownloadRow(overrides: Partial = {}): DownloadRow { contentType: 'episode', createdAt: '2026-08-02 10:00:00', episodeNumber: 3, + episodeIdentityScope: null, errorMessage: null, fileName: 'episode.mp4', filePath: null, @@ -65,6 +66,50 @@ function createQueryHarness( } 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], []); @@ -135,10 +180,13 @@ describe('resolveExistingDownloadIdentity', () => { ); it('fails closed when multiple rows share the legacy coordinates', async () => { - const harness = createQueryHarness([], [ - createDownloadRow({ id: 42, xtreamId: 77 }), - createDownloadRow({ id: 43, xtreamId: 78 }), - ]); + const harness = createQueryHarness( + [], + [ + createDownloadRow({ id: 42, xtreamId: 77 }), + createDownloadRow({ id: 43, xtreamId: 78 }), + ] + ); await expect( resolveExistingDownloadIdentity(harness.db, episodeRequest) 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 index 314427424..d78c43926 100644 --- a/apps/electron-backend/src/app/events/database/download-request-identity.ts +++ b/apps/electron-backend/src/app/events/database/download-request-identity.ts @@ -1,4 +1,5 @@ -import { and, eq } from 'drizzle-orm'; +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'; @@ -25,13 +26,12 @@ interface DownloadIdentityMatch { } export type ExistingDownloadIdentityResolution = - | DownloadIdentityNone - | DownloadIdentityConflict - | DownloadIdentityMatch; + DownloadIdentityNone | DownloadIdentityConflict | DownloadIdentityMatch; export interface DownloadIdentityRequest { contentType: DownloadRow['contentType']; episodeNumber?: number; + episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope; playlistId: string; seasonNumber?: number; seriesXtreamId?: number; @@ -49,9 +49,17 @@ function rowHasConflictingCoordinates( 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 || @@ -92,6 +100,7 @@ export async function resolveExistingDownloadIdentity( const seriesXtreamId = request.seriesXtreamId; const seasonNumber = request.seasonNumber; const episodeNumber = request.episodeNumber; + const episodeIdentityScope = request.episodeIdentityScope; if ( request.contentType !== 'episode' || @@ -117,21 +126,41 @@ export async function resolveExistingDownloadIdentity( eq(schema.downloads.contentType, 'episode'), eq(schema.downloads.seriesXtreamId, seriesXtreamId), eq(schema.downloads.seasonNumber, seasonNumber), - eq(schema.downloads.episodeNumber, episodeNumber) + eq(schema.downloads.episodeNumber, episodeNumber), + episodeIdentityScope === undefined + ? isNull(schema.downloads.episodeIdentityScope) + : or( + eq( + schema.downloads.episodeIdentityScope, + episodeIdentityScope + ), + isNull(schema.downloads.episodeIdentityScope) + ) ) ) .limit(2); - if (coordinateRows.length > 1) { + 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 coordinateRow = coordinateRows[0]; - if ( - canonicalRow && - coordinateRow && - canonicalRow.id !== coordinateRow.id - ) { + 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 }; } @@ -139,6 +168,7 @@ export async function resolveExistingDownloadIdentity( canonicalRow && rowHasConflictingCoordinates(canonicalRow, { episodeNumber, + episodeIdentityScope, seasonNumber, seriesXtreamId, }) 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 f4b8fcefb..03f8e7ab2 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 @@ -11,7 +11,8 @@ const metadataSnapshot: DownloadMetadataSnapshot = { async function setupStartMetadataRequest( existing: Record | undefined, - coordinateRows: Record[] = [] + coordinateRows: Record[] = [], + completedFileAvailable = false ) { jest.resetModules(); const schema = await import('../../database/schema'); @@ -53,6 +54,9 @@ async function setupStartMetadataRequest( jest.doMock('./download-runtime', () => ({ enqueueDownload, })); + jest.doMock('./download-file-availability', () => ({ + isAvailableDownloadFile: jest.fn(() => completedFileAvailable), + })); const { startDownloadRequest } = await import('./download-requests'); return { @@ -66,14 +70,13 @@ async function setupStartMetadataRequest( }; } -function createStartDownloadRow( - overrides: Partial = {} -): Download { +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, @@ -431,6 +434,35 @@ 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], + true + ); + + 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(); + }); + it.each(['queued', 'downloading', 'paused'] as const)( 'returns the stable duplicate result for an active legacy-coordinate %s row', async (status) => { 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 9eebcc055..e8266c039 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -1,7 +1,9 @@ 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'; @@ -9,6 +11,7 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; +import { isAvailableDownloadFile } from './download-file-availability'; import { resolveExistingDownloadIdentity } from './download-request-identity'; import { resolveStoredDownloadHeaders } from './download-request-headers'; import { @@ -32,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; @@ -143,6 +147,18 @@ export async function startDownloadRequest( data.url ); } + if ( + item.contentType === 'episode' && + item.status === 'completed' && + isAvailableDownloadFile(item.filePath) + ) { + 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', @@ -191,6 +207,9 @@ export async function startDownloadRequest( ...(identity.migrateCanonicalId ? { xtreamId: data.xtreamId } : {}), + ...(data.episodeIdentityScope === undefined + ? {} + : { episodeIdentityScope: data.episodeIdentityScope }), }) .where(eq(schema.downloads.id, item.id)); enqueueDownload({ @@ -231,6 +250,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/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 9f801b24b..110473254 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -79,22 +79,26 @@ variants, contextual buttons, and theme-aware styling. in URL resolution. The exact `(playlistId, contentType, xtreamId)` identity is authoritative. Complete `(playlistId, seriesXtreamId, seasonNumber, episodeNumber)` - coordinates are a legacy episode-compatibility fallback; ambiguous or - conflicting matches resolve to an explicit renderer conflict state rather - than masquerading as a missing row. Both the episode action and season count - fail closed for that state. SQLite `null` and optional `undefined` coordinates - both mean that a canonical legacy row is incomplete, matching the backend - resolver, while restarting an eligible legacy row migrates it to the - canonical id. 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. `DOWNLOADS_START` remains the only start IPC - and may return the stable `reason: 'already-in-progress'` for an active match, - which the coordinator counts as skipped. There is no batch IPC, schema - migration, parallel transfer, or queue reordering: destination authorization, - persisted header handling, and the backend's one-active-transfer FIFO - semantics remain unchanged. + 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` 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 + coordinator counts both 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 0ddde12fc..365e07cc1 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -528,6 +528,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/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 index 2c86a94d1..90dce914a 100644 --- 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 @@ -172,6 +172,20 @@ describe('SeasonDownloadCoordinator', () => { 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).not.toHaveBeenCalled(); + }); + it('continues after preparation rejects and refreshes once after successes', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { /* captured */ 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 index 3555ed8de..132be844e 100644 --- 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 @@ -129,7 +129,9 @@ export class SeasonDownloadCoordinator { } if ( result.reason === - ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyInProgress + ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyInProgress || + result.reason === + ELECTRON_BRIDGE_DOWNLOAD_START_REASONS.AlreadyDownloaded ) { return EPISODE_DOWNLOAD_SUBMISSIONS.Skipped; } 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 index 91ab59911..54c6671a4 100644 --- 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 @@ -33,6 +33,48 @@ function row( } 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(); @@ -173,4 +215,18 @@ describe('episode download identity', () => { }) ); }); + + 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 index e16a83afb..217de539c 100644 --- a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts @@ -1,5 +1,6 @@ import type { ElectronBridgeDownloadStatus, + ElectronBridgeEpisodeIdentityScope, ElectronBridgePlaybackContentType, ElectronDownloadFileAvailability, } from '@iptvnator/shared/interfaces'; @@ -11,6 +12,7 @@ export interface EpisodeDownloadIdentity { readonly seriesXtreamId: number; readonly seasonNumber: number; readonly episodeNumber: number; + readonly episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope; } export interface EpisodeDownloadRecord { @@ -21,6 +23,7 @@ export interface EpisodeDownloadRecord { 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; @@ -36,6 +39,13 @@ function hasConflictingCompleteCoordinates( 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 || @@ -67,7 +77,7 @@ export function resolveEpisodeDownload( download.contentType === identity.contentType && download.xtreamId === identity.xtreamId ); - const coordinateMatches = downloads.filter( + const coordinateOwners = downloads.filter( (download) => download.playlistId === identity.playlistId && download.contentType === identity.contentType && @@ -78,6 +88,21 @@ export function resolveEpisodeDownload( 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' }; } @@ -124,5 +149,6 @@ export function createEpisodeDownloadIdentityKey( seriesXtreamId: identity.seriesXtreamId, seasonNumber: identity.seasonNumber, episodeNumber: identity.episodeNumber, + episodeIdentityScope: identity.episodeIdentityScope, }); } 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 index 438f6143c..aa4462069 100644 --- 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 @@ -69,11 +69,34 @@ function options( 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() @@ -147,6 +170,7 @@ describe('createStalkerSeriesDownloadAdapter', () => { seriesXtreamId: 50001, seasonNumber: 2, episodeNumber: 3, + episodeIdentityScope: 'stalker-regular-series', }); if (!candidate) { throw new Error('expected a valid Stalker episode candidate'); @@ -161,6 +185,7 @@ describe('createStalkerSeriesDownloadAdapter', () => { 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, 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 index 2a816b3cd..f27018130 100644 --- 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 @@ -8,6 +8,7 @@ import type { 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< @@ -26,12 +27,31 @@ export interface StalkerSeriesDownloadAdapterOptions { 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; } @@ -67,6 +87,8 @@ export function createStalkerSeriesDownloadAdapter( episode.season || Number(fallbackSeasonKey) || 1 ); const episodeNumber = positiveSafeInteger(episode.episode_num || 1); + const episodeIdentityScope = + IDENTITY_SCOPE_BY_MODE[options.seriesMode]; if ( !playlist || @@ -90,6 +112,7 @@ export function createStalkerSeriesDownloadAdapter( seriesXtreamId, seasonNumber, episodeNumber, + episodeIdentityScope, }; return { 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 db85b9e16..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 @@ -371,10 +371,7 @@ describe('StalkerSeriesViewComponent', () => { '2' ); - if (!candidate) { - throw new Error('expected a bound Stalker download adapter'); - } - const request = await candidate.prepare(); + const request = await candidate?.prepare(); expect(fetchLinkToPlay).toHaveBeenCalledWith( 'https://stalker.example.test', @@ -384,6 +381,7 @@ describe('StalkerSeriesViewComponent', () => { ); expect(request).toEqual( expect.objectContaining({ + episodeIdentityScope: 'stalker-lazy-vod', playlistId: 'stalker-1', seriesXtreamId: 50001, xtreamId: 61001, @@ -430,6 +428,7 @@ describe('StalkerSeriesViewComponent', () => { ); expect(updatedRequest).toEqual( expect.objectContaining({ + episodeIdentityScope: 'stalker-regular-series', playlistId: 'stalker-2', seriesXtreamId: 40002, xtreamId: 62001, 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 46f23a4df..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 @@ -81,7 +81,10 @@ import { saveStalkerSeriesPosition, StalkerSeriesPositionPartialSaveError, } from './stalker-series-position-compatibility'; -import { createStalkerSeriesDownloadAdapter } from './stalker-series-download.adapter'; +import { + createStalkerSeriesDownloadAdapter, + STALKER_SERIES_DOWNLOAD_MODES, +} from './stalker-series-download.adapter'; interface SeriesPositionContext { readonly generation: number; @@ -542,6 +545,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { 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 ?? '', diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts index b12c36fe7..2cd5dc421 100644 --- a/libs/services/src/lib/downloads.models.ts +++ b/libs/services/src/lib/downloads.models.ts @@ -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/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 fab86341f..28cfa0707 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -139,12 +139,22 @@ 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'; @@ -541,6 +551,7 @@ export interface ElectronBridgeDownloadStartPayload { seriesXtreamId?: number; seasonNumber?: number; episodeNumber?: number; + episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope; playlistName?: string; playlistType?: ElectronBridgePlaylistType; serverUrl?: string; @@ -565,6 +576,7 @@ export interface ElectronDownloadItem { seriesXtreamId?: number; seasonNumber?: number; episodeNumber?: number; + episodeIdentityScope?: ElectronBridgeEpisodeIdentityScope | null; title: string; url: string; fileName?: string;