fix(xtream): drop URL-only season overviews and fall back to TMDB (#1382)

Xtream panels routinely fill get_series_info seasons[].overview with a
bare cover-image URL, which rendered verbatim under the season tabs.
URL-only overviews are now treated as absent (sanitizeProviderOverview),
and the lazy season enrichment stores the TMDB season overview on the
selection (tmdb_season_overviews) as the fallback description - same
cached /tv/{id}/season/{n} payload, so no extra requests. Provider text
keeps priority when it is real prose.

The enrichment write is also convergent now: the serial detail re-fires
season enrichment after every selection write, and the previous
unconditional rewrite scheduled the next cache-served run indefinitely.
A repeat run that changes nothing no longer writes.

buildSeasonDescriptions is extracted from SerialDetailsComponent, which
would otherwise cross the 400-line max-lines limit.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-08 11:12:59 +02:00
1 parent 7103f7e734
commit 92be39ef66
13 files changed
+411 -43

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: xtream
---
Series pages no longer show a raw image link under the season tabs when the
provider fills the season description with a cover URL. URL-only descriptions
are dropped, and with TMDB metadata enabled the missing season description is
filled from TMDB instead.
+1 -1
View File
@@ -1104,7 +1104,7 @@ engine` (restart required) or
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
+18 -3
View File
@@ -49,7 +49,7 @@ store imports):
| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution |
| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups |
| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:<id>` rows) |
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
| `tmdb-season.service.ts` | Cached lazy per-season payloads — overview + episode list (`id:<id>\|season:<n>` rows) |
| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) |
Integration glue per portal:
@@ -215,8 +215,9 @@ project site as Referer for YouTube embed hosts
Show-level merges store the matched id as `tmdb_id` on the enriched info
(`XtreamSerieInfo` / `StalkerVodInfo`). When the user opens a season, the
detail views lazily fetch `/tv/{tmdbId}/season/{n}` via
`TmdbEnrichmentService.getSeasonEpisodes` (cached per language under
`id:{tmdbId}|season:{n}`) and overlay it with `mergeEpisodesWithTmdb`:
`TmdbEnrichmentService.getSeason` (cached per language under
`id:{tmdbId}|season:{n}`) and overlay its episodes with
`mergeEpisodesWithTmdb`:
- generic provider titles ("Episode 4", "Серия 4", "S01E04", bare numbers)
are replaced with real episode names; meaningful provider titles are kept
@@ -225,6 +226,20 @@ detail views lazily fetch `/tv/{tmdbId}/season/{n}` via
- episodes without a TMDB counterpart (by episode number) pass through
untouched
Season enrichment re-fires from the serial detail view after every
selection write, so its own write is convergent: a repeat cache-served run
that would change nothing (episodes already merged, overview already
stored) writes nothing, ending the re-fire chain.
The same payload's season `overview` is stored on the Xtream selection as
`tmdb_season_overviews[seasonKey]`. The serial detail view renders season
descriptions provider-first (`buildSeasonDescriptions` in
`libs/portal/xtream/feature/src/lib/serial-details/season-descriptions.util.ts`):
a `get_series_info` season overview wins when it is real prose, but panels
routinely fill it with a bare cover-image URL —
`sanitizeProviderOverview` (`@iptvnator/shared/interfaces`) treats a
URL-only value as absent, and the stored TMDB overview fills the gap.
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
@@ -26,7 +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),
getSeason: jest.fn().mockResolvedValue(null),
...overrides,
} as unknown as TmdbEnrichmentService;
}
@@ -266,16 +266,14 @@ describe('enrichSerialSeasonWithTmdb', () => {
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest
.fn()
.mockResolvedValue([
{ episode_number: 1, name: 'The Marshal' },
]),
getSeason: jest.fn().mockResolvedValue({
episodes: [{ episode_number: 1, name: 'The Marshal' }],
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 2);
expect(enrichment.getSeason).toHaveBeenCalledWith(82856, 2);
const updated = store.setSelectedItem.mock.calls[0][0] as {
episodes: Record<string, { title: string }[]>;
};
@@ -289,28 +287,117 @@ describe('enrichSerialSeasonWithTmdb', () => {
'2': [{ episode_num: 1, season: 2 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockResolvedValue([]),
} as Partial<TmdbEnrichmentService>);
const enrichment = createEnrichment();
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
expect(enrichment.getSeason).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();
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeason).toHaveBeenCalledWith(82856, 1);
});
it('stores the TMDB season overview for the description fallback', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockResolvedValue([]),
getSeason: jest.fn().mockResolvedValue({
overview: 'The Mandalorian and the Child continue.',
episodes: [{ episode_number: 1, name: 'The Marshal' }],
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1);
const updated = store.setSelectedItem.mock.calls[0][0] as {
episodes: Record<string, { title: string }[]>;
tmdb_season_overviews: Record<string, string>;
};
expect(updated.episodes['1'][0].title).toBe('The Marshal');
expect(updated.tmdb_season_overviews).toEqual({
'1': 'The Mandalorian and the Child continue.',
});
});
it('patches only the overview when TMDB returns no episodes', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeason: jest.fn().mockResolvedValue({
overview: 'Season overview only.',
episodes: [],
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
const updated = store.setSelectedItem.mock.calls[0][0] as {
episodes: Record<string, { title: string }[]>;
tmdb_season_overviews: Record<string, string>;
};
expect(updated.episodes['1'][0].title).toBe('Episode 1');
expect(updated.tmdb_season_overviews).toEqual({
'1': 'Season overview only.',
});
});
it('converges: a repeat cache-served run does not rewrite the selection', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeason: jest.fn().mockResolvedValue({
overview: 'Season overview.',
episodes: [{ episode_number: 1, name: 'The Marshal' }],
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
// The selection effect re-fires after every write; the second run
// sees already-merged data and must not write again.
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
});
it('does not store a blank TMDB season overview', async () => {
const store = createStore(
seasonSliceItem('The Mandalorian', {
'1': [{ episode_num: 1, season: 1 }],
})
);
const enrichment = createEnrichment({
getSeason: jest.fn().mockResolvedValue({
overview: ' ',
episodes: [{ episode_number: 1, name: 'The Marshal' }],
}),
} as Partial<TmdbEnrichmentService>);
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
const updated = store.setSelectedItem.mock.calls[0][0] as {
tmdb_season_overviews?: Record<string, string>;
};
expect(updated.tmdb_season_overviews).toBeUndefined();
});
it('drops a same-id season result after its playlist becomes stale', async () => {
@@ -321,14 +408,16 @@ describe('enrichSerialSeasonWithTmdb', () => {
})
);
const enrichment = createEnrichment({
getSeasonEpisodes: jest.fn().mockImplementation(async () => {
getSeason: jest.fn().mockImplementation(async () => {
isCurrentPlaylist = false;
store.replaceItem(
seasonSliceItem('Series from playlist B', {
'1': [{ episode_num: 1, season: 1 }],
})
);
return [{ episode_number: 1, name: 'Playlist A episode' }];
return {
episodes: [{ episode_number: 1, name: 'Playlist A episode' }],
};
}),
} as Partial<TmdbEnrichmentService>);
@@ -139,10 +139,12 @@ export async function enrichSerialSelectionWithTmdb<
}
/**
* Lazy per-season episode enrichment, fired when the user opens a season.
* Lazy per-season enrichment, fired when the user opens a season.
* Requires a prior show-level match (`info.tmdb_id` set by
* enrichSerialSelectionWithTmdb). Merges real episode names, overviews and
* stills into `episodes[seasonKey]` by episode number.
* stills into `episodes[seasonKey]` by episode number, and stores the
* TMDB season overview in `tmdb_season_overviews[seasonKey]` — the detail
* view's fallback when the provider season overview is empty or URL junk.
*/
export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
store: EnrichableSelectionStore<TItem>,
@@ -182,11 +184,10 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
providerSeasonCount: Object.keys(selected?.episodes ?? {}).length,
});
const tmdbEpisodes = await enrichment.getSeasonEpisodes(
info.tmdb_id,
seasonNumber
);
if (!tmdbEpisodes?.length || !isCurrentSelection()) {
const season = await enrichment.getSeason(info.tmdb_id, seasonNumber);
const tmdbEpisodes = season?.episodes ?? [];
const seasonOverview = season?.overview?.trim() || null;
if ((!tmdbEpisodes.length && !seasonOverview) || !isCurrentSelection()) {
return;
}
@@ -202,15 +203,39 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
}
try {
const mergedEpisodes = tmdbEpisodes.length
? mergeEpisodesWithTmdb(currentEpisodes, tmdbEpisodes)
: null;
// The detail view re-fires enrichment on every selection write, so
// a repeat cache-served run must converge: write only what actually
// changed, or each write would schedule the next run forever.
const episodesChanged =
mergedEpisodes !== null &&
JSON.stringify(mergedEpisodes) !== JSON.stringify(currentEpisodes);
const overviewChanged =
seasonOverview !== null &&
current.tmdb_season_overviews?.[seasonKey] !== seasonOverview;
if (!episodesChanged && !overviewChanged) {
return;
}
store.setSelectedItem({
...current,
episodes: {
...current.episodes,
[seasonKey]: mergeEpisodesWithTmdb(
currentEpisodes,
tmdbEpisodes
),
},
...(episodesChanged
? {
episodes: {
...current.episodes,
[seasonKey]: mergedEpisodes,
},
}
: {}),
...(overviewChanged
? {
tmdb_season_overviews: {
...current.tmdb_season_overviews,
[seasonKey]: seasonOverview,
},
}
: {}),
} as unknown as TItem);
} catch (error) {
console.warn('[TMDB] season merge failed:', error);
@@ -0,0 +1,68 @@
import type { XtreamSerieSeason } from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
function season(
seasonNumber: number,
overview: string
): Pick<XtreamSerieSeason, 'season_number' | 'overview'> {
return { season_number: seasonNumber, overview };
}
describe('buildSeasonDescriptions', () => {
it('returns an empty map for a missing item', () => {
expect(buildSeasonDescriptions(null)).toEqual({});
});
it('keys provider overviews by season number', () => {
expect(
buildSeasonDescriptions({
seasons: [
season(1, 'Season one text'),
season(2, 'Season two text'),
] as XtreamSerieSeason[],
})
).toEqual({ '1': 'Season one text', '2': 'Season two text' });
});
it('drops URL-only provider overviews', () => {
expect(
buildSeasonDescriptions({
seasons: [
season(
1,
'http://line.example.net:80/images/series/x_small.jpg'
),
] as XtreamSerieSeason[],
})
).toEqual({});
});
it('falls back to TMDB overviews where provider text is absent or junk', () => {
expect(
buildSeasonDescriptions({
seasons: [
season(1, 'https://cdn.example.com/cover.jpg'),
season(2, 'Real provider text'),
] as XtreamSerieSeason[],
tmdb_season_overviews: {
'1': 'TMDB season 1',
'2': 'TMDB season 2',
'3': 'TMDB season 3',
},
})
).toEqual({
'1': 'TMDB season 1',
'2': 'Real provider text',
'3': 'TMDB season 3',
});
});
it('covers seasons the provider seasons array does not list', () => {
expect(
buildSeasonDescriptions({
seasons: [],
tmdb_season_overviews: { '1': 'TMDB only' },
})
).toEqual({ '1': 'TMDB only' });
});
});
@@ -0,0 +1,31 @@
import {
sanitizeProviderOverview,
XtreamSerieDetails,
} from '@iptvnator/shared/interfaces';
/**
* Season descriptions keyed by season key: provider text from
* `get_series_info` when it is real prose, otherwise the TMDB season
* overview stored by the lazy season enrichment. Panels routinely put a
* cover-image URL into `seasons[].overview`; a bare URL is junk, not a
* description, so it is dropped instead of rendered.
*/
export function buildSeasonDescriptions(
item: Pick<XtreamSerieDetails, 'seasons' | 'tmdb_season_overviews'> | null
): Record<string, string> {
const descriptions: Record<string, string> = {};
for (const season of item?.seasons ?? []) {
const overview = sanitizeProviderOverview(season?.overview);
if (overview && season.season_number !== undefined) {
descriptions[String(season.season_number)] = overview;
}
}
for (const [seasonKey, overview] of Object.entries(
item?.tmdb_season_overviews ?? {}
)) {
if (!descriptions[seasonKey]) {
descriptions[seasonKey] = overview;
}
}
return descriptions;
}
@@ -419,6 +419,57 @@ describe('SerialDetailsComponent', () => {
});
});
it('filters URL-only season overviews and falls back to TMDB descriptions', async () => {
selectedItem.set({
series_id: 103,
info: {
name: 'Series One',
plot: 'Series plot',
cover: 'cover.jpg',
backdrop_path: [],
genre: 'Drama',
category_id: '3',
},
seasons: [
{
season_number: 1,
overview:
'http://line.example.net:80/images/series/cover_small.jpg',
},
{
season_number: 2,
overview: 'Provider season 2 text',
},
],
tmdb_season_overviews: {
'1': 'TMDB season 1 overview',
'2': 'TMDB season 2 overview',
},
episodes: {
'1': [
{ id: '1001', episode_num: 1, title: 'E1', season: 1 },
],
'2': [
{ id: '2001', episode_num: 1, title: 'E1', season: 2 },
],
},
});
fixture.detectChanges();
await fixture.whenStable();
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
)?.componentInstance as StubSeasonContainerComponent;
// The bare cover URL is junk → TMDB fills season 1; real provider
// text keeps priority over TMDB for season 2.
expect(seasonContainer.seasonDescriptions()).toEqual({
'1': 'TMDB season 1 overview',
'2': 'Provider season 2 text',
});
});
it('keeps every provider episode but disables download presentation in provider-only mode', async () => {
window.history.replaceState(
{ detailPresentation: 'provider-only' },
@@ -38,6 +38,7 @@ import {
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarItem,
@@ -172,16 +173,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
/** Season overviews from get_series_info, keyed by season key. */
readonly seasonDescriptions = computed<Record<string, string>>(() => {
const descriptions: Record<string, string> = {};
for (const season of this.selectedItem()?.seasons ?? []) {
if (season?.overview && season.season_number !== undefined) {
descriptions[String(season.season_number)] = season.overview;
}
}
return descriptions;
});
/** Season descriptions (provider text, TMDB fallback, URL junk dropped). */
readonly seasonDescriptions = computed<Record<string, string>>(() =>
buildSeasonDescriptions(this.selectedItem())
);
/** TMDB recommendations matched against the loaded series catalog */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
+1
View File
@@ -35,6 +35,7 @@ export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/playlist-display-label.util';
export * from './lib/portal-playback.interface';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
@@ -0,0 +1,51 @@
import { sanitizeProviderOverview } from './provider-overview.util';
describe('sanitizeProviderOverview', () => {
it('keeps real description text', () => {
expect(sanitizeProviderOverview('A tense hijack drama.')).toBe(
'A tense hijack drama.'
);
});
it('trims surrounding whitespace', () => {
expect(sanitizeProviderOverview(' Season text ')).toBe('Season text');
});
it('returns null for empty and missing values', () => {
expect(sanitizeProviderOverview('')).toBeNull();
expect(sanitizeProviderOverview(' ')).toBeNull();
expect(sanitizeProviderOverview(null)).toBeNull();
expect(sanitizeProviderOverview(undefined)).toBeNull();
});
it('drops a bare http image URL', () => {
expect(
sanitizeProviderOverview(
'http://line.example.net:80/images/series/cover_small.jpg'
)
).toBeNull();
});
it('drops a bare https URL with a query string', () => {
expect(
sanitizeProviderOverview('https://cdn.example.com/p.jpg?w=300')
).toBeNull();
});
it('drops a bare URL padded with whitespace', () => {
expect(
sanitizeProviderOverview(' http://cdn.example.com/cover.png ')
).toBeNull();
});
it('drops a protocol-relative URL', () => {
expect(
sanitizeProviderOverview('//cdn.example.com/cover.jpg')
).toBeNull();
});
it('keeps prose that merely contains a URL', () => {
const text = 'More info at http://example.com/season1';
expect(sanitizeProviderOverview(text)).toBe(text);
});
});
@@ -0,0 +1,26 @@
/**
* Xtream panels routinely fill editorial text fields with junk — most
* commonly a season `overview` that holds a bare cover-image URL instead
* of a description. Rendering that verbatim puts a raw URL on screen, so
* a value that is nothing but a URL is treated as absent.
*/
/**
* Trimmed overview text, or `null` when the value is empty or a bare URL.
* Prose that merely contains a URL is kept — only a single URL token with
* no surrounding text is junk.
*/
export function sanitizeProviderOverview(
text: string | null | undefined
): string | null {
const trimmed = text?.trim();
if (!trimmed) {
return null;
}
return isBareUrl(trimmed) ? null : trimmed;
}
/** A single absolute or protocol-relative URL token (`http://…/x.jpg`). */
function isBareUrl(text: string): boolean {
return /^(?:https?:)?\/\/\S+$/i.test(text);
}
@@ -8,6 +8,13 @@ export interface XtreamSerieDetails {
seasons: XtreamSerieSeason[];
info: XtreamSerieInfo;
episodes: Record<string, XtreamSerieEpisode[]>;
/**
* Populated by lazy TMDB season enrichment; absent in raw provider
* responses. Keyed by the provider season key (the `episodes` record
* key). Detail views use it as the season-description fallback when
* the provider's `seasons[].overview` is empty or URL-only junk.
*/
tmdb_season_overviews?: Record<string, string>;
}
export interface XtreamSerieInfo {