fix(downloads): preserve specials season identity

This commit is contained in:
4gray committed 2026-08-02 23:48:27 +02:00
1 parent ef1d20ed63
commit 4fb04b340c
6 files changed
+78 -21

No files matched your search

+3 -1
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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)(
@@ -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 =
@@ -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) => {
@@ -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);