From 0273ded8e2605dc480dd81423be9d21df70777f8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:46:48 +0200 Subject: [PATCH] fix(tmdb): resolve season number from title markers for per-season series slices (#1229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tmdb): resolve season number from title markers for per-season series slices Providers often slice a show into per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон", "The Boys S05") and renumber the single contained season to 1, so season enrichment fetched the wrong TMDB season (season 1 metadata for a season 2 item). - new season-marker.util.ts in shared/interfaces: extractSeasonFromTitle (word-first, number-first and S-form markers, bracketed or trailing) and resolveEnrichmentSeasonNumber (title marker wins only for single-season items whose provider number disagrees) - wired into Xtream enrichSerialSeasonWithTmdb and the Stalker series-view season service (cache/overlay still keyed by provider season key) - SEASON_SUFFIX_PATTERN now also strips number-first season suffixes ("2 season", "2 сезон", "2-й сезон" incl. NFD-decomposed ordinals) so such titles match the show at all Co-Authored-By: Claude Fable 5 * fix(stalker): wait for the season map before TMDB season fetch The TMDB match can arrive before the async season resource; fetching then passed seasonCount 0, suppressed the title-marker override and cached the wrong season forever (fetchSeason is idempotent). The effect now reads the season map tracked and skips while it is empty — overlay-driven re-runs are safe because fetchSeason early-returns per (tmdbId, seasonKey). Addresses Greptile review on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): reset season selection on detail-to-detail navigation The router reuses the series view for detail-to-detail navigation, and a retained season selection let the NEW item's tmdb_id pair with the PREVIOUS series' season context in the TMDB fetch effect, poisoning its idempotent per-season cache before the new season resource loaded. The selection is now a linkedSignal keyed on the displayed item's identity — compared inside the computation, since displayItem produces a fresh object on every recomputation. Addresses Greptile review round 2 on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): include the resolved season in the TMDB season cache identity Per-season slices of one show share (tmdbId, provider season key "1") but resolve to different TMDB seasons — plain key idempotency served the first slice's episodes to every later slice. Each cache entry now records the resolved season it was fetched for: a call resolving a different season refetches and overwrites (also self-healing a fetch made with stale navigation context), an in-flight marker dedups concurrent runs, and a superseded fetch may not store its result. Failed fetches stay uncached so later triggers retry. Addresses Codex review (P1) and Greptile review round 3 on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): drop a mismatched season cache entry before its replacement fetch If a replacement fetch (same key, different resolved season) failed, the previous slice's entry stayed visible indefinitely through overlay() and descriptions(). The mismatched entry is now removed up front, so a failed replacement falls back to provider data until a retry succeeds. Addresses Codex review (P2) on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): title-based season reset identity and o_name marker support - The season-selection reset identity now combines provider id and title: distinct items can share or lack provider ids, and an id-only identity retained the previous item's selection across such navigation - The season marker is read from whichever title field carries it via pickSeasonMarkedTitle: providers put the descriptive title in o_name while name stays generic, and the show-level match already used o_name Addresses Greptile review round 4 (P1) and Codex review (P2) on #1229. Co-Authored-By: Claude Fable 5 * fix(stalker): gate TMDB season fetch on resource coherence, not selection resets Resetting the parent season selection on item identity change (previous round) silently disabled enrichment after detail-to-detail navigation between items sharing one season-key set: the season container keeps its own selection and deduplicates seasonSelected emissions, so the parent key stayed null forever. The reset is gone; instead the fetch effect gates on coherence — it waits while the season resource reloads (the window in which a reused component pairs the new item's tmdb_id with the previous item's map) and requires the selected key to exist in the map with episodes. Stale-snapshot fetches remain self-healing through the resolution-aware cache. Addresses Codex review (P2) and Greptile review round 5 on #1229. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 25 ++- ...talker-series-tmdb-seasons.service.spec.ts | 158 ++++++++++++++++++ .../stalker-series-tmdb-seasons.service.ts | 120 ++++++++++--- .../stalker-series-view.component.spec.ts | 145 +++++++++++++++- .../stalker-series-view.component.ts | 46 ++++- .../lib/stores/xtream-tmdb-enrichment.spec.ts | 78 +++++++++ .../src/lib/stores/xtream-tmdb-enrichment.ts | 13 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/season-marker.util.spec.ts | 116 +++++++++++++ .../interfaces/src/lib/season-marker.util.ts | 122 ++++++++++++++ .../src/lib/title-normalization.util.spec.ts | 15 ++ .../src/lib/title-normalization.util.ts | 22 ++- 13 files changed, 822 insertions(+), 41 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts create mode 100644 libs/shared/interfaces/src/lib/season-marker.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/season-marker.util.ts diff --git a/CLAUDE.md b/CLAUDE.md index 04e4c99a9..536b830aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -803,7 +803,7 @@ engine` (restart required) or - Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation -- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) +- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint - Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) - Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 08fd79010..5c8b322fb 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -176,10 +176,33 @@ detail views lazily fetch `/tv/{tmdbId}/season/{n}` via - episodes without a TMDB counterpart (by episode number) pass through untouched +The season number `{n}` is the provider's episode season number, with one +correction (`resolveEnrichmentSeasonNumber` in +`libs/shared/interfaces/src/lib/season-marker.util.ts`): providers often +slice a show into per-season catalog items ("The Mandalorian (2 season)", +"Пацаны 2 сезон", "The Boys S05") and renumber the single contained season +to 1. When the item contains exactly ONE season and the raw title carries +an explicit season marker (`extractSeasonFromTitle`: `s02`, `season 2`, +`2 season`, `2nd season`, `сезон 2`, `2-й сезон`, `staffel`/`temporada`/ +`saison` forms, bracketed or not) that differs from the provider's number, +the marker wins and that TMDB season is fetched. Multi-season items always +keep provider numbering. `normalizeTitleKeys` strips the same markers +(including number-first forms like "2 сезон") from search titles, so the +show-level match is unaffected by them. + Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired from the serial detail's `(seasonSelected)`; Stalker — the series view keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its -`mappedSeasons` computed. Without a show-level match or with enrichment +`mappedSeasons` computed. Each Stalker entry records the RESOLVED season +it was fetched for: per-season slices of one show share +(tmdbId, provider key "1") but resolve to different seasons, and a fetch +made with stale detail-to-detail navigation context is overwritten once +the real context re-resolves. The fetch effect gates on coherence rather +than timing: it waits while the season resource reloads and requires the +selected key to exist in the map with episodes. The retained season +selection deliberately survives navigation — the season container +deduplicates `seasonSelected` emissions, so items sharing one season-key +set would otherwise never re-trigger enrichment. Without a show-level match or with enrichment disabled everything is a no-op — the `SeasonContainer` UI already renders every episode field conditionally. diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts new file mode 100644 index 000000000..046beade4 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts @@ -0,0 +1,158 @@ +import { TestBed } from '@angular/core/testing'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; + +describe('StalkerSeriesTmdbSeasonsService', () => { + let service: StalkerSeriesTmdbSeasonsService; + let getSeason: jest.Mock; + + const episodesOfSeason = (season: number): XtreamSerieEpisode[] => [ + { + id: `${season}-1`, + episode_num: 1, + season, + title: 'Episode 1', + } as unknown as XtreamSerieEpisode, + ]; + + beforeEach(() => { + getSeason = jest.fn().mockResolvedValue({ + overview: 'Season overview', + episodes: [{ episode_number: 1, name: 'The Marshal' }], + }); + TestBed.configureTestingModule({ + providers: [ + StalkerSeriesTmdbSeasonsService, + { + provide: TmdbEnrichmentService, + useValue: { getSeason }, + }, + ], + }); + service = TestBed.inject(StalkerSeriesTmdbSeasonsService); + }); + + it('fetches the title-marked season for a renumbered single-season slice', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledWith(82856, 2); + + // Overlay still keys by the provider's season key + const overlaid = service.overlay( + { '1': episodesOfSeason(1) }, + 82856 + ); + expect(overlaid['1'][0].title).toBe('The Marshal'); + }); + + it('keeps provider numbering for multi-season items despite a marker', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 3, + }); + + expect(getSeason).toHaveBeenCalledWith(82856, 1); + }); + + it('keeps provider numbering without fetch context', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1)); + + expect(getSeason).toHaveBeenCalledWith(82856, 1); + }); + + it('skips a repeat fetch for the same resolved season', async () => { + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledTimes(1); + }); + + it('refetches when the same provider key resolves to another season', async () => { + getSeason.mockResolvedValueOnce({ + overview: 'Season 2 overview', + episodes: [{ episode_number: 1, name: 'Season 2 Episode' }], + }); + // Per-season slices of ONE show share (tmdbId, provider key "1") + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + expect(getSeason).toHaveBeenCalledWith(82856, 2); + + getSeason.mockResolvedValueOnce({ + overview: 'Season 3 overview', + episodes: [{ episode_number: 1, name: 'Season 3 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + expect(getSeason).toHaveBeenCalledWith(82856, 3); + + // The newer resolution overwrote the entry under the shared key + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('Season 3 Episode'); + expect(service.descriptions(82856)['1']).toBe('Season 3 overview'); + }); + + it('drops the stale entry when a replacement fetch fails', async () => { + getSeason.mockResolvedValueOnce({ + overview: 'Season 2 overview', + episodes: [{ episode_number: 1, name: 'Season 2 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + // Replacement resolution (another slice of the show) fails — + // the season-2 entry must not stay on screen for the new slice + getSeason.mockResolvedValueOnce(null); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('Episode 1'); + expect(service.descriptions(82856)).toEqual({}); + + // A later trigger retries and fills the correct season + getSeason.mockResolvedValueOnce({ + overview: 'Season 3 overview', + episodes: [{ episode_number: 1, name: 'Season 3 Episode' }], + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (3 сезон)', + seasonCount: 1, + }); + const healed = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(healed['1'][0].title).toBe('Season 3 Episode'); + }); + + it('does not cache a failed fetch, so a later trigger retries', async () => { + getSeason.mockResolvedValueOnce(null); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + await service.fetchSeason(82856, '1', episodesOfSeason(1), { + rawTitle: 'Мандалорец (2 сезон)', + seasonCount: 1, + }); + + expect(getSeason).toHaveBeenCalledTimes(2); + const overlaid = service.overlay({ '1': episodesOfSeason(1) }, 82856); + expect(overlaid['1'][0].title).toBe('The Marshal'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts index 0f53e518d..0d5539bf8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts @@ -4,7 +4,16 @@ import { mergeEpisodesWithTmdb, type TmdbEpisode, } from '@iptvnator/services'; -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + XtreamSerieEpisode, + resolveEnrichmentSeasonNumber, +} from '@iptvnator/shared/interfaces'; + +interface FetchedTmdbSeason { + /** Resolved TMDB season number this entry was fetched for */ + seasonNumber: number; + episodes: TmdbEpisode[]; +} /** * Component-scoped holder for lazily fetched TMDB season data (episode @@ -12,18 +21,24 @@ import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; * the component's `providers`). * * Entries are keyed by `${tmdbId}|${seasonKey}` so data from a previously - * shown series can never leak into the current one. + * shown series can never leak into the current one, and each entry records + * the RESOLVED TMDB season it holds: per-season slices of one show share + * (tmdbId, provider key "1") but resolve to different seasons, and a fetch + * made with stale navigation context must be overwritten once the real + * context re-resolves — plain key idempotency would block both. */ @Injectable() export class StalkerSeriesTmdbSeasonsService { private readonly tmdbEnrichment = inject(TmdbEnrichmentService); - private readonly episodesByKey = signal< - ReadonlyMap + private readonly seasonsByKey = signal< + ReadonlyMap >(new Map()); private readonly overviewsByKey = signal>( new Map() ); + /** mapKey → season number currently being fetched (in-flight dedup) */ + private readonly pending = new Map(); /** * Overlays fetched TMDB episode data (real names, overviews, stills) @@ -34,14 +49,14 @@ export class StalkerSeriesTmdbSeasonsService { seasons: Record, tmdbId: number | null | undefined ): Record { - const fetched = this.episodesByKey(); + const fetched = this.seasonsByKey(); if (!tmdbId || fetched.size === 0) { return seasons; } const merged: Record = {}; for (const [seasonKey, episodes] of Object.entries(seasons)) { - const forSeason = fetched.get(`${tmdbId}|${seasonKey}`); + const forSeason = fetched.get(`${tmdbId}|${seasonKey}`)?.episodes; merged[seasonKey] = forSeason?.length ? mergeEpisodesWithTmdb(episodes, forSeason) : episodes; @@ -72,47 +87,98 @@ export class StalkerSeriesTmdbSeasonsService { /** * Lazily pulls the TMDB season (episode list + overview) for an opened * season; a no-op without a show-level TMDB match, with enrichment - * disabled, or when the season was already fetched. + * disabled, or when the entry already holds the resolved season. + * `context` carries the raw provider title and total season count so a + * per-season slice ("The Mandalorian (2 season)" with its single season + * renumbered to 1) fetches the season the title names instead of the + * provider's number. A later call resolving a DIFFERENT season for the + * same key (another slice of the show, or corrected navigation context) + * refetches and overwrites the entry. */ async fetchSeason( tmdbId: number | null | undefined, seasonKey: string, - episodes: XtreamSerieEpisode[] | undefined + episodes: XtreamSerieEpisode[] | undefined, + context?: { rawTitle?: string | null; seasonCount?: number } ): Promise { if (!tmdbId) { return; } + const providerSeasonNumber = Number(episodes?.[0]?.season ?? seasonKey); + if (!Number.isFinite(providerSeasonNumber)) { + return; + } + + const seasonNumber = resolveEnrichmentSeasonNumber({ + rawTitle: context?.rawTitle, + providerSeasonNumber, + providerSeasonCount: context?.seasonCount ?? 0, + }); + const mapKey = `${tmdbId}|${seasonKey}`; - if (this.episodesByKey().has(mapKey)) { + const cached = this.seasonsByKey().get(mapKey); + if ( + cached?.seasonNumber === seasonNumber || + this.pending.get(mapKey) === seasonNumber + ) { return; } - const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey); - if (!Number.isFinite(seasonNumber)) { - return; - } + this.pending.set(mapKey, seasonNumber); - const season = await this.tmdbEnrichment.getSeason( - tmdbId, - seasonNumber - ); - if (!season) { - return; + // A mismatched entry belongs to another slice/context of this + // show — drop it BEFORE fetching so a failed replacement fetch + // can never leave the wrong season's metadata on screen (the + // overlay then falls back to provider data until a retry). + if (cached) { + this.deleteEntry(mapKey); } + try { + const season = await this.tmdbEnrichment.getSeason( + tmdbId, + seasonNumber + ); + + // A newer resolution for this key superseded us mid-flight — + // only the latest requested fetch may store its result. + if (this.pending.get(mapKey) !== seasonNumber) { + return; + } + if (!season) { + // Transient failure — stays uncached so a later trigger + // can retry. + return; + } - if (season.overview) { const overviews = new Map(this.overviewsByKey()); - overviews.set(mapKey, season.overview); + if (season.overview) { + overviews.set(mapKey, season.overview); + } else { + overviews.delete(mapKey); + } this.overviewsByKey.set(overviews); - } - if (!season.episodes?.length) { - return; + const next = new Map(this.seasonsByKey()); + next.set(mapKey, { + seasonNumber, + episodes: season.episodes ?? [], + }); + this.seasonsByKey.set(next); + } finally { + if (this.pending.get(mapKey) === seasonNumber) { + this.pending.delete(mapKey); + } } + } - const next = new Map(this.episodesByKey()); - next.set(mapKey, season.episodes); - this.episodesByKey.set(next); + private deleteEntry(mapKey: string): void { + const next = new Map(this.seasonsByKey()); + next.delete(mapKey); + this.seasonsByKey.set(next); + + const overviews = new Map(this.overviewsByKey()); + overviews.delete(mapKey); + this.overviewsByKey.set(overviews); } } 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 3aaa2899b..693fc5824 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 @@ -79,6 +79,7 @@ describe('StalkerSeriesViewComponent', () => { const selectedItem = signal(null); const serialSeasonsResource = signal([]); const vodSeriesSeasonsResource = signal([]); + const isSerialSeasonsLoading = signal(false); const fetchVodSeriesEpisodes = jest.fn(); const resolveVodPlayback = jest.fn(); const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); @@ -106,6 +107,7 @@ describe('StalkerSeriesViewComponent', () => { }, ]); vodSeriesSeasonsResource.set([]); + isSerialSeasonsLoading.set(false); fetchVodSeriesEpisodes.mockReset(); resolveVodPlayback.mockReset(); resolveVodPlayback.mockImplementation( @@ -156,7 +158,7 @@ describe('StalkerSeriesViewComponent', () => { getVodSeriesSeasonsResource: () => vodSeriesSeasonsResource(), isVodSeriesSeasonsLoading: signal(false), - isSerialSeasonsLoading: signal(false), + isSerialSeasonsLoading, fetchVodSeriesEpisodes, resolveVodPlayback, fetchLinkToPlay: jest.fn(), @@ -816,4 +818,145 @@ describe('StalkerSeriesViewComponent', () => { expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); }); + + it('waits for the season map before fetching so a per-season slice gets the title-marked season', async () => { + serialSeasonsResource.set([]); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + // Season resource still loading — fetching now would pass a zero + // season count, suppress the title-marker override and cache the + // wrong season forever (fetchSeason is idempotent). + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Single-season slice whose provider season is renumbered to 1: + // the title marker names the real TMDB season. + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); + + it('gates the fetch on the reloading season resource during detail-to-detail navigation', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + // No show-level TMDB match yet — nothing fetched for the first item + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Detail-to-detail navigation reuses the component; the new item's + // TMDB match can arrive while the season resource reloads and the + // map still shows the previous series' seasons. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30002', + cmd: '/media/file_30002.mpg', + info: { + name: 'Other Series (2 season)', + description: 'Other description', + movie_image: 'poster2.jpg', + tmdb_id: 888, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + // The new tmdb_id must NOT pair with the previous series' season + // context while the resource reloads. + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + // Once the new item's own seasons land, the fetch runs WITHOUT a + // new seasonSelected emission — the season container deduplicates + // emissions when both items share the same season-key set, so the + // retained key must stay usable. + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30002.mpg', + series: [1, 2], + }, + ]); + isSerialSeasonsLoading.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).toHaveBeenCalledWith(888, 2); + }); + + it('enriches after equal-id navigation once the season resource settles', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + // Distinct items can reuse a provider id; the loading gate (not an + // id comparison) keeps the stale map from being used. + isSerialSeasonsLoading.set(true); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Different Series (3 season)', + description: 'Different description', + movie_image: 'poster3.jpg', + tmdb_id: 999, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).not.toHaveBeenCalled(); + + isSerialSeasonsLoading.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + expect(tmdbGetSeason).toHaveBeenCalledWith(999, 3); + }); + + it('reads the season marker from o_name when name is generic', async () => { + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + o_name: 'Regular Series (2 season)', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); + }); }); 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 55316a158..0c0a0a02f 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 @@ -27,6 +27,7 @@ import { ResolvedPortalPlayback, TmdbEnrichedCastMember, XtreamSerieEpisode, + pickSeasonMarkedTitle, youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; import { SafePipe } from '@iptvnator/pipes'; @@ -162,7 +163,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { }); }); - /** Season currently selected in the season container. */ + /** + * Season currently selected in the season container. Deliberately NOT + * reset on detail-to-detail navigation: the season container keeps its + * own selection and deduplicates `seasonSelected` emissions, so when + * two items share the same season-key set (commonly just "1") it never + * re-emits — a parent-side reset would leave the new item permanently + * unenriched. Stale-context safety lives in the fetch effect's + * coherence gates instead (see the constructor). + */ private readonly selectedSeasonKey = signal(null); /** Season descriptions for the season tabs (TMDB overview per season). */ @@ -204,14 +213,43 @@ export class StalkerSeriesViewComponent implements OnDestroy { // tmdb_id — so the fetch must re-run when the match arrives, not only // on selection. fetchSeason is idempotent per (tmdbId, season). effect(() => { - const tmdbId = this.displayItem()?.info?.tmdb_id; + const item = this.displayItem(); + const tmdbId = item?.info?.tmdb_id; const seasonKey = this.selectedSeasonKey(); - if (tmdbId && seasonKey) { + // Coherence gates instead of timing assumptions. All inputs are + // read TRACKED so the effect re-runs as each one settles: + // - the season resource must not be mid-reload — during + // detail-to-detail navigation a reused component briefly + // pairs the NEW item's tmdb_id with the PREVIOUS item's map + // - the selected key must exist in the map with episodes — an + // empty map would pass seasonCount 0 (suppressing the + // title-marker override), and a key retained from the + // previous item is only usable when the new item has that + // season too (otherwise the container's auto-select re-emits) + // Re-running on overlay updates cannot loop (fetchSeason skips + // when its entry already holds the resolved season), and a + // fetch made with a stale snapshot is overwritten once the + // real context re-resolves to a different season. + const seasonsLoading = this.isVodSeries() + ? this.isVodSeriesSeasonsLoading() + : this.isSerialSeasonsLoading(); + const seasons = this.mappedSeasons(); + const episodes = seasonKey ? seasons[seasonKey] : undefined; + if (tmdbId && seasonKey && !seasonsLoading && episodes?.length) { untracked(() => void this.tmdbSeasons.fetchSeason( tmdbId, seasonKey, - this.mappedSeasons()[seasonKey] + episodes, + { + // The season marker can live in either title + // field (generic name + descriptive o_name) + rawTitle: pickSeasonMarkedTitle( + item?.info?.name, + item?.info?.o_name + ), + seasonCount: Object.keys(seasons).length, + } ) ); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts index 9c4690bbb..5df070cc8 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -1,5 +1,6 @@ import type { TmdbEnrichmentService } from '@iptvnator/services'; import { + enrichSerialSeasonWithTmdb, enrichSerialSelectionWithTmdb, enrichVodSelectionWithTmdb, } from './xtream-tmdb-enrichment'; @@ -25,6 +26,7 @@ function createEnrichment(overrides: Partial = {}) { isEnabled: jest.fn(() => true), enrichMovie: jest.fn().mockResolvedValue(null), enrichTv: jest.fn().mockResolvedValue(null), + getSeasonEpisodes: jest.fn().mockResolvedValue(null), ...overrides, } as unknown as TmdbEnrichmentService; } @@ -173,3 +175,79 @@ describe('enrichSerialSelectionWithTmdb', () => { expect(enrichment.enrichTv).not.toHaveBeenCalled(); }); }); + +describe('enrichSerialSeasonWithTmdb', () => { + function seasonSliceItem( + name: string, + episodes: Record + ) { + return { + series_id: '7', + info: { ...serialItem.info, name, tmdb_id: 82856 }, + episodes: Object.fromEntries( + Object.entries(episodes).map(([key, list]) => [ + key, + list.map((episode) => ({ + id: `${key}-${episode.episode_num}`, + title: `Episode ${episode.episode_num}`, + ...episode, + })), + ]) + ), + }; + } + + it('fetches the title-marked season for a renumbered single-season slice', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian (2 season)', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest + .fn() + .mockResolvedValue([ + { episode_number: 1, name: 'The Marshal' }, + ]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 2); + const updated = store.setSelectedItem.mock.calls[0][0] as { + episodes: Record; + }; + expect(updated.episodes['1'][0].title).toBe('The Marshal'); + }); + + it('keeps provider numbering for multi-season items despite a marker', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian (2 season)', { + '1': [{ episode_num: 1, season: 1 }], + '2': [{ episode_num: 1, season: 2 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockResolvedValue([]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); + }); + + it('keeps provider numbering when the title has no marker', async () => { + const store = createStore( + seasonSliceItem('The Mandalorian', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockResolvedValue([]), + } as Partial); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 79bc4de43..8d32d389d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -9,6 +9,7 @@ import { XtreamSerieDetails, XtreamVodDetails, getXtreamVodInfo, + resolveEnrichmentSeasonNumber, } from '@iptvnator/shared/interfaces'; /** @@ -162,11 +163,19 @@ export async function enrichSerialSeasonWithTmdb< return; } - const seasonNumber = Number(episodes[0]?.season ?? seasonKey); - if (!Number.isFinite(seasonNumber)) { + const providerSeasonNumber = Number(episodes[0]?.season ?? seasonKey); + if (!Number.isFinite(providerSeasonNumber)) { return; } + // Per-season provider slices ("The Mandalorian (2 season)") renumber + // their single season to 1 — the title marker names the real TMDB season + const seasonNumber = resolveEnrichmentSeasonNumber({ + rawTitle: info.name, + providerSeasonNumber, + providerSeasonCount: Object.keys(selected?.episodes ?? {}).length, + }); + const tmdbEpisodes = await enrichment.getSeasonEpisodes( info.tmdb_id, seasonNumber diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2f25d78e0..2f99efe03 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -35,6 +35,7 @@ export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; +export * from './lib/season-marker.util'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; export * from './lib/xtream-account-info-dialog-data.interface'; diff --git a/libs/shared/interfaces/src/lib/season-marker.util.spec.ts b/libs/shared/interfaces/src/lib/season-marker.util.spec.ts new file mode 100644 index 000000000..f03132810 --- /dev/null +++ b/libs/shared/interfaces/src/lib/season-marker.util.spec.ts @@ -0,0 +1,116 @@ +import { + extractSeasonFromTitle, + pickSeasonMarkedTitle, + resolveEnrichmentSeasonNumber, +} from './season-marker.util'; + +describe('extractSeasonFromTitle', () => { + it('reads bracketed number-first markers', () => { + expect(extractSeasonFromTitle('The Mandalorian (2 season)')).toBe(2); + expect(extractSeasonFromTitle('Кухня (6 сезон)')).toBe(6); + }); + + it('reads word-first markers with any joiner', () => { + expect(extractSeasonFromTitle('Breaking Bad Season 2')).toBe(2); + expect(extractSeasonFromTitle('Breaking Bad season_02')).toBe(2); + expect(extractSeasonFromTitle('Breaking Bad season2')).toBe(2); + expect(extractSeasonFromTitle('Мандалорец сезон 2')).toBe(2); + expect(extractSeasonFromTitle('Dark Staffel 3')).toBe(3); + expect(extractSeasonFromTitle('La Casa Temporada 4')).toBe(4); + }); + + it('reads number-first markers including ordinals', () => { + expect(extractSeasonFromTitle('The Boys 2 Season')).toBe(2); + expect(extractSeasonFromTitle('The Boys 2nd Season')).toBe(2); + expect(extractSeasonFromTitle('Пацаны 2 сезон')).toBe(2); + expect(extractSeasonFromTitle('Пацаны 2-й сезон')).toBe(2); + }); + + it('reads S-form markers, including S..E.. episode tags', () => { + expect(extractSeasonFromTitle('The Boys S05')).toBe(5); + expect(extractSeasonFromTitle('Gintama s2')).toBe(2); + expect(extractSeasonFromTitle('[S03] Dark')).toBe(3); + expect(extractSeasonFromTitle('Dark S02E05')).toBe(2); + }); + + it('never fires on titles without an explicit marker', () => { + expect(extractSeasonFromTitle('The Mandalorian')).toBeNull(); + expect(extractSeasonFromTitle("Ocean's 11")).toBeNull(); + expect(extractSeasonFromTitle('Cars 2')).toBeNull(); + expect(extractSeasonFromTitle('The 4400')).toBeNull(); + expect(extractSeasonFromTitle('The Four Seasons')).toBeNull(); + expect(extractSeasonFromTitle('2 Fast 2 Furious')).toBeNull(); + expect(extractSeasonFromTitle('2001: A Space Odyssey')).toBeNull(); + expect(extractSeasonFromTitle('')).toBeNull(); + expect(extractSeasonFromTitle(null)).toBeNull(); + }); + + it('rejects plurals, word-internal hits and season zero', () => { + expect(extractSeasonFromTitle('Best of 2 Seasons')).toBeNull(); + expect(extractSeasonFromTitle('Postseason 3')).toBeNull(); + expect(extractSeasonFromTitle('Dark S00')).toBeNull(); + }); +}); + +describe('pickSeasonMarkedTitle', () => { + it('prefers the title that carries a season marker', () => { + expect( + pickSeasonMarkedTitle( + 'Regular Series', + 'The Mandalorian (2 season)' + ) + ).toBe('The Mandalorian (2 season)'); + expect( + pickSeasonMarkedTitle('The Boys S05', 'The Boys') + ).toBe('The Boys S05'); + }); + + it('falls back to the first non-empty title without markers', () => { + expect(pickSeasonMarkedTitle(undefined, 'The Mandalorian')).toBe( + 'The Mandalorian' + ); + expect(pickSeasonMarkedTitle('', null)).toBeNull(); + }); +}); + +describe('resolveEnrichmentSeasonNumber', () => { + it('overrides a renumbered single-season slice with the title season', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 1, + providerSeasonCount: 1, + }) + ).toBe(2); + }); + + it('keeps provider numbering for multi-season items', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 1, + providerSeasonCount: 3, + }) + ).toBe(1); + }); + + it('keeps provider numbering when the marker agrees with it', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian (2 season)', + providerSeasonNumber: 2, + providerSeasonCount: 1, + }) + ).toBe(2); + }); + + it('keeps provider numbering without a title marker', () => { + expect( + resolveEnrichmentSeasonNumber({ + rawTitle: 'The Mandalorian', + providerSeasonNumber: 1, + providerSeasonCount: 1, + }) + ).toBe(1); + }); +}); diff --git a/libs/shared/interfaces/src/lib/season-marker.util.ts b/libs/shared/interfaces/src/lib/season-marker.util.ts new file mode 100644 index 000000000..996044630 --- /dev/null +++ b/libs/shared/interfaces/src/lib/season-marker.util.ts @@ -0,0 +1,122 @@ +/** + * Season markers in provider titles. Providers often slice a show into + * per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон", + * "The Boys S05") and renumber the single contained season to 1 — the + * marker in the title is then the only source of the real season number. + * Pure functions shared by the renderer and tests — no Angular/Node deps. + */ + +/** + * Season vocabulary shared with the trailing-suffix stripper in + * `title-normalization.util.ts`. Words only — digit placement (before or + * after the word) is handled by the patterns built from it. + */ +export const SEASON_WORD_ALTERNATIVES = 'season|сезон|staffel|temporada|saison'; + +const SEASON_WORD = `(?:${SEASON_WORD_ALTERNATIVES})`; + +/** + * "Season 2", "season_02", "season2", "сезон 2". The leading guard blocks + * word-internal matches ("postseason 3"); the digit lookahead rejects + * 3+-digit numbers, which are never season markers. + */ +const WORD_FIRST_MARKER = new RegExp( + `(?:^|[^\\p{L}])${SEASON_WORD}[\\s_.-]*(\\d{1,2})(?!\\d)`, + 'iu' +); + +/** + * "2 season", "2nd Season", "2 сезон", "2-й сезон". The digit guard keeps + * long numbers out ("2001 season" never yields season 20/01), the trailing + * lookahead rejects plurals ("The Four Seasons", "2 Seasons"). + */ +const NUMBER_FIRST_MARKER = new RegExp( + `(?:^|[^\\d])(\\d{1,2})\\s*(?:st|nd|rd|th|-?й|-?я|-?ой)?[\\s_.-]+${SEASON_WORD}(?!\\p{L})`, + 'iu' +); + +/** + * "S02", "s2" — and the season half of "S02E05". Tightly bounded on both + * sides so words ending in "s" followed by a number ("Ocean's 11", + * "Cars 2") can never match. + */ +const S_FORM_MARKER = /(?:^|[\s([{_.-])s(\d{1,2})(?=$|[\s)\]}_.-]|e\d)/i; + +/** + * Extract an explicit season number from a RAW provider title (before any + * normalization — bracket groups like "(2 season)" are removed by + * `normalizeTitleKeys`, so this must see the original string). Returns + * `null` when there is no unambiguous marker; season 0 ("specials") is + * deliberately rejected. + */ +export function extractSeasonFromTitle( + raw: string | null | undefined +): number | null { + if (!raw) { + return null; + } + + for (const pattern of [ + WORD_FIRST_MARKER, + NUMBER_FIRST_MARKER, + S_FORM_MARKER, + ]) { + const match = raw.match(pattern); + if (match) { + const season = Number(match[1]); + if (season >= 1) { + return season; + } + } + } + + return null; +} + +/** + * The first candidate title carrying an explicit season marker, else the + * first non-empty one. Providers spread the descriptive title across + * fields (a generic `name` with the marker only in `o_name`), and the + * marker must be read from whichever field carries it. + */ +export function pickSeasonMarkedTitle( + ...titles: readonly (string | null | undefined)[] +): string | null { + for (const title of titles) { + if (extractSeasonFromTitle(title) !== null) { + return title ?? null; + } + } + return titles.find((title) => !!title) ?? null; +} + +export interface SeasonNumberResolution { + /** RAW provider title of the series item (not normalized) */ + rawTitle: string | null | undefined; + /** Season number reported by the provider's episode data */ + providerSeasonNumber: number; + /** How many seasons the provider item contains in total */ + providerSeasonCount: number; +} + +/** + * The TMDB season number to fetch for a provider season. Provider data + * stays authoritative except for the one case it is known to lie about: + * a SINGLE-season item whose title carries a different explicit season + * marker is a per-season slice with renumbered seasons — the marker names + * the real TMDB season. Multi-season items always keep provider numbering + * (overriding all their seasons from one title marker would be wrong). + */ +export function resolveEnrichmentSeasonNumber( + resolution: SeasonNumberResolution +): number { + const fromTitle = extractSeasonFromTitle(resolution.rawTitle); + if ( + fromTitle !== null && + resolution.providerSeasonCount === 1 && + fromTitle !== resolution.providerSeasonNumber + ) { + return fromTitle; + } + return resolution.providerSeasonNumber; +} diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts index 6566ae95c..8f8df8318 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts @@ -54,6 +54,21 @@ describe('normalizeTitleKeys', () => { expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys'); expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны'); }); + + it('strips number-first season suffixes ("2 season", "2 сезон")', () => { + expect(normalizeTitle('The Mandalorian 2 Season')).toBe( + 'the mandalorian' + ); + expect(normalizeTitle('The Mandalorian 2nd Season')).toBe( + 'the mandalorian' + ); + expect(normalizeTitle('Мандалорец 2 сезон')).toBe('мандалорец'); + expect(normalizeTitle('Пацаны 2-й сезон')).toBe('пацаны'); + }); + + it('keeps plural "seasons" endings — only singular markers are tags', () => { + expect(normalizeTitle('Best of 2 Seasons')).toBe('best of 2 seasons'); + }); }); describe('provider tag stripping', () => { diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index b06c73694..e2963c83b 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -4,6 +4,8 @@ * matching). Pure functions — no Angular/Node dependencies. */ +import { SEASON_WORD_ALTERNATIVES } from './season-marker.util'; + const QUALITY_TAGS = new Set([ '4k', 'uhd', @@ -175,12 +177,22 @@ const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/; const TRAILING_YEAR_PATTERN = /(?:^|\s)(19\d{2}|20\d{2})$/; /** - * Trailing season markers on series titles: "The Boys s05", "сезон 2". - * Uses (?:^|\s) instead of \b — JS word boundaries are ASCII-only and - * never fire next to Cyrillic letters. + * Trailing season markers on series titles: "The Boys s05", "сезон 2", + * plus number-first forms ("2 season", "Пацаны 2 сезон", "2nd Season" — + * "2-й" normalizes to "2 й", hence the optional ordinal token). The + * ordinal list carries NFD-decomposed forms too: this pattern runs after + * diacritics stripping, which turns "й" into "и" ("2-й сезон" → "2 и + * сезон"). Uses (?:^|\s) instead of \b — JS word boundaries are + * ASCII-only and never fire next to Cyrillic letters. */ -const SEASON_SUFFIX_PATTERN = - /(?:^|\s)(?:s\d{1,2}|season\s*\d{1,2}|сезон\s*\d{1,2}|staffel\s*\d{1,2}|temporada\s*\d{1,2})$/i; +const SEASON_SUFFIX_PATTERN = new RegExp( + '(?:^|\\s)(?:' + + 's\\d{1,2}' + + `|(?:${SEASON_WORD_ALTERNATIVES})\\s*\\d{1,2}` + + `|\\d{1,2}\\s*(?:st|nd|rd|th|й|и|я|ой|ои)?\\s+(?:${SEASON_WORD_ALTERNATIVES})` + + ')$', + 'iu' +); /** * A provider title normalized on two tiers. Trailing years on provider