mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(downloads): preserve scoped episode ownership
This commit is contained in:
1 parent
a667bfbe05
commit
33ca53e86d
20 files changed
+395
-57
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
@@ -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 */
|
||||
|
||||
+3
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
+25
@@ -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,
|
||||
|
||||
+23
@@ -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 {
|
||||
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user