mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(stalker): preserve is_series episode metadata (#1218)
This commit is contained in:
1 parent
48ff4b9cc5
commit
d308749e2c
14 files changed
+1065
-41
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
+7
-12
@@ -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
|
||||
);
|
||||
}
|
||||
+4
-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">
|
||||
|
||||
+90
-1
@@ -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',
|
||||
|
||||
+24
-2
@@ -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([
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user