mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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[]>(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user