mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(portal): show each season's own poster beside the season tabs (#1628)
Series detail pages now render the selected season's poster as a season
cover next to the season tabs and description, and the fullscreen episode
panel shows the same poster as a season strip above its tabs.
Resolution is TMDB-first, like the show artwork merge: the lazy season
enrichment stores `/tv/{id}/season/{n}` `poster_path` as a w342 URL in
`tmdb_season_posters` (Xtream) or `StalkerSeriesTmdbSeasonsService.posters()`
(Stalker), under the same write-only-if-changed convergence guard as the
season overview. Xtream falls back to the provider's `seasons[].cover_big`/
`cover` when it is an http(s) URL other than the show poster, because panels
repeat the show poster on every season. Stalker is TMDB-only.
The cover column is not rendered for one-season items, seasons without a
poster, or a failed image, so every fallback is today's markup. It is sized
by a new `--season-cover-width` token (96/120/144px per Settings.coverSize).
The hero poster never follows the season.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
43c1ceac16
commit
7790e68147
36 files changed
+1119
-102
No files matched your search
+58
-8
@@ -50,15 +50,16 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
|
||||
seasonCount: 1,
|
||||
});
|
||||
|
||||
expect(getSeason).toHaveBeenCalledWith(82856, 2);
|
||||
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');
|
||||
});
|
||||
// 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), {
|
||||
@@ -151,6 +152,55 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
|
||||
expect(healed['1'][0].title).toBe('Season 3 Episode');
|
||||
});
|
||||
|
||||
it('exposes each fetched season poster per show and drops it with the entry', async () => {
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Season 2 overview',
|
||||
poster_path: '/season-two.jpg',
|
||||
episodes: [{ episode_number: 1, name: 'Season 2 Episode' }],
|
||||
});
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
rawTitle: 'Мандалорец (2 сезон)',
|
||||
seasonCount: 1,
|
||||
});
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Other show',
|
||||
poster_path: '/other-show.jpg',
|
||||
episodes: [],
|
||||
});
|
||||
await service.fetchSeason(99, '3', episodesOfSeason(3));
|
||||
|
||||
expect(service.posters(82856)).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-two.jpg',
|
||||
});
|
||||
expect(service.posters(99)).toEqual({
|
||||
'3': 'https://image.tmdb.org/t/p/w342/other-show.jpg',
|
||||
});
|
||||
expect(service.posters(null)).toEqual({});
|
||||
|
||||
// A replacement resolution that fails drops the poster with the
|
||||
// stale entry, so another slice never wears season two's art.
|
||||
getSeason.mockResolvedValueOnce(null);
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
rawTitle: 'Мандалорец (3 сезон)',
|
||||
seasonCount: 1,
|
||||
});
|
||||
expect(service.posters(82856)).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps no poster for a season TMDB has no art for', async () => {
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Season overview',
|
||||
poster_path: null,
|
||||
episodes: [{ episode_number: 1, name: 'The Marshal' }],
|
||||
});
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1));
|
||||
|
||||
expect(service.descriptions(82856)).toEqual({
|
||||
'1': 'Season overview',
|
||||
});
|
||||
expect(service.posters(82856)).toEqual({});
|
||||
});
|
||||
|
||||
it('does not cache a failed fetch, so a later trigger retries', async () => {
|
||||
getSeason.mockResolvedValueOnce(null);
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
|
||||
+39
-6
@@ -2,6 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
mergeEpisodesWithTmdb,
|
||||
tmdbSeasonPosterUrl,
|
||||
type TmdbEpisode,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
@@ -37,6 +38,10 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
/** mapKey → full URL of the season's own TMDB poster */
|
||||
private readonly postersByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
/** mapKey → season number currently being fetched (in-flight dedup) */
|
||||
private readonly pending = new Map<string, number>();
|
||||
|
||||
@@ -69,19 +74,34 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
* given show. Reads a signal, so callers can use it in a `computed`.
|
||||
*/
|
||||
descriptions(tmdbId: number | null | undefined): Record<string, string> {
|
||||
const overviews = this.overviewsByKey();
|
||||
if (!tmdbId || overviews.size === 0) {
|
||||
return this.forShow(this.overviewsByKey(), tmdbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Season poster URLs for the season cover and the fullscreen episode
|
||||
* panel, keyed by season key of the given show. Stalker rows carry no
|
||||
* provider season art, so this is the only source. Reads a signal.
|
||||
*/
|
||||
posters(tmdbId: number | null | undefined): Record<string, string> {
|
||||
return this.forShow(this.postersByKey(), tmdbId);
|
||||
}
|
||||
|
||||
private forShow(
|
||||
entries: ReadonlyMap<string, string>,
|
||||
tmdbId: number | null | undefined
|
||||
): Record<string, string> {
|
||||
if (!tmdbId || entries.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const prefix = `${tmdbId}|`;
|
||||
const descriptions: Record<string, string> = {};
|
||||
for (const [mapKey, overview] of overviews) {
|
||||
const bySeason: Record<string, string> = {};
|
||||
for (const [mapKey, value] of entries) {
|
||||
if (mapKey.startsWith(prefix)) {
|
||||
descriptions[mapKey.slice(prefix.length)] = overview;
|
||||
bySeason[mapKey.slice(prefix.length)] = value;
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
return bySeason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,6 +179,15 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
}
|
||||
this.overviewsByKey.set(overviews);
|
||||
|
||||
const posters = new Map(this.postersByKey());
|
||||
const poster = tmdbSeasonPosterUrl(season.poster_path);
|
||||
if (poster) {
|
||||
posters.set(mapKey, poster);
|
||||
} else {
|
||||
posters.delete(mapKey);
|
||||
}
|
||||
this.postersByKey.set(posters);
|
||||
|
||||
const next = new Map(this.seasonsByKey());
|
||||
next.set(mapKey, {
|
||||
seasonNumber,
|
||||
@@ -180,5 +209,9 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
const overviews = new Map(this.overviewsByKey());
|
||||
overviews.delete(mapKey);
|
||||
this.overviewsByKey.set(overviews);
|
||||
|
||||
const posters = new Map(this.postersByKey());
|
||||
posters.delete(mapKey);
|
||||
this.postersByKey.set(posters);
|
||||
}
|
||||
}
|
||||
+2
@@ -212,6 +212,7 @@
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="mappedSeasons()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
[seasonLoadStates]="vodSeasonLoadStates()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
@@ -243,6 +244,7 @@
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[downloadsEnabled]="!providerOnly()"
|
||||
[downloadAdapter]="episodeDownloadAdapter()"
|
||||
[isLoading]="
|
||||
|
||||
+9
-5
@@ -305,6 +305,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.tmdbSeasons.descriptions(this.displayItem()?.info?.tmdb_id)
|
||||
);
|
||||
|
||||
/** Season posters for the season cover and the fullscreen episode panel. */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() =>
|
||||
this.tmdbSeasons.posters(this.displayItem()?.info?.tmdb_id)
|
||||
);
|
||||
|
||||
/**
|
||||
* Track VOD series seasons with their loaded episodes
|
||||
*/
|
||||
@@ -1253,7 +1258,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
/** Per-season load state for the fullscreen episode panel (lazy VOD). */
|
||||
readonly vodSeasonLoadStates = computed(() =>
|
||||
this.isVodSeries() ? getVodSeasonLoadStates(this.vodSeriesSeasons()) : {}
|
||||
this.isVodSeries()
|
||||
? getVodSeasonLoadStates(this.vodSeriesSeasons())
|
||||
: {}
|
||||
);
|
||||
|
||||
async handleSeriesPlaybackToggleRequested(
|
||||
@@ -1411,10 +1418,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
request.requests.map((item) =>
|
||||
(item.nextPosition
|
||||
? this.persistSeriesPosition(playlistId, item.nextPosition)
|
||||
: this.clearSeriesPosition(
|
||||
playlistId,
|
||||
item.contentXtreamId
|
||||
)
|
||||
: this.clearSeriesPosition(playlistId, item.contentXtreamId)
|
||||
).then(
|
||||
() => true,
|
||||
// The scoped watched row was saved and published —
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ export class StubSeasonContainerComponent {
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly downloadsEnabled = input(true);
|
||||
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
|
||||
@@ -49,6 +50,7 @@ export class StubPortalInlinePlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
|
||||
@@ -379,6 +379,84 @@ describe('enrichSerialSeasonWithTmdb', () => {
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the TMDB season poster as a full w342 URL beside the overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
overview: 'Season overview.',
|
||||
poster_path: '/season-one.jpg',
|
||||
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_posters: Record<string, string>;
|
||||
};
|
||||
expect(updated.tmdb_season_posters).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-one.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('patches only the poster when TMDB returns neither episodes nor overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
poster_path: '/season-one.jpg',
|
||||
episodes: [],
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
episodes: Record<string, { title: string }[]>;
|
||||
tmdb_season_overviews?: Record<string, string>;
|
||||
tmdb_season_posters: Record<string, string>;
|
||||
};
|
||||
expect(updated.episodes['1'][0].title).toBe('Episode 1');
|
||||
expect(updated.tmdb_season_overviews).toBeUndefined();
|
||||
expect(updated.tmdb_season_posters).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-one.jpg',
|
||||
});
|
||||
|
||||
// Convergence: the same cache-served poster writes nothing again.
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not store a season poster when TMDB has none', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
overview: 'Season overview.',
|
||||
poster_path: null,
|
||||
episodes: [],
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
tmdb_season_posters?: Record<string, string>;
|
||||
};
|
||||
expect(updated.tmdb_season_posters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not store a blank TMDB season overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
@@ -416,7 +494,9 @@ describe('enrichSerialSeasonWithTmdb', () => {
|
||||
})
|
||||
);
|
||||
return {
|
||||
episodes: [{ episode_number: 1, name: 'Playlist A episode' }],
|
||||
episodes: [
|
||||
{ episode_number: 1, name: 'Playlist A episode' },
|
||||
],
|
||||
};
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mergeEpisodesWithTmdb,
|
||||
mergeSerieInfoWithTmdb,
|
||||
mergeVodInfoWithTmdb,
|
||||
tmdbSeasonPosterUrl,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
XtreamSerieDetails,
|
||||
@@ -187,7 +188,11 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
const season = await enrichment.getSeason(info.tmdb_id, seasonNumber);
|
||||
const tmdbEpisodes = season?.episodes ?? [];
|
||||
const seasonOverview = season?.overview?.trim() || null;
|
||||
if ((!tmdbEpisodes.length && !seasonOverview) || !isCurrentSelection()) {
|
||||
const seasonPoster = tmdbSeasonPosterUrl(season?.poster_path);
|
||||
if (
|
||||
(!tmdbEpisodes.length && !seasonOverview && !seasonPoster) ||
|
||||
!isCurrentSelection()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +220,10 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
const overviewChanged =
|
||||
seasonOverview !== null &&
|
||||
current.tmdb_season_overviews?.[seasonKey] !== seasonOverview;
|
||||
if (!episodesChanged && !overviewChanged) {
|
||||
const posterChanged =
|
||||
seasonPoster !== null &&
|
||||
current.tmdb_season_posters?.[seasonKey] !== seasonPoster;
|
||||
if (!episodesChanged && !overviewChanged && !posterChanged) {
|
||||
return;
|
||||
}
|
||||
store.setSelectedItem({
|
||||
@@ -236,6 +244,14 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(posterChanged
|
||||
? {
|
||||
tmdb_season_posters: {
|
||||
...current.tmdb_season_posters,
|
||||
[seasonKey]: seasonPoster,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as unknown as TItem);
|
||||
} catch (error) {
|
||||
console.warn('[TMDB] season merge failed:', error);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { XtreamSerieSeason } from '@iptvnator/shared/interfaces';
|
||||
import { buildSeasonPosters } from './season-posters.util';
|
||||
|
||||
const SHOW_POSTER = 'https://cdn.example.com/show.jpg';
|
||||
|
||||
function season(
|
||||
seasonNumber: number,
|
||||
cover: Partial<Pick<XtreamSerieSeason, 'cover' | 'cover_big'>> = {}
|
||||
): XtreamSerieSeason {
|
||||
return {
|
||||
season_number: seasonNumber,
|
||||
cover: '',
|
||||
cover_big: '',
|
||||
...cover,
|
||||
} as XtreamSerieSeason;
|
||||
}
|
||||
|
||||
describe('buildSeasonPosters', () => {
|
||||
it('returns an empty map for a missing item', () => {
|
||||
expect(buildSeasonPosters(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('prefers the TMDB season poster over the provider cover', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, {
|
||||
cover_big: 'https://cdn.example.com/s1-big.jpg',
|
||||
}),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'1': 'https://image.tmdb.org/t/p/w342/s1.jpg',
|
||||
},
|
||||
})
|
||||
).toEqual({ '1': 'https://image.tmdb.org/t/p/w342/s1.jpg' });
|
||||
});
|
||||
|
||||
it('falls back to cover_big, then cover, where TMDB has no season poster', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, {
|
||||
cover: 'https://cdn.example.com/s1.jpg',
|
||||
cover_big: 'https://cdn.example.com/s1-big.jpg',
|
||||
}),
|
||||
season(2, { cover: 'https://cdn.example.com/s2.jpg' }),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
'1': 'https://cdn.example.com/s1-big.jpg',
|
||||
'2': 'https://cdn.example.com/s2.jpg',
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops provider covers that repeat the show poster', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, { cover_big: SHOW_POSTER, cover: SHOW_POSTER }),
|
||||
season(2, {
|
||||
cover_big: SHOW_POSTER,
|
||||
cover: 'https://cdn.example.com/s2.jpg',
|
||||
}),
|
||||
],
|
||||
})
|
||||
).toEqual({ '2': 'https://cdn.example.com/s2.jpg' });
|
||||
});
|
||||
|
||||
it('trims padded URLs and still recognizes a padded show-poster duplicate', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: ` ${SHOW_POSTER}\n` },
|
||||
seasons: [
|
||||
season(1, { cover_big: ` ${SHOW_POSTER} ` }),
|
||||
season(2, {
|
||||
cover_big: ' https://cdn.example.com/s2.jpg ',
|
||||
}),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'3': '\thttps://image.tmdb.org/t/p/w342/s3.jpg ',
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
'2': 'https://cdn.example.com/s2.jpg',
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts only http(s) URLs from either source', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, { cover_big: 'season one', cover: '/s1.jpg' }),
|
||||
season(2, { cover_big: 'javascript:alert(1)' }),
|
||||
],
|
||||
tmdb_season_posters: { '3': 'data:image/png;base64,AAAA' },
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores seasons without a number and an empty-array provider info', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: [],
|
||||
seasons: [
|
||||
{
|
||||
cover_big: 'https://cdn.example.com/x.jpg',
|
||||
} as XtreamSerieSeason,
|
||||
season(2, { cover_big: 'https://cdn.example.com/s2.jpg' }),
|
||||
],
|
||||
})
|
||||
).toEqual({ '2': 'https://cdn.example.com/s2.jpg' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { XtreamSerieDetails } from '@iptvnator/shared/interfaces';
|
||||
|
||||
const HTTP_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
type SeasonPosterSource = Pick<
|
||||
XtreamSerieDetails,
|
||||
'seasons' | 'tmdb_season_posters'
|
||||
> & {
|
||||
/** Provider `info` is `[]` on some panels when no metadata exists */
|
||||
info?: { cover?: string } | [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Season posters keyed by season key, TMDB-first like the show artwork
|
||||
* merge (`prefer(tmdbPoster, provider)` in `tmdb-merge.ts`): the season's
|
||||
* own TMDB poster stored by the lazy season enrichment wins, and the
|
||||
* provider's `seasons[].cover_big`/`cover` from `get_series_info` fills
|
||||
* the gap. A provider season cover is accepted only as an http(s) URL
|
||||
* that differs from the show poster — panels routinely repeat the show
|
||||
* poster on every season, and a duplicate would render the hero poster a
|
||||
* second time under the season tabs.
|
||||
*/
|
||||
export function buildSeasonPosters(
|
||||
item: SeasonPosterSource | null
|
||||
): Record<string, string> {
|
||||
const posters: Record<string, string> = {};
|
||||
for (const [seasonKey, url] of Object.entries(
|
||||
item?.tmdb_season_posters ?? {}
|
||||
)) {
|
||||
const usable = usableUrl(url);
|
||||
if (usable) {
|
||||
posters[seasonKey] = usable;
|
||||
}
|
||||
}
|
||||
const showPoster =
|
||||
item?.info && !Array.isArray(item.info)
|
||||
? usableUrl(item.info.cover)
|
||||
: null;
|
||||
for (const season of item?.seasons ?? []) {
|
||||
if (season?.season_number === undefined) {
|
||||
continue;
|
||||
}
|
||||
const seasonKey = String(season.season_number);
|
||||
if (posters[seasonKey]) {
|
||||
continue;
|
||||
}
|
||||
// Panels pad URLs with whitespace now and then; compare and store
|
||||
// the trimmed form so a padded copy of the show poster is still
|
||||
// recognized as the duplicate it is.
|
||||
const cover = [season.cover_big, season.cover]
|
||||
.map(usableUrl)
|
||||
.find(
|
||||
(candidate) => candidate !== null && candidate !== showPoster
|
||||
);
|
||||
if (cover) {
|
||||
posters[seasonKey] = cover;
|
||||
}
|
||||
}
|
||||
return posters;
|
||||
}
|
||||
|
||||
/** The trimmed http(s) URL, or null for anything else. */
|
||||
function usableUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return HTTP_URL_PATTERN.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
@@ -219,6 +219,7 @@
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="item.episodes"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
@@ -249,6 +250,7 @@
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[seasonWatchBatchRunning]="seasonWatchBatchRunning()"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
|
||||
|
||||
@@ -52,6 +52,7 @@ class StubSeasonContainerComponent {
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
@@ -72,6 +73,7 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
XtreamSerieInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { buildSeasonDescriptions } from './season-descriptions.util';
|
||||
import { buildSeasonPosters } from './season-posters.util';
|
||||
import {
|
||||
createDiscoverFacetNavigation,
|
||||
isProviderOnlyDetailState,
|
||||
@@ -193,6 +194,11 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
buildSeasonDescriptions(this.selectedItem())
|
||||
);
|
||||
|
||||
/** Season posters (TMDB season poster first, provider season cover next). */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() =>
|
||||
buildSeasonPosters(this.selectedItem())
|
||||
);
|
||||
|
||||
/** The "Similar" rail: catalog matches plus cross-portal matches */
|
||||
private readonly similar = inject(SerialDetailsSimilarService);
|
||||
readonly similarItems = this.similar.similarItems;
|
||||
|
||||
@@ -6,6 +6,8 @@ export const TMDB_POSTER_SIZE = 'w500';
|
||||
export const TMDB_BACKDROP_SIZE = 'w1280';
|
||||
export const TMDB_PROFILE_SIZE = 'w185';
|
||||
export const TMDB_STILL_SIZE = 'w300';
|
||||
/** Season covers render at most 144 CSS px wide, so w342 covers 2× DPR */
|
||||
export const TMDB_SEASON_POSTER_SIZE = 'w342';
|
||||
|
||||
/**
|
||||
* Embedded application API key used when the user has not configured their
|
||||
@@ -107,3 +109,11 @@ export function tmdbProfileUrl(
|
||||
export function tmdbStillUrl(path: string | null | undefined): string | null {
|
||||
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_STILL_SIZE}${path}` : null;
|
||||
}
|
||||
|
||||
export function tmdbSeasonPosterUrl(
|
||||
path: string | null | undefined
|
||||
): string | null {
|
||||
return path
|
||||
? `${TMDB_IMAGE_BASE_URL}/${TMDB_SEASON_POSTER_SIZE}${path}`
|
||||
: null;
|
||||
}
|
||||
@@ -133,6 +133,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
|
||||
name: string;
|
||||
profile_path?: string | null;
|
||||
}[];
|
||||
/** Per-season summaries, including each season's own poster */
|
||||
seasons?: TmdbSeasonSummary[];
|
||||
}
|
||||
|
||||
export interface TmdbEpisode {
|
||||
@@ -150,9 +152,19 @@ export interface TmdbEpisode {
|
||||
export interface TmdbSeasonDetails {
|
||||
season_number?: number;
|
||||
overview?: string;
|
||||
/** The season's own poster; distinct from the show poster on most shows */
|
||||
poster_path?: string | null;
|
||||
episodes?: TmdbEpisode[];
|
||||
}
|
||||
|
||||
/** One entry of a TV details payload's `seasons[]` summary list */
|
||||
export interface TmdbSeasonSummary {
|
||||
season_number?: number;
|
||||
episode_count?: number;
|
||||
air_date?: string | null;
|
||||
poster_path?: string | null;
|
||||
}
|
||||
|
||||
/** One credit from /person/{id} combined_credits (cast or crew) */
|
||||
export interface TmdbPersonCredit {
|
||||
id: number;
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface XtreamSerieDetails {
|
||||
* the provider's `seasons[].overview` is empty or URL-only junk.
|
||||
*/
|
||||
tmdb_season_overviews?: Record<string, string>;
|
||||
/**
|
||||
* Populated by lazy TMDB season enrichment; absent in raw provider
|
||||
* responses. Keyed like `tmdb_season_overviews`. Full image URLs of
|
||||
* each season's own TMDB poster, the first choice for the season
|
||||
* cover in the detail view and the fullscreen episode panel.
|
||||
*/
|
||||
tmdb_season_posters?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface XtreamSerieInfo {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { isPortalPlaybackInProgress } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AutoSeasonContext {
|
||||
/** Season keys in display order. */
|
||||
keys: readonly string[];
|
||||
/** Season of the inline-playing episode, if it is in the loaded set. */
|
||||
playingSeasonKey: string | null;
|
||||
seasons: Record<string, XtreamSerieEpisode[]>;
|
||||
positionOf: (
|
||||
episode: XtreamSerieEpisode
|
||||
) => PlaybackPositionData | undefined;
|
||||
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
|
||||
hasUnloadedSeasons: boolean;
|
||||
episodeCounts: Record<string, number>;
|
||||
watchedCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The season the container opens on when the season set or the initial
|
||||
* playback positions change: the inline-playing episode's season, else the
|
||||
* most recently updated in-progress episode's season, else the default
|
||||
* fallback below. Pure, so the container's auto-select effect stays a thin
|
||||
* wrapper (see `SeasonContainerComponent.selectedSeason`).
|
||||
*/
|
||||
export function resolveAutoSelectedSeason(
|
||||
context: AutoSeasonContext
|
||||
): string | undefined {
|
||||
const { keys } = context;
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (context.playingSeasonKey) {
|
||||
return context.playingSeasonKey;
|
||||
}
|
||||
return (
|
||||
findMostRecentInProgressSeason(context) ?? resolveDefaultSeason(context)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when nothing is playing or in progress: the earliest season with
|
||||
* unwatched episodes, or — once everything loaded is watched — the latest
|
||||
* non-empty season, where new episodes land (issue #1441). Loaded-but-empty
|
||||
* seasons (a valid Stalker answer) are never picked over one that has
|
||||
* episodes. Stalker lazy-VOD series with unhydrated seasons keep the first
|
||||
* season: their watched state is unknown, so skipping past them would be a
|
||||
* guess.
|
||||
*/
|
||||
function resolveDefaultSeason({
|
||||
keys,
|
||||
hasUnloadedSeasons,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
}: AutoSeasonContext): string {
|
||||
if (hasUnloadedSeasons) {
|
||||
return keys[0];
|
||||
}
|
||||
const firstUnwatched = keys.find((key) => {
|
||||
const total = episodeCounts[key] ?? 0;
|
||||
return total > 0 && (watchedCounts[key] ?? 0) < total;
|
||||
});
|
||||
if (firstUnwatched) {
|
||||
return firstUnwatched;
|
||||
}
|
||||
const latestWithEpisodes = [...keys]
|
||||
.reverse()
|
||||
.find((key) => (episodeCounts[key] ?? 0) > 0);
|
||||
return latestWithEpisodes ?? keys[0];
|
||||
}
|
||||
|
||||
function findMostRecentInProgressSeason({
|
||||
seasons,
|
||||
positionOf,
|
||||
}: AutoSeasonContext): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(seasons)) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = positionOf(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSeason;
|
||||
}
|
||||
@@ -22,16 +22,37 @@
|
||||
/>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
<!-- Season card: the selected season's own cover beside the tabs and
|
||||
description. Without a cover the column is not rendered at all,
|
||||
so the tabs sit exactly where they did before season posters. -->
|
||||
<div
|
||||
class="season-card"
|
||||
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
|
||||
>
|
||||
@if (selectedSeasonPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-card__cover"
|
||||
data-testid="season-cover"
|
||||
[src]="posterUrl"
|
||||
[alt]="
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() }
|
||||
"
|
||||
loading="lazy"
|
||||
(error)="onSeasonPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
|
||||
@@ -77,9 +77,42 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
// Season card: an optional cover column (--season-cover-width follows
|
||||
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
|
||||
// next to the tabs + description. Without a cover the grid is one column and
|
||||
// the tabs render exactly as they did before season posters existed.
|
||||
.season-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&--with-cover {
|
||||
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
app-season-tabs {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.season-card--with-cover {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
|
||||
@@ -1185,9 +1185,7 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (2)'
|
||||
);
|
||||
expect(button?.textContent).toContain('Mark season as watched (2)');
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
@@ -1277,9 +1275,7 @@ describe('SeasonContainerComponent', () => {
|
||||
|
||||
const button = toggleButton();
|
||||
// 103 is the only markable episode: 101 watched, 102 playing.
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (1)'
|
||||
);
|
||||
expect(button?.textContent).toContain('Mark season as watched (1)');
|
||||
button?.click();
|
||||
expect(
|
||||
emitted[0].requests.map((item) => item.contentXtreamId)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import { resolveAutoSelectedSeason } from './season-auto-select.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import {
|
||||
type EpisodeViewMode,
|
||||
@@ -95,6 +96,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
/** Per-season descriptions (TMDB/provider), keyed by season key. */
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs (TMDB season poster, provider season cover),
|
||||
* keyed by season key. Rendered as the season cover beside the tabs.
|
||||
*/
|
||||
readonly seasonPosters = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
/**
|
||||
@@ -201,6 +207,26 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return this.seasonDescriptions()?.[selected] ?? null;
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; the cover column then folds. */
|
||||
private readonly failedSeasonPosters = signal<ReadonlySet<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
/**
|
||||
* The selected season's cover. Withheld for one-season items — that
|
||||
* poster is the show poster again, a few hundred pixels below the hero —
|
||||
* and for a URL whose image failed, so a dead provider link never leaves
|
||||
* a broken-image frame beside the tabs.
|
||||
*/
|
||||
readonly selectedSeasonPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (!selected || this.sortedSeasonKeys().length < 2) {
|
||||
return null;
|
||||
}
|
||||
const url = this.seasonPosters()?.[selected] ?? null;
|
||||
return url && !this.failedSeasonPosters().has(url) ? url : null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.downloadPresenter.connect({
|
||||
adapter: this.downloadAdapter,
|
||||
@@ -298,6 +324,10 @@ export class SeasonContainerComponent implements OnInit {
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
|
||||
onSeasonPosterError(url: string): void {
|
||||
this.failedSeasonPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
scrollToPlayingEpisode(): void {
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (!playingSeason) {
|
||||
@@ -427,66 +457,16 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Auto-select rules live in season-auto-select.util.ts. */
|
||||
private resolveAutoSeason(): string | undefined {
|
||||
const keys = this.sortedSeasonKeys();
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (playingSeason) {
|
||||
return playingSeason;
|
||||
}
|
||||
|
||||
const resumeSeason = this.findMostRecentInProgressSeason();
|
||||
return resumeSeason ?? this.resolveDefaultSeason(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when nothing is playing or in progress: the earliest season
|
||||
* with unwatched episodes, or — once everything loaded is watched — the
|
||||
* latest non-empty season, where new episodes land (issue #1441).
|
||||
* Loaded-but-empty seasons (a valid Stalker answer) are never picked over
|
||||
* one that has episodes. Stalker lazy-VOD series with unhydrated seasons
|
||||
* keep the first season: their watched state is unknown, so skipping
|
||||
* past them would be a guess.
|
||||
*/
|
||||
private resolveDefaultSeason(keys: readonly string[]): string {
|
||||
if (this.hasUnloadedSeasons()) {
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
const episodeCounts = this.episodeCounts();
|
||||
const watchedCounts = this.watchedCounts();
|
||||
const firstUnwatched = keys.find((key) => {
|
||||
const total = episodeCounts[key] ?? 0;
|
||||
return total > 0 && (watchedCounts[key] ?? 0) < total;
|
||||
return resolveAutoSelectedSeason({
|
||||
keys: this.sortedSeasonKeys(),
|
||||
playingSeasonKey: this.playingSeasonKey(),
|
||||
seasons: this.seasons(),
|
||||
positionOf: (episode) => this.getEpisodePosition(episode),
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons(),
|
||||
episodeCounts: this.episodeCounts(),
|
||||
watchedCounts: this.watchedCounts(),
|
||||
});
|
||||
if (firstUnwatched) {
|
||||
return firstUnwatched;
|
||||
}
|
||||
const latestWithEpisodes = [...keys]
|
||||
.reverse()
|
||||
.find((key) => (episodeCounts[key] ?? 0) > 0);
|
||||
return latestWithEpisodes ?? keys[0];
|
||||
}
|
||||
|
||||
private findMostRecentInProgressSeason(): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSeason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// Coverage for the season cover beside the season tabs (the `seasonPosters`
|
||||
// input). Lives beside season-container.component.spec.ts, which sits at the
|
||||
// max-lines cap.
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
describe('SeasonContainerComponent season cover', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
|
||||
const cover = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-cover"]'
|
||||
) as HTMLImageElement | null;
|
||||
|
||||
const coverColumnRendered = () =>
|
||||
fixture.nativeElement.querySelector('.season-card--with-cover') !==
|
||||
null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders the selected season cover and swaps it with the tab', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(coverColumnRendered()).toBe(true);
|
||||
|
||||
const tabs = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
tabs[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(cover()?.src).toBe('https://img.test/season-2.jpg');
|
||||
});
|
||||
|
||||
it('folds the cover column when the image request fails', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// A dead image link folds the cover column instead of leaving a
|
||||
// broken-image frame beside the tabs; the tabs stay in place.
|
||||
cover()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('withholds the season cover for a one-season item', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
});
|
||||
|
||||
it('withholds the cover when the selected season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-season-tabs')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+31
@@ -1,5 +1,36 @@
|
||||
<div class="episode-panel">
|
||||
<div class="episode-panel__tabs">
|
||||
@if (seasonStrip(); as strip) {
|
||||
<div
|
||||
class="episode-panel__season"
|
||||
data-test-id="fullscreen-episode-panel-season"
|
||||
>
|
||||
<img
|
||||
class="episode-panel__season-poster"
|
||||
[src]="strip.posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onSeasonPosterError(strip.posterUrl)"
|
||||
/>
|
||||
<span class="episode-panel__season-meta">
|
||||
<span class="episode-panel__season-name">
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: strip.seasonKey }
|
||||
}}
|
||||
</span>
|
||||
<span class="episode-panel__season-count">
|
||||
{{
|
||||
(strip.episodeCount === 1
|
||||
? 'DOWNLOADS.EPISODE_COUNT_ONE'
|
||||
: 'DOWNLOADS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count: strip.episodeCount }
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<app-season-tabs
|
||||
[seasonKeys]="seasonKeys()"
|
||||
[selectedSeason]="selectedSeasonKey() ?? undefined"
|
||||
|
||||
+36
@@ -29,6 +29,42 @@
|
||||
--accent-success: #4ade80;
|
||||
}
|
||||
|
||||
// ─── Season strip: the shown season's poster, name and count above the tabs ──
|
||||
|
||||
.episode-panel__season {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 4px 10px;
|
||||
}
|
||||
|
||||
.episode-panel__season-poster {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.episode-panel__season-meta {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-panel__season-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.episode-panel__season-count {
|
||||
font-size: 0.76rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.episode-panel__list {
|
||||
// Positioned so a row's offsetTop is measured against this scroll box.
|
||||
position: relative;
|
||||
|
||||
+69
-1
@@ -1,6 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { FullscreenEpisodePanelComponent } from './fullscreen-episode-panel.component';
|
||||
import type {
|
||||
FullscreenEpisodePanelItem,
|
||||
@@ -188,6 +188,74 @@ describe('FullscreenEpisodePanelComponent', () => {
|
||||
expect(selected).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('shows the season strip with poster, name and count, follows the tab and folds on a dead image', () => {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { SEASON_TAB: 'Season {{number}}' },
|
||||
DOWNLOADS: {
|
||||
EPISODE_COUNT_ONE: '1 episode',
|
||||
EPISODE_COUNT_OTHER: '{{count}} episodes',
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
const withPosters = seasons(12).map((season) =>
|
||||
season.key === '1'
|
||||
? { ...season, posterUrl: 'https://img.test/season-1.jpg' }
|
||||
: season
|
||||
);
|
||||
fixture.componentRef.setInput('seasons', withPosters);
|
||||
fixture.detectChanges();
|
||||
|
||||
const strip = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="fullscreen-episode-panel-season"]'
|
||||
) as HTMLElement | null;
|
||||
expect(
|
||||
strip()?.querySelector<HTMLImageElement>(
|
||||
'.episode-panel__season-poster'
|
||||
)?.src
|
||||
).toBe('https://img.test/season-1.jpg');
|
||||
expect(
|
||||
strip()
|
||||
?.querySelector('.episode-panel__season-name')
|
||||
?.textContent?.trim()
|
||||
).toBe('Season 1');
|
||||
expect(
|
||||
strip()
|
||||
?.querySelector('.episode-panel__season-count')
|
||||
?.textContent?.trim()
|
||||
).toBe('2 episodes');
|
||||
|
||||
// Season two has no poster: the strip is withheld, tabs stay.
|
||||
pills()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(strip()).toBeNull();
|
||||
expect(pills()).toHaveLength(3);
|
||||
|
||||
pills()[0].click();
|
||||
fixture.detectChanges();
|
||||
strip()
|
||||
?.querySelector('.episode-panel__season-poster')
|
||||
?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(strip()).toBeNull();
|
||||
});
|
||||
|
||||
it('withholds the season strip for a one-season series', () => {
|
||||
const [first] = seasons(12);
|
||||
fixture.componentRef.setInput('seasons', [
|
||||
{ ...first, posterUrl: 'https://img.test/season-1.jpg' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="fullscreen-episode-panel-season"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(rows()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows a pending season as loading and a loaded empty season as empty', () => {
|
||||
fixture.componentRef.setInput('seasons', seasons(12));
|
||||
fixture.detectChanges();
|
||||
|
||||
+32
@@ -106,6 +106,34 @@ export class FullscreenEpisodePanelComponent {
|
||||
readonly episodeCounts = computed(() =>
|
||||
countBySeason(this.seasons(), (season) => season.episodes.length)
|
||||
);
|
||||
/** Season poster URLs whose image request failed; the strip then folds. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
/**
|
||||
* The season strip above the tabs: the shown season's own poster with
|
||||
* its name and episode count. Withheld for one-season series (that
|
||||
* poster is the show poster) and for a poster whose image failed.
|
||||
*/
|
||||
readonly seasonStrip = computed<{
|
||||
posterUrl: string;
|
||||
seasonKey: string;
|
||||
episodeCount: number;
|
||||
} | null>(() => {
|
||||
const season = this.selectedSeason();
|
||||
const posterUrl = season?.posterUrl;
|
||||
if (
|
||||
!season ||
|
||||
!posterUrl ||
|
||||
this.seasons().length < 2 ||
|
||||
this.failedPosters().has(posterUrl)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
posterUrl,
|
||||
seasonKey: season.key,
|
||||
episodeCount: season.episodes.length,
|
||||
};
|
||||
});
|
||||
readonly watchedCounts = computed(() =>
|
||||
countBySeason(
|
||||
this.seasons(),
|
||||
@@ -155,6 +183,10 @@ export class FullscreenEpisodePanelComponent {
|
||||
this.failedStills.update((failed) => new Set(failed).add(item.id));
|
||||
}
|
||||
|
||||
onSeasonPosterError(url: string): void {
|
||||
this.failedPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
hasStill(item: FullscreenEpisodePanelItem): boolean {
|
||||
return item.thumbnailUrl !== null && !this.failedStills().has(item.id);
|
||||
}
|
||||
|
||||
+11
@@ -163,6 +163,17 @@ describe('buildFullscreenEpisodePanelSeasons', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the host’s season poster only for seasons that have one', () => {
|
||||
const seasons = buildFullscreenEpisodePanelSeasons({
|
||||
episodesBySeason: { '1': [episode(11, 1, 1)], '2': [] },
|
||||
currentEpisodeId: 11,
|
||||
seasonPosters: { '1': 'https://img.test/season-1.jpg' },
|
||||
});
|
||||
|
||||
expect(seasons[0].posterUrl).toBe('https://img.test/season-1.jpg');
|
||||
expect('posterUrl' in seasons[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the season key and the list index when an episode carries no numbers', () => {
|
||||
const [season] = buildFullscreenEpisodePanelSeasons({
|
||||
episodesBySeason: {
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface FullscreenEpisodePanelSeason<TEpisode = unknown> {
|
||||
key: string;
|
||||
episodes: FullscreenEpisodePanelItem<TEpisode>[];
|
||||
loadState: FullscreenPanelSeasonLoadState;
|
||||
/** The season's own poster (TMDB or provider), when the host has one. */
|
||||
posterUrl?: string;
|
||||
}
|
||||
|
||||
export interface BuildFullscreenEpisodePanelSeasonsOptions<
|
||||
@@ -73,6 +75,8 @@ export interface BuildFullscreenEpisodePanelSeasonsOptions<
|
||||
seasonLoadStates?: Readonly<
|
||||
Record<string, Exclude<FullscreenPanelSeasonLoadState, 'loaded'>>
|
||||
> | null;
|
||||
/** Per season key, the season's poster URL; absent means none known. */
|
||||
seasonPosters?: Readonly<Record<string, string>> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +91,7 @@ export function buildFullscreenEpisodePanelSeasons<
|
||||
currentEpisodeId,
|
||||
playbackPositions,
|
||||
seasonLoadStates,
|
||||
seasonPosters,
|
||||
}: BuildFullscreenEpisodePanelSeasonsOptions<TEpisode>): FullscreenEpisodePanelSeason<TEpisode>[] {
|
||||
if (!episodesBySeason) {
|
||||
return [];
|
||||
@@ -99,9 +104,11 @@ export function buildFullscreenEpisodePanelSeasons<
|
||||
|
||||
return sortSeasonKeys(Object.keys(episodesBySeason)).map((seasonKey) => {
|
||||
const episodes = episodesBySeason[seasonKey] ?? [];
|
||||
const posterUrl = seasonPosters?.[seasonKey];
|
||||
return {
|
||||
key: seasonKey,
|
||||
loadState: seasonLoadStates?.[seasonKey] ?? 'loaded',
|
||||
...(posterUrl ? { posterUrl } : {}),
|
||||
episodes: episodes.map((episode, index) =>
|
||||
toPanelItem(
|
||||
episode,
|
||||
|
||||
+3
@@ -30,6 +30,8 @@ export interface EpisodePanelHostInputs {
|
||||
> | null>;
|
||||
playbackPositions: Signal<ReadonlyMap<number, PlaybackPositionData> | null>;
|
||||
seasonLoadStates: Signal<SeasonLoadStates | null>;
|
||||
/** Per season key, the season's poster for the panel's season strip. */
|
||||
seasonPosters?: Signal<Readonly<Record<string, string>> | null>;
|
||||
seriesTitle: Signal<string | null>;
|
||||
/** Playback title, the header fallback when the host names no series. */
|
||||
fallbackTitle: Signal<string>;
|
||||
@@ -72,6 +74,7 @@ export function createEpisodePanelHost(
|
||||
currentEpisodeId: playingEpisodeId(),
|
||||
playbackPositions: inputs.playbackPositions(),
|
||||
seasonLoadStates: inputs.seasonLoadStates(),
|
||||
seasonPosters: inputs.seasonPosters?.() ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ export class PortalInlinePlayerComponent {
|
||||
* series), keyed by season; absent keys are loaded.
|
||||
*/
|
||||
readonly seasonLoadStates = input<SeasonLoadStates | null>(null);
|
||||
/**
|
||||
* Per season key, the season's own poster (TMDB season poster or the
|
||||
* provider's season cover) for the fullscreen panel's season strip.
|
||||
*/
|
||||
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
|
||||
null
|
||||
);
|
||||
/**
|
||||
* Initial player volume. Only hosts that own a persisted volume pass it
|
||||
* (the M3U player shares one across its channels); the portals keep the
|
||||
@@ -230,6 +237,7 @@ export class PortalInlinePlayerComponent {
|
||||
seriesEpisodes: this.seriesEpisodes,
|
||||
playbackPositions: this.episodePlaybackPositions,
|
||||
seasonLoadStates: this.seasonLoadStates,
|
||||
seasonPosters: this.seasonPosters,
|
||||
seriesTitle: this.seriesTitle,
|
||||
fallbackTitle: this.title,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user