From d308749e2ca5e5b9ab55e29f91c5166686de3569 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:51:37 +0200 Subject: [PATCH] fix(stalker): preserve is_series episode metadata (#1218) --- .codex/skills/stalker-portal/SKILL.md | 66 +++ AGENTS.md | 5 + CLAUDE.md | 1 + docs/architecture/stalker-portal.md | 73 ++- docs/architecture/workspace-dashboard.md | 10 +- ...-20-stalker-is-series-playback-metadata.md | 552 ++++++++++++++++++ ...lker-is-series-playback-metadata-design.md | 135 +++++ .../src/lib/stalker-series.adapters.spec.ts | 38 ++ .../src/lib/stalker-series.adapters.ts | 28 +- .../stalker-series-quick-start.ts | 19 +- .../stalker-series-view.component.html | 5 +- .../stalker-series-view.component.spec.ts | 91 ++- .../stalker-series-view.component.ts | 26 +- .../src/lib/dashboard-data.service.spec.ts | 57 ++ 14 files changed, 1065 insertions(+), 41 deletions(-) create mode 100644 .codex/skills/stalker-portal/SKILL.md create mode 100644 docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md create mode 100644 docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md new file mode 100644 index 000000000..d59fa7a20 --- /dev/null +++ b/.codex/skills/stalker-portal/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index fb252fee7..01f664e84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 47bbfd6d2..d8c1f2c9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 42b3f4b20..931a54610 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 913421a86..7e0cb4ee7 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md b/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md new file mode 100644 index 000000000..a47a677e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-stalker-is-series-playback-metadata.md @@ -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 + ) => { + 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; + 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. diff --git a/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md b/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md new file mode 100644 index 000000000..0d8422ac1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-stalker-is-series-playback-metadata-design.md @@ -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`: + +- 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. diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts index 80c6d85b4..6a65d44c6 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.spec.ts @@ -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( { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts index 7a94a40bf..23bd5e1fc 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts @@ -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 +): 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; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts index a94388d7c..b9f9f2913 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts @@ -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; 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 ): 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 + ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 3b90c38b0..861be1769 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -104,7 +104,10 @@ - {{ action.labelKey | translate }} + {{ + action.labelKey + | translate: action.labelParams + }} @if (action.episodeLabel) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 77e9599b5..3aaa2899b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -241,7 +241,17 @@ describe('StalkerSeriesViewComponent', () => { StubSeasonContainerComponent, MockPipe( TranslatePipe, - (value: string | null | undefined) => value ?? '' + ( + value: string | null | undefined, + params?: Record + ) => { + 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', diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 5e7cb5da8..55316a158 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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 = diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index f2124918c..09cbe4c5d 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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([ {