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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 11:45:54 +02:00
1 parent 43c1ceac16
commit 7790e68147
36 files changed
+1119 -102

No files matched your search

+70
View File
@@ -719,6 +719,76 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// Season cover beside the season tabs (provider tier)
//
// The default scenario's get_series_info carries a distinct seasons[].cover
// per season, which differs from the show cover, so with TMDB enrichment off
// the serial details page shows that cover next to the tabs and swaps it on
// a tab click. Image requests are answered locally so a slow or blocked CDN
// cannot fold the column (the app hides a cover whose image failed).
// ---------------------------------------------------------------------------
test('@xtream season cover — shows the provider season cover and follows the selected tab', async ({
page,
request,
}) => {
const TRANSPARENT_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64'
);
await page.route('**/picsum.photos/**', (route) =>
route.fulfill({ contentType: 'image/png', body: TRANSPARENT_PNG })
);
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// Season 1 is auto-selected; its provider cover sits beside the tabs.
// The mock seeds season art as `season-<id>-<n>` (cover) and
// `season-big-<id>-<n>` (cover_big); the app prefers cover_big.
const cover = page.locator('[data-testid="season-cover"]');
await expect(cover).toBeVisible({ timeout: 15_000 });
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-1/`)
);
// The cover follows the selected tab.
await page.locator('.season-tabs__pill').nth(1).click();
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-2/`)
);
});
// ---------------------------------------------------------------------------
// Series-level watched toggle: the season header's ⋮ menu marks EVERY season
// in one action (default scenario: 3 seasons × 8 episodes = 24), flips to