mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(tmdb): resolve season number from title markers for per-season series slices (#1229)
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13b27a51ba
commit
0273ded8e2
13 files changed
+822
-41
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+158
@@ -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');
|
||||
});
|
||||
});
|
||||
+93
-27
@@ -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<string, TmdbEpisode[]>
|
||||
private readonly seasonsByKey = signal<
|
||||
ReadonlyMap<string, FetchedTmdbSeason>
|
||||
>(new Map());
|
||||
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
/** mapKey → season number currently being fetched (in-flight dedup) */
|
||||
private readonly pending = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Overlays fetched TMDB episode data (real names, overviews, stills)
|
||||
@@ -34,14 +49,14 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
tmdbId: number | null | undefined
|
||||
): Record<string, XtreamSerieEpisode[]> {
|
||||
const fetched = this.episodesByKey();
|
||||
const fetched = this.seasonsByKey();
|
||||
if (!tmdbId || fetched.size === 0) {
|
||||
return seasons;
|
||||
}
|
||||
|
||||
const merged: Record<string, XtreamSerieEpisode[]> = {};
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+144
-1
@@ -79,6 +79,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
const selectedItem = signal<StalkerVodSource | null>(null);
|
||||
const serialSeasonsResource = signal<unknown[]>([]);
|
||||
const vodSeriesSeasonsResource = signal<unknown[]>([]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
+42
-4
@@ -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<string | null>(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,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TmdbEnrichmentService> = {}) {
|
||||
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<string, { episode_num: number; season: number }[]>
|
||||
) {
|
||||
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<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 2);
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
episodes: Record<string, { title: string }[]>;
|
||||
};
|
||||
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<TmdbEnrichmentService>);
|
||||
|
||||
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<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user