fix(stalker): preserve is_series episode metadata (#1218)

This commit is contained in:
4gray authored and GitHub committed 2026-07-21 07:51:37 +02:00
1 parent 48ff4b9cc5
commit d308749e2c
14 files changed
+1065 -41

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
---
name: stalker-portal
description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control.
---
# Stalker Portal
Use this skill when changing Stalker/Ministra routes, stores, catalog/detail
views, playback, favorites/recent activity, EPG, or remote control.
## Read First
- `docs/architecture/stalker-portal.md`
- `docs/architecture/stalker-epg.md` for ITV EPG work
- `docs/architecture/remote-control.md` for live remote-control work
## Ownership
- Feature UI: `libs/portal/stalker/feature/src/lib/`
- Store/API data access: `libs/portal/stalker/data-access/src/lib/`
- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts`
- Shared Stalker item normalization:
`libs/shared/interfaces/src/lib/stalker-item.normalizer.ts`
- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/`
Keep provider-specific API and normalization behavior in Stalker data access.
Keep shared portal layouts/utilities provider-neutral. Preserve full-portal
session auth and simple IPC request paths.
## `is_series` Cross-Surface Checklist
Treat VOD items with `is_series` as series across every downstream surface.
Do not stop after making the detail view render.
1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers.
Preserve all three modes: regular `/series`, embedded VOD `series[]`, and
lazy Ministra VOD `is_series`.
2. Build quick-start state through the shared series utility. Preserve
`labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker;
translation parameters must reach the template.
3. Preserve `is_series` and the VOD origin in favorites/recent activity.
`extractStalkerItemType()` must normalize that activity to dashboard type
`series`.
4. Before either inline or external episode playback, persist the parent
`seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep
generated episode tracking IDs stable for lazy `is_series` episodes. When
`season_number` is absent, derive the coordinate from the same naturally
ordered season list used by quick start; do not default every season to 1.
5. The dashboard reads saved playback positions; it must not infer episode
numbers from provider payloads. Legacy rows without season/episode metadata
remain badge-less until that episode is played again.
## Regression Coverage
- Series view/UI and playback handoff:
`pnpm nx test portal-stalker-feature`
- Stalker shape/store behavior:
`pnpm nx test portal-stalker-data-access`
- Dashboard classification and position lookup:
`pnpm nx test workspace-dashboard-data-access`
- Dashboard badge rendering when changed:
`pnpm nx test workspace-dashboard-feature`
For user-visible workflow changes, run the closest available E2E target. If no
fixture covers the affected portal shape, record that gap and perform the
strongest targeted unit/build validation available.
+5
View File
@@ -383,6 +383,11 @@ Key files:
Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows.
File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
- `stalker-portal`
Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control.
Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control.
File: `.codex/skills/stalker-portal/SKILL.md`
- `xtream-electron`
Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations.
Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior.
+1
View File
@@ -780,6 +780,7 @@ engine` (restart required) or
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
- 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()`)
- 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: 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. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
- 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 → first) 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) or TMDB (Stalker)
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
+52 -21
View File
@@ -29,7 +29,8 @@ Stalker support covers:
## Routing Structure
Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
Primary route tree lives in
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
- `/stalker/:id/vod`
- `/stalker/:id/series`
@@ -45,30 +46,31 @@ Primary route tree lives in `/Users/4gray/Code/iptvnator/libs/portal/stalker/fea
1. Angular Stalker screens call methods/resources in `StalkerStore`.
2. `StalkerStore` builds request params based on selected content type and current view state.
3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth).
4. Electron main process handles `STALKER_REQUEST` in `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/stalker.events.ts`.
4. Electron main process handles `STALKER_REQUEST` in
`apps/electron-backend/src/app/events/stalker.events.ts`.
5. Axios calls Stalker `load.php` API with required headers/cookies and returns normalized payloads to renderer.
## Main UI Components
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts`
- Category + content layout for `vod` and `series`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
- ITV live playback, radio playback, channel/station navigation, EPG panel integration
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- Shared inline audio player used by M3U radio channels and Stalker radio stations
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- Season/episode UI for all Stalker series modes
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
## Store and Data Flow
Stalker store is now feature-composed:
- Facade: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker.store.ts`
- Feature slices: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stores/features/*`
- Shared helpers: `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/*`
- Facade: `libs/portal/stalker/data-access/src/lib/stalker.store.ts`
- Feature slices: `libs/portal/stalker/data-access/src/lib/stores/features/*`
- Shared helpers: `libs/portal/stalker/data-access/src/lib/*`
Important store responsibilities:
@@ -185,6 +187,9 @@ Stalker has multiple real-world data shapes. The current implementation supports
- For unloaded VOD-series seasons, the CTA target label is derived from season
metadata and rendered as `SxxE01` until episode details are loaded.
- Uses unique generated tracking IDs for episode playback position compatibility.
- Quick-start actions preserve both their translation key and interpolation
parameters when adapted for the Stalker CTA. Dropping `labelParams` exposes
the raw `{{episode}}` placeholder.
Series inline playback behavior is shared across all three modes:
@@ -194,11 +199,30 @@ Series inline playback behavior is shared across all three modes:
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
- Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback.
- Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.
- Before either inline or external playback starts, the resolved content info
includes the parent `seriesXtreamId` and the mapped `seasonNumber` /
`episodeNumber`. Future playback-position rows therefore carry enough
metadata for workspace surfaces to render an episode badge. Existing rows
without those fields are intentionally not migrated and remain badge-less
until the episode is played again.
- Ministra payloads may omit `season_number`. Episode mapping and lazy
quick-start labels share the same naturally ordered season fallback so later
seasons are not persisted as season 1.
The VOD-series contract is cross-surface:
- Favorites and recently viewed records preserve the raw `is_series` flag and
VOD origin so reopening still uses the lazy Ministra resources.
- `extractStalkerItemType()` normalizes those activity records to dashboard
type `series`.
- The dashboard resolves episode progress by the parent `seriesXtreamId` and
renders the saved season/episode metadata. It does not infer episode numbers
from provider payloads.
Core decision logic and normalization are centralized in:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/models/*.ts`
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `libs/portal/stalker/data-access/src/lib/models/*.ts`
## Favorites and Recently Viewed
@@ -213,10 +237,10 @@ Current implementation is shared via Stalker-specific helpers:
Where this is used:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts`
- `libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
Navigation rule to preserve:
@@ -264,7 +288,7 @@ Import rule:
Stalker live remote control is implemented in:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
- `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts`
Supported today:
@@ -299,9 +323,12 @@ This reduces duplicate UI logic across portal types and keeps compatibility beha
## Regression Coverage
Focused regression tests for Stalker VOD mode branching live in:
Focused regression tests for Stalker VOD mode branching and the cross-surface
series contract live in:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts`
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts`
- `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
- `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts`
Covered scenarios include:
@@ -310,3 +337,7 @@ Covered scenarios include:
- VOD-backed series favorites keep VOD-series loading semantics when opened from
favorites/global favorites
- Favorite toggle helper path invokes the expected add/remove flow
- Quick-start episode labels interpolate their episode number
- Inline and external episode handoffs carry resolved season/episode metadata
- Dashboard activity classifies `is_series` VOD as series and resolves its
saved episode position
+7 -3
View File
@@ -93,10 +93,14 @@ Render rules:
2. It derives the dashboard surface via `computed()`:
1. `hero` — first item of `globalRecentItems()`.
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
cover cards. Xtream playback positions are bulk-loaded per playlist so
cover cards. Portal playback positions are bulk-loaded per playlist so
hero and cards can show progress, remaining time, and series season/
episode badges. Series lookup uses keyed maps for both direct episode ids
and series ids; card renders must not scan the full playback-position map.
episode badges. This includes Stalker VOD activity normalized to series
through `is_series`. Series lookup uses keyed maps for both direct
episode ids and parent series ids; card renders must not scan the full
playback-position map. The badge uses saved `seasonNumber` /
`episodeNumber` metadata and does not infer it from provider payloads;
legacy rows without that metadata remain badge-less until replay.
Dashboard-originated Xtream series clicks also carry that exact episode
target through the global-recent inline-detail handoff. Once the series
metadata and playback positions load, the detail player consumes the
@@ -0,0 +1,552 @@
# Stalker `is_series` Playback Metadata Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Correct Stalker/Ministra VOD-backed series quick-start translations and persist episode coordinates so the workspace dashboard can show its season/episode badge.
**Architecture:** Keep the provider-neutral dashboard contract unchanged. Preserve the shared quick-start translation parameters in the Stalker button view model, then enrich resolved Stalker series playback at the feature boundary by resolving the selected episode against the existing normalized `mappedSeasons()` data.
**Tech Stack:** Angular standalone components and signals, ngx-translate, TypeScript, Jest, Nx, Markdown repository documentation.
---
### Task 1: Establish the implementation branch and workspace
**Files:**
- Verify: `package.json`
- Verify: `pnpm-lock.yaml`
- [ ] **Step 1: Create the feature branch**
Run:
```bash
git switch -c agent/fix-stalker-is-series-metadata
```
Expected: Git reports a new branch named
`agent/fix-stalker-is-series-metadata`.
- [ ] **Step 2: Install the frozen workspace dependencies**
Run:
```bash
pnpm install --frozen-lockfile
```
Expected: installation completes without changing `pnpm-lock.yaml`.
- [ ] **Step 3: Verify Nx workspace discovery**
Run:
```bash
pnpm nx show projects
```
Expected: output includes `portal-stalker-feature`,
`workspace-dashboard-data-access`, and `web`.
### Task 2: Add failing Stalker quick-start and playback regressions
**Files:**
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
- [ ] **Step 1: Make the test translate pipe expose missing parameters**
Change the `MockPipe(TranslatePipe, ...)` transform to render the problematic
translation with its parameter:
```ts
MockPipe(
TranslatePipe,
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${params?.['episode'] ?? '{{episode}}'}`;
}
return value ?? '';
}
),
```
- [ ] **Step 2: Add a quick-start interpolation regression**
Add a component test that uses a loaded `is_series` episode with a non-resume,
non-watched position:
```ts
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
const episode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.episodePlaybackPositions.set(
new Map([
[
Number(episode.id),
{
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 5,
durationSeconds: 100,
},
],
])
);
fixture.detectChanges();
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(button?.textContent).toContain('Play episode 1');
expect(button?.textContent).not.toContain('{{episode}}');
});
```
- [ ] **Step 3: Extend the existing external `is_series` quick-start test**
After clicking the quick-start button, assert that the external playback object
contains episode coordinates:
```ts
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
}),
true
);
```
- [ ] **Step 4: Extend the existing inline `is_series` playback test**
After `onEpisodeClicked(firstEpisode)`, assert:
```ts
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
})
);
```
- [ ] **Step 5: Run the Stalker feature tests and verify RED**
Run:
```bash
pnpm nx test portal-stalker-feature
```
Expected: the new interpolation and playback-coordinate assertions fail for
the missing `labelParams`, `seasonNumber`, and `episodeNumber`.
### Task 3: Preserve translation parameters and enrich Stalker playback
**Files:**
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts`
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html`
- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- Test: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts`
- [ ] **Step 1: Preserve label parameters in the Stalker button model**
Add the optional field:
```ts
export interface StalkerQuickStartButton {
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string | null;
icon: string;
disabled: boolean;
action: SeriesQuickStartAction | null;
lazySeason: VodSeriesSeasonVm | null;
}
```
Copy it from a loaded action:
```ts
return {
labelKey: action.labelKey,
labelParams: action.labelParams,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
action,
lazySeason: null,
};
```
- [ ] **Step 2: Pass parameters to the Stalker translate pipe**
Replace the quick-start label expression with:
```html
{{
action.labelKey
| translate: action.labelParams
}}
```
- [ ] **Step 3: Resolve episode coordinates before playback handoff**
In `startPlayback(...)`, derive normalized episode state from the existing
mapped seasons and enrich only episode playback:
```ts
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
...playback,
contentInfo: {
...playback.contentInfo,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
},
}
: playback;
```
Use `resolvedPlayback` for both branches:
```ts
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(resolvedPlayback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
```
- [ ] **Step 4: Run the Stalker feature tests and verify GREEN**
Run:
```bash
pnpm nx test portal-stalker-feature
```
Expected: all Stalker feature tests pass.
- [ ] **Step 5: Commit the focused Stalker fix**
Run:
```bash
git add libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
git commit -m "fix(stalker): preserve series episode metadata"
```
Expected: one commit containing the Stalker tests and minimal production fix.
### Task 4: Lock the dashboard’s existing `is_series` contract
**Files:**
- Modify: `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts`
- [ ] **Step 1: Add Stalker dashboard contract coverage**
Add a test that supplies a Stalker playlist-backed recent item and its saved
episode position:
```ts
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
playlistsSignal.set([
...createDefaultPlaylists(),
{
_id: 'stalker-series',
title: 'Ministra Portal',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: '50001',
title: 'VOD Flagged Series',
category_id: 'vod',
is_series: '1',
added_at: '2026-07-20T12:00:00.000Z',
},
],
},
]);
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
async (playlistId: string) =>
playlistId === 'stalker-series'
? [
{
playlistId,
contentXtreamId: 5000101,
contentType: 'episode',
seriesXtreamId: 50001,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 120,
durationSeconds: 1800,
},
]
: []
);
await service.reloadPlaybackPositions();
const item = service
.globalRecentItems()
.find((recent) => recent.playlist_id === 'stalker-series');
expect(item?.type).toBe('series');
expect(service.getPlaybackPositionForItem(item!)).toEqual(
expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
})
);
});
```
- [ ] **Step 2: Run the dashboard data-access tests**
Run:
```bash
pnpm nx test workspace-dashboard-data-access
```
Expected: all tests pass, proving that the dashboard already consumes the
metadata without a provider-specific branch.
- [ ] **Step 3: Commit the dashboard contract test**
Run:
```bash
git add libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts
git commit -m "test(dashboard): cover Stalker is_series positions"
```
Expected: one test-only commit.
### Task 5: Document the cross-surface `is_series` contract
**Files:**
- Modify: `docs/architecture/stalker-portal.md`
- Modify: `docs/architecture/workspace-dashboard.md`
- Modify: `.codex/skills/stalker-portal/SKILL.md`
- [ ] **Step 1: Update Stalker architecture documentation**
Add these rules to the `VOD/Series Modes` and regression sections:
```markdown
- All three series modes must preserve shared quick-start translation
parameters.
- Episode playback must carry `seriesXtreamId`, `seasonNumber`, and
`episodeNumber` through both inline and external player handoffs.
- Playlist-backed activity keeps `is_series` so dashboard normalization
classifies the VOD-origin record as `series`.
- Existing playback rows without episode coordinates remain badge-less until
the next episode playback; the dashboard must not query the portal to infer
them.
```
- [ ] **Step 2: Update workspace dashboard documentation**
Generalize the playback-position contract from Xtream-only wording and record:
```markdown
Stalker VOD-backed `is_series` activity is normalized as series activity.
New Stalker episode positions include season/episode coordinates, so the hero
and Continue Watching cards use the same badge path as Xtream. Legacy rows
without those fields remain valid but do not show a badge.
```
- [ ] **Step 3: Add an agent-facing `is_series` checklist**
In `.codex/skills/stalker-portal/SKILL.md`, require every Stalker series change
to verify:
```markdown
1. Detail mode selection for regular, embedded `series[]`, and `is_series`.
2. Parameterized quick-start labels.
3. Recent/favorite normalization retaining VOD origin and series identity.
4. Playback `seriesXtreamId` plus season/episode coordinates.
5. Dashboard hero and Continue Watching consumption.
```
- [ ] **Step 4: Verify Markdown changes**
Run:
```bash
git diff --check
```
Expected: no whitespace errors.
- [ ] **Step 5: Commit the documentation**
Run:
```bash
git add docs/architecture/stalker-portal.md docs/architecture/workspace-dashboard.md .codex/skills/stalker-portal/SKILL.md
git commit -m "docs(stalker): record is_series cross-surface contract"
```
Expected: one documentation commit.
### Task 6: Validate the complete change
**Files:**
- Verify: `libs/portal/stalker/feature/project.json`
- Verify: `libs/workspace/dashboard/data-access/project.json`
- Verify: `apps/web/project.json`
- [ ] **Step 1: Run targeted unit tests**
Run:
```bash
pnpm nx test portal-stalker-feature
pnpm nx test workspace-dashboard-data-access
```
Expected: both targets pass.
- [ ] **Step 2: Run affected lint targets**
Run:
```bash
pnpm nx lint portal-stalker-feature
pnpm nx lint workspace-dashboard-data-access
```
Expected: both targets pass.
- [ ] **Step 3: Compile the Angular application**
Run:
```bash
pnpm nx build web
```
Expected: the web build completes successfully, proving the updated template
and TypeScript compile together.
- [ ] **Step 4: Perform the test-impact audit**
Confirm that no Stalker/Ministra fixture-backed E2E target covers lazy
`is_series` playback. Record that targeted unit coverage plus the Angular build
is the strongest automated validation if no such target exists.
- [ ] **Step 5: Inspect the final diff and history**
Run:
```bash
git diff master...HEAD --check
git status --short
git log --oneline master..HEAD
```
Expected: no diff errors, a clean worktree, and intentional commits only.
### Task 7: Independent review, PR creation, and review loop
**Files:**
- Review: all files in `git diff master...HEAD`
- [ ] **Step 1: Dispatch an independent subagent review**
Ask a separate subagent to inspect the complete diff for correctness,
regressions, type safety, test sufficiency, and adherence to the approved
design. Require file/line evidence for every actionable finding.
- [ ] **Step 2: Address validated subagent findings**
For each finding, reproduce or confirm it, add or adjust tests first when
behavior changes, implement the smallest correction, and rerun the affected
validation targets. Commit any corrections separately.
- [ ] **Step 3: Create the pull request**
Push `agent/fix-stalker-is-series-metadata` and create a ready PR with:
```text
Summary:
- interpolate Stalker series quick-start episode labels
- persist season/episode coordinates for all Stalker series modes
- document and test the Ministra is_series dashboard contract
Validation:
- pnpm nx test portal-stalker-feature
- pnpm nx test workspace-dashboard-data-access
- pnpm nx lint portal-stalker-feature
- pnpm nx lint workspace-dashboard-data-access
- pnpm nx build web
```
- [ ] **Step 4: Inspect GitHub reviews, comments, and checks**
Wait for initial PR checks and review-bot feedback. Read every unresolved review
thread and failed check, classify each item as actionable or non-actionable
with evidence, and address all actionable findings.
- [ ] **Step 5: Repeat until clean**
After each correction, rerun affected validation, push the new commit, and
re-check PR reviews/comments/checks. Stop only when checks are green and no
actionable unresolved feedback remains.
@@ -0,0 +1,135 @@
# Stalker `is_series` Playback Metadata Design
## Context
Some Stalker/Ministra portals expose series inside the VOD catalog by setting
`is_series=1`. IPTVnator already normalizes this provider-specific shape and
loads its seasons and episodes lazily, but two cross-surface contracts are
incomplete:
1. The Stalker quick-start button renders `XTREAM.PLAY_EPISODE` without the
translation parameters supplied by the shared series quick-start action.
The result is a visible `{{episode}}` placeholder.
2. Stalker episode playback does not add `seasonNumber` and `episodeNumber` to
`ResolvedPortalPlayback.contentInfo`. Playback positions therefore lack the
metadata used by the workspace dashboard hero and Continue Watching cards
to render their season/episode badge.
The dashboard already classifies raw Stalker records carrying `is_series` as
series activity. No new dashboard-specific content-type branch is required.
## Goals
- Render parameterized Stalker quick-start translations correctly.
- Persist season and episode numbers for future Stalker episode playback,
including VOD-backed `is_series` series.
- Let the existing dashboard position lookup and badge rendering consume that
metadata without provider-specific duplication.
- Document `is_series` as a cross-surface compatibility contract so future
changes cover detail rendering, activity persistence, playback metadata, and
dashboard presentation together.
## Non-goals
- Migrating or reconstructing existing playback-position rows that do not
contain season/episode metadata.
- Loading Stalker catalog data from the dashboard to infer missing metadata.
- Changing Stalker season-loading behavior, episode ordering, tracking IDs, or
playback URLs.
- Redesigning the detail-page or dashboard UI.
## Design
### Quick-start translation
`StalkerQuickStartButton` will expose the optional `labelParams` already
provided by `SeriesQuickStartAction`. For loaded episode actions, the Stalker
adapter will copy those parameters into its button view model. Lazy-season
actions will leave the field undefined because their label keys do not require
an episode parameter.
The Stalker series template will call the translate pipe with
`action.labelParams`, matching the established Xtream series template. This
keeps translation-key selection in the shared quick-start utility and keeps
template behavior consistent across providers.
### Playback metadata
`StalkerSeriesViewComponent` already maps all three supported series shapes to
`Record<string, XtreamSerieEpisode[]>`:
- regular Stalker series;
- VOD with an embedded `series[]`;
- VOD with `is_series=1`.
When an episode is selected, the component will use the mapped episode identity
to resolve its normalized season and episode numbers. After
`StalkerStore.resolveVodPlayback(...)` returns, the component will enrich the
episode `contentInfo` with those two fields before handing the playback object
to either the inline player or an external player.
If no mapped episode state can be resolved, the component will preserve the
existing playback object unchanged. Missing metadata must never block
playback.
This placement avoids expanding the positional `resolveVodPlayback(...)`
contract and ensures both inline and external playback receive the same
metadata. Subsequent playback-position writes will therefore carry:
```text
playlistId
contentXtreamId
contentType = episode
seriesXtreamId
seasonNumber
episodeNumber
```
### Dashboard behavior
No new dashboard branching will be introduced. Existing behavior remains:
1. `extractStalkerItemType(...)` normalizes `is_series` activity to `series`.
2. `DashboardDataService` finds the newest episode position by
`seriesXtreamId`.
3. The dashboard hero and Continue Watching cards render the season/episode
badge when both metadata fields are present.
Existing positions without these fields will remain badge-less until the user
plays an episode again and a new position is saved.
## Tests
Regression coverage will be added before production changes:
1. Stalker series quick-start view-model/component coverage will demonstrate
that a parameterized `PLAY_EPISODE` action carries and renders the episode
number instead of `{{episode}}`.
2. Stalker `is_series` component coverage will demonstrate that resolved
playback contains the mapped season and episode numbers.
3. Dashboard coverage will use a Stalker `is_series` recent item plus an
episode playback position and assert that the hero exposes the expected
season/episode badge.
Targeted Nx tests will run for the affected Stalker feature and workspace
dashboard projects. Broader validation will be selected after checking the
available project targets.
## Documentation
The implementation will update:
- `docs/architecture/stalker-portal.md` with the activity/playback/dashboard
contract for all three series modes;
- `docs/architecture/workspace-dashboard.md` with Stalker episode-position
badge behavior and the forward-only limitation;
- `.codex/skills/stalker-portal/SKILL.md` with a cross-surface `is_series`
checklist for future agents.
## Compatibility and failure handling
- Raw `true`, `1`, and `"1"` `is_series` forms continue to normalize through
existing Stalker helpers.
- Existing series tracking IDs and saved positions remain valid.
- Playback remains functional when season/episode metadata cannot be derived.
- Old position rows are read unchanged and are not rewritten speculatively.
@@ -78,6 +78,44 @@ describe('stalker-series.adapters', () => {
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('derives missing VOD-series season numbers from natural season order', () => {
const mapped = mapVodSeriesEpisodes([
{
id: 'season-2',
video_id: 'v1',
name: 'Season 2',
season_number: '',
episodes: [
{
id: 'episode-2',
series_number: 1,
name: 'Second season pilot',
},
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-1',
video_id: 'v1',
name: 'Season 1',
season_number: '',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
expect(mapped['Season 1'][0].season).toBe(1);
expect(mapped['Season 2'][0].season).toBe(2);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
@@ -7,6 +7,11 @@ import {
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
const naturalSeasonCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
@@ -154,7 +159,7 @@ export function mapVodSeriesEpisodes(
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
const seasonNum = getVodSeriesSeasonNumber(season, seasons);
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
@@ -230,3 +235,24 @@ export function mapRegularSeriesEpisodes(
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
export function getVodSeriesSeasonNumber(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): number {
const parsedSeasonNumber = Number(season.season_number);
if (Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0) {
return parsedSeasonNumber;
}
const orderedSeasons = [...seasons].sort((seasonA, seasonB) =>
naturalSeasonCollator.compare(
getVodSeriesSeasonKey(seasonA),
getVodSeriesSeasonKey(seasonB)
)
);
const seasonIndex = orderedSeasons.findIndex(
(candidate) => candidate.id === season.id
);
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
}
@@ -6,6 +6,7 @@ import {
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
getVodSeriesSeasonNumber,
type VodSeriesSeasonVm,
} from '@iptvnator/portal/stalker/data-access';
import type {
@@ -15,6 +16,7 @@ import type {
export interface StalkerQuickStartButton {
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string | null;
icon: string;
disabled: boolean;
@@ -72,6 +74,7 @@ export function getStalkerSeriesQuickStartButton(
return {
labelKey: action.labelKey,
labelParams: action.labelParams,
episodeLabel: action.episodeLabel,
icon: action.icon,
disabled: action.disabled,
@@ -158,16 +161,8 @@ function getLazyVodSeriesEpisodeLabel(
season: VodSeriesSeasonVm,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): string {
const seasonIndex = seasons.findIndex((item) => item.id === season.id);
const parsedSeasonNumber = Number(season.season_number);
const seasonNumber =
Number.isInteger(parsedSeasonNumber) && parsedSeasonNumber > 0
? parsedSeasonNumber
: getFallbackSeasonNumber(seasonIndex);
return formatSeriesEpisodeCode(seasonNumber, 1);
}
function getFallbackSeasonNumber(seasonIndex: number): number {
return seasonIndex >= 0 ? seasonIndex + 1 : 1;
return formatSeriesEpisodeCode(
getVodSeriesSeasonNumber(season, seasons),
1
);
}
@@ -104,7 +104,10 @@
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
{{
action.labelKey
| translate: action.labelParams
}}
</span>
@if (action.episodeLabel) {
<span class="play-btn__meta">
@@ -241,7 +241,17 @@ describe('StalkerSeriesViewComponent', () => {
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
(
value: string | null | undefined,
params?: Record<string, number>
) => {
if (value === 'XTREAM.PLAY_EPISODE') {
return `Play episode ${
params?.['episode'] ?? '{{episode}}'
}`;
}
return value ?? '';
}
),
],
},
@@ -290,6 +300,68 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('interpolates the episode number for a recently started VOD is_series episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: '1',
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([]);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{
id: 'episode-1',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
const episode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.episodePlaybackPositions.set(
new Map([
[
Number(episode.id),
{
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: 50001,
positionSeconds: 5,
durationSeconds: 100,
},
],
])
);
fixture.detectChanges();
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(
fixture.componentInstance.quickStartAction()?.labelParams
).toEqual({ episode: 1 });
expect(button?.textContent).toContain('Play episode 1');
expect(button?.textContent).not.toContain('{{episode}}');
});
it('loads the first VOD-series season and starts its first episode from quick start', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -350,6 +422,15 @@ describe('StalkerSeriesViewComponent', () => {
expect.any(Number),
undefined
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
}),
true
);
});
it('loads an earlier unloaded VOD-series season before showing completed', async () => {
@@ -541,6 +622,14 @@ describe('StalkerSeriesViewComponent', () => {
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
expect(inlinePlayer.playback()).toEqual(
expect.objectContaining({
contentInfo: expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
}),
})
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
@@ -687,15 +687,37 @@ export class StalkerSeriesViewComponent implements OnDestroy {
episodeId,
startTime
);
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
...playback,
contentInfo: {
...playback.contentInfo,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
},
}
: playback;
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.inlinePlayback.set(resolvedPlayback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
true
);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
@@ -1003,6 +1003,63 @@ describe('DashboardDataService', () => {
});
});
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
playlistsSignal.set([
...createDefaultPlaylists(),
{
_id: 'stalker-series',
title: 'Ministra Portal',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
macAddress: '00:11:22:33:44:55',
recentlyViewed: [
{
id: '50001',
title: 'VOD Flagged Series',
category_id: 'vod',
is_series: '1',
added_at: '2026-07-20T12:00:00.000Z',
},
],
},
]);
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
async (playlistId: string) =>
playlistId === 'stalker-series'
? [
{
playlistId,
contentXtreamId: 5000101,
contentType: 'episode',
seriesXtreamId: 50001,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 120,
durationSeconds: 1800,
},
]
: []
);
await service.reloadPlaybackPositions();
const item = service
.globalRecentItems()
.find((recent) => recent.playlist_id === 'stalker-series');
if (!item) {
throw new Error('expected the Stalker is_series recent item');
}
expect(item.type).toBe('series');
expect(service.getPlaybackPositionForItem(item)).toEqual(
expect.objectContaining({
seasonNumber: 1,
episodeNumber: 1,
})
);
});
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{