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:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 11:46:48 +02:00
1 parent 13b27a51ba
commit 0273ded8e2
13 files changed
+822 -41

No files matched your search

@@ -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');
});
});
@@ -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);
}
}
@@ -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);
});
});
@@ -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
+1
View File
@@ -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