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

@@ -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 {