diff --git a/.codex/skills/stalker-portal/SKILL.md b/.codex/skills/stalker-portal/SKILL.md index 1976a934d..d59fa7a20 100644 --- a/.codex/skills/stalker-portal/SKILL.md +++ b/.codex/skills/stalker-portal/SKILL.md @@ -43,7 +43,9 @@ Do not stop after making the detail view render. `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. + 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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index e2dfb6502..931a54610 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -205,6 +205,9 @@ Series inline playback behavior is shared across all three modes: 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: 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 1d61c63e1..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 { @@ -160,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 + ); }