fix(downloads): preserve scoped episode ownership

This commit is contained in:
4gray committed 2026-08-02 20:19:27 +02:00
1 parent a667bfbe05
commit 33ca53e86d
20 files changed
+395 -57

No files matched your search

+16 -9
View File
@@ -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,
@@ -22,6 +22,7 @@ function createDownloadRow(overrides: Partial<DownloadRow> = {}): 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)
@@ -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<DownloadIdentityRequest, 'episodeIdentityScope'>
): boolean {
const { episodeNumber, seasonNumber, seriesXtreamId } = row;
if (
request.episodeIdentityScope !== undefined &&
row.episodeIdentityScope !== null &&
row.episodeIdentityScope !== request.episodeIdentityScope
) {
return true;
}
if (
seriesXtreamId === null ||
seriesXtreamId === undefined ||
@@ -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,
})
@@ -11,7 +11,8 @@ const metadataSnapshot: DownloadMetadataSnapshot = {
async function setupStartMetadataRequest(
existing: Record<string, unknown> | undefined,
coordinateRows: Record<string, unknown>[] = []
coordinateRows: Record<string, unknown>[] = [],
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> = {}
): Download {
function createStartDownloadRow(overrides: Partial<Download> = {}): Download {
return {
bytesDownloaded: 0,
contentType: 'episode',
createdAt: '2026-08-02 10:00:00',
episodeNumber: 3,
episodeIdentityScope: null,
errorMessage: null,
fileName: 'episode.mp4',
filePath: null,
@@ -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) => {
@@ -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,
+20 -16
View File
@@ -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,
+6
View File
@@ -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.
@@ -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 */
@@ -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;
}
@@ -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<EpisodeDownloadRecord>);
expect(resolveEpisodeDownload(scopedIdentity, [otherMode])).toEqual({
kind: 'missing',
});
});
it('fails closed for unscoped coordinate ownership when the identity is scoped', () => {
const scopedIdentity = {
...identity,
episodeIdentityScope: 'stalker-regular-series',
} as EpisodeDownloadIdentity;
const legacyRow = row({ xtreamId: 999 });
expect(resolveEpisodeDownload(scopedIdentity, [legacyRow])).toEqual({
kind: 'conflict',
});
});
it('fails closed for an incomplete canonical row owned by another explicit scope', () => {
const scopedIdentity = {
...identity,
episodeIdentityScope: 'stalker-regular-series',
} as EpisodeDownloadIdentity;
const otherMode = row({
episodeIdentityScope: 'stalker-lazy-vod',
seriesXtreamId: null,
} as Partial<EpisodeDownloadRecord>);
expect(resolveEpisodeDownload(scopedIdentity, [otherMode])).toEqual({
kind: 'conflict',
});
});
it('returns a canonical row when the same row also owns the coordinates', () => {
const canonical = row();
@@ -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',
})
);
});
});
@@ -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<T extends EpisodeDownloadRecord>(
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<T extends EpisodeDownloadRecord>(
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,
});
}
@@ -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,
@@ -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<string>;
}
export const STALKER_SERIES_DOWNLOAD_MODES = {
EmbeddedVod: 'embedded-vod',
LazyVod: 'lazy-vod',
RegularSeries: 'regular-series',
} as const;
export type StalkerSeriesDownloadMode =
(typeof STALKER_SERIES_DOWNLOAD_MODES)[keyof typeof STALKER_SERIES_DOWNLOAD_MODES];
const IDENTITY_SCOPE_BY_MODE = {
[STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerEmbeddedVod,
[STALKER_SERIES_DOWNLOAD_MODES.LazyVod]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerLazyVod,
[STALKER_SERIES_DOWNLOAD_MODES.RegularSeries]:
ELECTRON_BRIDGE_EPISODE_IDENTITY_SCOPES.StalkerRegularSeries,
} as const;
function isPresent(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -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 {
@@ -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,
@@ -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 ?? '',
@@ -20,6 +20,8 @@ export interface DownloadItem {
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeIdentityScope?:
ElectronBridgeDownloadStartPayload['episodeIdentityScope'] | null;
title: string;
url: string;
fileName?: string;
@@ -25,6 +25,7 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
}
function rebuildDownloadsInElectron(metadataSnapshot: string): {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -51,6 +52,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -84,20 +86,22 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
content_type,
title,
url,
metadata_snapshot
) VALUES (?, ?, ?, ?, ?, ?)
metadata_snapshot,
episode_identity_scope
) VALUES (?, ?, ?, ?, ?, ?, ?)
\`).run(
'playlist-1',
42,
'vod',
'Offline title',
'https://example.com/movie',
metadataSnapshot
metadataSnapshot,
'stalker-lazy-vod'
);
console.log = () => undefined;
__databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite);
const row = sqlite.prepare(
'SELECT metadata_snapshot FROM downloads WHERE id = 1'
'SELECT metadata_snapshot, episode_identity_scope FROM downloads WHERE id = 1'
).get();
const table = sqlite.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
@@ -109,6 +113,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
).get());
sqlite.close();
process.stdout.write(JSON.stringify({
episodeIdentityScope: row.episode_identity_scope,
metadataSnapshot: row.metadata_snapshot,
retainedAfterPlaylistDelete,
schemaSql: table.sql,
@@ -129,6 +134,7 @@ function rebuildDownloadsInElectron(metadataSnapshot: string): {
);
return JSON.parse(output) as {
episodeIdentityScope: string;
metadataSnapshot: string;
retainedAfterPlaylistDelete: boolean;
schemaSql: string;
@@ -240,6 +246,8 @@ describe('database schema statements', () => {
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain('metadata_snapshot TEXT');
expect(downloadColumns).toContain('metadata_snapshot');
expect(schemaSql).toContain('episode_identity_scope TEXT');
expect(downloadColumns).toContain('episode_identity_scope');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
@@ -288,6 +296,9 @@ describe('database schema statements', () => {
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT'
);
expect(columnMigrationStatements).toContain(
'ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT'
);
});
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
@@ -658,6 +669,7 @@ describe('database schema statements', () => {
expect(rebuilt.schemaSql).toContain(`'paused'`);
expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists');
expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot);
expect(rebuilt.episodeIdentityScope).toBe('stalker-lazy-vod');
expect(rebuilt.retainedAfterPlaylistDelete).toBe(true);
});
@@ -98,6 +98,7 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
episode_identity_scope TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
@@ -381,6 +382,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
// Offline details: provider-neutral display metadata captured at download time
`ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`,
// Series queue: scope coordinate compatibility across provider series modes
`ALTER TABLE downloads ADD COLUMN episode_identity_scope TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -1010,6 +1013,12 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
const legacyResumeValidatorSelect = hasResumeValidator
? 'resume_validator'
: 'NULL AS resume_validator';
const hasEpisodeIdentityScope = row.sql.includes(
'episode_identity_scope'
);
const legacyEpisodeIdentityScopeSelect = hasEpisodeIdentityScope
? 'episode_identity_scope'
: 'NULL AS episode_identity_scope';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
@@ -1036,6 +1045,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
episode_identity_scope,
title,
url,
file_name,
@@ -1059,6 +1069,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
series_xtream_id,
season_number,
episode_number,
${legacyEpisodeIdentityScopeSelect},
title,
url,
file_name,
+1
View File
@@ -323,6 +323,7 @@ export const downloads = sqliteTable(
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
episodeNumber: integer('episode_number'),
episodeIdentityScope: text('episode_identity_scope'),
// Download metadata
title: text('title').notNull(),
url: text('url').notNull(),
@@ -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;