From 4cce4acaadd5bc08e5707d20e065d2008652c32f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:54:54 +0200 Subject: [PATCH] feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups (#1633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- .changes/portal-season-dropdown-posters.md | 10 + CLAUDE.md | 2 +- apps/stalker-mock-server/README.md | 3 +- apps/stalker-mock-server/project.json | 2 - apps/stalker-mock-server/src/main.ts | 22 +- apps/web-e2e/playwright.config.ts | 11 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + apps/xtream-mock-server/README.md | 4 +- .../src/app/generators/marketing.generator.ts | 6 +- .../src/app/mock-port.spec.ts | 68 ++++++ apps/xtream-mock-server/src/app/mock-port.ts | 25 ++ .../xtream-mock-server/src/app/server.spec.ts | 15 ++ apps/xtream-mock-server/src/app/server.ts | 6 +- docs/architecture/embedded-inline-playback.md | 17 +- docs/architecture/stalker-mock-server.md | 20 +- docs/architecture/xtream-mock-server.md | 4 +- .../season-auto-select.util.spec.ts | 213 ++++++++++++++++++ .../season-container.component.html | 1 + .../season-container.season-cover.spec.ts | 117 +++++++++- .../season-tabs.component.html | 26 +++ .../season-tabs.component.scss | 22 ++ .../season-container/season-tabs.component.ts | 35 +++ .../fullscreen-episode-panel.component.html | 5 +- ...fullscreen-episode-panel.component.spec.ts | 49 +++- .../fullscreen-episode-panel.component.ts | 10 + 43 files changed, 699 insertions(+), 32 deletions(-) create mode 100644 .changes/portal-season-dropdown-posters.md create mode 100644 apps/xtream-mock-server/src/app/mock-port.spec.ts create mode 100644 apps/xtream-mock-server/src/app/mock-port.ts create mode 100644 libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts diff --git a/.changes/portal-season-dropdown-posters.md b/.changes/portal-season-dropdown-posters.md new file mode 100644 index 000000000..f6348eb46 --- /dev/null +++ b/.changes/portal-season-dropdown-posters.md @@ -0,0 +1,10 @@ +--- +type: feature +area: portal +--- + +Series with more than six seasons now show a small season poster in the season +dropdown — on each row that has one and on the closed selector — so a long +list of "Season N" entries is easier to scan. The fullscreen episode panel's +season strip also gets its own "N episodes" translation instead of borrowing +the download manager's. diff --git a/CLAUDE.md b/CLAUDE.md index 3cddc4928..2650dfbb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1571,7 +1571,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists. - Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block -- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates +- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons; the dropdown's menu rows and closed trigger carry a 28×42 season thumbnail from the same `seasonPosters` map when that season has one — no placeholder when it does not, a failed image is dropped — while the pill row deliberately stays text-only) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count, `PORTALS.EPISODE_COUNT_ONE/OTHER`) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates, and hands the same map to its tabs' dropdown - The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md` - Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total). - The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only. diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index f67cf38ff..4bf66d156 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -101,7 +101,8 @@ flags a real portal sends. Outside the `static-channel-cmd` scenario they are | Environment Variable | Default | Description | |---|---|---| -| `PORT` | `3210` | HTTP port the server listens on | +| `PORT` | `3210` | HTTP port the server listens on. The Nx serve targets do not pin it, so `PORT=3310 pnpm nx run stalker-mock-server:serve` relocates the server | +| `MOCK_PORT` | — | Fallback for `PORT`. It is the client-side knob `apps/web-e2e/playwright.config.ts` and the specs already read, so `MOCK_PORT=3310 pnpm exec playwright test …` moves the mock AND the tests together when another worktree holds 3210 | | `HOST` | `127.0.0.1` | Bind address. Loopback by default — the fixture serves fabricated, unauthenticated content, so set `HOST=0.0.0.0` only to deliberately point a phone or STB at it | | `NODE_ENV` | `development` | Node environment | diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json index 97c88ee44..7433d23c0 100644 --- a/apps/stalker-mock-server/project.json +++ b/apps/stalker-mock-server/project.json @@ -13,7 +13,6 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3210", "NODE_ENV": "development" } } @@ -26,7 +25,6 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3210", "NODE_ENV": "development" } } diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 0ff74a987..baf4e53a3 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -19,7 +19,27 @@ import { resolveMarketingPosterUrls, } from './app/marketing-poster-url.js'; -const PORT = parseInt(process.env['PORT'] ?? '3210', 10); +function resolvePort(raw: string | undefined): number { + if (raw === undefined) { + return 3210; + } + const port = /^\d+$/.test(raw) ? Number(raw) : Number.NaN; + if (!Number.isInteger(port) || port > 65_535) { + console.error( + `[stalker-mock] Invalid port "${raw}": expected an integer in 0..65535` + ); + process.exit(1); + } + return port; +} + +// `PORT` is the server's own knob; `MOCK_PORT` is the client-side alias +// Playwright and the specs read (`apps/web-e2e/playwright.config.ts`), honoured +// here so one variable relocates the whole E2E run when another worktree +// holds 3210. The Nx serve targets deliberately do not pin `PORT`: an `env` +// entry in `nx:run-commands` overrides the shell, which is what made the +// override impossible before. +const PORT = resolvePort(process.env['PORT'] ?? process.env['MOCK_PORT']); // Loopback by default: the fixture serves fabricated but unauthenticated // content, so it should not be reachable from other hosts unless a dev // explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it). diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 07459da9e..0aa4c6a5d 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -52,11 +52,12 @@ export default defineConfig({ /* Run local dev servers before starting the tests. * Both the Angular app and the Stalker mock server start in parallel. * - * MOCK_PORT only moves where the CLIENT looks — this health check and the - * specs' MOCK_SERVER constants. The mock reads PORT, which - * stalker-mock-server's serve target pins to 3210, and nothing maps one to - * the other, so MOCK_PORT alone makes the wait below time out. Use it to - * point at a mock you started yourself on that port. + * MOCK_PORT / XTREAM_MOCK_PORT relocate a mock for the whole run: the + * health checks below and the specs' MOCK_SERVER constants read them, and + * both mock servers honour them as a fallback for PORT (their serve + * targets no longer pin PORT, so an explicit shell value reaches the + * process). That is what lets two worktrees run E2E side by side when one + * already holds 3210/3211. */ webServer: [ { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 46cf09b3a..00e92dc71 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1249,6 +1249,8 @@ "ABOUT": "نبذة", "SEASONS_AND_EPISODES": "المواسم والحلقات", "SEASON_TAB": "الموسم {{number}}", + "EPISODE_COUNT_ONE": "حلقة واحدة", + "EPISODE_COUNT_OTHER": "{{count}} حلقات", "BACK_TO_PLAYING_EPISODE": "العودة إلى الحلقة قيد التشغيل", "GRID_VIEW": "عرض شبكي", "LIST_VIEW": "عرض قائمة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d70f556e7..1b4be9931 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1249,6 +1249,8 @@ "ABOUT": "حول", "SEASONS_AND_EPISODES": "المواسم والحلقات", "SEASON_TAB": "الموسم {{number}}", + "EPISODE_COUNT_ONE": "حلقة وحدة", + "EPISODE_COUNT_OTHER": "{{count}} حلقات", "BACK_TO_PLAYING_EPISODE": "رجوع للحلقة اللي كتشغل", "GRID_VIEW": "عرض الشبكة", "LIST_VIEW": "عرض اللائحة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a0c5d0077..36d299200 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1249,6 +1249,8 @@ "ABOUT": "Пра фільм", "SEASONS_AND_EPISODES": "Сезоны і эпізоды", "SEASON_TAB": "Сезон {{number}}", + "EPISODE_COUNT_ONE": "1 эпізод", + "EPISODE_COUNT_OTHER": "{{count}} эпізодаў", "BACK_TO_PLAYING_EPISODE": "Назад да бягучага эпізоду", "GRID_VIEW": "Выгляд сеткай", "LIST_VIEW": "Выгляд спісам", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4378a1641..f60cf8531 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1249,6 +1249,8 @@ "ABOUT": "Info", "SEASONS_AND_EPISODES": "Staffeln und Folgen", "SEASON_TAB": "Staffel {{number}}", + "EPISODE_COUNT_ONE": "1 Episode", + "EPISODE_COUNT_OTHER": "{{count}} Episoden", "BACK_TO_PLAYING_EPISODE": "Zurück zur laufenden Folge", "GRID_VIEW": "Rasteransicht", "LIST_VIEW": "Listenansicht", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 63da59999..dbfb4cc58 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1249,6 +1249,8 @@ "ABOUT": "Σχετικά", "SEASONS_AND_EPISODES": "Σεζόν και επεισόδια", "SEASON_TAB": "Σεζόν {{number}}", + "EPISODE_COUNT_ONE": "1 επεισόδιο", + "EPISODE_COUNT_OTHER": "{{count}} επεισόδια", "BACK_TO_PLAYING_EPISODE": "Πίσω στο επεισόδιο που παίζει", "GRID_VIEW": "Προβολή πλέγματος", "LIST_VIEW": "Προβολή λίστας", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6c8d3f9ff..6c99bdb4c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1249,6 +1249,8 @@ "ABOUT": "About", "SEASONS_AND_EPISODES": "Seasons and Episodes", "SEASON_TAB": "Season {{number}}", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", "BACK_TO_PLAYING_EPISODE": "Back to playing episode", "GRID_VIEW": "Grid view", "LIST_VIEW": "List view", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 41ddaaaba..c0a51add3 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1249,6 +1249,8 @@ "ABOUT": "Acerca de", "SEASONS_AND_EPISODES": "Temporadas y episodios", "SEASON_TAB": "Temporada {{number}}", + "EPISODE_COUNT_ONE": "1 episodio", + "EPISODE_COUNT_OTHER": "{{count}} episodios", "BACK_TO_PLAYING_EPISODE": "Volver al episodio en reproducción", "GRID_VIEW": "Vista de cuadrícula", "LIST_VIEW": "Vista de lista", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 47782117c..4b014fc6e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1249,6 +1249,8 @@ "ABOUT": "À propos", "SEASONS_AND_EPISODES": "Saisons et épisodes", "SEASON_TAB": "Saison {{number}}", + "EPISODE_COUNT_ONE": "1 épisode", + "EPISODE_COUNT_OTHER": "{{count}} épisodes", "BACK_TO_PLAYING_EPISODE": "Retour à l'épisode en cours", "GRID_VIEW": "Vue en grille", "LIST_VIEW": "Vue en liste", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index a528cca10..8da46cb54 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1249,6 +1249,8 @@ "ABOUT": "Ismertető", "SEASONS_AND_EPISODES": "Évadok és epizódok", "SEASON_TAB": "{{number}}. évad", + "EPISODE_COUNT_ONE": "1 epizód", + "EPISODE_COUNT_OTHER": "{{count}} epizód", "BACK_TO_PLAYING_EPISODE": "Vissza a lejátszás alatt álló epizódhoz", "GRID_VIEW": "Rácsnézet", "LIST_VIEW": "Listanézet", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 200ccd428..9bc6b53d7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1249,6 +1249,8 @@ "ABOUT": "Info", "SEASONS_AND_EPISODES": "Stagioni ed episodi", "SEASON_TAB": "Stagione {{number}}", + "EPISODE_COUNT_ONE": "1 episodio", + "EPISODE_COUNT_OTHER": "{{count}} episodi", "BACK_TO_PLAYING_EPISODE": "Torna all'episodio in riproduzione", "GRID_VIEW": "Vista griglia", "LIST_VIEW": "Vista elenco", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e09dcfce7..971dd433f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1249,6 +1249,8 @@ "ABOUT": "概要", "SEASONS_AND_EPISODES": "シーズンとエピソード", "SEASON_TAB": "シーズン{{number}}", + "EPISODE_COUNT_ONE": "1 エピソード", + "EPISODE_COUNT_OTHER": "{{count}} エピソード", "BACK_TO_PLAYING_EPISODE": "再生中のエピソードに戻る", "GRID_VIEW": "グリッド表示", "LIST_VIEW": "リスト表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 8d0f14c4b..d98dc32c7 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1249,6 +1249,8 @@ "ABOUT": "정보", "SEASONS_AND_EPISODES": "시즌 및 에피소드", "SEASON_TAB": "시즌 {{number}}", + "EPISODE_COUNT_ONE": "에피소드 1개", + "EPISODE_COUNT_OTHER": "에피소드 {{count}}개", "BACK_TO_PLAYING_EPISODE": "재생 중인 에피소드로 돌아가기", "GRID_VIEW": "그리드 보기", "LIST_VIEW": "목록 보기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 58052df24..cb2737e25 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1249,6 +1249,8 @@ "ABOUT": "Over", "SEASONS_AND_EPISODES": "Seizoenen en afleveringen", "SEASON_TAB": "Seizoen {{number}}", + "EPISODE_COUNT_ONE": "1 aflevering", + "EPISODE_COUNT_OTHER": "{{count}} afleveringen", "BACK_TO_PLAYING_EPISODE": "Terug naar lopende aflevering", "GRID_VIEW": "Rasterweergave", "LIST_VIEW": "Lijstweergave", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 81de4a649..3456e2102 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1249,6 +1249,8 @@ "ABOUT": "Informacje", "SEASONS_AND_EPISODES": "Sezony i odcinki", "SEASON_TAB": "Sezon {{number}}", + "EPISODE_COUNT_ONE": "1 odcinek", + "EPISODE_COUNT_OTHER": "{{count}} odcinków", "BACK_TO_PLAYING_EPISODE": "Wróć do odtwarzanego odcinka", "GRID_VIEW": "Widok siatki", "LIST_VIEW": "Widok listy", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index c40749e33..e48fde94a 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1249,6 +1249,8 @@ "ABOUT": "Sobre", "SEASONS_AND_EPISODES": "Temporadas e episódios", "SEASON_TAB": "Temporada {{number}}", + "EPISODE_COUNT_ONE": "1 episódio", + "EPISODE_COUNT_OTHER": "{{count}} episódios", "BACK_TO_PLAYING_EPISODE": "Voltar ao episódio em reprodução", "GRID_VIEW": "Visualização em grade", "LIST_VIEW": "Visualização em lista", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0b487a112..ea575277c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1249,6 +1249,8 @@ "ABOUT": "Об этом", "SEASONS_AND_EPISODES": "Сезоны и эпизоды", "SEASON_TAB": "Сезон {{number}}", + "EPISODE_COUNT_ONE": "1 эпизод", + "EPISODE_COUNT_OTHER": "{{count}} эпизодов", "BACK_TO_PLAYING_EPISODE": "Вернуться к воспроизводимому эпизоду", "GRID_VIEW": "Вид сеткой", "LIST_VIEW": "Вид списком", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f96a89748..6bbcf920d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1249,6 +1249,8 @@ "ABOUT": "Hakkında", "SEASONS_AND_EPISODES": "Sezonlar ve Bölümler", "SEASON_TAB": "{{number}}. Sezon", + "EPISODE_COUNT_ONE": "1 bölüm", + "EPISODE_COUNT_OTHER": "{{count}} bölüm", "BACK_TO_PLAYING_EPISODE": "Oynatılan bölüme dön", "GRID_VIEW": "Izgara görünümü", "LIST_VIEW": "Liste görünümü", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0e2a2605d..e289f60c5 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1249,6 +1249,8 @@ "ABOUT": "简介", "SEASONS_AND_EPISODES": "季与集", "SEASON_TAB": "第 {{number}} 季", + "EPISODE_COUNT_ONE": "1 集", + "EPISODE_COUNT_OTHER": "{{count}} 集", "BACK_TO_PLAYING_EPISODE": "返回正在播放的剧集", "GRID_VIEW": "网格视图", "LIST_VIEW": "列表视图", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f74e6cc3e..ed991534f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1249,6 +1249,8 @@ "ABOUT": "簡介", "SEASONS_AND_EPISODES": "季數與集數", "SEASON_TAB": "第 {{number}} 季", + "EPISODE_COUNT_ONE": "1 集", + "EPISODE_COUNT_OTHER": "{{count}} 集", "BACK_TO_PLAYING_EPISODE": "返回正在播放的集數", "GRID_VIEW": "格狀檢視", "LIST_VIEW": "清單檢視", diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index 10bbafd34..57641741a 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -42,7 +42,9 @@ When enabled, every `/__control/*` request requires the exact requests. The server refuses non-loopback binds and an empty token. The control routes do not exist when the flag is absent or is any value other than `1`. The Nx serve targets preserve an explicit shell `PORT`; when it is omitted, the -server parser still defaults to `3211`. +server parser falls back to `XTREAM_MOCK_PORT` — the client-side knob +`apps/web-e2e/playwright.config.ts` and the specs read, so one variable moves +the mock and the tests together — and then to `3211`. Prepare the fixed synthetic fixture before starting a capture: diff --git a/apps/xtream-mock-server/src/app/generators/marketing.generator.ts b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts index bf8aee164..5310c848f 100644 --- a/apps/xtream-mock-server/src/app/generators/marketing.generator.ts +++ b/apps/xtream-mock-server/src/app/generators/marketing.generator.ts @@ -14,6 +14,7 @@ import { marketingTitleFromSlug, renderMarketingLogoSvg, } from '@iptvnator/shared/marketing-fixtures'; +import { resolveXtreamMockPortString } from '../mock-port.js'; import { RawCategory } from './categories.generator.js'; import { buildEpgListing, @@ -931,8 +932,9 @@ function buildMarketingEpgListings( } function marketingAssetOrigin(): string { - const port = process.env['PORT'] ?? '3211'; - return `http://localhost:${port}`; + // Same resolution as the listener, so a run relocated through + // `XTREAM_MOCK_PORT` alone still mints asset URLs on the bound port. + return `http://localhost:${resolveXtreamMockPortString(process.env)}`; } function parseSize( diff --git a/apps/xtream-mock-server/src/app/mock-port.spec.ts b/apps/xtream-mock-server/src/app/mock-port.spec.ts new file mode 100644 index 000000000..9bce5336b --- /dev/null +++ b/apps/xtream-mock-server/src/app/mock-port.spec.ts @@ -0,0 +1,68 @@ +import { marketingAssetUrl } from './generators/marketing.generator.js'; + +// The generator module pulls in faker (ESM-only under this CJS Jest setup) +// through its sibling generators; `marketingAssetUrl` never calls it, so an +// empty stub keeps the import resolvable — same pattern as server.spec.ts. +jest.mock('@faker-js/faker', () => ({ faker: {} })); +import { + DEFAULT_XTREAM_MOCK_PORT, + resolveXtreamMockPortString, +} from './mock-port.js'; + +describe('resolveXtreamMockPortString', () => { + it('prefers PORT, then the Playwright-side XTREAM_MOCK_PORT alias, then the default', () => { + expect(resolveXtreamMockPortString({})).toBe( + String(DEFAULT_XTREAM_MOCK_PORT) + ); + expect(resolveXtreamMockPortString({ XTREAM_MOCK_PORT: '3311' })).toBe( + '3311' + ); + expect( + resolveXtreamMockPortString({ + PORT: '3221', + XTREAM_MOCK_PORT: '3311', + }) + ).toBe('3221'); + }); +}); + +describe('marketing asset origin', () => { + const saved = { + PORT: process.env['PORT'], + XTREAM_MOCK_PORT: process.env['XTREAM_MOCK_PORT'], + }; + + const setEnvironment = (values: Record) => { + for (const key of ['PORT', 'XTREAM_MOCK_PORT'] as const) { + if (values[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = values[key]; + } + } + }; + + afterEach(() => setEnvironment(saved)); + + it('mints asset URLs on the same port the listener resolves', () => { + // A run relocated through the Playwright alias alone: the fixture's + // poster/backdrop/logo/episode URLs must follow the bound port, or + // the images load from another worktree's server (or nothing). + setEnvironment({ PORT: undefined, XTREAM_MOCK_PORT: '3311' }); + expect(marketingAssetUrl('poster', 'Aurora News', '300x450')).toMatch( + /^http:\/\/localhost:3311\/assets\/marketing\/poster\// + ); + + setEnvironment({ PORT: '3221', XTREAM_MOCK_PORT: '3311' }); + expect( + marketingAssetUrl('backdrop', 'Aurora News', '1280x720') + ).toMatch(/^http:\/\/localhost:3221\/assets\/marketing\/backdrop\//); + + setEnvironment({ PORT: undefined, XTREAM_MOCK_PORT: undefined }); + expect(marketingAssetUrl('logo', 'Aurora News', '256x256')).toMatch( + new RegExp( + `^http://localhost:${DEFAULT_XTREAM_MOCK_PORT}/assets/marketing/logo/` + ) + ); + }); +}); diff --git a/apps/xtream-mock-server/src/app/mock-port.ts b/apps/xtream-mock-server/src/app/mock-port.ts new file mode 100644 index 000000000..04a137674 --- /dev/null +++ b/apps/xtream-mock-server/src/app/mock-port.ts @@ -0,0 +1,25 @@ +/** Port the Xtream mock listens on when nothing in the environment says otherwise. */ +export const DEFAULT_XTREAM_MOCK_PORT = 3211; + +/** + * The one place the mock's port is read from the environment: `PORT` (the + * server's own knob) first, then `XTREAM_MOCK_PORT` — the client-side alias + * Playwright and the specs read, honoured so one variable relocates the whole + * E2E run — then the default. Returned as the raw string so the caller + * decides how strictly to validate it. + * + * Both the listener (`parseXtreamMockServerEnvironment`) and the marketing + * fixture's asset origin (`marketingAssetUrl`) go through this, because the + * poster/backdrop/logo/episode URLs the fixture mints must point at the port + * the server actually bound — a fixture that read `PORT` alone sent every + * `XTREAM_MOCK_PORT`-relocated run's images to 3211. + */ +export function resolveXtreamMockPortString( + environment: NodeJS.ProcessEnv +): string { + return ( + environment['PORT'] ?? + environment['XTREAM_MOCK_PORT'] ?? + String(DEFAULT_XTREAM_MOCK_PORT) + ); +} diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 28fe84430..be65ea573 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -463,6 +463,21 @@ describe('Xtream mock environment parsing', () => { ).toEqual({ host: '127.0.0.1', port: 3211 }); }); + it('falls back to the Playwright-side XTREAM_MOCK_PORT alias when PORT is unset', () => { + expect( + parseXtreamMockServerEnvironment({ XTREAM_MOCK_PORT: '3311' }) + ).toEqual({ host: '127.0.0.1', port: 3311 }); + expect( + parseXtreamMockServerEnvironment({ + PORT: '3221', + XTREAM_MOCK_PORT: '3311', + }) + ).toEqual({ host: '127.0.0.1', port: 3221 }); + expect(() => + parseXtreamMockServerEnvironment({ XTREAM_MOCK_PORT: '12x' }) + ).toThrow(/port/i); + }); + it.each([ [{ PORT: '-1' }, /port/i], [{ PORT: '12x' }, /port/i], diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index 753e050eb..df2f0beab 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -7,6 +7,7 @@ import express, { type Response, } from 'express'; import { installLiveFormatFixture } from './live-format-fixture.js'; +import { resolveXtreamMockPortString } from './mock-port.js'; import { resetAll } from './data-store.js'; import { renderMarketingAssetSvg } from './generators/marketing.generator.js'; import { installPerformanceControlRoutes } from './performance-control-routes.js'; @@ -50,7 +51,6 @@ https://example.channels/path-to-file/3.m3u8 https://example.channels/path-to-file/4.m3u8 `; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; -const DEFAULT_PORT = 3211; // Loopback by default: the fixtures serve fabricated but unauthenticated // content, so they should not be reachable from other hosts unless a dev // explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them). @@ -129,7 +129,7 @@ export function createXtreamMockApp( export function parseXtreamMockServerEnvironment( environment: NodeJS.ProcessEnv ): XtreamMockServerOptions { - const rawPort = environment['PORT'] ?? String(DEFAULT_PORT); + const rawPort = resolveXtreamMockPortString(environment); if (!/^\d+$/.test(rawPort)) { throw new Error('Xtream mock port must be an integer'); } @@ -219,7 +219,7 @@ function dispatchProxyAction(request: Request, response: Response): void { /** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */ function installDemoGuideRoute(app: express.Express): void { app.get('/demo/guide.xml', (request, response) => { - const origin = `${request.protocol}://${request.get('host') ?? `localhost:${DEFAULT_PORT}`}`; + const origin = `${request.protocol}://${request.get('host') ?? `localhost:${resolveXtreamMockPortString(process.env)}`}`; response .type('application/xml') .set('Cache-Control', 'no-store') diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index d1d94826f..590e8bbfa 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -291,10 +291,25 @@ poster is the show poster a few hundred pixels below the hero), or when the image request failed. The hero poster never follows the season: the show keeps its identity element, the season gets its own picture next to its own text. The fullscreen episode panel shows the same poster as a compact season -strip (poster, season name, episode count) above its season tabs, under the +strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` / +`PORTALS.EPISODE_COUNT_OTHER`) above its season tabs, under the same gates, from `PortalInlinePlayerComponent.seasonPosters` through `buildFullscreenEpisodePanelSeasons`. +Beyond six seasons the tabs become a `mat-menu` dropdown, and that dropdown +carries **season thumbnails** from the same `seasonPosters` map +(`SeasonTabsComponent.seasonPosters`, passed by the season container and by +the fullscreen episode panel): a 28×42 poster projected into the leading slot +of each menu row that has one, and the selected season's poster inside the +closed trigger (`season-tabs__dropdown--with-thumb` tightens the pill around +it). A season without a poster gets no placeholder tile — its row simply +starts with the text — and a poster whose image request fails is dropped from +both places rather than left as a broken-image frame (the component keeps its +own failed-URL set, independent of the container's cover column). The pill +row (six seasons or fewer) deliberately stays text-only: the design review +rejected per-pill thumbnails as a second poster rail, and the season cover +beside the tabs already shows the selected season's picture. + ### Manual watched toggle for movies Movies carry the same manual "watched" affordance as episodes, in the diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 09a22f847..e1d6f2311 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -374,15 +374,17 @@ webServer: [ Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance. -**`MOCK_PORT` moves the CLIENT side only** — Playwright's health-check URL and -the `MOCK_SERVER` constants in the specs. The server's own port comes from -`PORT` (`main.ts`), which the `serve` and `serve-with-watch` targets pin to -`3210` in `project.json`, and nothing maps one variable to the other. Setting -`MOCK_PORT` alone therefore points Playwright at a port nothing is listening -on and the run times out waiting for `/health`. It is only useful against a -mock you started yourself on that port (`reuseExistingServer` is on outside -CI); relocating the Nx-managed one would need `MOCK_PORT` passed through as -`PORT`. +**`MOCK_PORT` relocates the whole run.** Playwright's health-check URL and the +`MOCK_SERVER` constants in the specs read it, and `main.ts` resolves the +server's port as `PORT`, then `MOCK_PORT`, then `3210` — so +`MOCK_PORT=3310 pnpm exec playwright test …` starts the Nx-managed mock on +3310 and points every spec at it, which is how two worktrees run E2E side by +side when one already holds 3210. This only works because the `serve` and +`serve-with-watch` targets no longer pin `PORT` in `project.json`: an `env` +entry in `nx:run-commands` overrides the shell (`{...process.env, ...env}`), +so a pinned value silently discarded every override. `PORT=3310 pnpm nx run +stalker-mock-server:serve` relocates the server alone. The Xtream mock has the +same shape with `XTREAM_MOCK_PORT`. ### Test Isolation diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index fbd2bc353..ffcbd26af 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -122,7 +122,9 @@ unauthenticated content, so it should not be reachable from other hosts by accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need when driving the mock from a phone, an STB, a container, or another machine. Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell -value reaches the parser; its no-value default remains `3211`. +value reaches the parser; without one it falls back to `XTREAM_MOCK_PORT` (the +client-side knob Playwright and the specs read, so one variable relocates the +mock and the tests together) and then to `3211`. Use a dedicated port rather than the normal `3211` E2E server: diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts b/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts new file mode 100644 index 000000000..5c02ba02b --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts @@ -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 { + 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, + positions: Record = {}, + overrides: Partial = {} +): AutoSeasonContext { + const keys = Object.keys(seasons).sort((a, b) => Number(a) - Number(b)); + const episodes: Record = {}; + const episodeCounts: Record = {}; + const watchedCounts: Record = {}; + 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'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index 9bf06fa4b..2bb13242a 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -48,6 +48,7 @@ [episodeCounts]="episodeCounts()" [watchedCounts]="watchedCounts()" [seasonDescription]="selectedSeasonDescription()" + [seasonPosters]="seasonPosters()" [playingSeasonKey]="playingSeasonKey()" (seasonSelected)="selectSeason($event)" (backToPlayingRequested)="scrollToPlayingEpisode()" diff --git a/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts b/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts index 76137b686..5296508e3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts @@ -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 => { + const seasons: Record = {}; + 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( + '[data-testid="season-dropdown-thumb"]' + ) ?? null; + const openMenu = (): HTMLButtonElement[] => { + trigger()?.click(); + fixture.detectChanges(); + return Array.from( + document.querySelectorAll( + '.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( + '[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', diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html index caa3a0cd2..a2b73eccd 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.html +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html @@ -3,9 +3,21 @@