From 4fb04b340c8c09cd3e3eff2fb2af0c681915b489 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 23:48:27 +0200 Subject: [PATCH] fix(downloads): preserve specials season identity --- CLAUDE.md | 4 +++- docs/architecture/download-manager.md | 6 ++--- .../stalker-series-download.adapter.spec.ts | 24 ++++++++++++++----- .../stalker-series-download.adapter.ts | 22 +++++++++++++++-- .../xtream-series-download.adapter.spec.ts | 21 ++++++++++------ .../xtream-series-download.adapter.ts | 22 +++++++++++++++-- 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc312d877..80391508a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -978,7 +978,9 @@ engine` (restart required) or 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. Stalker persists + fallback that migrates reusable rows to the canonical id. Numeric season + zero, including fallback key `"0"`, remains a valid Specials coordinate for + both providers. Stalker persists `episode_identity_scope` separately for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not match; a pre-scope coordinate row is ambiguous and blocked, while an exact diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 96217a21e..74e38c1cf 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -85,10 +85,10 @@ variants, contextual buttons, and theme-aware styling. the preflight joins the single trailing refresh; later download-update broadcasts cannot delay that assigned refresh. Restored files therefore become stable skips without requiring a Stalker URL/network request. Both - providers - use normalized `episode.id` as the canonical + providers use normalized `episode.id` as the canonical episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only - in URL resolution. + in URL resolution. Provider adapters preserve numeric season zero, including + a fallback season key of `"0"`, so Specials keep distinct `S00` coordinates. The exact `(playlistId, contentType, xtreamId)` identity is authoritative. Complete `(playlistId, seriesXtreamId, seasonNumber, episodeNumber)` coordinates are a legacy episode-compatibility fallback. Stalker also stores 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 aa4462069..8efbd13cc 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 @@ -268,23 +268,35 @@ describe('createStalkerSeriesDownloadAdapter', () => { } ); - it('preserves the season-key and episode-number fallback rules', () => { + it('preserves specials season zero and the existing fallback rules', async () => { const adapter = createStalkerSeriesDownloadAdapter(options()); + const specials = adapter.createCandidate( + episode({ season: 0, episode_num: 0 }), + '4' + ); + expect(specials?.identity).toEqual( + expect.objectContaining({ seasonNumber: 0, episodeNumber: 1 }) + ); expect( - adapter.createCandidate(episode({ season: 0, episode_num: 0 }), '4') + adapter.createCandidate(episode({ season: Number.NaN }), '0') ?.identity ).toEqual( - expect.objectContaining({ seasonNumber: 4, episodeNumber: 1 }) + expect.objectContaining({ seasonNumber: 0, episodeNumber: 3 }) ); expect( adapter.createCandidate( - episode({ season: 0, episode_num: Number.NaN }), + episode({ season: Number.NaN, episode_num: Number.NaN }), undefined )?.identity ).toEqual( expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 }) ); + await expect(specials?.prepare()).resolves.toEqual( + expect.objectContaining({ + title: 'Signal House - S00E01 - The Call', + }) + ); }); it('uses the Stalker Portal title fallback', async () => { @@ -356,11 +368,11 @@ describe('createStalkerSeriesDownloadAdapter', () => { ['non-positive season', {}, { season: -1 }, '2'], ['unsafe episode number', {}, { episode_num: 1.5 }, '2'], ['non-positive episode number', {}, { episode_num: -1 }, '2'], - ['non-positive fallback season', {}, { season: 0 }, '-2'], + ['non-positive fallback season', {}, { season: Number.NaN }, '-2'], [ 'unsafe fallback season', {}, - { season: 0 }, + { season: Number.NaN }, String(Number.MAX_SAFE_INTEGER + 1), ], ] as const)( 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 f27018130..f0fbce720 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 @@ -61,6 +61,23 @@ function positiveSafeInteger(value: unknown): number | null { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } +function downloadSeasonNumber( + value: unknown, + fallbackSeasonKey: string | undefined +): number | null { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) { + return parsed >= 0 ? parsed : null; + } + if (value != null && !(typeof value === 'number' && Number.isNaN(value))) { + return null; + } + + const normalizedFallback = fallbackSeasonKey?.trim(); + const fallback = Number(normalizedFallback || 1); + return Number.isSafeInteger(fallback) && fallback >= 0 ? fallback : null; +} + function episodeInfo( episode: XtreamSerieEpisode ): XtreamSerieEpisodeInfo | undefined { @@ -83,8 +100,9 @@ export function createStalkerSeriesDownloadAdapter( const item = options.item; const xtreamId = positiveSafeInteger(episode.id); const seriesXtreamId = positiveSafeInteger(options.seriesId); - const seasonNumber = positiveSafeInteger( - episode.season || Number(fallbackSeasonKey) || 1 + const seasonNumber = downloadSeasonNumber( + episode.season, + fallbackSeasonKey ); const episodeNumber = positiveSafeInteger(episode.episode_num || 1); const episodeIdentityScope = diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts index 76733d306..9db0e96e2 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.spec.ts @@ -117,26 +117,33 @@ describe('createXtreamSeriesDownloadAdapter', () => { ); }); - it('preserves the existing season and episode fallback rules', async () => { + it('preserves specials season zero and the existing fallback rules', async () => { const adapter = createXtreamSeriesDownloadAdapter(OPTIONS); - const fromSeasonKey = adapter.createCandidate( + const specials = adapter.createCandidate( episode({ season: 0, episode_num: 0 }), '4' ); + const fromSeasonKey = adapter.createCandidate( + episode({ season: Number.NaN }), + '0' + ); const defaults = adapter.createCandidate( - episode({ season: 0, episode_num: Number.NaN }), + episode({ season: Number.NaN, episode_num: Number.NaN }), undefined ); + expect(specials?.identity).toEqual( + expect.objectContaining({ seasonNumber: 0, episodeNumber: 1 }) + ); expect(fromSeasonKey?.identity).toEqual( - expect.objectContaining({ seasonNumber: 4, episodeNumber: 1 }) + expect.objectContaining({ seasonNumber: 0, episodeNumber: 3 }) ); expect(defaults?.identity).toEqual( expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 }) ); - await expect(fromSeasonKey?.prepare()).resolves.toEqual( + await expect(specials?.prepare()).resolves.toEqual( expect.objectContaining({ - title: 'Signal House - S04E01 - The One', + title: 'Signal House - S00E01 - The One', }) ); }); @@ -180,7 +187,7 @@ describe('createXtreamSeriesDownloadAdapter', () => { ['non-positive series id', { seriesId: 0 }, {}, undefined], ['unsafe season', {}, { season: 1.5 }, undefined], ['unsafe episode number', {}, { episode_num: -1 }, undefined], - ['unsafe fallback season', {}, { season: 0 }, '-2'], + ['unsafe fallback season', {}, { season: Number.NaN }, '-2'], ] as const)( 'returns null for %s', (_label, optionOverrides, episodeOverrides, fallbackSeasonKey) => { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts index fa711d402..5530bd34e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/xtream-series-download.adapter.ts @@ -31,6 +31,23 @@ function positiveSafeInteger(value: unknown): number | null { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; } +function downloadSeasonNumber( + value: unknown, + fallbackSeasonKey: string | undefined +): number | null { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) { + return parsed >= 0 ? parsed : null; + } + if (value != null && !(typeof value === 'number' && Number.isNaN(value))) { + return null; + } + + const normalizedFallback = fallbackSeasonKey?.trim(); + const fallback = Number(normalizedFallback || 1); + return Number.isSafeInteger(fallback) && fallback >= 0 ? fallback : null; +} + function episodeInfo( episode: XtreamSerieEpisode ): XtreamSerieEpisodeInfo | undefined { @@ -48,8 +65,9 @@ export function createXtreamSeriesDownloadAdapter( const password = options.password; const xtreamId = positiveSafeInteger(episode.id); const seriesXtreamId = positiveSafeInteger(options.seriesId); - const seasonNumber = positiveSafeInteger( - episode.season || Number(fallbackSeasonKey) || 1 + const seasonNumber = downloadSeasonNumber( + episode.season, + fallbackSeasonKey ); const episodeNumber = positiveSafeInteger(episode.episode_num || 1);