feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups (#1633)

* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups

Follow-ups to the season posters shipped in #1628:

- The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42
  season thumbnail at the start of each menu row that has a poster and in
  the closed trigger for the selected season, fed by a new `seasonPosters`
  input from the season container and the fullscreen episode panel. Rows
  without a poster get no placeholder, a failed image is dropped, and the
  pill row stays text-only as the design review decided.
- The fullscreen season strip's episode count uses its own
  `PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download
  manager's; all 18 locales filled through the i18n merger from their
  existing `DOWNLOADS.EPISODE_COUNT_*` translations.
- The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands
  `env` entry overrides the shell), and `main.ts` resolves `PORT`, then the
  Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now
  relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT`
  the same way.
- `resolveAutoSelectedSeason` gets a direct spec covering every branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream-mock): mint marketing asset URLs on the port the server bound

Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but
`marketingAssetOrigin()` still read `PORT` alone, so a run relocated only
through the alias sent every poster/backdrop/logo/episode URL to 3211.

One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`,
then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the
marketing asset origin and the demo-guide origin fallback. A spec pins the
precedence and that `marketingAssetUrl` follows the bound port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

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 14:54:54 +02:00
1 parent 01c423ac43
commit 4cce4acaad
43 files changed
+699 -32

No files matched your search

@@ -0,0 +1,213 @@
import {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import {
type AutoSeasonContext,
resolveAutoSelectedSeason,
} from './season-auto-select.util';
// Direct coverage for the pure season auto-select rule. The container specs
// exercise it through the component; this file pins each branch on its own.
function episode(id: string, season: number): XtreamSerieEpisode {
return {
id,
episode_num: 1,
title: `Episode ${id}`,
container_extension: 'mp4',
info: {},
custom_sid: '',
added: '',
season,
direct_source: '',
} as XtreamSerieEpisode;
}
function position(
contentXtreamId: number,
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
return {
contentXtreamId,
contentType: 'episode',
positionSeconds: 600,
durationSeconds: 2700,
...overrides,
};
}
const inProgress = (contentXtreamId: number, updatedAt?: string) =>
position(contentXtreamId, { positionSeconds: 600, updatedAt });
const watched = (contentXtreamId: number) =>
position(contentXtreamId, { positionSeconds: 2700 });
/**
* Builds the context the container would hand over: one episode per listed
* id, season keys in display order, counts derived from the same data.
*/
function context(
seasons: Record<string, string[]>,
positions: Record<string, PlaybackPositionData> = {},
overrides: Partial<AutoSeasonContext> = {}
): AutoSeasonContext {
const keys = Object.keys(seasons).sort((a, b) => Number(a) - Number(b));
const episodes: Record<string, XtreamSerieEpisode[]> = {};
const episodeCounts: Record<string, number> = {};
const watchedCounts: Record<string, number> = {};
for (const key of keys) {
episodes[key] = seasons[key].map((id) => episode(id, Number(key)));
episodeCounts[key] = seasons[key].length;
watchedCounts[key] = seasons[key].filter((id) => {
const stored = positions[id];
return (
stored !== undefined &&
stored.positionSeconds / (stored.durationSeconds ?? 1) >= 0.9
);
}).length;
}
return {
keys,
playingSeasonKey: null,
seasons: episodes,
positionOf: (item) => positions[item.id],
hasUnloadedSeasons: false,
episodeCounts,
watchedCounts,
...overrides,
};
}
describe('resolveAutoSelectedSeason', () => {
it('returns undefined without season keys', () => {
expect(resolveAutoSelectedSeason(context({}))).toBeUndefined();
expect(
resolveAutoSelectedSeason(
context({}, {}, { playingSeasonKey: '3' })
)
).toBeUndefined();
});
it('prefers the playing season over every other rule', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'], '3': ['301'] },
{ '201': inProgress(201, '2026-07-02T00:00:00.000Z') },
{ playingSeasonKey: '3' }
);
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
});
it('picks the most recently updated in-progress season', () => {
const ctx = context(
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
{
'101': inProgress(101, '2026-07-03T00:00:00.000Z'),
'301': inProgress(301, '2026-07-01T00:00:00.000Z'),
}
);
// Season three is later in display order but its resume point is
// older; recency, not position, decides.
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
});
it('treats a missing updatedAt as the oldest resume point', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'] },
{
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
'201': inProgress(201),
}
);
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
});
it('resolves an updatedAt tie to the season scanned last', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'] },
{
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
'201': inProgress(201, '2026-07-01T00:00:00.000Z'),
}
);
// `>=` keeps the later entry on a tie: the scan order of the seasons
// map, which follows the container's sorted keys.
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
});
it('ignores watched and barely-started positions when looking for progress', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'], '3': ['301'] },
{
'101': watched(101),
'301': position(301, {
positionSeconds: 5,
updatedAt: '2026-07-09T00:00:00.000Z',
}),
}
);
// Season one is watched, season three's 5 s is below the in-progress
// threshold, so the default rule runs: earliest unwatched season.
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
});
it('falls back to the earliest season with unwatched episodes', () => {
const ctx = context(
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
{ '101': watched(101), '102': watched(102) }
);
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
});
it('opens the latest non-empty season once everything is watched', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'], '3': ['301'] },
{ '101': watched(101), '201': watched(201), '301': watched(301) }
);
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
});
it('skips loaded-but-empty seasons in both fallback branches', () => {
// An empty season is never "unwatched": the first season with
// episodes wins.
expect(
resolveAutoSelectedSeason(
context({ '1': [], '2': ['201'], '3': ['301'] })
)
).toBe('2');
// Nor is it the "latest" season once everything is watched.
expect(
resolveAutoSelectedSeason(
context(
{ '1': ['101'], '2': ['201'], '3': [] },
{ '101': watched(101), '201': watched(201) }
)
)
).toBe('2');
});
it('returns the first key when every season is empty', () => {
expect(resolveAutoSelectedSeason(context({ '1': [], '2': [] }))).toBe(
'1'
);
});
it('pins the first key while seasons are still unloaded', () => {
const ctx = context(
{ '1': ['101'], '2': ['201'], '3': [] },
{ '101': watched(101) },
{ hasUnloadedSeasons: true }
);
// Watched state of the unloaded seasons is unknown, so the fallback
// does not skip ahead — but a resume point still outranks the pin.
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
expect(
resolveAutoSelectedSeason(
context(
{ '1': ['101'], '2': ['201'], '3': [] },
{ '201': inProgress(201, '2026-07-01T00:00:00.000Z') },
{ hasUnloadedSeasons: true }
)
)
).toBe('2');
});
});
@@ -48,6 +48,7 @@
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
@@ -9,7 +9,8 @@ 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`
// Coverage for the season cover beside the season tabs and the season
// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters`
// input). Lives beside season-container.component.spec.ts, which sits at the
// max-lines cap.
@@ -133,6 +134,120 @@ describe('SeasonContainerComponent season cover', () => {
expect(coverColumnRendered()).toBe(false);
});
describe('dropdown thumbnails (more than six seasons)', () => {
const sevenSeasons = (): Record<string, XtreamSerieEpisode[]> => {
const seasons: Record<string, XtreamSerieEpisode[]> = {};
for (let index = 1; index <= 7; index++) {
seasons[String(index)] = [
createEpisode({
id: String(100 + index),
season: index,
}),
];
}
return seasons;
};
const trigger = () =>
fixture.nativeElement.querySelector(
'[data-testid="season-dropdown"]'
) as HTMLButtonElement | null;
const triggerThumb = () =>
trigger()?.querySelector<HTMLImageElement>(
'[data-testid="season-dropdown-thumb"]'
) ?? null;
const openMenu = (): HTMLButtonElement[] => {
trigger()?.click();
fixture.detectChanges();
return Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.mat-mdc-menu-panel .mat-mdc-menu-item'
)
);
};
afterEach(() => fixture.destroy());
it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
'3': 'https://img.test/season-3.jpg',
});
setRequiredInputs(sevenSeasons());
fixture.detectChanges();
// The pill row is untouched: the dropdown replaces it.
expect(
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
).toHaveLength(0);
const items = openMenu();
expect(items).toHaveLength(7);
const thumbOf = (item: HTMLButtonElement) =>
item.querySelector<HTMLImageElement>(
'[data-testid="season-menu-thumb"]'
);
expect(thumbOf(items[0])?.src).toBe(
'https://img.test/season-1.jpg'
);
// Projected into the leading slot, ahead of Material's text span.
expect(thumbOf(items[0])?.parentElement).toBe(items[0]);
expect(thumbOf(items[1])).toBeNull();
expect(thumbOf(items[2])?.src).toBe(
'https://img.test/season-3.jpg'
);
// Rows without a poster carry no placeholder either.
expect(items[1].querySelector('.season-tabs__thumb')).toBeNull();
});
it('shows the selected season poster in the closed trigger and swaps it with the selection', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
'2': 'https://img.test/season-2.jpg',
});
setRequiredInputs(sevenSeasons());
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg');
expect(
trigger()?.classList.contains(
'season-tabs__dropdown--with-thumb'
)
).toBe(true);
openMenu()[1].click();
fixture.detectChanges();
expect(component.selectedSeason()).toBe('2');
expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg');
// A season without a poster leaves the trigger text-only.
openMenu()[2].click();
fixture.detectChanges();
expect(component.selectedSeason()).toBe('3');
expect(triggerThumb()).toBeNull();
expect(
trigger()?.classList.contains(
'season-tabs__dropdown--with-thumb'
)
).toBe(false);
});
it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
});
setRequiredInputs(sevenSeasons());
fixture.detectChanges();
triggerThumb()?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(triggerThumb()).toBeNull();
// The same URL is dropped from the menu row too.
expect(
openMenu()[0].querySelector('[data-testid="season-menu-thumb"]')
).toBeNull();
});
});
it('withholds the cover when the selected season has no poster', () => {
fixture.componentRef.setInput('seasonPosters', {
'2': 'https://img.test/season-2.jpg',
@@ -3,9 +3,21 @@
<button
class="season-tabs__dropdown"
data-testid="season-dropdown"
[class.season-tabs__dropdown--with-thumb]="selectedPosterUrl()"
[matMenuTriggerFor]="seasonMenu"
type="button"
>
@if (selectedPosterUrl(); as posterUrl) {
<img
class="season-tabs__thumb"
data-testid="season-dropdown-thumb"
[src]="posterUrl"
alt=""
aria-hidden="true"
loading="lazy"
(error)="onPosterError(posterUrl)"
/>
}
<span>
{{
'PORTALS.SEASON_TAB'
@@ -23,6 +35,20 @@
"
(click)="selectSeason(seasonKey)"
>
<!-- Projected into the item's leading slot so the row
stays a flex line: thumbnail, name, count, check. -->
@if (posterUrlOf(seasonKey); as posterUrl) {
<img
matMenuItemIcon
class="season-tabs__thumb season-tabs__thumb--menu"
data-testid="season-menu-thumb"
[src]="posterUrl"
alt=""
aria-hidden="true"
loading="lazy"
(error)="onPosterError(posterUrl)"
/>
}
<span>
{{
'PORTALS.SEASON_TAB'
@@ -50,6 +50,28 @@
font-weight: 600;
font-family: inherit;
cursor: pointer;
// The trigger hugs its thumbnail: less inset on the picture side and
// vertically, so the pill grows only as much as the 42px poster needs.
&--with-thumb {
gap: 8px;
padding: 4px 10px 4px 4px;
}
}
// Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at
// 28×42 — small enough to keep the 48px menu row height Material sets.
&__thumb {
flex: none;
width: 28px;
height: 42px;
object-fit: cover;
border-radius: 3px;
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
&--menu {
margin-right: 12px;
}
}
&__menu-count {
@@ -4,6 +4,7 @@ import {
computed,
input,
output,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -18,6 +19,15 @@ const MAX_SEASON_PILLS = 6;
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
* description under the tabs and a "back to playing episode" chip when the
* currently playing episode belongs to a different season.
*
* The dropdown carries season thumbnails (`seasonPosters`): a small poster at
* the start of each menu row and, for the selected season, in the closed
* trigger — a long menu of "Season 7 … Season 14" rows reads far faster with
* a picture per row. The pill row deliberately stays text-only: a thumbnail
* per pill turns a compact tab strip into a second poster rail. A season
* without a poster simply has no thumbnail (no placeholder tile), and a
* poster whose image request fails is dropped rather than left as a
* broken-image frame.
*/
@Component({
selector: 'app-season-tabs',
@@ -36,6 +46,13 @@ export class SeasonTabsComponent {
readonly seasonDescription = input<string | null>(null);
/** Season key of the episode currently playing inline, if any. */
readonly playingSeasonKey = input<string | null>(null);
/**
* Per-season poster URLs keyed by season key, rendered as thumbnails in
* the dropdown only (menu rows + the selected season's trigger).
*/
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
null
);
readonly seasonSelected = output<string>();
readonly backToPlayingRequested = output<void>();
@@ -49,6 +66,24 @@ export class SeasonTabsComponent {
return playing !== null && playing !== this.selectedSeason();
});
/** Poster URLs whose image request failed; their thumbnail is dropped. */
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
/** Thumbnail shown in the closed dropdown trigger. */
readonly selectedPosterUrl = computed(() => {
const selected = this.selectedSeason();
return selected === undefined ? null : this.posterUrlOf(selected);
});
posterUrlOf(seasonKey: string): string | null {
const url = this.seasonPosters()?.[seasonKey];
return url && !this.failedPosters().has(url) ? url : null;
}
onPosterError(url: string): void {
this.failedPosters.update((failed) => new Set(failed).add(url));
}
isSeasonCompleted(seasonKey: string): boolean {
const total = this.episodeCounts()[seasonKey] ?? 0;
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
@@ -23,8 +23,8 @@
<span class="episode-panel__season-count">
{{
(strip.episodeCount === 1
? 'DOWNLOADS.EPISODE_COUNT_ONE'
: 'DOWNLOADS.EPISODE_COUNT_OTHER'
? 'PORTALS.EPISODE_COUNT_ONE'
: 'PORTALS.EPISODE_COUNT_OTHER'
) | translate: { count: strip.episodeCount }
}}
</span>
@@ -36,6 +36,7 @@
[selectedSeason]="selectedSeasonKey() ?? undefined"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonPosters]="seasonPosters()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="onSeasonSelected($event)"
(backToPlayingRequested)="backToPlaying()"
@@ -191,8 +191,8 @@ describe('FullscreenEpisodePanelComponent', () => {
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: {
PORTALS: {
SEASON_TAB: 'Season {{number}}',
EPISODE_COUNT_ONE: '1 episode',
EPISODE_COUNT_OTHER: '{{count}} episodes',
},
@@ -241,6 +241,51 @@ describe('FullscreenEpisodePanelComponent', () => {
expect(strip()).toBeNull();
});
it('hands the season posters to the tabs dropdown of a long series', () => {
const many: FullscreenEpisodePanelSeason[] = Array.from(
{ length: 7 },
(_, index) => ({
key: String(index + 1),
loadState: 'loaded',
episodes: [item(10 * (index + 1), String(index + 1), 1)],
...(index % 2 === 0
? { posterUrl: `https://img.test/season-${index + 1}.jpg` }
: {}),
})
);
fixture.componentRef.setInput('seasons', many);
fixture.detectChanges();
expect(component.seasonPosters()).toEqual({
'1': 'https://img.test/season-1.jpg',
'3': 'https://img.test/season-3.jpg',
'5': 'https://img.test/season-5.jpg',
'7': 'https://img.test/season-7.jpg',
});
expect(pills()).toHaveLength(0);
const trigger = fixture.nativeElement.querySelector(
'[data-testid="season-dropdown"]'
) as HTMLButtonElement;
expect(
trigger.querySelector<HTMLImageElement>(
'[data-testid="season-dropdown-thumb"]'
)?.src
).toBe('https://img.test/season-1.jpg');
trigger.click();
fixture.detectChanges();
const thumbs = Array.from(
document.querySelectorAll<HTMLImageElement>(
'.mat-mdc-menu-panel [data-testid="season-menu-thumb"]'
)
).map((thumb) => thumb.src);
expect(thumbs).toEqual([
'https://img.test/season-1.jpg',
'https://img.test/season-3.jpg',
'https://img.test/season-5.jpg',
'https://img.test/season-7.jpg',
]);
});
it('withholds the season strip for a one-season series', () => {
const [first] = seasons(12);
fixture.componentRef.setInput('seasons', [
@@ -106,6 +106,16 @@ export class FullscreenEpisodePanelComponent {
readonly episodeCounts = computed(() =>
countBySeason(this.seasons(), (season) => season.episodes.length)
);
/** Season poster URLs keyed by season, for the tabs' dropdown thumbnails. */
readonly seasonPosters = computed<Record<string, string>>(() => {
const posters: Record<string, string> = {};
for (const season of this.seasons()) {
if (season.posterUrl) {
posters[season.key] = season.posterUrl;
}
}
return posters;
});
/** Season poster URLs whose image request failed; the strip then folds. */
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
/**