Merge remote-tracking branch 'origin/master' into agent/light-detail-surfaces

# Conflicts:
#	apps/web-e2e/src/stalker.e2e.ts
This commit is contained in:
4gray committed 2026-09-05 22:27:41 +02:00
commit ce60b3d890
16 files changed
+546 -74

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: stalker
---
Stalker series with season markers such as “s02” or “2 сезон” now show the correct season in tabs and episode labels while preserving watch progress. TMDB updates no longer reset loaded episodes, and delayed portal responses cannot mix episodes from different series.
+1 -1
View File
@@ -1424,7 +1424,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
@@ -0,0 +1,162 @@
import type { Page } from '@playwright/test';
import { expect } from './fixtures';
/** Complete lazy VOD season workflow, using fictional provider/TMDB replies. */
export async function verifyStalkerSeasonMarkers(
page: Page,
addPortal: () => Promise<void>
): Promise<void> {
const titles = ['Signal House s02', 'Сигнальный дом (3 сезон)'];
const seasonRequests: number[] = [];
const searchLanguages: string[] = [];
await page.route('https://api.themoviedb.org/**', async (route) => {
const url = new URL(route.request().url());
const seasonMatch = url.pathname.match(/\/tv\/777\/season\/(\d+)$/);
if (seasonMatch) {
const season = Number(seasonMatch[1]);
seasonRequests.push(season);
return route.fulfill({
json: {
season_number: season,
overview: `Season ${season} overview`,
episodes: [
{
episode_number: 1,
season_number: season,
name: `Season ${season} premiere`,
overview: `Season ${season} episode plot`,
still_path: `/season-${season}.jpg`,
},
],
},
});
}
const details = {
id: 777,
name: 'Signal House',
original_name: 'Signal House',
original_language: 'en',
first_air_date: '2024-01-01',
overview: 'A fictional series.',
vote_count: 500,
vote_average: 7,
genres: [],
credits: { cast: [], crew: [] },
};
if (url.pathname.endsWith('/search/tv')) {
searchLanguages.push(url.searchParams.get('language') ?? '');
return route.fulfill({
json: {
results: [
{
...details,
name: /[а-я]/i.test(
url.searchParams.get('query') ?? ''
)
? 'Сигнальный дом'
: details.name,
},
],
},
});
}
return route.fulfill({ json: details });
});
await page.route('https://image.tmdb.org/**', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"/>',
})
);
await page.route('**/localhost:3000/stalker**', async (route) => {
const params = new URL(route.request().url()).searchParams;
if (
params.get('action') !== 'get_ordered_list' ||
params.get('type') !== 'vod'
)
return route.fallback();
const movieId = params.get('movie_id');
const rows = !movieId
? titles.map((name, index) => ({
id: String(99001 + index),
name,
o_name: name,
is_series: '1',
category_id: '2001',
}))
: params.has('season_id')
? [
{
id: `episode-${movieId}`,
is_episode: true,
series_number: 1,
name: 'Episode 1',
},
]
: [
{
id: 'provider-season-1',
video_id: movieId,
name: 'Season 1',
season_number: '1',
is_season: true,
},
];
return route.fulfill({
json: {
payload: {
js: {
data: rows,
total_items: rows.length,
max_page_items: 20,
},
},
},
});
});
await page.goto('/workspace/settings/tmdb');
await page.locator('[data-test-id="tmdb-enabled"] input').check();
await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key');
const save = page.getByRole('button', { name: 'Save changes' });
await save.click();
await expect(save).toBeHidden();
await page.goto('/');
await addPortal();
for (const [index, title] of titles.entries()) {
const season = index + 2;
await page.getByText(title, { exact: true }).first().click();
await expect(
page.getByRole('tab', { name: `Season ${season}`, exact: true })
).toBeVisible();
await expect(page.getByTestId('series-quick-start')).toContainText(
`S0${season}E01`
);
await expect(
page.getByRole('heading', {
name: `1. Season ${season} premiere`,
exact: true,
})
).toBeVisible();
await expect(page.getByTestId('season-description')).toContainText(
`Season ${season} overview`
);
await expect(page.locator('.episode-card')).toContainText(
`Season ${season} episode plot`
);
await expect(page.locator('.episode-card img')).toHaveAttribute(
'src',
new RegExp(`/season-${season}\\.jpg$`)
);
await page.locator('[data-test-id="toggle-season-watched"]').click();
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
await page.getByRole('button', { name: 'Back', exact: true }).click();
}
await page.getByText(titles[0], { exact: true }).first().click();
await expect(
page.getByRole('tab', { name: 'Season 2', exact: true })
).toBeVisible();
await expect(page.locator('.episode-card--watched')).toHaveCount(1);
expect(seasonRequests).toEqual(expect.arrayContaining([2, 3]));
expect(seasonRequests).not.toContain(1);
expect(searchLanguages).toEqual(expect.arrayContaining(['en-US', 'ru-RU']));
}
+11 -8
View File
@@ -1,8 +1,6 @@
import {
type APIRequestContext,
type Page,
} from '@playwright/test';
import { type APIRequestContext, type Page } from '@playwright/test';
import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers';
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
@@ -1030,10 +1028,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [
...row.series,
String(row.series.length + 1),
];
row.series = [...row.series, String(row.series.length + 1)];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
@@ -1066,6 +1061,14 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
).toBeVisible({ timeout: 10_000 });
});
test('@stalker title season markers align lazy VOD labels, TMDB episodes and watched state', async ({
page,
}) => {
await verifyStalkerSeasonMarkers(page, () =>
addStalkerPortal(page, { name: 'Season Marker Portal' })
);
});
test('@stalker season watched toggle — embedded series marks and clears every episode', async ({
page,
request,
+11
View File
@@ -1415,6 +1415,17 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string
- Treated as series flow from VOD context.
- Seasons are fetched lazily.
- Episodes are fetched on season select.
- The season resource depends on the VOD item id and series mode, so a TMDB
metadata patch does not reload seasons or discard loaded episodes. Pending
episode requests belong to the exact loading season VM; replies from an old
selection cannot fill a replacement list with reused provider season ids.
- A single-season item's explicit title marker (`s02`, `season2`, `(2 сезон)`,
etc., in `name` or `o_name`) supplies the displayed season number, quick-start
code and episode/playback/download metadata, independently of UI language and
TMDB availability. Regular and embedded VOD series apply the same rule.
Multi-season items keep provider numbering. Lazy VOD retains the original
season key/number separately for stable tracking IDs and compatible legacy
progress; provider request ids remain unchanged.
- The series quick-start CTA can load the first unloaded VOD-series season
before playback. Unloaded seasons are considered unplayed in full season
order, so an earlier unloaded season is not skipped just because a later
+19 -5
View File
@@ -261,14 +261,28 @@ keep provider numbering. `normalizeTitleKeys` strips the same markers
(including number-first forms like "2 сезон") from search titles, so the
show-level match is unaffected by them.
Stalker applies the same correction before rendering: single-season regular,
embedded VOD and lazy `is_series` items use the resolved season for tabs,
quick-start labels, episode metadata and TMDB lookups. Lazy VOD corrects the
season VM before episodes load, retaining `providerSeasonKey` and
`providerSeasonNumber` for stable tracking IDs and legacy-position matching.
Portal requests still use the original `video_id` and season `id`. The marker
parser is independent of the UI language and accepts Russian and English
markers in either title field (`name` or `o_name`).
Lazy VOD season resources depend on provider item identity and mode rather
than the full selected metadata object, so a TMDB patch cannot reload the
season resource and discard loaded episodes. Episode replies apply only to
the exact loading season VM; a response from a previous selection or refresh
cannot populate a replacement season that happens to reuse its provider id.
Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired
from the serial detail's `(seasonSelected)`; Stalker — the series view
keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its
`mappedSeasons` computed. Each Stalker entry records the RESOLVED season
it was fetched for: per-season slices of one show share
(tmdbId, provider key "1") but resolve to different seasons, and a fetch
made with stale detail-to-detail navigation context is overwritten once
the real context re-resolves. The fetch effect gates on coherence rather
`mappedSeasons` computed using the corrected display keys. Each Stalker entry
also records the resolved season it was fetched for, so a fetch made with
stale detail-to-detail navigation context can be replaced once the real
context resolves. The fetch effect gates on coherence rather
than timing: it waits while the season resource reloads and requires the
selected key to exist in the map with episodes. The retained season
selection deliberately survives navigation — the season container
@@ -16,6 +16,100 @@ type EpisodeWithMetadata = {
};
describe('stalker-series.adapters', () => {
it.each([
'The Gentlemen s02',
'The Gentlemen season2',
'The Gentlemen (s02)',
'The Gentlemen (season 2)',
'Олдскул (2 сезон)',
'Олдскул (сезон 2)',
'Олдскул s02',
'Олдскул season2',
])(
'uses the title season for lazy VOD %s without changing tracking IDs',
(title) => {
const provider = [
{
id: 's1',
video_id: 'v1',
season_number: '1',
name: 'Season 1',
},
];
const load = (
seasons: ReturnType<typeof mapVodSeriesSeasonsToVm>
) =>
seasons.map((season) => ({
...season,
episodes: [
{
id: 'provider-episode-1',
series_number: 1,
name: 'Episode 1',
},
],
}));
const original = mapVodSeriesEpisodes(
load(mapVodSeriesSeasonsToVm(provider)),
{ parentSeriesId: 100 }
);
const correctedSeasons = mapVodSeriesSeasonsToVm(provider, title);
expect(correctedSeasons[0].season_number).toBe('2');
expect(correctedSeasons[0].episodes).toEqual([]);
expect(correctedSeasons[0].id).toBe('s1');
expect(correctedSeasons[0].video_id).toBe('v1');
const corrected = mapVodSeriesEpisodes(load(correctedSeasons), {
parentSeriesId: 100,
});
expect(Object.keys(corrected)).toEqual(['2']);
expect(corrected['2'][0]).toEqual(
expect.objectContaining({
id: original['1'][0].id,
season: 2,
providerSeasonNumber: 1,
legacyTrackingId: (original['1'][0] as EpisodeWithMetadata)
.legacyTrackingId,
originalId: 'provider-episode-1',
})
);
}
);
it('keeps multi-season VOD numbering despite a parent title marker', () => {
const provider = [1, 2].map((number) => ({
id: `s${number}`,
video_id: 'v1',
season_number: String(number),
}));
expect(
mapVodSeriesSeasonsToVm(provider, 'Show s02').map(
(season) => season.season_number
)
).toEqual(['1', '2']);
});
it('corrects embedded and single regular season coordinates without changing episode IDs', () => {
const seasons = [
{
id: 's1',
name: 'Episodes',
cmd: '/media/file_100.mpg',
series: [1, 2],
},
];
const original = mapRegularSeriesEpisodes(seasons);
const corrected = mapRegularSeriesEpisodes(
seasons,
undefined,
'Show (season 2)'
);
expect(Object.keys(corrected)).toEqual(['2']);
expect(corrected['2'][0].season).toBe(2);
expect(corrected['2'].map((episode) => episode.id)).toEqual(
original['1'].map((episode) => episode.id)
);
});
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
expect(isVodSeriesItem({ is_series: 1 })).toBe(true);
@@ -1,4 +1,7 @@
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import {
XtreamSerieEpisode,
resolveEnrichmentSeasonNumber,
} from '@iptvnator/shared/interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
@@ -17,6 +20,9 @@ export interface VodSeriesSeasonVm {
video_id: string;
name: string;
season_number: string;
/** Original provider coordinates, retained when the title corrects a slice. */
providerSeasonKey?: string;
providerSeasonNumber?: number;
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
@@ -43,6 +49,8 @@ export interface MapVodSeriesEpisodesOptions {
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
legacyTrackingId?: number;
/** Allows pre-correction legacy progress to match the same episode. */
providerSeasonNumber?: number;
originalId?: string;
originalCmd?: string;
}
@@ -119,9 +127,10 @@ export function isVodSeriesItem(
}
export function mapVodSeriesSeasonsToVm(
seasons: StalkerVodSeriesSeason[] | undefined
seasons: StalkerVodSeriesSeason[] | undefined,
rawTitle?: string | null
): VodSeriesSeasonVm[] {
return (seasons ?? []).map((season) => ({
const mapped = (seasons ?? []).map((season) => ({
id: String(season.id),
video_id: String(season.video_id),
name: toNonEmptyString(
@@ -133,6 +142,23 @@ export function mapVodSeriesSeasonsToVm(
isLoading: false,
isExpanded: false,
}));
if (mapped.length !== 1) return mapped;
return mapped.map((season) => {
const providerSeasonNumber = getVodSeriesSeasonNumber(season, mapped);
const seasonNumber = resolveEnrichmentSeasonNumber({
rawTitle,
providerSeasonNumber,
providerSeasonCount: mapped.length,
});
return seasonNumber === providerSeasonNumber
? season
: {
...season,
season_number: String(seasonNumber),
providerSeasonKey: getVodSeriesSeasonKey(season),
providerSeasonNumber,
};
});
}
export function mapRegularSeriesSeasons(
@@ -188,6 +214,7 @@ export function mapVodSeriesEpisodes(
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const trackingSeasonKey = season.providerSeasonKey ?? seasonKey;
const seasonNum = getVodSeriesSeasonNumber(season, seasons);
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
@@ -197,12 +224,12 @@ export function mapVodSeriesEpisodes(
const providerEpisodeId = String(episode.id ?? '');
const legacyTrackingId = generateLegacyVodEpisodeId(
episodeNum,
seasonKey
trackingSeasonKey
);
const trackingId = generateVodEpisodeId({
parentSeriesId: options.parentSeriesId,
providerEpisodeId,
seasonKey,
seasonKey: trackingSeasonKey,
episodeNum,
});
@@ -223,6 +250,9 @@ export function mapVodSeriesEpisodes(
}
),
legacyTrackingId,
...(season.providerSeasonNumber !== undefined
? { providerSeasonNumber: season.providerSeasonNumber }
: {}),
originalId: providerEpisodeId,
} as StalkerMappedEpisode;
});
@@ -233,12 +263,19 @@ export function mapVodSeriesEpisodes(
export function mapRegularSeriesEpisodes(
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
fallbackPoster?: string
fallbackPoster?: string,
rawTitle?: string | null
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
const seasonKey = String(
resolveEnrichmentSeasonNumber({
rawTitle,
providerSeasonNumber: index + 1,
providerSeasonCount: seasons.length,
})
);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateRegularEpisodeId(
String(season.cmd ?? ''),
@@ -114,6 +114,43 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
void store.isVodSeriesSeasonsLoading();
});
it('keeps VOD seasons loaded when TMDB patches the selected item metadata', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: {
data: [
{
id: 's1',
video_id: '11',
is_season: true,
season_number: '1',
},
],
},
});
store.setSelectedContentType('vod');
store.setSelectedItem({ id: '11', name: 'Show s02', is_series: '1' });
await waitForCondition(
() => store.getVodSeriesSeasonsResource()?.length === 1
);
const originalSeasons = store.getVodSeriesSeasonsResource();
const item = store.selectedItem();
if (!item) throw new Error('Expected the selected VOD series');
patchState(store, {
selectedItem: { ...item, info: { ...item.info, tmdb_id: 777 } },
});
await flushResources();
await flushResources();
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
expect(store.getVodSeriesSeasonsResource()).toBe(originalSeasons);
store.setSelectedItem({ id: '12', name: 'Show s03', is_series: '1' });
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length === 2
);
expect(dataService.sendIpcEvent.mock.calls[1][1]).toMatchObject({
params: { movie_id: '12' },
});
});
it('fetches seasons for a regular series selection', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [{ id: '42:1', name: 'Season 1', series: [1, 2] }],
@@ -100,6 +100,16 @@ export function withStalkerSeries() {
) => {
const storeContext = store as typeof store &
StalkerSeriesStoreContext;
// Enrichment patches selectedItem in place. Only provider
// identity/mode changes should reload seasons and reset episodes.
const vodSeriesMovieId = computed(() => {
const item = storeContext.selectedItem();
return storeContext.selectedContentType() === 'vod' &&
isStalkerSeriesFlag(item?.is_series) &&
item?.id != null
? String(item.id)
: null;
});
const requestDeps = {
dataService,
stalkerSession,
@@ -147,32 +157,14 @@ export function withStalkerSeries() {
vodSeriesSeasonsResource: resource({
params: () => ({
currentPlaylist: storeContext.currentPlaylist(),
selectedItem: storeContext.selectedItem(),
selectedContentType:
storeContext.selectedContentType(),
movieId: vodSeriesMovieId(),
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const { currentPlaylist, selectedItem } = params;
const { currentPlaylist, movieId } = params;
logger.debug(
'vodSeriesSeasonsResource loader called',
{
item: selectedItem,
isSeries: selectedItem?.is_series,
currentPlaylist,
}
);
if (
!currentPlaylist ||
params.selectedContentType !== 'vod' ||
!selectedItem ||
selectedItem.id === undefined ||
selectedItem.id === null ||
!isStalkerSeriesFlag(selectedItem.is_series)
) {
if (!currentPlaylist || movieId === null) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
@@ -184,7 +176,7 @@ export function withStalkerSeries() {
>(requestDeps, currentPlaylist, {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: selectedItem.id,
movie_id: movieId,
p: '1',
});
@@ -75,6 +75,36 @@ function reconcile(
}
describe('stalker series position compatibility', () => {
it('retains pre-correction legacy progress only for the original provider season', () => {
const episode = {
...createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
seasonNumber: 2,
}),
providerSeasonNumber: 1,
};
const legacyPosition = createPosition({
contentXtreamId: LEGACY_TRACKING_ID,
});
const result = reconcile([episode], [legacyPosition]);
expect(result.positionsByTrackingId.get(SCOPED_TRACKING_ID)).toEqual({
...legacyPosition,
contentXtreamId: SCOPED_TRACKING_ID,
seasonNumber: 2,
});
expect(result.legacyPositionByTrackingId.get(SCOPED_TRACKING_ID)).toBe(
legacyPosition
);
expect(
reconcile([episode], [{ ...legacyPosition, seasonNumber: 3 }])
.positionsByTrackingId.size
).toBe(0);
expect(
reconcile([episode], [{ ...legacyPosition, episodeNumber: 3 }])
.positionsByTrackingId.size
).toBe(0);
});
it('prefers an exact scoped row while retaining compatible legacy cleanup metadata', () => {
const episode = createEpisode({
legacyTrackingId: LEGACY_TRACKING_ID,
@@ -557,10 +587,7 @@ describe('stalker series position compatibility', () => {
legacyPosition,
})
).rejects.toBe(clearError);
expect(order).toEqual([
LEGACY_TRACKING_ID,
SCOPED_TRACKING_ID,
]);
expect(order).toEqual([LEGACY_TRACKING_ID, SCOPED_TRACKING_ID]);
expect(rows.has(LEGACY_TRACKING_ID)).toBe(false);
expect(rows.get(SCOPED_TRACKING_ID)).toBe(exactPosition);
@@ -34,8 +34,17 @@ function isCompatibleLegacyPosition(
position: PlaybackPositionData,
episode: XtreamSerieEpisode
): boolean {
const providerSeason = (episode as StalkerMappedEpisode)
.providerSeasonNumber;
const matchesSeason =
matchesMappedCoordinate(
position.seasonNumber,
Number(episode.season)
) ||
(providerSeason !== undefined &&
matchesMappedCoordinate(position.seasonNumber, providerSeason));
return (
matchesMappedCoordinate(position.seasonNumber, Number(episode.season)) &&
matchesSeason &&
matchesMappedCoordinate(
position.episodeNumber,
Number(episode.episode_num)
@@ -33,11 +33,22 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
service = TestBed.inject(StalkerSeriesTmdbSeasonsService);
});
it('fetches the title-marked season for a renumbered single-season slice', async () => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle: 'Мандалорец (2 сезон)',
seasonCount: 1,
});
it.each([
'The Gentlemen s02',
'The Gentlemen season2',
'The Gentlemen (s02)',
'The Gentlemen (season 2)',
'Мандалорец (2 сезон)',
'Мандалорец (сезон 2)',
'Мандалорец s02',
'Мандалорец season2',
])(
'fetches the title-marked season for the single-season slice %s',
async (rawTitle) => {
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
rawTitle,
seasonCount: 1,
});
expect(getSeason).toHaveBeenCalledWith(82856, 2);
@@ -191,7 +191,6 @@ describe('StalkerSeriesViewComponent', () => {
fetchVodSeriesEpisodes,
resolveVodPlayback,
fetchLinkToPlay,
clearSelectedItem: jest.fn(),
},
},
{
@@ -226,7 +225,6 @@ describe('StalkerSeriesViewComponent', () => {
useValue: {
isEnabled: () => true,
getSeason: tmdbGetSeason,
getSeasonEpisodes: jest.fn().mockResolvedValue(null),
},
},
{
@@ -1217,7 +1215,7 @@ describe('StalkerSeriesViewComponent', () => {
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
// Season resource still loading — fetching now would pass a zero
@@ -1268,10 +1266,9 @@ describe('StalkerSeriesViewComponent', () => {
// context while the resource reloads.
expect(tmdbGetSeason).not.toHaveBeenCalled();
// Once the new item's own seasons land, the fetch runs WITHOUT a
// new seasonSelected emission — the season container deduplicates
// emissions when both items share the same season-key set, so the
// retained key must stay usable.
// Once the new item's own seasons land, the fetch runs without a
// stale-season fetch. The container selects the corrected key once
// the new season map is rendered.
serialSeasonsResource.set([
{
id: 'season-1',
@@ -1281,6 +1278,7 @@ describe('StalkerSeriesViewComponent', () => {
},
]);
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2);
});
@@ -1307,6 +1305,7 @@ describe('StalkerSeriesViewComponent', () => {
expect(tmdbGetSeason).not.toHaveBeenCalled();
isSerialSeasonsLoading.set(false);
fixture.componentInstance.onSeasonSelected('3');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3);
});
@@ -1325,7 +1324,7 @@ describe('StalkerSeriesViewComponent', () => {
} as never);
await stabilize();
fixture.componentInstance.onSeasonSelected('1');
fixture.componentInstance.onSeasonSelected('2');
await stabilize();
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2);
@@ -388,7 +388,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
this.vodSeriesSeasons.set(
mapVodSeriesSeasonsToVm(seasons, this.seriesSeasonTitle())
);
} else {
this.vodSeriesSeasons.set([]);
}
@@ -523,6 +525,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
private readonly seriesSeasonTitle = computed(() =>
pickSeasonMarkedTitle(
this.displayItem()?.info?.name,
this.displayItem()?.info?.o_name
)
);
readonly seriesMode = computed(() =>
this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
@@ -575,7 +584,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
})
: mapRegularSeriesEpisodes(
this.regularSeasons(),
displayItem?.info?.movie_image
displayItem?.info?.movie_image,
this.seriesSeasonTitle()
);
// Overlay lazily fetched TMDB episode data (real names,
@@ -723,7 +733,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly vodSeasonEpisodeLoads = new Map<
string,
Promise<boolean>
{ season: VodSeriesSeasonVm; promise: Promise<boolean> }
>();
/**
@@ -737,15 +747,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
/** Resolves true when the portal answered, false when the request failed. */
loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
const key = `${season.video_id}:${season.id}`;
const key = JSON.stringify([
this.seriesPlaybackOwnerKey(),
season.video_id,
season.id,
getVodSeriesSeasonKey(season),
]);
const inFlight = this.vodSeasonEpisodeLoads.get(key);
if (inFlight) {
return inFlight;
if (inFlight && this.vodSeriesSeasons().includes(inFlight.season)) {
return inFlight.promise;
}
const load = this.fetchEpisodesForSeason(season).finally(() => {
this.vodSeasonEpisodeLoads.delete(key);
if (this.vodSeasonEpisodeLoads.get(key)?.promise === load) {
this.vodSeasonEpisodeLoads.delete(key);
}
});
this.vodSeasonEpisodeLoads.set(key, load);
const loadingSeason = this.vodSeriesSeasons().find(
(candidate) =>
candidate.id === season.id &&
candidate.video_id === season.video_id
);
if (loadingSeason) {
this.vodSeasonEpisodeLoads.set(key, {
season: loadingSeason,
promise: load,
});
}
return load;
}
@@ -754,11 +781,17 @@ export class StalkerSeriesViewComponent implements OnDestroy {
): Promise<boolean> {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
const index = seasons.findIndex(
(s) =>
s.id === season.id &&
s.video_id === season.video_id &&
getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season)
);
if (index === -1) return false;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
const loadingSeason = { ...updatedSeasons[index], isLoading: true };
updatedSeasons[index] = loadingSeason;
this.vodSeriesSeasons.set(updatedSeasons);
try {
@@ -769,7 +802,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// Update with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
// Only the exact loading VM owns this response. A navigation or
// refresh can reuse provider ids while replacing the season list.
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
@@ -782,11 +817,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
};
this.vodSeriesSeasons.set(newSeasons);
}
return true;
return newIndex !== -1;
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
const newIndex = newSeasons.indexOf(loadingSeason);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
@@ -321,6 +321,41 @@ describe('StalkerSeriesViewComponent season watched toggle', () => {
jest.restoreAllMocks();
});
it('ignores an old episode response after navigating to another season slice', async () => {
await startWithTwoLoadedEpisodes();
const store = TestBed.inject(StalkerStore);
let finishOld!: (episodes: VodSeriesSeasonVm['episodes']) => void;
jest.spyOn(store, 'fetchVodSeriesEpisodes').mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOld = resolve;
})
);
const oldLoad = fixture.componentInstance.loadEpisodesForSeason(
fixture.componentInstance.vodSeriesSeasons()[0]
);
selectedItem.set({
...createVodItem(SERIES_B_ID),
info: { name: 'Series B s02' },
});
vodSeriesSeasonsResource.set([createSeason(SERIES_B_ID)]);
await settle();
expect(fixture.componentInstance.quickStartAction()?.episodeLabel).toBe(
'S02E01'
);
finishOld([createProviderEpisode('old-season-episode')]);
await oldLoad;
await settle();
expect(fixture.componentInstance.mappedSeasons()['2']).toEqual([]);
await fixture.componentInstance.loadEpisodesForSeason(
fixture.componentInstance.vodSeriesSeasons()[0]
);
await settle();
expect(fixture.componentInstance.mappedSeasons()['2'][0].season).toBe(
2
);
});
it('marks a season watched sequentially, counting failed legacy cleanup as watched', async () => {
const [firstId, secondId] = await startWithTwoLoadedEpisodes();
const loadsBefore = getSeriesPlaybackPositions.mock.calls.length;