feat(portals): make year, genre and country metadata clickable (#1449) (#1453)

* feat(portals): make year, genre and country metadata clickable (#1449)

Year, genre and country chips on movie and series detail pages now open a
Discover page inside the portal: popular TMDB titles for that facet, matched
against the user's own catalog. Generalizes the existing actor-page pattern
(TMDB list -> what's in my library -> else portal search) to metadata facets.

- All three TMDB merges emit structured `tmdb_genres`/`tmdb_countries`
  (+ `tmdb_media_type` on Stalker, whose embedded-VOD series route as movies).
  Cached details payloads already carry both, so existing rows need no refetch.
- Chips are clickable only with TMDB backing, like person chips today; the
  year chip gates on a merge-written numeric `tmdb_id`, since provider
  payloads ship junk string ids.
- `TmdbDiscoverService` fetches up to 5 `/discover` pages by popularity and
  caches them in memory only — popularity rankings are volatile and must not
  reach the persisted `tmdb_metadata` table.
- New `discover` route in both portals; containers clone the actor route,
  staleness-guarded by a facet key because facets change via query params on
  the same route instance.
- The grid, filter chips and badges move out of `ActorViewComponent` into a
  shared `TitleResultsComponent` used by both pages.

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

* refactor(portals): share the discover facet navigation across detail pages

The four render sites each carried their own copy of the year/genre/country
click handlers, which also pushed serial-details.component.ts past the
400-line limit. `createDiscoverFacetNavigation()` now owns the navigation,
the numeric-tmdb_id gate and the year parsing.

Year parsing moves from a fixed 4-char slice to the first four-digit run,
so a day-first provider date resolves instead of producing NaN.

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

* fix(portals): address review findings on the Discover pages

- The matching indicator was keyed on the request's subject, so an
  obsolete response skipped clearing it while no replacement request
  ever ran — the results grid then sat under the spinner forever.
  `createLatestRequestGuard()` now owns the indicator: the newest
  request always clears it, and the subject check keeps deciding whether
  the RESULT is still wanted. The actor pages carried the same latent
  bug and use the same guard now.

- Country chips came from `production_countries` while Discover filters
  by `with_origin_country`, so clicking a co-production partner returned
  titles originating there instead of titles it produced. Chips are now
  built from `origin_country` and labelled from `production_countries`;
  a code TMDB does not name is dropped rather than shown as a bare code.

- A cold load of an `actor` or `discover` route never initialized the
  catalog, so every result claimed to be missing from a library that
  actually holds it. Both are import-driven sections now.

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

* refactor(portals): extract the series Similar rail into its own service

Rebasing onto master pushed serial-details.component.ts back over the
400-line limit. The "Similar" rail moves into SerialDetailsSimilarService,
mirroring VodDetailsSimilarService next to it: same two sources, same
component-provided lifetime so a cross-portal lookup dies with the page.

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

* fix(portals): make facet chips keyboard-operable and reject zero years

- The chips were plain spans with a click handler, so the whole feature
  was mouse-only. Actionable chips are <button> now (focusable, Enter and
  Space activate); a year chip that cannot be discovered by stays an
  informational span rather than becoming a disabled button. The button
  chrome is neutralized so they render identically to the chips beside
  them, with a visible focus ring.

- `0000-00-00`, the placeholder providers ship for "no date", read as a
  four-digit year: the chip offered it, and the request then dropped the
  filter because 0 is falsy, so the page answered with unfiltered popular
  titles. `isTmdbYearFacet()` now gates both the chip and the route
  params, so a deep link cannot reach a state the chips refuse to offer.

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

* fix(portals): hydrate the offline catalog and hide results while matching

- `toCachedContentScope` returned null for the actor and discover routes,
  so an expired, inactive or offline portal skipped hydration entirely
  and both pages answered "not in your library" from an empty catalog
  even though a full imported catalog sat in SQLite. Both map to the
  aggregate `search` scope now — neither reads a single content type.

- The results grid stayed rendered under the matching spinner. Until the
  matches land every card reads as unavailable, so a click during the
  worker request opened the portal search for a title the next tick would
  have resolved in another playlist. The grid is hidden while matching.

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

* fix(portals): gate facet chips on enrichment, keep portal results visible

- `typeof tmdb_id === 'number'` was the wrong proof that enrichment ran:
  XtreamVodInfo.tmdb_id allows a provider-sent JSON number, so with TMDB
  disabled the year chip stayed clickable and opened a Discover page that
  cannot load anything. The target now answers the real question — can a
  facet click land anywhere — and returns null when enrichment is off, so
  the id argument is gone from the chip API entirely.

- Hiding the grid on the raw matching flag blanked valid portal results
  when the user switched back to "This portal" mid-request; a stuck worker
  would have blanked them indefinitely. The spinner belongs to the global
  scope, so it only replaces the grid while that scope is active.

- CLAUDE.md and docs/architecture/stalker-portal.md list the portal child
  routes explicitly; both now include `discover`.

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

* fix(portals): label the year chip with the year it navigates to

`facetYear()` reads the first four-digit run so a day-first provider date
resolves, but the templates still sliced the first four characters — so
`31-03-1999` rendered as `31-0` while the click opened 1999. The label
now comes from the same parser as the destination (`yearLabel`), and the
informational chip keeps its previous rendering only when no year parses.

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

* fix(portals): match Discover results by original title, guard stale loads

- `/discover` returns titles localized to the app language while the
  provider catalog stores whatever the panel named the file, usually the
  original. Discarding `original_title`/`original_name` marked owned
  titles unavailable and sent the click to a search for the wrong name.
  Results carry the alias now, and both local and cross-playlist matching
  pass it the way the recommendations rail already does.

- A facet change to B and back to A leaves two in-flight loads with the
  SAME key, so the key could not tell them apart: an older request
  failing after the newer one succeeded replaced valid results with an
  empty page. Recency decides who may commit, via the same request guard
  the matching path uses.

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

* fix(portals): wait for the catalog before stating Discover availability

Triggering initializeContent() for the discover route was only half the
fix: TMDB usually answers before a cold catalog finishes importing, and
the content gate renders the route while that runs. The page dropped its
spinner as soon as the TMDB request settled, so cards computed against an
empty catalog claimed that titles the user owns are missing and their
clicks opened a search instead of the detail page.

Availability now waits for the catalog too. Readiness is keyed on what is
in flight rather than on isContentInitialized, mirroring the recently-added
route, so a failed import settles the page instead of spinning forever.

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

* test(portals): cover the Discover catalog-readiness gate

Holds the TMDB request and the catalog flags independently so the
cold-load regression cannot return: results settling first must keep the
page loading, a finished catalog must publish them, a failed import must
still settle the page, and a running import must keep it loading.

Verified to fail on the pre-fix gate: reverting isLoading to the results
signal alone turns two of the four cases red.

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

* docs(portals): describe the Discover gates the code actually implements

Three rounds of review fixes moved the contracts out from under the
prose. The year chip no longer gates on a merge-written numeric tmdb_id
(that gate was wrong: the field is number | string, so a provider-sent
number passed it with enrichment never having run) but on the navigation
target, which requires a playlist and enabled enrichment. Discover loads
are guarded by recency, not by facet key, because A→B→A leaves two
in-flight requests sharing one key. Availability additionally waits for
catalog readiness. Both canonical entries say so now.

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

* refactor(portals): drop the normalized tmdbId that proved nothing

`NormalizedVodMeta.tmdbId` existed only to gate the year chip, and its
comment claimed a numeric `tmdb_id` proved enrichment had run. That test
was wrong — the provider field is `number | string` — so the gate moved
to the navigation target and the field lost its last consumer. Removing
it beats re-documenting it: a field that survives with a false guarantee
in its doc comment is how the rejected gate gets reintroduced.

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

* fix(portals): read the year with one rule everywhere

The shared detail path fed the chip `meta.year`, which the adapter built
with a fixed-prefix fallback: a day-first `31-03-1999` became `31-0`, so
that path both displayed the wrong label and lost the facet, since the
guard could not parse it back.

`parseFacetYear()` moves to shared/interfaces and both callers delegate
to it, so the adapters and the Discover chips cannot drift into
disagreeing about what a date says. The adapter keeps its date-parse
fallback for shapes stating no four-digit run, but no longer invents one
by slicing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-16 13:59:58 +02:00
1 parent bee7df1e02
commit df8962969e
80 files changed
+3278 -393

No files matched your search

@@ -0,0 +1,11 @@
---
type: feature
area: portals
issues: [1449]
---
Year, genre, and country on movie and series detail pages are now clickable
(with TMDB metadata enabled): each chip opens a Discover page of popular
titles for that facet, marked with what's already in your library — matches
jump straight to their detail page, the rest open the portal search. Works
in Xtream and Stalker, like the existing actor and director chips.
+4 -3
View File
@@ -537,8 +537,8 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
- Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources`
- M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player`
- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`
- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `discover`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`
- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `discover`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
- Global collections: `/workspace/global-favorites`, `/workspace/global-recent`
- Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`)
- Downloads: `/workspace/downloads` with focused
@@ -1365,7 +1365,8 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`. Xtream rows carry the same identity on the `content` row: the detail views back-fill `tmdb_id`/`release_year`/`original_title` next to `backdrop_url` (`xtreamDetailContentMetadata` → `XtreamStore.backfillContentMetadata` → `DB_SET_CONTENT_METADATA_IF_MISSING` → `persistContentMetadataIfMissing`), the activity SELECTs project them onto `PortalActivityItem`, and `buildDashboardTmdbAttempts` reads them back. Writes are per-column and never overwrite (enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one); `release_year` is the year the PROVIDER stated, never one read out of the title (readers still apply that fallback themselves, so an absent column means "no provider date" — and "2001: A Space Odyssey" can never be frozen in as a 2001 film), which holds only because the TMDB merge marks the dates it substitutes itself with `tmdb_supplied_release_date` and the extractor skips those — the merge's other `tmdb_*` fields are conditional on having content, so they cannot serve as an "enrichment ran" signal; the id is stored unvetted because every consumer re-gates it through `assessProviderId`; and there is no media-type column, since for Xtream `content.type` already is the media type. Both sides validate through `normalizeContentMetadataPatch` (`libs/shared/interfaces`), so legacy rows, never-opened rows and provider junk all collapse to the title-only fallback — as does the PWA, whose catalog cache is rebuilt from the API on every load
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`, whose grid is the extracted `TitleResultsComponent` shared with the Discover pages)
- Discover pages (clickable metadata chips, issue #1449): year, genre, and country chips on all four detail views (Xtream VOD/series, shared Stalker detail, Stalker series) are clickable when TMDB matched the item — the merges emit structured `tmdb_genres`/`tmdb_countries` (+ `tmdb_media_type` on Stalker), the year chip renders from provider data so it is gated on the navigation TARGET instead of the item's identity — `createDiscoverFacetNavigation()` offers a facet only when a playlist resolves AND `TmdbEnrichmentService.isEnabled()`, since Discover reads its results from TMDB (gating on `typeof tmdb_id === 'number'` is WRONG: the field is `number | string` and a provider-sent number satisfied it with enrichment never having run) — and clicks navigate via `discoverLink()` (`libs/portal/shared/util`) to the portal-scoped `discover` route (`?type&year&genre&genreLabel&country&countryLabel`). The route containers (`XtreamDiscoverRouteComponent`, `StalkerDiscoverRouteComponent`) clone the actor-page pattern: TMDB `/discover` top-5-pages by popularity via `TmdbDiscoverService` (session-only Map cache, never persisted to `tmdb_metadata`), matched against the catalog (Xtream in-memory index / all-portals `DB_MATCH_TITLES`; Stalker search-prefill), rendered by `DiscoverViewComponent`. Facets change via query params on the same route instance, so the discover load is guarded by recency (`createLatestRequestGuard()`) — A→B→A leaves two in-flight requests sharing one `discoverFacetKey()` — while the catalog match uses the guard for its spinner and the facet key for its results; availability also waits for catalog readiness (in-flight flags, not `isContentInitialized`, so a failed import still settles). See "Discover Pages" in `docs/architecture/tmdb-metadata-enrichment.md`
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
- All `DB_MATCH_TITLES` consumers (Trending rail, "Because you watched" recommendations rail, cross-portal Similar rail, actor "All portals" scope) resolve the worker's flat result list through the shared `groupTitleMatchesByKey()` + `pickTitleMatch()` in `libs/services/src/lib/catalog-title-match.service.ts`. The grouping keeps EVERY row per `type:exactNormalizedTitle` on purpose — the year that separates same-titled rows belongs to the lookup, which the grouping cannot see, so collapsing first made a catalog holding both "Dune 1984" and "Dune 2021" drop whichever copy the user actually owns. `pickTitleMatch` then ranks year-compatible rows by evidence (exact year → untagged → any compatible) across all title aliases at once; only the recommendations rail passes an alias (TMDB `original_title`, via `candidateLookup()`). Multi-source VOD discovery deliberately stays off these helpers: there every copy is a distinct selectable source, not one best answer
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "البحث في البوابة",
"ACTOR_BORN": "تاريخ الميلاد",
"ACTOR_DIED": "توفي",
"DISCOVER_TITLE": "استكشاف",
"DISCOVER_RESULTS": "عناوين شائعة",
"DISCOVER_MOVIES": "الأفلام",
"DISCOVER_SERIES": "المسلسلات",
"DISCOVER_EMPTY": "لا توجد نتائج لهذا الاختيار. تتطلب ميزة الاستكشاف تفعيل بيانات TMDB الوصفية في الإعدادات.",
"SERIES_STATUS_RETURNING": "مستمر",
"SERIES_STATUS_PLANNED": "مخطط",
"SERIES_STATUS_IN_PRODUCTION": "قيد الإنتاج",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "قلب فالبوابة",
"ACTOR_BORN": "مزداد",
"ACTOR_DIED": "توفا",
"DISCOVER_TITLE": "اكتشف",
"DISCOVER_RESULTS": "عناوين مشهورة",
"DISCOVER_MOVIES": "الأفلام",
"DISCOVER_SERIES": "السلاسل",
"DISCOVER_EMPTY": "ما لقينا والو بهاد الاختيار. باش تخدم خاصية اكتشف، خاصك تفعّل ميتاداتا TMDB فالإعدادات.",
"SERIES_STATUS_RETURNING": "مستمر",
"SERIES_STATUS_PLANNED": "مخطط",
"SERIES_STATUS_IN_PRODUCTION": "كيتصور",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Пошук на партале",
"ACTOR_BORN": "Нарадзіўся",
"ACTOR_DIED": "Памёр",
"DISCOVER_TITLE": "Падборка",
"DISCOVER_RESULTS": "Папулярнае",
"DISCOVER_MOVIES": "Фільмы",
"DISCOVER_SERIES": "Серыялы",
"DISCOVER_EMPTY": "Нічога не знойдзена для гэтага выбару. Для падборкі патрэбныя ўключаныя метаданыя TMDB у наладах.",
"SERIES_STATUS_RETURNING": "Працягваецца",
"SERIES_STATUS_PLANNED": "Запланаваны",
"SERIES_STATUS_IN_PRODUCTION": "У вытворчасці",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Im Portal suchen",
"ACTOR_BORN": "Geboren",
"ACTOR_DIED": "Gestorben",
"DISCOVER_TITLE": "Entdecken",
"DISCOVER_RESULTS": "Beliebte Titel",
"DISCOVER_MOVIES": "Filme",
"DISCOVER_SERIES": "Serien",
"DISCOVER_EMPTY": "Für diese Auswahl wurde nichts gefunden. Entdecken erfordert aktivierte TMDB-Metadaten in den Einstellungen.",
"SERIES_STATUS_RETURNING": "Laufend",
"SERIES_STATUS_PLANNED": "Geplant",
"SERIES_STATUS_IN_PRODUCTION": "In Produktion",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Αναζήτηση στην πύλη",
"ACTOR_BORN": "Γεννήθηκε",
"ACTOR_DIED": "Απεβίωσε",
"DISCOVER_TITLE": "Ανακάλυψη",
"DISCOVER_RESULTS": "Δημοφιλείς τίτλοι",
"DISCOVER_MOVIES": "Ταινίες",
"DISCOVER_SERIES": "Σειρές",
"DISCOVER_EMPTY": "Δεν βρέθηκε τίποτα για αυτήν την επιλογή. Η Ανακάλυψη απαιτεί ενεργοποιημένα μεταδεδομένα TMDB στις ρυθμίσεις.",
"SERIES_STATUS_RETURNING": "Σε εξέλιξη",
"SERIES_STATUS_PLANNED": "Προγραμματισμένο",
"SERIES_STATUS_IN_PRODUCTION": "Σε παραγωγή",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Search in portal",
"ACTOR_BORN": "Born",
"ACTOR_DIED": "Died",
"DISCOVER_TITLE": "Discover",
"DISCOVER_RESULTS": "Popular titles",
"DISCOVER_MOVIES": "Movies",
"DISCOVER_SERIES": "Series",
"DISCOVER_EMPTY": "Nothing found for this selection. Discover needs TMDB metadata enabled in the settings.",
"SERIES_STATUS_RETURNING": "Returning",
"SERIES_STATUS_PLANNED": "Planned",
"SERIES_STATUS_IN_PRODUCTION": "In production",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Buscar en el portal",
"ACTOR_BORN": "Nacimiento",
"ACTOR_DIED": "Fallecido",
"DISCOVER_TITLE": "Descubrir",
"DISCOVER_RESULTS": "Títulos populares",
"DISCOVER_MOVIES": "Películas",
"DISCOVER_SERIES": "Series",
"DISCOVER_EMPTY": "No se encontró nada con esta selección. Descubrir requiere los metadatos de TMDB activados en los ajustes.",
"SERIES_STATUS_RETURNING": "En emisión",
"SERIES_STATUS_PLANNED": "Planificada",
"SERIES_STATUS_IN_PRODUCTION": "En producción",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Rechercher dans le portail",
"ACTOR_BORN": "Naissance",
"ACTOR_DIED": "Décédé",
"DISCOVER_TITLE": "Découvrir",
"DISCOVER_RESULTS": "Titres populaires",
"DISCOVER_MOVIES": "Films",
"DISCOVER_SERIES": "Séries",
"DISCOVER_EMPTY": "Aucun résultat pour cette sélection. Découvrir nécessite les métadonnées TMDB activées dans les paramètres.",
"SERIES_STATUS_RETURNING": "En cours",
"SERIES_STATUS_PLANNED": "Planifiée",
"SERIES_STATUS_IN_PRODUCTION": "En production",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Keresés a portálon",
"ACTOR_BORN": "Született",
"ACTOR_DIED": "Elhunyt",
"DISCOVER_TITLE": "Felfedezés",
"DISCOVER_RESULTS": "Népszerű címek",
"DISCOVER_MOVIES": "Filmek",
"DISCOVER_SERIES": "Sorozatok",
"DISCOVER_EMPTY": "Nincs találat ezzel a kiválasztással. A felfedezéshez engedélyezett TMDB-metaadatok szükségesek a beállításokban.",
"SERIES_STATUS_RETURNING": "Folyamatban",
"SERIES_STATUS_PLANNED": "Tervezett",
"SERIES_STATUS_IN_PRODUCTION": "Gyártás alatt",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Cerca nel portale",
"ACTOR_BORN": "Nato",
"ACTOR_DIED": "Deceduto",
"DISCOVER_TITLE": "Scopri",
"DISCOVER_RESULTS": "Titoli popolari",
"DISCOVER_MOVIES": "Film",
"DISCOVER_SERIES": "Serie",
"DISCOVER_EMPTY": "Nessun risultato per questa selezione. Scopri richiede i metadati TMDB abilitati nelle impostazioni.",
"SERIES_STATUS_RETURNING": "In corso",
"SERIES_STATUS_PLANNED": "Pianificata",
"SERIES_STATUS_IN_PRODUCTION": "In produzione",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "ポータルで検索",
"ACTOR_BORN": "生年月日",
"ACTOR_DIED": "死去",
"DISCOVER_TITLE": "発見",
"DISCOVER_RESULTS": "人気の作品",
"DISCOVER_MOVIES": "映画",
"DISCOVER_SERIES": "シリーズ",
"DISCOVER_EMPTY": "この条件に一致するものはありません。この機能を使うには、設定で TMDB メタデータを有効にしてください。",
"SERIES_STATUS_RETURNING": "放送中",
"SERIES_STATUS_PLANNED": "企画中",
"SERIES_STATUS_IN_PRODUCTION": "製作中",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "포털에서 검색",
"ACTOR_BORN": "출생",
"ACTOR_DIED": "사망",
"DISCOVER_TITLE": "탐색",
"DISCOVER_RESULTS": "인기 작품",
"DISCOVER_MOVIES": "영화",
"DISCOVER_SERIES": "시리즈",
"DISCOVER_EMPTY": "이 조건에 해당하는 결과가 없습니다. 탐색 기능을 사용하려면 설정에서 TMDB 메타데이터를 사용하도록 설정하세요.",
"SERIES_STATUS_RETURNING": "방영 중",
"SERIES_STATUS_PLANNED": "기획 중",
"SERIES_STATUS_IN_PRODUCTION": "제작 중",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Zoeken in portal",
"ACTOR_BORN": "Geboren",
"ACTOR_DIED": "Overleden",
"DISCOVER_TITLE": "Ontdekken",
"DISCOVER_RESULTS": "Populaire titels",
"DISCOVER_MOVIES": "Films",
"DISCOVER_SERIES": "Series",
"DISCOVER_EMPTY": "Niets gevonden voor deze selectie. Ontdekken vereist ingeschakelde TMDB-metadata in de instellingen.",
"SERIES_STATUS_RETURNING": "Lopend",
"SERIES_STATUS_PLANNED": "Gepland",
"SERIES_STATUS_IN_PRODUCTION": "In productie",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Szukaj w portalu",
"ACTOR_BORN": "Urodzony",
"ACTOR_DIED": "Zmarł",
"DISCOVER_TITLE": "Odkrywaj",
"DISCOVER_RESULTS": "Popularne tytuły",
"DISCOVER_MOVIES": "Filmy",
"DISCOVER_SERIES": "Seriale",
"DISCOVER_EMPTY": "Nic nie znaleziono dla tego wyboru. Odkrywanie wymaga włączonych metadanych TMDB w ustawieniach.",
"SERIES_STATUS_RETURNING": "W trakcie",
"SERIES_STATUS_PLANNED": "Zaplanowany",
"SERIES_STATUS_IN_PRODUCTION": "W produkcji",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Buscar no portal",
"ACTOR_BORN": "Nascido em",
"ACTOR_DIED": "Falecido",
"DISCOVER_TITLE": "Descobrir",
"DISCOVER_RESULTS": "Títulos populares",
"DISCOVER_MOVIES": "Filmes",
"DISCOVER_SERIES": "Séries",
"DISCOVER_EMPTY": "Nada encontrado para esta seleção. Descobrir requer metadados do TMDB ativados nas configurações.",
"SERIES_STATUS_RETURNING": "Em exibição",
"SERIES_STATUS_PLANNED": "Planeada",
"SERIES_STATUS_IN_PRODUCTION": "Em produção",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Искать на портале",
"ACTOR_BORN": "Дата рождения",
"ACTOR_DIED": "Умер",
"DISCOVER_TITLE": "Подборка",
"DISCOVER_RESULTS": "Популярное",
"DISCOVER_MOVIES": "Фильмы",
"DISCOVER_SERIES": "Сериалы",
"DISCOVER_EMPTY": "По этому выбору ничего не найдено. Для подборки нужны включённые метаданные TMDB в настройках.",
"SERIES_STATUS_RETURNING": "Продолжается",
"SERIES_STATUS_PLANNED": "Запланирован",
"SERIES_STATUS_IN_PRODUCTION": "В производстве",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Portalda ara",
"ACTOR_BORN": "Doğum",
"ACTOR_DIED": "Vefat",
"DISCOVER_TITLE": "Keşfet",
"DISCOVER_RESULTS": "Popüler yapımlar",
"DISCOVER_MOVIES": "Filmler",
"DISCOVER_SERIES": "Diziler",
"DISCOVER_EMPTY": "Bu seçim için sonuç bulunamadı. Keşfet, ayarlarda TMDB meta verisinin etkin olmasını gerektirir.",
"SERIES_STATUS_RETURNING": "Devam ediyor",
"SERIES_STATUS_PLANNED": "Planlandı",
"SERIES_STATUS_IN_PRODUCTION": "Yapım aşamasında",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "在门户中搜索",
"ACTOR_BORN": "出生:",
"ACTOR_DIED": "逝世",
"DISCOVER_TITLE": "发现",
"DISCOVER_RESULTS": "热门作品",
"DISCOVER_MOVIES": "电影",
"DISCOVER_SERIES": "剧集",
"DISCOVER_EMPTY": "此条件下没有结果。发现功能需要在设置中启用 TMDB 元数据。",
"SERIES_STATUS_RETURNING": "连载中",
"SERIES_STATUS_PLANNED": "筹备中",
"SERIES_STATUS_IN_PRODUCTION": "制作中",
+5
View File
@@ -877,6 +877,11 @@
"ACTOR_SEARCH_IN_PORTAL": "在入口網站搜尋",
"ACTOR_BORN": "出生:",
"ACTOR_DIED": "逝世",
"DISCOVER_TITLE": "探索",
"DISCOVER_RESULTS": "熱門作品",
"DISCOVER_MOVIES": "電影",
"DISCOVER_SERIES": "影集",
"DISCOVER_EMPTY": "此條件下沒有結果。探索功能需要在設定中啟用 TMDB 中繼資料。",
"SERIES_STATUS_RETURNING": "連載中",
"SERIES_STATUS_PLANNED": "籌備中",
"SERIES_STATUS_IN_PRODUCTION": "製作中",
+2
View File
@@ -42,6 +42,8 @@ below is reached as `/workspace/stalker/:id/…`.
- `/workspace/stalker/:id/recent`
- `/workspace/stalker/:id/search`
- `/workspace/stalker/:id/actor/:personId`
- `/workspace/stalker/:id/discover` (facet query params; see
`docs/architecture/tmdb-metadata-enrichment.md`)
- `/workspace/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
- `/workspace/stalker/:id/downloads/:downloadId` (focused local movie/series
detail with no category context panel)
+90 -2
View File
@@ -312,8 +312,8 @@ Filmography has two scopes:
### Resolving a batched match
Every `DB_MATCH_TITLES` consumer — the Trending rail, the "Because you
watched" recommendations rail, the cross-portal "Similar" rail and the
actor page's "All portals" scope — turns the worker's flat result list
watched" recommendations rail, the cross-portal "Similar" rail, and the
actor and Discover pages' "All portals" scope — turns the worker's flat result list
into one row through the same pair of helpers in
`libs/services/src/lib/catalog-title-match.service.ts`:
@@ -343,6 +343,94 @@ Multi-source VOD discovery deliberately does NOT use these
(`operations/title-sources.operations.ts`): there every copy in every
playlist is a distinct selectable source, not a single best answer.
## Discover Pages (clickable metadata chips)
The year, genre, and country chips on VOD/series detail pages are the
Discover entry points — the same "TMDB list → what's in my library →
else search" pattern as actor pages, generalized to metadata facets
(issue #1449).
**Structured facets from the merge.** All three merge functions in
`tmdb-merge.ts` additionally emit `tmdb_genres: {id, name}[]` (from
`details.genres`) and `tmdb_countries: {code, name}[]`. Countries come
from `details.origin_country`, NOT the fuller `production_countries`:
Discover filters by `with_origin_country`, so offering a co-production's
other partners would promise "titles from here" and return a different
set. Names are read out of `production_countries` (the only place TMDB
states them) and a code it does not name is dropped rather than rendered
as a bare `FR`;
`mergeStalkerInfoWithTmdb` also emits `tmdb_media_type`, because Stalker
embedded-VOD series route as movies structurally and the shared detail
view needs the real media type for the Discover link. Cached details
payloads already carry `genres`/`production_countries`
(`trimDetailsForCache` only trims `aggregate_credits`), so existing cache
rows produce facets without a refetch. Like person chips, facet chips are
clickable ONLY with TMDB backing: genre/country chips render per-entry
from the structured arrays (falling back to today's static joined-string
chip without them), while the year chip renders from provider data and
so needs a gate of its own. That gate is the navigation TARGET, not the
item's identity: `createDiscoverFacetNavigation()` offers a facet only
when its click can land somewhere, and the four hosts return `null` from
their target unless a playlist resolves AND
`TmdbEnrichmentService.isEnabled()` — Discover reads its results from
TMDB, so a chip must never promise a page enrichment cannot fill. An
earlier version gated on `typeof tmdb_id === 'number'` instead; that is
wrong, because `XtreamVodInfo.tmdb_id` is `number | string` and a
provider sending a JSON number satisfied it with enrichment never having
run. Discover-by-year does not use the item's TMDB id at all.
**Navigation.** Chip clicks navigate (via
`createDiscoverFacetNavigation()`, shared by all four render sites) to
the portal-scoped
`discover` route: `/workspace/{xtreams|stalker}/:id/discover?type=
movie|tv&year=&genre=<id>&genreLabel=&country=<iso>&countryLabel=`. The
query-param assembly is centralized in `discoverLink()`
(`libs/portal/shared/util/.../navigation/discover-link.util.ts`); parsing
lives in `parseDiscoverParams()` (`libs/ui/shared-portals/.../
discover-view/discover-params.ts`) — labels are dropped without their
filter values, and `type`+`genre` are an atomic pair (movie and TV genre
id spaces differ, so a genre never survives a type flip).
**Data.** `TmdbDiscoverService` (facade via
`TmdbEnrichmentService.discoverTitles`) fetches `/discover/{movie,tv}`
sorted by popularity, up to 5 pages (early stop at `total_pages`),
mapped/deduped by `mapDiscoverResults` (`tmdb-discover.ts`). Results are
deliberately session-cached in memory only (FIFO-bounded Map keyed by
mediaType|facets|language) and never persisted to `tmdb_metadata` —
popularity rankings are volatile. Any failure returns `null` (not
cached); the page shows its empty state, which also covers deep links
with enrichment disabled.
**Route containers.** `XtreamDiscoverRouteComponent`
(`libs/portal/xtream/feature/src/lib/discover/`) and
`StalkerDiscoverRouteComponent` clone the actor route containers: same
scope toggle, same portal/global matching (Xtream in-memory index by the
facet's media type; Stalker search-prefill only, global scope matching
Xtream playlists), same navigation on click.
Facets change via query params on the SAME route instance, so staleness
cannot be decided by instance — and, for the discover load, not by facet
either: A→B→A leaves two in-flight requests whose `discoverFacetKey()`
is identical, so an older one failing after the newer succeeded would
replace valid results with an empty page. Recency decides instead, via
`createLatestRequestGuard()` (`libs/portal/shared/util`). The catalog
match keeps both: the guard owns the in-flight INDICATOR (a request whose
subject changed must not clear a spinner its replacement now owns, and a
request with no replacement must still clear it) while the facet key
decides whether the RESULT is still wanted. The actor pages use the same
guard for the same reason.
Availability also waits for the catalog, not just for TMDB: the content
gate renders the route while a cold import runs, and TMDB usually answers
first, so publishing then would state that owned titles are missing.
Readiness is keyed on the in-flight flags rather than
`isContentInitialized`, so a failed import settles the page instead of
spinning forever. Both
render the shared `DiscoverViewComponent`, whose grid is the
`TitleResultsComponent` extracted from `ActorViewComponent`
(`libs/ui/shared-portals/src/lib/title-results/`) — one grid, filter
chips, and badge set shared by actor and Discover pages.
## Cache
Single table with several row kinds discriminated by the `lookup_key`
+3
View File
@@ -1,3 +1,4 @@
export * from './lib/latest-request-guard';
export * from './lib/logger';
export * from './lib/category-sort';
export * from './lib/channel-sort';
@@ -22,6 +23,8 @@ export * from './lib/workspace-header-context.service';
export * from './lib/workspace-view-command.types';
export * from './lib/workspace-view-command.service';
export * from './lib/navigation/collection-detail-portal-navigation';
export * from './lib/navigation/discover-facet-navigation';
export * from './lib/navigation/discover-link.util';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/provider-detail-mode';
export * from './lib/navigation/portal-route.utils';
@@ -0,0 +1,50 @@
import { createLatestRequestGuard } from './latest-request-guard';
describe('createLatestRequestGuard', () => {
it('treats the only started request as latest', () => {
const guard = createLatestRequestGuard();
expect(guard.isLatest(guard.start())).toBe(true);
});
it('retires every earlier request when a new one starts', () => {
const guard = createLatestRequestGuard();
const first = guard.start();
const second = guard.start();
expect(guard.isLatest(first)).toBe(false);
expect(guard.isLatest(second)).toBe(true);
});
it('keeps the newest owner even when responses settle out of order', () => {
const guard = createLatestRequestGuard();
const first = guard.start();
const second = guard.start();
// The stale response settling last must not reclaim ownership
expect(guard.isLatest(second)).toBe(true);
expect(guard.isLatest(first)).toBe(false);
});
it('never reports an unclaimed token as latest', () => {
const guard = createLatestRequestGuard();
expect(guard.isLatest(0)).toBe(false);
guard.start();
expect(guard.isLatest(99)).toBe(false);
});
it('isolates guards from each other', () => {
const one = createLatestRequestGuard();
const other = createLatestRequestGuard();
const token = one.start();
other.start();
other.start();
expect(one.isLatest(token)).toBe(true);
});
});
@@ -0,0 +1,28 @@
/**
* Ownership token for a "only the newest request may commit" sequence.
*
* Pages that re-issue the same request as the user navigates (catalog
* matching on the actor and Discover pages) cannot key their in-flight
* indicator on the SUBJECT of the request: when a replacement request is
* never issued — the user switched away from the scope that needs it —
* the obsolete response finds a changed subject and leaves the spinner
* up forever. The guard separates the two questions: `isLatest` says
* whether this response still owns the shared state, and the caller
* keeps whatever subject check decides if the RESULT is still wanted.
*/
export interface LatestRequestGuard {
/** Claims ownership for a new request and returns its token */
start(): number;
/** Whether `token` still belongs to the most recently started request */
isLatest(token: number): boolean;
}
export function createLatestRequestGuard(): LatestRequestGuard {
// Tokens start at 1 so the "nothing started yet" state cannot be
// mistaken for ownership by a caller holding a zeroed token
let latest = 0;
return {
start: () => ++latest,
isLatest: (token: number) => token > 0 && token === latest,
};
}
@@ -0,0 +1,153 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { Router } from '@angular/router';
import {
DiscoverFacetTarget,
createDiscoverFacetNavigation,
} from './discover-facet-navigation';
describe('createDiscoverFacetNavigation', () => {
let navigate: jest.Mock;
function create(target: () => DiscoverFacetTarget | null) {
const injector = Injector.create({
providers: [{ provide: Router, useValue: { navigate } }],
});
return runInInjectionContext(injector, () =>
createDiscoverFacetNavigation(target)
);
}
const xtreamMovie = (): DiscoverFacetTarget => ({
portal: 'xtream',
mediaType: 'movie',
playlistId: 'pl-1',
});
beforeEach(() => {
navigate = jest.fn();
});
it('navigates to the portal-scoped discover route for a genre', () => {
create(xtreamMovie).openGenre({ id: 18, name: 'Drama' });
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl-1', 'discover'],
{
queryParams: {
type: 'movie',
genre: '18',
genreLabel: 'Drama',
},
}
);
});
it('routes Stalker targets to the stalker segment and media type', () => {
create(() => ({
portal: 'stalker',
mediaType: 'tv',
playlistId: 'pl-2',
})).openCountry({ code: 'DE', name: 'Germany' });
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'stalker', 'pl-2', 'discover'],
{
queryParams: {
type: 'tv',
country: 'DE',
countryLabel: 'Germany',
},
}
);
});
it('reads the year out of any provider date shape', () => {
const discover = create(xtreamMovie);
// Day-first dates are why this is a search, not a fixed slice
for (const [date, year] of [
['1976', '1976'],
['1999-03-31', '1999'],
['31-03-1999', '1999'],
]) {
navigate.mockClear();
discover.openYear(date);
expect(navigate).toHaveBeenCalledWith(expect.anything(), {
queryParams: { type: 'movie', year },
});
}
});
it('labels the chip with the year it navigates to', () => {
const discover = create(xtreamMovie);
// The label and the destination must not disagree: slicing the
// first four characters of a day-first date renders '31-0'
expect(discover.yearLabel('31-03-1999')).toBe('1999');
expect(discover.yearLabel('1999-03-31')).toBe('1999');
expect(discover.yearLabel('1976')).toBe('1976');
});
it('has no label for a date stating no usable year', () => {
const discover = create(xtreamMovie);
expect(discover.yearLabel('0000-00-00')).toBeNull();
expect(discover.yearLabel('unknown')).toBeNull();
expect(discover.yearLabel(undefined)).toBeNull();
});
it('refuses the year facet when the target cannot be reached', () => {
// Hosts return null when enrichment is off, and Discover reads its
// results from TMDB — a year chip must not promise an empty page
const discover = create(() => null);
expect(discover.canOpenYear('1999-03-31')).toBe(false);
discover.openYear('1999-03-31');
expect(navigate).not.toHaveBeenCalled();
});
it('refuses the year facet without a parsable year', () => {
const discover = create(xtreamMovie);
expect(discover.canOpenYear('')).toBe(false);
expect(discover.canOpenYear(undefined)).toBe(false);
expect(discover.canOpenYear('unknown')).toBe(false);
discover.openYear('unknown');
expect(navigate).not.toHaveBeenCalled();
});
it('refuses the zero-date placeholder providers ship for "no date"', () => {
const discover = create(xtreamMovie);
// '0000-00-00' reads as a four-digit year but filters by nothing
expect(discover.canOpenYear('0000-00-00')).toBe(false);
expect(discover.canOpenYear('0000')).toBe(false);
discover.openYear('0000-00-00');
expect(navigate).not.toHaveBeenCalled();
});
it('does not navigate while the host cannot resolve its playlist', () => {
const discover = create(() => null);
discover.openGenre({ id: 18, name: 'Drama' });
discover.openCountry({ code: 'US', name: 'United States' });
discover.openYear('1999');
expect(navigate).not.toHaveBeenCalled();
});
it('re-reads the target on every click', () => {
let target: DiscoverFacetTarget | null = null;
const discover = create(() => target);
discover.openGenre({ id: 18, name: 'Drama' });
expect(navigate).not.toHaveBeenCalled();
target = xtreamMovie();
discover.openGenre({ id: 18, name: 'Drama' });
expect(navigate).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,91 @@
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import {
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
parseFacetYear,
} from '@iptvnator/shared/interfaces';
import { discoverLink } from './discover-link.util';
/**
* Where a detail page's facet chips should navigate. `null` means the
* page cannot send a facet anywhere — it has no playlist yet, or TMDB
* enrichment is off, which is what Discover reads its results from.
*/
export interface DiscoverFacetTarget {
portal: 'xtream' | 'stalker';
mediaType: TmdbMediaType;
playlistId: string;
}
export interface DiscoverFacetNavigation {
/**
* The year a chip should DISPLAY, so its label and its destination
* cannot disagree — a day-first `31-03-1999` navigates to 1999 and
* must not render as the first four characters. `null` when the date
* states no usable year; the caller then keeps its own rendering.
*/
yearLabel(releaseDate: string | null | undefined): string | null;
canOpenYear(releaseDate: string | null | undefined): boolean;
openYear(releaseDate: string | null | undefined): void;
openGenre(genre: TmdbGenreFacet): void;
openCountry(country: TmdbCountryFacet): void;
}
/**
* Shared behavior behind the clickable year/genre/country chips on every
* detail page. Call it from a field initializer (injection context).
*
* A chip is offered only when its click can actually land somewhere, which
* is what the target answers. Genre and country chips are inherently safe
* (they exist only on an enriched item), but the year chip renders from
* provider data, so the target is what keeps it from promising a Discover
* page that enrichment cannot fill.
*/
export function createDiscoverFacetNavigation(
target: () => DiscoverFacetTarget | null
): DiscoverFacetNavigation {
const router = inject(Router);
const navigate = (
facet: Parameters<typeof discoverLink>[3]
): void => {
const resolved = target();
if (!resolved) {
return;
}
const link = discoverLink(
resolved.portal,
resolved.playlistId,
resolved.mediaType,
facet
);
void router.navigate(link.commands, {
queryParams: link.queryParams,
});
};
const canOpenYear = (releaseDate: string | null | undefined): boolean =>
target() !== null && parseFacetYear(releaseDate) !== null;
return {
yearLabel(releaseDate) {
const year = parseFacetYear(releaseDate);
return year === null ? null : String(year);
},
canOpenYear,
openYear(releaseDate) {
const year = parseFacetYear(releaseDate);
if (year !== null) {
navigate({ kind: 'year', year });
}
},
openGenre(genre) {
navigate({ kind: 'genre', genre });
},
openCountry(country) {
navigate({ kind: 'country', country });
},
};
}
@@ -0,0 +1,51 @@
import {
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
} from '@iptvnator/shared/interfaces';
/**
* One metadata chip click on a detail page (year, genre or country),
* resolved into the portal Discover route. Kept in one place so the four
* chip render sites cannot drift in how they assemble the query params.
*/
export type DiscoverFacetClick =
| { kind: 'year'; year: number }
| { kind: 'genre'; genre: TmdbGenreFacet }
| { kind: 'country'; country: TmdbCountryFacet };
export interface DiscoverLink {
commands: (string | number)[];
queryParams: Record<string, string>;
}
export function discoverLink(
portal: 'xtream' | 'stalker',
playlistId: string,
type: TmdbMediaType,
facet: DiscoverFacetClick
): DiscoverLink {
const queryParams: Record<string, string> = { type };
switch (facet.kind) {
case 'year':
queryParams['year'] = String(facet.year);
break;
case 'genre':
queryParams['genre'] = String(facet.genre.id);
queryParams['genreLabel'] = facet.genre.name;
break;
case 'country':
queryParams['country'] = facet.country.code;
queryParams['countryLabel'] = facet.country.name;
break;
}
return {
commands: [
'/workspace',
portal === 'xtream' ? 'xtreams' : 'stalker',
playlistId,
'discover',
],
queryParams,
};
}
@@ -20,6 +20,7 @@ import {
pickTitleMatch,
} from '@iptvnator/services';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { createLatestRequestGuard } from '@iptvnator/portal/shared/util';
import {
ActorViewComponent,
ActorViewItem,
@@ -71,6 +72,7 @@ export class StalkerActorRouteComponent {
readonly scope = signal<ActorViewScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(null);
private readonly matchRequest = createLatestRequestGuard();
private readonly globalIndex = computed(() =>
groupTitleMatchesByKey(this.globalMatches() ?? [])
);
@@ -167,14 +169,21 @@ export class StalkerActorRouteComponent {
// previous person must not overwrite the current one's results
const requestedPersonId = this.personId();
const titles = this.filmography().map((credit) => credit.title);
const matchToken = this.matchRequest.start();
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (this.personId() === requestedPersonId) {
if (
this.matchRequest.isLatest(matchToken) &&
this.personId() === requestedPersonId
) {
this.globalMatches.set(matches);
}
} finally {
if (this.personId() === requestedPersonId) {
// Only the newest request may clear the indicator. Keying this
// on the subject instead strands the spinner when the user
// leaves the scope and no replacement request ever runs.
if (this.matchRequest.isLatest(matchToken)) {
this.isMatchingGlobal.set(false);
}
}
@@ -0,0 +1,230 @@
import { Location } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import {
CatalogTitleMatchService,
DiscoverTitle,
TmdbEnrichmentService,
groupTitleMatchesByKey,
pickTitleMatch,
} from '@iptvnator/services';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { createLatestRequestGuard } from '@iptvnator/portal/shared/util';
import {
DiscoverViewComponent,
TitleResultsScope,
discoverFacetKey,
hasDiscoverFacet,
parseDiscoverParams,
} from '@iptvnator/ui/shared-portals';
/** One Discover result annotated with catalog availability */
interface DiscoverItem extends DiscoverTitle {
available: boolean;
availableIn?: string;
}
/**
* Discover page inside a Stalker portal. The portal catalog is
* server-paginated, so "This portal" has no availability matching — every
* title opens the portal search prefilled. "All portals" (Electron only)
* matches against the imported Xtream playlists and navigates there.
*/
@Component({
template: `<app-discover-view
[facets]="facets()"
[items]="items()"
[isLoading]="isLoading()"
[isMatching]="isMatchingGlobal()"
[showAvailabilityFilter]="scope() === 'global'"
[showScopeToggle]="showScopeToggle"
[scope]="scope()"
(scopeChanged)="onScopeChanged($event)"
(itemClicked)="openItem($event)"
(backClicked)="goBack()"
/>`,
styles: [':host { display: block; height: 100%; min-height: 0; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DiscoverViewComponent],
})
export class StalkerDiscoverRouteComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
private readonly titleMatch = inject(CatalogTitleMatchService);
private readonly queryParams = toSignal(this.route.queryParams, {
initialValue: this.route.snapshot.queryParams,
});
readonly facets = computed(() => parseDiscoverParams(this.queryParams()));
// Facets change via query params on the SAME route instance, so every
// async result is validated against this key, not the instance
private readonly facetKey = computed(() => discoverFacetKey(this.facets()));
private readonly results = signal<DiscoverTitle[]>([]);
readonly isLoading = signal(true);
readonly showScopeToggle = this.titleMatch.isAvailable;
readonly scope = signal<TitleResultsScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(null);
private readonly matchRequest = createLatestRequestGuard();
private readonly discoverRequest = createLatestRequestGuard();
private readonly globalIndex = computed(() =>
groupTitleMatchesByKey(this.globalMatches() ?? [])
);
readonly items = computed<DiscoverItem[]>(() => {
if (this.scope() === 'global') {
return this.results().map((title) => {
const match = this.globalMatchFor(title);
return {
...title,
available: match !== null,
...(match ? { availableIn: match.playlistName } : {}),
};
});
}
return this.results().map((title) => ({
...title,
available: false,
}));
});
constructor() {
effect(() => {
this.facetKey();
void this.loadDiscover();
});
}
onScopeChanged(scope: TitleResultsScope): void {
this.scope.set(scope);
if (scope === 'global' && this.globalMatches() === null) {
void this.loadGlobalMatches();
}
}
openItem(item: DiscoverItem): void {
if (this.scope() === 'global') {
const match = this.globalMatchFor(item);
if (match) {
void this.router.navigate([
'/workspace/xtreams',
match.playlistId,
match.type === 'movie' ? 'vod' : 'series',
match.categoryId,
match.xtreamId,
]);
return;
}
}
const playlistId = this.playlistIdFromRoute();
if (!playlistId) {
return;
}
void this.router.navigate(
['/workspace/stalker', playlistId, 'search'],
{ queryParams: { q: item.title } }
);
}
goBack(): void {
this.location.back();
}
/** The portal ':id' param lives on an ancestor route */
private playlistIdFromRoute(): string | null {
for (const snapshot of this.route.snapshot.pathFromRoot) {
const id = snapshot.params['id'];
if (typeof id === 'string' && id !== '') {
return id;
}
}
return null;
}
private globalMatchFor(title: DiscoverTitle): CatalogTitleMatch | null {
return pickTitleMatch(
{
type: title.mediaType === 'movie' ? 'movie' : 'series',
// The localized title first, then TMDB's original: the
// catalog stores whatever the panel named the file
titles: title.originalTitle
? [title.title, title.originalTitle]
: [title.title],
year: title.year,
},
this.globalIndex()
);
}
private async loadGlobalMatches(): Promise<void> {
const requestedKey = this.facetKey();
// Both names go to the worker so its FTS can hit either one
const titles: string[] = [];
for (const result of this.results()) {
titles.push(result.title);
if (result.originalTitle) {
titles.push(result.originalTitle);
}
}
const matchToken = this.matchRequest.start();
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (
this.matchRequest.isLatest(matchToken) &&
this.facetKey() === requestedKey
) {
this.globalMatches.set(matches);
}
} finally {
// Only the newest request may clear the indicator. Keying this
// on the subject instead strands the spinner when the user
// leaves the scope and no replacement request ever runs.
if (this.matchRequest.isLatest(matchToken)) {
this.isMatchingGlobal.set(false);
}
}
}
private async loadDiscover(): Promise<void> {
const facets = this.facets();
// A facet change to B and back to A leaves two in-flight requests
// with the SAME key, so recency — not the key — decides who may
// commit: otherwise the older one's failure blanks valid results
const token = this.discoverRequest.start();
this.isLoading.set(true);
this.globalMatches.set(null);
if (!hasDiscoverFacet(facets)) {
this.results.set([]);
this.isLoading.set(false);
return;
}
const titles = await this.tmdbEnrichment.discoverTitles(facets.type, {
year: facets.year,
genreId: facets.genreId,
countryCode: facets.countryCode,
});
if (!this.discoverRequest.isLatest(token)) {
return;
}
this.results.set(titles ?? []);
this.isLoading.set(false);
if (this.scope() === 'global') {
void this.loadGlobalMatches();
}
}
}
@@ -41,6 +41,11 @@ const loadStalkerActorRouteComponent: ComponentLoader = () =>
(c) => c.StalkerActorRouteComponent
);
const loadStalkerDiscoverRouteComponent: ComponentLoader = () =>
import('./stalker-discover-route.component').then(
(c) => c.StalkerDiscoverRouteComponent
);
export function createStalkerRoutes(): Route[] {
return [
{
@@ -134,6 +139,10 @@ export function createStalkerRoutes(): Route[] {
path: 'actor/:personId',
loadComponent: loadStalkerActorRouteComponent,
},
{
path: 'discover',
loadComponent: loadStalkerDiscoverRouteComponent,
},
{
path: 'downloads/:downloadId',
loadComponent: loadDownloadOfflineDetailComponent,
@@ -16,9 +16,32 @@
}}</span>
}
@if (serial.info.releasedate) {
<span class="details__tag">{{ serial.info.releasedate }}</span>
@if (discover.canOpenYear(serial.info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(serial.info.releasedate)"
>
{{ discover.yearLabel(serial.info.releasedate) }}
</button>
} @else {
<span class="details__tag">{{
discover.yearLabel(serial.info.releasedate) ??
serial.info.releasedate
}}</span>
}
}
@if (serial.info.genre) {
@if (serial.info.tmdb_genres?.length) {
@for (genre of serial.info.tmdb_genres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (serial.info.genre) {
<span class="details__tag">{{ serial.info.genre }}</span>
}
@if (serial.info.rating_imdb) {
@@ -31,6 +54,20 @@
KP {{ serial.info.rating_kinopoisk }}
</span>
}
@if (serial.info.tmdb_countries?.length) {
@for (
country of serial.info.tmdb_countries;
track country.code
) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
}
</ng-template>
<!-- Metadata Grid -->
@@ -46,6 +46,7 @@ import {
PORTAL_PLAYER,
createLogger,
consumeStalkerReturnMarker,
createDiscoverFacetNavigation,
resolveStalkerBackNavigation,
} from '@iptvnator/portal/shared/util';
import {
@@ -77,6 +78,7 @@ import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
PlaybackPositionRuntimeBridgeService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
import {
@@ -874,6 +876,18 @@ export class StalkerSeriesViewComponent implements OnDestroy {
]);
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && this.tmdbEnrichment.isEnabled()
? { portal: 'stalker', mediaType: 'tv', playlistId }
: null;
});
goBack() {
const back = resolveStalkerBackNavigation(
window.history.state,
@@ -21,6 +21,7 @@ import {
pickTitleMatch,
} from '@iptvnator/services';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { createLatestRequestGuard } from '@iptvnator/portal/shared/util';
import {
ActorViewComponent,
ActorViewItem,
@@ -78,6 +79,7 @@ export class XtreamActorRouteComponent {
readonly scope = signal<ActorViewScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(null);
private readonly matchRequest = createLatestRequestGuard();
private readonly vodIndex = computed(() =>
buildCatalogTitleIndex(this.xtreamStore.vodStreams())
@@ -195,14 +197,21 @@ export class XtreamActorRouteComponent {
// previous person must not overwrite the current one's results
const requestedPersonId = this.personId();
const titles = this.filmography().map((credit) => credit.title);
const matchToken = this.matchRequest.start();
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (this.personId() === requestedPersonId) {
if (
this.matchRequest.isLatest(matchToken) &&
this.personId() === requestedPersonId
) {
this.globalMatches.set(matches);
}
} finally {
if (this.personId() === requestedPersonId) {
// Only the newest request may clear the indicator. Keying this
// on the subject instead strands the spinner when the user
// leaves the scope and no replacement request ever runs.
if (this.matchRequest.isLatest(matchToken)) {
this.isMatchingGlobal.set(false);
}
}
@@ -0,0 +1,165 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CatalogTitleMatchService,
DiscoverTitle,
TmdbEnrichmentService,
} from '@iptvnator/services';
import { XtreamDiscoverRouteComponent } from './xtream-discover-route.component';
/**
* The cold-load contract: a Discover page must not state that a title is
* missing from a catalog that has not finished loading. TMDB routinely
* answers before a cold import does, and the content gate renders this
* route while that runs.
*/
describe('XtreamDiscoverRouteComponent — catalog readiness', () => {
const isLoadingContent = signal(false);
const isLoadingCategories = signal(false);
const isImporting = signal(false);
const vodStreams = signal<unknown[]>([]);
const serialStreams = signal<unknown[]>([]);
let resolveDiscover: (titles: DiscoverTitle[] | null) => void;
let discoverTitles: jest.Mock;
/** Creates the component and flushes the effect that starts the load */
function createComponent(): XtreamDiscoverRouteComponent {
const component = TestBed.runInInjectionContext(
() => new XtreamDiscoverRouteComponent()
);
TestBed.tick();
return component;
}
/** Lets the pending TMDB request settle and effects flush */
async function settle(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
TestBed.tick();
}
beforeEach(() => {
isLoadingContent.set(true);
isLoadingCategories.set(true);
isImporting.set(false);
vodStreams.set([]);
serialStreams.set([]);
discoverTitles = jest.fn().mockImplementation(
() =>
new Promise<DiscoverTitle[] | null>((resolve) => {
resolveDiscover = resolve;
})
);
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
queryParams: of({ type: 'movie', year: '1990' }),
snapshot: {
queryParams: { type: 'movie', year: '1990' },
pathFromRoot: [],
},
},
},
{
provide: XtreamStore,
useValue: {
isLoadingContent,
isLoadingCategories,
isImporting,
vodStreams,
serialStreams,
currentPlaylist: () => ({ id: 'pl-1' }),
},
},
{
provide: TmdbEnrichmentService,
useValue: { discoverTitles },
},
{
provide: CatalogTitleMatchService,
useValue: { isAvailable: false, matchTitles: jest.fn() },
},
],
});
});
it('keeps loading while the catalog is still importing', async () => {
const component = createComponent();
// TMDB wins the race a cold import always loses
resolveDiscover([
{
tmdbId: 1,
mediaType: 'movie',
title: 'Goodfellas',
originalTitle: null,
year: 1990,
posterUrl: null,
},
]);
await settle();
// Before the fix the spinner dropped here and every card claimed
// the title was missing from the (still empty) catalog
expect(component.isLoading()).toBe(true);
});
it('publishes results once the catalog finishes loading', async () => {
const component = createComponent();
resolveDiscover([
{
tmdbId: 1,
mediaType: 'movie',
title: 'Goodfellas',
originalTitle: null,
year: 1990,
posterUrl: null,
},
]);
await settle();
isLoadingContent.set(false);
isLoadingCategories.set(false);
await settle();
expect(component.isLoading()).toBe(false);
expect(component.items()).toHaveLength(1);
});
it('settles when the catalog load fails instead of spinning forever', async () => {
const component = createComponent();
resolveDiscover(null);
await settle();
// A failed import clears the in-flight flags without ever marking
// the catalog initialized — the page must still settle
isLoadingContent.set(false);
isLoadingCategories.set(false);
isImporting.set(false);
await settle();
expect(component.isLoading()).toBe(false);
});
it('keeps loading while an import is still running', async () => {
const component = createComponent();
resolveDiscover([]);
await settle();
isLoadingContent.set(false);
isLoadingCategories.set(false);
isImporting.set(true);
await settle();
expect(component.isLoading()).toBe(true);
});
});
@@ -0,0 +1,285 @@
import { Location } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CatalogTitleMatchService,
DiscoverTitle,
TmdbEnrichmentService,
groupTitleMatchesByKey,
pickTitleMatch,
} from '@iptvnator/services';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import { createLatestRequestGuard } from '@iptvnator/portal/shared/util';
import {
DiscoverViewComponent,
TitleResultsScope,
discoverFacetKey,
hasDiscoverFacet,
parseDiscoverParams,
} from '@iptvnator/ui/shared-portals';
import {
buildCatalogTitleIndex,
lookupCatalogTitle,
} from '../tmdb-similar.util';
/** One Discover result annotated with catalog availability */
interface DiscoverItem extends DiscoverTitle {
available: boolean;
availableIn?: string;
}
/**
* Discover page inside an Xtream portal: popular TMDB titles for one
* metadata facet (year, genre or country, from the detail-page chips).
* Scope "This portal" matches against the loaded catalog; "All portals"
* (Electron only) matches against every imported Xtream playlist via the
* DB worker. Matched titles navigate straight to their detail view, the
* rest open the current portal's search prefilled.
*/
@Component({
template: `<app-discover-view
[facets]="facets()"
[items]="items()"
[isLoading]="isLoading()"
[isMatching]="isMatchingGlobal()"
[showAvailabilityFilter]="true"
[showScopeToggle]="showScopeToggle"
[scope]="scope()"
(scopeChanged)="onScopeChanged($event)"
(itemClicked)="openItem($event)"
(backClicked)="goBack()"
/>`,
styles: [':host { display: block; height: 100%; min-height: 0; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DiscoverViewComponent],
})
export class XtreamDiscoverRouteComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly xtreamStore = inject(XtreamStore);
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
private readonly titleMatch = inject(CatalogTitleMatchService);
private readonly queryParams = toSignal(this.route.queryParams, {
initialValue: this.route.snapshot.queryParams,
});
readonly facets = computed(() => parseDiscoverParams(this.queryParams()));
// Facets change via query params on the SAME route instance, so every
// async result is validated against this key, not the instance
private readonly facetKey = computed(() => discoverFacetKey(this.facets()));
private readonly results = signal<DiscoverTitle[]>([]);
private readonly isLoadingResults = signal(true);
/**
* TMDB usually answers before a cold catalog finishes importing, and
* the content gate renders this route while that runs. Publishing
* results against an empty catalog would state that titles the user
* owns are missing, so availability waits for the catalog too.
*/
readonly isLoading = computed(
() => this.isLoadingResults() || !this.isCatalogReady()
);
// Keyed on what is in flight rather than on isContentInitialized, so
// a failed import settles the page instead of spinning forever
private readonly isCatalogReady = computed(
() =>
!this.xtreamStore.isLoadingContent() &&
!this.xtreamStore.isLoadingCategories() &&
!this.xtreamStore.isImporting()
);
readonly showScopeToggle = this.titleMatch.isAvailable;
readonly scope = signal<TitleResultsScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(null);
private readonly matchRequest = createLatestRequestGuard();
private readonly discoverRequest = createLatestRequestGuard();
private readonly vodIndex = computed(() =>
buildCatalogTitleIndex(this.xtreamStore.vodStreams())
);
private readonly serialIndex = computed(() =>
buildCatalogTitleIndex(this.xtreamStore.serialStreams())
);
private readonly globalIndex = computed(() =>
groupTitleMatchesByKey(this.globalMatches() ?? [])
);
readonly items = computed<DiscoverItem[]>(() => {
if (this.scope() === 'global') {
return this.results().map((title) => {
const match = this.globalMatchFor(title);
return {
...title,
available: match !== null,
...(match ? { availableIn: match.playlistName } : {}),
};
});
}
return this.results().map((title) => ({
...title,
available: this.portalMatchFor(title) !== null,
}));
});
constructor() {
effect(() => {
this.facetKey();
void this.loadDiscover();
});
}
onScopeChanged(scope: TitleResultsScope): void {
this.scope.set(scope);
if (scope === 'global' && this.globalMatches() === null) {
void this.loadGlobalMatches();
}
}
openItem(item: DiscoverItem): void {
if (this.scope() === 'global') {
const match = this.globalMatchFor(item);
if (match) {
void this.router.navigate([
'/workspace/xtreams',
match.playlistId,
match.type === 'movie' ? 'vod' : 'series',
match.categoryId,
match.xtreamId,
]);
return;
}
this.openPortalSearch(item.title);
return;
}
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const match = this.portalMatchFor(item);
if (playlistId && match) {
void this.router.navigate([
'/workspace/xtreams',
playlistId,
item.mediaType === 'movie' ? 'vod' : 'series',
match.categoryId,
match.id,
]);
return;
}
this.openPortalSearch(item.title);
}
goBack(): void {
this.location.back();
}
private openPortalSearch(title: string): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
void this.router.navigate(
['/workspace/xtreams', playlistId, 'search'],
{ queryParams: { q: title } }
);
}
private portalMatchFor(title: DiscoverTitle) {
const index =
title.mediaType === 'movie' ? this.vodIndex() : this.serialIndex();
// The localized title first, then TMDB's original: the catalog
// stores whatever the panel named the file
return (
lookupCatalogTitle(index, title.title, title.year) ??
(title.originalTitle
? lookupCatalogTitle(index, title.originalTitle, title.year)
: null)
);
}
private globalMatchFor(title: DiscoverTitle): CatalogTitleMatch | null {
return pickTitleMatch(
{
type: title.mediaType === 'movie' ? 'movie' : 'series',
// The localized title first, then TMDB's original: the
// catalog stores whatever the panel named the file
titles: title.originalTitle
? [title.title, title.originalTitle]
: [title.title],
year: title.year,
},
this.globalIndex()
);
}
private async loadGlobalMatches(): Promise<void> {
const requestedKey = this.facetKey();
// Both names go to the worker so its FTS can hit either one
const titles: string[] = [];
for (const result of this.results()) {
titles.push(result.title);
if (result.originalTitle) {
titles.push(result.originalTitle);
}
}
const matchToken = this.matchRequest.start();
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (
this.matchRequest.isLatest(matchToken) &&
this.facetKey() === requestedKey
) {
this.globalMatches.set(matches);
}
} finally {
// Only the newest request may clear the indicator. Keying this
// on the subject instead strands the spinner when the user
// leaves the scope and no replacement request ever runs.
if (this.matchRequest.isLatest(matchToken)) {
this.isMatchingGlobal.set(false);
}
}
}
private async loadDiscover(): Promise<void> {
const facets = this.facets();
// A facet change to B and back to A leaves two in-flight requests
// with the SAME key, so recency — not the key — decides who may
// commit: otherwise the older one's failure blanks valid results
const token = this.discoverRequest.start();
this.isLoadingResults.set(true);
this.globalMatches.set(null);
if (!hasDiscoverFacet(facets)) {
this.results.set([]);
this.isLoadingResults.set(false);
return;
}
const titles = await this.tmdbEnrichment.discoverTitles(facets.type, {
year: facets.year,
genreId: facets.genreId,
countryCode: facets.countryCode,
});
if (!this.discoverRequest.isLatest(token)) {
return;
}
this.results.set(titles ?? []);
this.isLoadingResults.set(false);
if (this.scope() === 'global') {
void this.loadGlobalMatches();
}
}
}
@@ -0,0 +1,99 @@
import {
Injectable,
Signal,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import { normalizeTitleKeys } from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
/**
* The "Similar" rail on the series details page — the series counterpart of
* {@link VodDetailsSimilarService}, with the same two sources: TMDB
* recommendations matched against this playlist's loaded catalog, and — in
* Electron — the same recommendations matched across every OTHER imported
* playlist, filtered against the first list so one show cannot appear twice.
*
* Component-provided: the cross-portal lookup is per-series and must die with
* the page rather than leak into the next show.
*/
@Injectable()
export class SerialDetailsSimilarService {
private readonly xtreamStore = inject(XtreamStore);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private selectedItem: Signal<XtreamSerieDetailsView | null> = signal(null);
bind(bindings: {
selectedItem: Signal<XtreamSerieDetailsView | null>;
}): void {
this.selectedItem = bindings.selectedItem;
}
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const item = this.selectedItem();
const recommendations = item?.info?.tmdb_recommendations;
if (!recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
recommendations,
this.xtreamStore.serialStreams(),
{ excludeId: Number(item?.series_id) }
);
});
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.selectedItem()?.info?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'series', {
excludePlaylistId: playlistId,
})
.then((items) => {
// Staleness guard: a slow lookup for the previous series
// must not populate the rail of the one now on screen
if (
this.selectedItem()?.info?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
}
@@ -17,11 +17,32 @@
}}</span>
}
@if (info.releaseDate) {
<span class="details__tag">{{
info.releaseDate | slice: 0 : 4
}}</span>
@if (discover.canOpenYear(info.releaseDate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(info.releaseDate)"
>
{{ discover.yearLabel(info.releaseDate) }}
</button>
} @else {
<span class="details__tag">{{
discover.yearLabel(info.releaseDate) ??
(info.releaseDate | slice: 0 : 4)
}}</span>
}
}
@if (info.genre) {
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (info.episode_run_time) {
@@ -34,6 +55,17 @@
⭐ {{ info.rating }}
</span>
}
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
}
</ng-template>
<ng-template appDetailMeta>
@@ -37,7 +37,6 @@ import {
type UpNextRailItem,
} from '@iptvnator/ui/playback';
import {
normalizeTitleKeys,
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieDetails,
@@ -45,20 +44,22 @@ import {
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
createDiscoverFacetNavigation,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
SerialDetailsPlaybackService,
type XtreamSerieDetailsView,
} from './serial-details-playback.service';
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import { SerialDetailsSimilarService } from './serial-details-similar.service';
import { SimilarCatalogItem } from '../tmdb-similar.util';
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
@@ -79,7 +80,11 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
}
`,
],
providers: [SerialDetailsPlaybackService, SerialDetailsSeasonWatchService],
providers: [
SerialDetailsPlaybackService,
SerialDetailsSeasonWatchService,
SerialDetailsSimilarService,
],
imports: [
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -186,61 +191,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
buildSeasonDescriptions(this.selectedItem())
);
/** TMDB recommendations matched against the loaded series catalog */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const item = this.selectedItem();
const recommendations = item?.info?.tmdb_recommendations;
if (!recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
recommendations,
this.xtreamStore.serialStreams(),
{ excludeId: Number(item?.series_id) }
);
});
/** Recommendations found in the user's OTHER portals (Electron only) */
private readonly crossPortalItems = signal<CrossPortalSimilarItem[]>([]);
readonly similarInPortals = computed<CrossPortalSimilarItem[]>(() => {
const localTitles = new Set(
this.similarItems().map(
(item) => normalizeTitleKeys(item.title).exact
)
);
return this.crossPortalItems().filter(
(item) => !localTitles.has(normalizeTitleKeys(item.title).exact)
);
});
private readonly loadCrossPortalSimilar = effect(() => {
const recommendations = this.selectedItem()?.info?.tmdb_recommendations;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
untracked(() => {
this.crossPortalItems.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'series', {
excludePlaylistId: playlistId,
})
.then((items) => {
if (
this.selectedItem()?.info?.tmdb_recommendations ===
recommendations
) {
this.crossPortalItems.set(items);
}
});
});
});
/** The "Similar" rail: catalog matches plus cross-portal matches */
private readonly similar = inject(SerialDetailsSimilarService);
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
constructor() {
this.playback.bind({ selectedItem: this.selectedItem });
this.similar.bind({ selectedItem: this.selectedItem });
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
// season tabs the first seasonSelected fires as soon as seasons load —
@@ -341,6 +299,18 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
]);
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && this.tmdbEnrichment.isEnabled()
? { portal: 'xtream', mediaType: 'tv', playlistId }
: null;
});
onSeasonSelected(seasonKey: string): void {
// The enrichment call itself runs from the constructor effect keyed
// on (tmdb_id, selectedSeasonKey) — see the race note there.
@@ -295,11 +295,32 @@
>
<ng-template appDetailTags>
@if (info.releasedate) {
<span class="details__tag">{{
info.releasedate | slice: 0 : 4
}}</span>
@if (discover.canOpenYear(info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(info.releasedate)"
>
{{ discover.yearLabel(info.releasedate) }}
</button>
} @else {
<span class="details__tag">{{
discover.yearLabel(info.releasedate) ??
(info.releasedate | slice: 0 : 4)
}}</span>
}
}
@if (info.genre) {
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (info.duration) {
@@ -310,7 +331,17 @@
⭐ {{ info.rating_imdb }}
</span>
}
@if (info.country) {
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
} @else if (info.country) {
<span class="details__tag">{{ info.country }}</span>
}
@if (isDownloaded()) {
@@ -27,6 +27,7 @@ import {
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import {
createDiscoverFacetNavigation,
createLogger,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
@@ -44,6 +45,7 @@ import {
CrossPortalSimilarService,
DownloadsService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
getXtreamVodInfo,
@@ -487,6 +489,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
]);
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && this.tmdbEnrichment.isEnabled()
? { portal: 'xtream', mediaType: 'movie', playlistId }
: null;
});
ngOnDestroy(): void {
if (this.favoritePulseTimer) {
clearTimeout(this.favoritePulseTimer);
@@ -14,6 +14,8 @@ describe('createXtreamRoutes', () => {
'vod',
'series',
'search',
'actor/:personId',
'discover',
'recently-added',
])
);
@@ -59,6 +59,11 @@ const loadXtreamActorRouteComponent: ComponentLoader = () =>
(c) => c.XtreamActorRouteComponent
);
const loadXtreamDiscoverRouteComponent: ComponentLoader = () =>
import('./discover/xtream-discover-route.component').then(
(c) => c.XtreamDiscoverRouteComponent
);
export function createXtreamRoutes(): Route[] {
return [
{
@@ -130,6 +135,10 @@ export function createXtreamRoutes(): Route[] {
path: 'actor/:personId',
loadComponent: loadXtreamActorRouteComponent,
},
{
path: 'discover',
loadComponent: loadXtreamDiscoverRouteComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
@@ -56,13 +56,25 @@ function toXtreamPlaylistData(
};
}
function isImportDrivenSection(section: PortalRailSection | null): boolean {
/**
* Sections whose content comes from the imported catalog, so a cold load
* of one has to initialize it first.
*
* The parameter is a raw URL segment, not a rail section: `actor` and
* `discover` have no rail entry, yet both read the catalog to decide
* whether a TMDB title is "In your library". Without initialization a
* direct load of either answers "no" for everything the user owns —
* a wrong claim rather than a missing one.
*/
function isImportDrivenSection(section: string | null): boolean {
return (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
section === 'recently-added' ||
section === 'actor' ||
section === 'discover'
);
}
@@ -80,7 +92,7 @@ function toContentInitBlockReason(
}
function toCachedContentScope(
section: PortalRailSection | null
section: string | null
): XtreamCachedContentScope | null {
switch (section) {
case 'live':
@@ -89,6 +101,13 @@ function toCachedContentScope(
case 'search':
case 'recently-added':
return section;
// Neither reads one content type: both match TMDB titles against
// the whole catalog, so they take the aggregate scope search uses.
// Without it an expired or offline portal skips hydration and the
// page answers "not in your library" from an empty catalog.
case 'actor':
case 'discover':
return 'search';
default:
return null;
}
+1
View File
@@ -2,6 +2,7 @@ export * from './tmdb-api.service';
export * from './tmdb-cache.service';
export * from './tmdb-config';
export * from './tmdb-credits';
export * from './tmdb-discover';
export * from './tmdb-enrichment.service';
export * from './tmdb-id-resolver.service';
export * from './tmdb-episode-merge';
@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core';
import { TMDB_API_BASE_URL } from './tmdb-config';
import {
TmdbDiscoverFilters,
TmdbDiscoverResponse,
TmdbMovieDetails,
TmdbPersonDetails,
TmdbSearchResponse,
@@ -116,6 +118,40 @@ export class TmdbApiService {
return response.results ?? [];
}
/**
* One `/discover` page for the given facets, most popular first. The
* year filter parameter differs per media type; genre ids are only
* valid within their own media type's id space.
*/
async discoverTitles(
mediaType: 'movie' | 'tv',
filters: TmdbDiscoverFilters,
page: number,
language: string,
apiKey: string
): Promise<TmdbDiscoverResponse> {
const yearParam =
mediaType === 'movie'
? 'primary_release_year'
: 'first_air_date_year';
return this.request<TmdbDiscoverResponse>(
`/discover/${mediaType}`,
{
language,
sort_by: 'popularity.desc',
page: String(page),
...(filters.year ? { [yearParam]: String(filters.year) } : {}),
...(filters.genreId
? { with_genres: String(filters.genreId) }
: {}),
...(filters.countryCode
? { with_origin_country: filters.countryCode }
: {}),
},
apiKey
);
}
async getPersonDetails(
personId: number,
language: string,
@@ -0,0 +1,159 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { TmdbApiService } from './tmdb-api.service';
import { TmdbDiscoverService } from './tmdb-discover.service';
import { TmdbRuntimeService } from './tmdb-runtime.service';
import { TmdbDiscoverResponse } from './tmdb.types';
describe('TmdbDiscoverService', () => {
let discoverTitles: jest.Mock;
let isEnabled: boolean;
function page(
start: number,
count: number,
totalPages: number
): TmdbDiscoverResponse {
return {
total_pages: totalPages,
results: Array.from({ length: count }, (_, index) => ({
id: start + index,
title: `Movie ${start + index}`,
release_date: '1990-06-01',
poster_path: null,
})),
};
}
// The services Jest target has no @angular/core/testing — build the
// service in a plain injection context instead of TestBed.
function createService(): TmdbDiscoverService {
const injector = Injector.create({
providers: [
{
provide: TmdbRuntimeService,
useValue: {
isEnabled: () => isEnabled,
apiKey: () => 'key',
language: () => 'en-US',
appLanguage: () => 'en',
},
},
{ provide: TmdbApiService, useValue: { discoverTitles } },
],
});
return runInInjectionContext(
injector,
() => new TmdbDiscoverService()
);
}
beforeEach(() => {
isEnabled = true;
discoverTitles = jest
.fn()
.mockImplementation((_type, _filters, pageNumber: number) =>
Promise.resolve(page(pageNumber * 100, 20, 2))
);
});
it('returns null without a request when enrichment is disabled', async () => {
isEnabled = false;
const service = createService();
expect(await service.discoverTitles('movie', { year: 1990 })).toBeNull();
expect(discoverTitles).not.toHaveBeenCalled();
});
it('fetches all pages up to total_pages', async () => {
const service = createService();
const titles = await service.discoverTitles('movie', { year: 1990 });
expect(discoverTitles).toHaveBeenCalledTimes(2);
expect(discoverTitles).toHaveBeenNthCalledWith(
1,
'movie',
{ year: 1990 },
1,
'en-US',
'key'
);
expect(discoverTitles).toHaveBeenNthCalledWith(
2,
'movie',
{ year: 1990 },
2,
'en-US',
'key'
);
expect(titles).toHaveLength(40);
expect(titles?.[0]).toEqual({
tmdbId: 100,
mediaType: 'movie',
title: 'Movie 100',
originalTitle: null,
year: 1990,
posterUrl: null,
});
});
it('caps the page fan-out at five pages', async () => {
discoverTitles.mockImplementation((_t, _f, pageNumber: number) =>
Promise.resolve(page(pageNumber * 100, 20, 500))
);
const service = createService();
await service.discoverTitles('movie', { genreId: 18 });
expect(discoverTitles).toHaveBeenCalledTimes(5);
});
it('serves repeated facet lookups from the session cache', async () => {
const service = createService();
const first = await service.discoverTitles('movie', { year: 1990 });
const second = await service.discoverTitles('movie', { year: 1990 });
expect(discoverTitles).toHaveBeenCalledTimes(2);
expect(second).toBe(first);
});
it('keys the cache by media type and facets', async () => {
const service = createService();
await service.discoverTitles('movie', { year: 1990 });
await service.discoverTitles('tv', { year: 1990 });
await service.discoverTitles('movie', { year: 1991 });
expect(discoverTitles).toHaveBeenCalledTimes(6);
});
it('returns null on failure and does not cache the error', async () => {
discoverTitles.mockRejectedValue(new Error('offline'));
const service = createService();
expect(
await service.discoverTitles('movie', { countryCode: 'US' })
).toBeNull();
discoverTitles.mockImplementation((_t, _f, pageNumber: number) =>
Promise.resolve(page(pageNumber * 100, 20, 1))
);
const retried = await service.discoverTitles('movie', {
countryCode: 'US',
});
expect(retried).toHaveLength(20);
});
it('deduplicates titles repeated across pages', async () => {
// Both pages return the same ids — popularity shifted mid-fetch
discoverTitles.mockImplementation(() =>
Promise.resolve(page(100, 20, 2))
);
const service = createService();
const titles = await service.discoverTitles('movie', { year: 1990 });
expect(titles).toHaveLength(20);
});
});
@@ -0,0 +1,101 @@
import { Injectable, inject } from '@angular/core';
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
import { TmdbApiService } from './tmdb-api.service';
import { DiscoverTitle, mapDiscoverResults } from './tmdb-discover';
import { TmdbRuntimeService } from './tmdb-runtime.service';
import { TmdbDiscoverFilters } from './tmdb.types';
/**
* Popularity caps how deep a facet page digs: fetching every 1990 movie
* TMDB knows is neither possible nor useful, so the Discover page shows
* the most popular slice and lets catalog matching do the rest.
*/
const MAX_DISCOVER_PAGES = 5;
/** FIFO bound for the session cache — a browse session revisits few keys */
const MAX_CACHE_ENTRIES = 30;
/**
* `/discover` facade for the portal Discover pages. Deliberately session-
* scoped: results are popularity-ranked and volatile, so they are cached
* in memory only and never reach the persisted `tmdb_metadata` table.
* Any failure returns `null` (not cached) — pages show their empty state
* and a retry is a fresh request.
*/
@Injectable({ providedIn: 'root' })
export class TmdbDiscoverService {
private readonly runtime = inject(TmdbRuntimeService);
private readonly api = inject(TmdbApiService);
private readonly cache = new Map<string, DiscoverTitle[]>();
async discoverTitles(
mediaType: TmdbMediaType,
filters: TmdbDiscoverFilters
): Promise<DiscoverTitle[] | null> {
if (!this.runtime.isEnabled()) {
return null;
}
const language = this.runtime.language();
const key = [
mediaType,
filters.year ?? '',
filters.genreId ?? '',
filters.countryCode ?? '',
language,
].join('|');
const cached = this.cache.get(key);
if (cached) {
return cached;
}
try {
const apiKey = this.runtime.apiKey();
const first = await this.api.discoverTitles(
mediaType,
filters,
1,
language,
apiKey
);
const pages = Math.min(
Math.max(first.total_pages ?? 1, 1),
MAX_DISCOVER_PAGES
);
const rest =
pages > 1
? await Promise.all(
Array.from({ length: pages - 1 }, (_, index) =>
this.api.discoverTitles(
mediaType,
filters,
index + 2,
language,
apiKey
)
)
)
: [];
// No .flatMap — the web dev-serve target compiles against a
// pre-es2019 lib and rejects it (production builds accept it)
const results = [];
for (const response of [first, ...rest]) {
results.push(...(response.results ?? []));
}
const titles = mapDiscoverResults(results, mediaType);
if (this.cache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, titles);
return titles;
} catch (error) {
console.warn('TMDB discover failed:', error);
return null;
}
}
}
@@ -0,0 +1,55 @@
import { mapDiscoverResults } from './tmdb-discover';
describe('mapDiscoverResults', () => {
it('keeps the original title as a matching alias', () => {
const [movie] = mapDiscoverResults(
[
{
id: 1,
title: 'Ирония судьбы',
original_title: 'Ирония судьбы, или С лёгким паром!',
release_date: '1976-01-01',
},
],
'movie'
);
// TMDB localizes `title` while the catalog stores whatever the
// panel named the file, which is often the original
expect(movie.title).toBe('Ирония судьбы');
expect(movie.originalTitle).toBe(
'Ирония судьбы, или С лёгким паром!'
);
});
it('reads the original name for tv results', () => {
const [show] = mapDiscoverResults(
[{ id: 2, name: 'Тьма', original_name: 'Dark' }],
'tv'
);
expect(show.originalTitle).toBe('Dark');
});
it('has no alias when the original matches the localized title', () => {
const [movie] = mapDiscoverResults(
[{ id: 3, title: 'Dune', original_title: 'Dune' }],
'movie'
);
expect(movie.originalTitle).toBeNull();
});
it('drops untitled results and deduplicates by id', () => {
const mapped = mapDiscoverResults(
[
{ id: 4, title: 'Alien' },
{ id: 4, title: 'Alien' },
{ id: 5, title: ' ' },
],
'movie'
);
expect(mapped.map((entry) => entry.tmdbId)).toEqual([4]);
});
});
@@ -0,0 +1,59 @@
import { tmdbPosterUrl } from './tmdb-config';
import { extractYear } from './tmdb-matcher';
import { TmdbSearchResult } from './tmdb.types';
/**
* View-friendly projection of a `/discover` result, shared by the portal
* Discover pages. Structurally a subset of `ActorFilmographyCredit`, so
* both feed the same title-results grid.
*/
export interface DiscoverTitle {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
/**
* TMDB's original-language title, kept as a matching alias: `title`
* is localized to the app language while the provider catalog stores
* whatever the panel named the file, which is often the original.
*/
originalTitle: string | null;
year: number | null;
posterUrl: string | null;
}
/**
* Maps raw discover results, dropping untitled entries and deduplicating
* by TMDB id (concatenated pages can repeat entries when popularity
* shifts between page fetches).
*/
export function mapDiscoverResults(
results: readonly TmdbSearchResult[],
mediaType: 'movie' | 'tv'
): DiscoverTitle[] {
const seen = new Set<number>();
const titles: DiscoverTitle[] = [];
for (const result of results) {
const title = (result.title ?? result.name ?? '').trim();
if (!title || seen.has(result.id)) {
continue;
}
seen.add(result.id);
const originalTitle = (
result.original_title ??
result.original_name ??
''
).trim();
titles.push({
tmdbId: result.id,
mediaType,
title,
originalTitle:
originalTitle && originalTitle !== title ? originalTitle : null,
year: extractYear(result.release_date ?? result.first_air_date),
posterUrl: tmdbPosterUrl(result.poster_path),
});
}
return titles;
}
@@ -1,6 +1,7 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { TmdbApiError, TmdbApiService } from './tmdb-api.service';
import { TmdbCacheService } from './tmdb-cache.service';
import { TmdbDiscoverService } from './tmdb-discover.service';
import { TmdbEnrichmentService } from './tmdb-enrichment.service';
import { TmdbIdResolverService } from './tmdb-id-resolver.service';
import { TmdbPersonService } from './tmdb-person.service';
@@ -74,6 +75,7 @@ describe('TmdbEnrichmentService — provider tmdb_id handling', () => {
{ provide: TmdbPersonService, useValue: {} },
{ provide: TmdbSeasonService, useValue: {} },
{ provide: TmdbTrendingService, useValue: {} },
{ provide: TmdbDiscoverService, useValue: {} },
],
});
return runInInjectionContext(
@@ -14,12 +14,15 @@ import {
detailsFallbackLanguage,
fillDetailsFromFallback,
} from './tmdb-language-fallback';
import { DiscoverTitle } from './tmdb-discover';
import { TmdbDiscoverService } from './tmdb-discover.service';
import { TmdbPersonService } from './tmdb-person.service';
import { TmdbRuntimeService } from './tmdb-runtime.service';
import { TmdbSeasonService } from './tmdb-season.service';
import { TmdbTrendingService } from './tmdb-trending.service';
import {
TmdbDetails,
TmdbDiscoverFilters,
TmdbEnrichmentQuery,
TmdbEpisode,
TmdbMovieDetails,
@@ -47,6 +50,7 @@ export class TmdbEnrichmentService {
private readonly person = inject(TmdbPersonService);
private readonly season = inject(TmdbSeasonService);
private readonly trending = inject(TmdbTrendingService);
private readonly discover = inject(TmdbDiscoverService);
private readonly idResolver = inject(TmdbIdResolverService);
isEnabled(): boolean {
@@ -102,6 +106,14 @@ export class TmdbEnrichmentService {
return this.person.getPersonDetails(personId);
}
/** Popular titles for one facet set (year/genre/country), session-cached */
async discoverTitles(
mediaType: TmdbMediaType,
filters: TmdbDiscoverFilters
): Promise<DiscoverTitle[] | null> {
return this.discover.discoverTitles(mediaType, filters);
}
private async enrich(
mediaType: TmdbMediaType,
query: TmdbEnrichmentQuery
@@ -98,6 +98,35 @@ describe('mergeStalkerInfoWithTmdb', () => {
expect(merged.director).toBe('Создатель Сериала');
});
it('emits facets and the matched media type for Discover chips', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
{
...tmdbMovieRu,
origin_country: ['RU'],
production_countries: [
{ iso_3166_1: 'RU', name: 'Russia' },
],
},
'movie'
);
expect(merged.tmdb_genres).toEqual([{ id: 35, name: 'комедия' }]);
expect(merged.tmdb_countries).toEqual([
{ code: 'RU', name: 'Russia' },
]);
expect(merged.tmdb_media_type).toBe('movie');
});
it('marks embedded-VOD series as tv so Discover routes correctly', () => {
const tv: TmdbTvDetails = { id: 1, overview: 'tv overview' };
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
expect(merged.tmdb_media_type).toBe('tv');
expect(merged.tmdb_genres).toBeUndefined();
expect(merged.tmdb_countries).toBeUndefined();
});
it('keeps provider values for missing TMDB fields', () => {
const merged = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
@@ -51,6 +51,7 @@ const tmdbMovie: TmdbMovieDetails = {
poster_path: '/matrix-poster.jpg',
backdrop_path: '/matrix-backdrop.jpg',
production_countries: [{ iso_3166_1: 'US', name: 'United States' }],
origin_country: ['US'],
credits: {
cast: [
{
@@ -235,6 +236,76 @@ describe('mergeVodInfoWithTmdb', () => {
expect(merged.tmdb_supplied_release_date).toBeUndefined();
});
it('emits structured genre and country facets for Discover chips', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie);
expect(merged.tmdb_genres).toEqual([
{ id: 28, name: 'Action' },
{ id: 878, name: 'Science Fiction' },
]);
expect(merged.tmdb_countries).toEqual([
{ code: 'US', name: 'United States' },
]);
});
it('keeps country facets to the origin countries the filter can query', () => {
// A co-production lists every partner in production_countries, but
// Discover filters by origin — offering "Canada" here would return
// Canadian-origin films rather than ones Canada co-produced
const merged = mergeVodInfoWithTmdb(providerVodInfo(), {
id: 476299,
origin_country: ['FR'],
production_countries: [
{ iso_3166_1: 'CA', name: 'Canada' },
{ iso_3166_1: 'FR', name: 'France' },
],
});
expect(merged.tmdb_countries).toEqual([
{ code: 'FR', name: 'France' },
]);
});
it('drops an origin country TMDB does not name', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), {
id: 603,
origin_country: ['US', 'XX'],
production_countries: [{ iso_3166_1: 'US', name: 'United States' }],
});
expect(merged.tmdb_countries).toEqual([
{ code: 'US', name: 'United States' },
]);
});
it('omits facet fields when TMDB states none', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), { id: 603 });
expect(merged.tmdb_genres).toBeUndefined();
expect(merged.tmdb_countries).toBeUndefined();
});
it('drops facet entries with missing ids or names', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), {
id: 603,
genres: [
{ id: 28, name: 'Action' },
{ id: 12, name: ' ' },
],
origin_country: ['US', 'ZZ'],
production_countries: [
{ iso_3166_1: 'US', name: 'United States' },
{ iso_3166_1: '', name: 'Nowhere' },
{ name: 'No Code' },
],
});
expect(merged.tmdb_genres).toEqual([{ id: 28, name: 'Action' }]);
expect(merged.tmdb_countries).toEqual([
{ code: 'US', name: 'United States' },
]);
});
it('does not mutate the provider object', () => {
const info = providerVodInfo();
const snapshot = JSON.parse(JSON.stringify(info));
@@ -318,6 +389,28 @@ describe('mergeSerieInfoWithTmdb', () => {
expect(merged.rating_5based).toBe(3.5);
expect(merged.cover).toBe('http://provider/cover.jpg');
});
it('emits structured genre and country facets for Discover chips', () => {
const merged = mergeSerieInfoWithTmdb(providerSerieInfo, {
...tmdbTv,
origin_country: ['DE'],
production_countries: [{ iso_3166_1: 'DE', name: 'Germany' }],
});
expect(merged.tmdb_genres).toEqual([{ id: 9648, name: 'Mystery' }]);
expect(merged.tmdb_countries).toEqual([
{ code: 'DE', name: 'Germany' },
]);
});
it('omits facet fields when TMDB states none', () => {
const merged = mergeSerieInfoWithTmdb(providerSerieInfo, {
id: 70523,
});
expect(merged.tmdb_genres).toBeUndefined();
expect(merged.tmdb_countries).toBeUndefined();
});
});
describe('series status', () => {
+51
View File
@@ -1,6 +1,8 @@
import {
normalizeSeriesStatus,
StalkerVodInfo,
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
XtreamSerieInfo,
@@ -66,6 +68,34 @@ function genreNames(details: TmdbDetails): string {
.join(', ');
}
/** Structured genres for clickable Discover chips */
function genreFacets(details: TmdbDetails): TmdbGenreFacet[] {
return (details.genres ?? [])
.filter((genre) => Number.isInteger(genre.id) && !!genre.name?.trim())
.map((genre) => ({ id: genre.id, name: genre.name }));
}
/**
* Structured countries for clickable Discover chips.
*
* Built from `origin_country`, never from the fuller
* `production_countries`: the Discover page filters by
* `with_origin_country`, so a co-production's other countries would
* promise "titles from here" and deliver a different set. Names come
* from `production_countries` (the only place TMDB states them), and a
* code it does not name is dropped rather than rendered as a bare "FR".
*/
function countryFacets(details: TmdbDetails): TmdbCountryFacet[] {
const namesByCode = new Map(
(details.production_countries ?? [])
.filter((entry) => !!entry.iso_3166_1 && !!entry.name)
.map((entry) => [entry.iso_3166_1 as string, entry.name as string])
);
return (details.origin_country ?? [])
.filter((code) => namesByCode.has(code))
.map((code) => ({ code, name: namesByCode.get(code) as string }));
}
function tmdbRating(details: TmdbDetails): number | null {
const { vote_average: average, vote_count: count } = details;
return average && average > 0 && count && count > 0
@@ -106,6 +136,8 @@ export function mergeVodInfoWithTmdb(
const cast = castNames(movieCast);
const director = directorNames(details.credits);
const genre = genreNames(details);
const tmdbGenres = genreFacets(details);
const tmdbCountries = countryFacets(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
const country = (details.production_countries ?? [])
@@ -140,6 +172,10 @@ export function mergeVodInfoWithTmdb(
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}),
...(tmdbCountries.length > 0
? { tmdb_countries: tmdbCountries }
: {}),
};
}
@@ -156,6 +192,8 @@ export function mergeSerieInfoWithTmdb(
const cast = castNames(seriesCast);
const creators = creatorNames(details);
const genre = genreNames(details);
const tmdbGenres = genreFacets(details);
const tmdbCountries = countryFacets(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
@@ -180,6 +218,10 @@ export function mergeSerieInfoWithTmdb(
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}),
...(tmdbCountries.length > 0
? { tmdb_countries: tmdbCountries }
: {}),
};
}
@@ -215,6 +257,8 @@ export function mergeStalkerInfoWithTmdb(
? directorNames(details.credits)
: creatorNames(details as TmdbTvDetails);
const genre = genreNames(details);
const tmdbGenres = genreFacets(details);
const tmdbCountries = countryFacets(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
const backdrop = tmdbBackdropUrl(details.backdrop_path);
@@ -243,5 +287,12 @@ export function mergeStalkerInfoWithTmdb(
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}),
...(tmdbCountries.length > 0
? { tmdb_countries: tmdbCountries }
: {}),
// Lets the shared detail view route Discover clicks to the media
// type this item actually matched as (embedded-VOD series → tv)
tmdb_media_type: mediaType,
};
}
+26 -1
View File
@@ -101,6 +101,15 @@ interface TmdbDetailsBase {
credits?: TmdbCredits;
videos?: { results?: TmdbVideo[] };
recommendations?: { results?: TmdbSearchResult[] };
/** Present on both /movie and /tv details payloads */
production_countries?: { iso_3166_1?: string; name?: string }[];
/**
* ISO 3166-1 codes of the countries the title ORIGINATES from — a
* subset of `production_countries` for co-productions. This is the
* dimension `/discover`'s `with_origin_country` filters on, so it is
* what a clickable country facet must be built from.
*/
origin_country?: string[];
}
export interface TmdbMovieDetails extends TmdbDetailsBase {
@@ -108,7 +117,6 @@ export interface TmdbMovieDetails extends TmdbDetailsBase {
original_title?: string;
release_date?: string;
runtime?: number;
production_countries?: { iso_3166_1?: string; name?: string }[];
}
export interface TmdbTvDetails extends TmdbDetailsBase {
@@ -181,6 +189,23 @@ export interface TmdbPersonDetails {
export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails;
/** `/discover/{movie,tv}` page; extends the search shape with paging */
export interface TmdbDiscoverResponse extends TmdbSearchResponse {
page?: number;
total_pages?: number;
}
/**
* Facet filters for `/discover`. All optional — a single chip click sends
* exactly one, but the shape supports combined queries for later versions.
*/
export interface TmdbDiscoverFilters {
year?: number | null;
genreId?: number | null;
/** ISO 3166-1 alpha-2 */
countryCode?: string | null;
}
/** Input for the enrichment orchestrator */
export interface TmdbEnrichmentQuery {
/** Provider-supplied TMDB id — trusted fully when valid */
@@ -1,5 +1,8 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
@@ -52,4 +55,13 @@ export interface StalkerVodInfo {
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB id — enables lazy season/episode enrichment */
tmdb_id?: number;
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
/**
* Media type the enrichment matched this item as. Stalker embedded-VOD
* series route as movies structurally, so the shared detail view needs
* this to send Discover clicks to the right media type.
*/
tmdb_media_type?: TmdbMediaType;
}
@@ -99,6 +99,55 @@ export interface TmdbRecommendation {
posterUrl: string | null;
}
/**
* Genre as stated by TMDB for one title. The id drives the Discover-page
* filter (`with_genres`), so it must never be mixed across media types —
* movie and TV genre id spaces differ.
*/
export interface TmdbGenreFacet {
id: number;
name: string;
}
/**
* Production country as stated by TMDB. `code` is ISO 3166-1 alpha-2 and
* drives the Discover-page filter (`with_origin_country`); `name` is the
* TMDB display name (English regardless of request language).
*/
export interface TmdbCountryFacet {
code: string;
name: string;
}
/**
* Whether a number is a year the Discover page can actually filter by.
*
* Providers ship `0000-00-00` as their "no date" placeholder, which reads
* as a four-digit year and would otherwise produce a `0000` chip that
* filters by nothing. Shared by the chip side and the route-param side so
* a deep link cannot smuggle in what a chip refuses to offer.
*/
export function isTmdbYearFacet(year: number): boolean {
return Number.isInteger(year) && year >= 1000 && year <= 9999;
}
/**
* The year stated by a provider date field, whatever shape it arrives in
* — `1976`, `1999-03-31` and `31-03-1999` all resolve to the same year.
*
* Reads the first four-digit run rather than a fixed prefix: slicing
* turns a day-first date into `31-0`, which is both the wrong label and
* an unusable filter. Lives here so the Discover chips and the detail
* adapters cannot drift into disagreeing about what a date says.
*/
export function parseFacetYear(
releaseDate: string | null | undefined
): number | null {
const match = releaseDate?.match(/\d{4}/);
const year = match ? Number(match[0]) : null;
return year !== null && isTmdbYearFacet(year) ? year : null;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
@@ -1,4 +1,75 @@
import { youtubeEmbedUrl } from './vod-details-adapters';
import {
normalizeStalkerVod,
normalizeXtreamVod,
youtubeEmbedUrl,
} from './vod-details-adapters';
describe('normalized release year', () => {
const stalker = (releasedate: string) =>
normalizeStalkerVod({
id: '1',
cmd: '',
info: {
movie_image: '',
description: '',
name: 'Film',
actors: '',
director: '',
releasedate,
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
}).year;
it('reads the year out of a day-first provider date', () => {
// A fixed prefix renders '31-0' here, which is both the wrong
// label and a year the Discover chip cannot filter by
expect(stalker('31-03-1999')).toBe('1999');
});
it('keeps reading ordinary date shapes', () => {
expect(stalker('1976')).toBe('1976');
expect(stalker('1999-03-31')).toBe('1999');
expect(
normalizeXtreamVod({
info: {
kinopoisk_url: '',
tmdb_id: 0,
name: 'Film',
o_name: '',
cover_big: '',
movie_image: '',
releasedate: '2018-05-01',
episode_run_time: 0,
youtube_trailer: '',
director: '',
actors: '',
cast: '',
description: '',
plot: '',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: '',
genre: '',
backdrop_path: [],
duration_secs: 0,
duration: '',
video: [],
audio: [],
bitrate: 0,
rating: 0,
},
}).year
).toBe('2018');
});
it('states no year for a placeholder or unusable date', () => {
expect(stalker('0000-00-00')).toBeUndefined();
expect(stalker('')).toBeUndefined();
});
});
describe('youtubeEmbedUrl', () => {
it('builds an embed URL from a plain video id (TMDB format)', () => {
@@ -1,5 +1,6 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import { parseFacetYear } from './tmdb.interface';
import {
NormalizedVodMeta,
VodDetailsItem,
@@ -32,6 +33,8 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -62,6 +65,9 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
tmdbMediaType: info?.tmdb_media_type,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -165,24 +171,28 @@ export function getVodNumericId(item: VodDetailsItem): number {
// ============ Helper Functions ============
/**
* Extracts 4-digit year from various date formats.
* Extracts the 4-digit year from the various date formats providers send.
*
* Delegates to the shared facet parser so the displayed year and the
* Discover chip built from it cannot disagree — the old fixed-prefix
* fallback turned a day-first `31-03-1999` into `31-0`, which is both
* the wrong label and an unusable filter. The date-parse fallback stays
* for shapes stating no four-digit run of their own.
*/
function extractYear(dateString?: string): string | undefined {
if (!dateString) return undefined;
// Try to extract 4-digit year from beginning
const yearMatch = dateString.match(/^(\d{4})/);
if (yearMatch) {
return yearMatch[1];
const facetYear = parseFacetYear(dateString);
if (facetYear !== null) {
return String(facetYear);
}
// Try to parse as date and extract year
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.getFullYear().toString();
}
return dateString.slice(0, 4);
return undefined;
}
/**
@@ -1,6 +1,10 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import { TmdbEnrichedCastMember,
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
} from './tmdb.interface';
@@ -88,6 +92,12 @@ export interface NormalizedVodMeta {
tmdbDirectors?: TmdbEnrichedCastMember[];
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
tmdbRecommendations?: TmdbRecommendation[];
/** Media type the enrichment matched as; Discover click routing */
tmdbMediaType?: TmdbMediaType;
/** Per-entry clickable genre chips (Discover page) */
tmdbGenres?: TmdbGenreFacet[];
/** Per-entry clickable country chips (Discover page) */
tmdbCountries?: TmdbCountryFacet[];
}
/**
@@ -1,5 +1,7 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbRecommendation,
TmdbSeriesStatus,
} from './tmdb.interface';
@@ -44,6 +46,9 @@ export interface XtreamSerieInfo {
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB show id — enables lazy season/episode enrichment */
tmdb_id?: number;
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
}
export interface XtreamSerieEpisode {
@@ -1,5 +1,7 @@
import {
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
TmdbRecommendation,
} from './tmdb.interface';
@@ -52,6 +54,9 @@ export interface XtreamVodInfo {
tmdb_directors?: TmdbEnrichedCastMember[];
/** Populated by TMDB enrichment; matched against the catalog in views */
tmdb_recommendations?: TmdbRecommendation[];
/** Populated by TMDB enrichment; per-entry clickable Discover chips */
tmdb_genres?: TmdbGenreFacet[];
tmdb_countries?: TmdbCountryFacet[];
}
export interface XtreamVodMovieData {
@@ -319,6 +319,30 @@
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
// Rendered as a <button> so the facet is keyboard-operable; the
// UA's own font and appearance have to be neutralized to keep it
// visually identical to the informational chips beside it.
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
line-height: inherit;
transition:
border-color 0.15s ease,
background 0.15s ease;
&:hover {
background: var(--tag-border);
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
}
&:focus-visible {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 2px;
}
}
}
&__description {
@@ -82,6 +82,30 @@
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
// Rendered as a <button> so the facet is keyboard-operable; the UA's
// own font and appearance have to be neutralized to keep it visually
// identical to the informational chips beside it.
&--clickable {
appearance: none;
cursor: pointer;
font-family: inherit;
letter-spacing: inherit;
line-height: inherit;
transition:
border-color 0.15s ease,
background 0.15s ease;
&:hover {
background: var(--tag-border, rgba(255, 255, 255, 0.2));
border-color: var(--text-secondary, rgba(255, 255, 255, 0.5));
}
&:focus-visible {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 2px;
}
}
}
.about ::ng-deep .details__meta-item {
@@ -12,9 +12,29 @@
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-template appDetailTags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
@if (discover.canOpenYear(meta.year)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(meta.year)"
>
{{ meta.year }}
</button>
} @else {
<span class="details__tag">{{ meta.year }}</span>
}
}
@if (meta.genre) {
@if (meta.tmdbGenres?.length) {
@for (genre of meta.tmdbGenres; track genre.id) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openGenre(genre)"
>
{{ genre.name }}
</button>
}
} @else if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
@@ -25,7 +45,17 @@
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
@if (meta.tmdbCountries?.length) {
@for (country of meta.tmdbCountries; track country.code) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openCountry(country)"
>
{{ country.name }}
</button>
}
} @else if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
@if (isDownloaded()) {
@@ -13,6 +13,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
import {
PORTAL_EXTERNAL_PLAYBACK,
createDiscoverFacetNavigation,
createExternalPlaybackButtonState,
} from '@iptvnator/portal/shared/util';
import {
@@ -36,6 +37,7 @@ import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
TmdbEnrichmentService,
} from '@iptvnator/services';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
@@ -319,6 +321,24 @@ export class VodDetailsComponent {
this.backClicked.emit();
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const item = this.item();
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return item.playlistId && this.tmdbEnrichment.isEnabled()
? {
portal: item.type === 'stalker' ? 'stalker' : 'xtream',
// Stalker embedded-VOD series render here but are matched
// as tv, so the merge's verdict decides — not the route
mediaType: this.normalizedMeta().tmdbMediaType ?? 'movie',
playlistId: item.playlistId,
}
: null;
});
/** Handle download request */
onDownload(): void {
this.downloadRequested.emit(this.item());
+3
View File
@@ -1,2 +1,5 @@
export * from './lib/actor-view/actor-view.component';
export * from './lib/discover-view/discover-params';
export * from './lib/discover-view/discover-view.component';
export * from './lib/live-epg-panel-summary';
export * from './lib/title-results/title-results.component';
@@ -51,136 +51,15 @@
</div>
</header>
<section class="actor-view__filmography">
<h2>
{{ 'XTREAM.ACTOR_FILMOGRAPHY' | translate }}
@if (showScopeToggle()) {
<span class="actor-view__filter">
<button
type="button"
class="actor-view__filter-chip"
[class.actor-view__filter-chip--active]="
scope() === 'portal'
"
(click)="scopeChanged.emit('portal')"
>
{{ 'XTREAM.ACTOR_SCOPE_THIS_PORTAL' | translate }}
</button>
<button
type="button"
class="actor-view__filter-chip"
[class.actor-view__filter-chip--active]="
scope() === 'global'
"
(click)="scopeChanged.emit('global')"
>
{{ 'XTREAM.ACTOR_SCOPE_ALL_PORTALS' | translate }}
</button>
</span>
}
@if (showAvailabilityFilter()) {
<span class="actor-view__filter">
<button
type="button"
class="actor-view__filter-chip"
[class.actor-view__filter-chip--active]="
filterMode() === 'all'
"
(click)="filterMode.set('all')"
>
{{ 'XTREAM.ACTOR_FILTER_ALL' | translate }}
</button>
<button
type="button"
class="actor-view__filter-chip"
[class.actor-view__filter-chip--active]="
filterMode() === 'available'
"
(click)="filterMode.set('available')"
>
{{ 'XTREAM.ACTOR_IN_LIBRARY' | translate }}
</button>
</span>
}
</h2>
@if (isMatching()) {
<div class="actor-view__matching">
<mat-spinner diameter="32" />
</div>
} @else if (visibleItems().length === 0) {
<div class="actor-view__empty">
<mat-icon>search_off</mat-icon>
<p>{{ 'XTREAM.ACTOR_NO_RESULTS' | translate }}</p>
</div>
}
<div class="actor-view__grid">
@for (
item of visibleItems();
track item.mediaType + '-' + item.tmdbId
) {
<button
type="button"
class="actor-view__card"
[class.actor-view__card--unavailable]="!item.available"
[title]="
item.character
? item.title + ' — ' + item.character
: item.title
"
(click)="itemClicked.emit(item)"
>
@if (item.posterUrl) {
<img
class="actor-view__poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="actor-view__poster actor-view__poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
@if (item.available && item.availableIn) {
<span class="actor-view__badge">
<mat-icon>check_circle</mat-icon>
{{ item.availableIn }}
</span>
} @else if (
item.available && filterMode() !== 'available'
) {
<span class="actor-view__badge">
<mat-icon>check_circle</mat-icon>
{{ 'XTREAM.ACTOR_IN_LIBRARY' | translate }}
</span>
} @else if (!item.available) {
<span
class="actor-view__badge actor-view__badge--search"
>
<mat-icon>search</mat-icon>
{{
'XTREAM.ACTOR_SEARCH_IN_PORTAL' | translate
}}
</span>
}
<span class="actor-view__card-title">
{{ item.title }}
</span>
<span class="actor-view__card-sub">
@if (item.year) {
{{ item.year }}
}
@if (item.character) {
· {{ item.character }}
} @else if (item.crewJob) {
· {{ crewJobKey(item.crewJob) | translate }}
}
</span>
</button>
}
</div>
</section>
<app-title-results
headingKey="XTREAM.ACTOR_FILMOGRAPHY"
[items]="items()"
[isMatching]="isMatching()"
[showAvailabilityFilter]="showAvailabilityFilter()"
[showScopeToggle]="showScopeToggle()"
[scope]="scope()"
(scopeChanged)="scopeChanged.emit($event)"
(itemClicked)="itemClicked.emit($event)"
/>
}
</div>
@@ -1,5 +1,6 @@
// Theme variables mirror the detail-view scope (_detail-view.scss) — the
// actor page lives outside content-hero, so it must define them itself.
// They cascade into the embedded app-title-results grid.
:host {
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
@@ -107,152 +108,4 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
}
&__filmography h2 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 16px;
}
&__grid {
display: grid;
gap: 20px 16px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
&__card {
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
padding: 0;
position: relative;
text-align: left;
&:hover .actor-view__poster {
transform: scale(1.03);
}
&--unavailable .actor-view__poster {
opacity: 0.45;
}
}
&__poster {
aspect-ratio: 2 / 3;
background: var(--poster-fallback-bg);
border-radius: 8px;
object-fit: cover;
transition:
transform 0.15s ease,
opacity 0.15s ease;
width: 100%;
&--fallback {
align-items: center;
color: var(--text-muted);
display: inline-flex;
font-size: 2.5rem;
font-weight: 600;
justify-content: center;
text-transform: uppercase;
}
}
&__matching,
&__empty {
align-items: center;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
padding: 48px 0;
mat-icon {
font-size: 40px;
height: 40px;
width: 40px;
}
p {
font-size: 0.9375rem;
margin: 0;
}
}
&__badge {
align-items: center;
background: rgba(0, 0, 0, 0.72);
border-radius: 999px;
color: #7ddb8a;
display: inline-flex;
font-size: 0.6875rem;
font-weight: 600;
gap: 4px;
left: 8px;
// Playlist names can be long URLs — never overflow the poster
max-width: calc(100% - 16px);
overflow: hidden;
padding: 3px 8px;
position: absolute;
text-overflow: ellipsis;
top: 8px;
white-space: nowrap;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&--search {
// The badge background is always dark, regardless of theme
color: rgba(255, 255, 255, 0.7);
}
}
&__filter {
display: inline-flex;
gap: 8px;
margin-left: 16px;
vertical-align: middle;
}
&__filter-chip {
background: var(--chip-bg);
border: 1px solid var(--chip-border);
border-radius: 999px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
padding: 4px 14px;
&--active {
border-color: transparent;
background: #2f7bff;
color: #fff;
}
}
&__card-title {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__card-sub {
color: var(--text-muted);
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -1,10 +1,8 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -13,6 +11,10 @@ import {
ActorFilmographyCredit,
ActorProfile,
} from '@iptvnator/services';
import {
TitleResultsComponent,
TitleResultsScope,
} from '../title-results/title-results.component';
/**
* One filmography entry as rendered on the actor page. `available` marks a
@@ -25,11 +27,16 @@ export interface ActorViewItem extends ActorFilmographyCredit {
availableIn?: string;
}
export type ActorViewScope = 'portal' | 'global';
export type ActorViewScope = TitleResultsScope;
@Component({
selector: 'app-actor-view',
imports: [MatIcon, MatProgressSpinnerModule, TranslatePipe],
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
],
templateUrl: './actor-view.component.html',
styleUrls: ['./actor-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -49,19 +56,4 @@ export class ActorViewComponent {
readonly itemClicked = output<ActorViewItem>();
readonly backClicked = output<void>();
readonly scopeChanged = output<ActorViewScope>();
readonly filterMode = signal<'all' | 'available'>('all');
/** Translated label key for a crew-only credit ("Director"/"Creator") */
crewJobKey(job: 'Director' | 'Creator'): string {
return job === 'Creator'
? 'XTREAM.CREW_JOB_CREATOR'
: 'XTREAM.CREW_JOB_DIRECTOR';
}
readonly visibleItems = computed(() =>
this.showAvailabilityFilter() && this.filterMode() === 'available'
? this.items().filter((item) => item.available)
: this.items()
);
}
@@ -0,0 +1,119 @@
import {
discoverFacetKey,
hasDiscoverFacet,
parseDiscoverParams,
} from './discover-params';
describe('parseDiscoverParams', () => {
it('parses a full valid facet set', () => {
const facets = parseDiscoverParams({
type: 'tv',
year: '1990',
genre: '18',
genreLabel: 'Drama',
country: 'us',
countryLabel: 'United States',
});
expect(facets).toEqual({
type: 'tv',
year: 1990,
genreId: 18,
genreLabel: 'Drama',
countryCode: 'US',
countryLabel: 'United States',
});
});
it('defaults an unknown media type to movie', () => {
expect(parseDiscoverParams({ type: 'series' }).type).toBe('movie');
expect(parseDiscoverParams({}).type).toBe('movie');
expect(parseDiscoverParams({ type: ['tv'] }).type).toBe('movie');
});
it('rejects malformed years', () => {
expect(parseDiscoverParams({ year: '199' }).year).toBeNull();
expect(parseDiscoverParams({ year: '19901' }).year).toBeNull();
expect(parseDiscoverParams({ year: 'abcd' }).year).toBeNull();
expect(parseDiscoverParams({ year: '1990' }).year).toBe(1990);
});
it('rejects a zero year a deep link could smuggle in', () => {
// The chips refuse '0000'; the route must refuse it too, or the
// request drops the filter and returns unfiltered popular titles
expect(parseDiscoverParams({ year: '0000' }).year).toBeNull();
expect(hasDiscoverFacet(parseDiscoverParams({ year: '0000' }))).toBe(
false
);
});
it('drops a genre label without a valid genre id', () => {
const facets = parseDiscoverParams({
genre: 'drama',
genreLabel: 'Drama',
});
expect(facets.genreId).toBeNull();
expect(facets.genreLabel).toBeNull();
});
it('rejects zero and negative genre ids', () => {
expect(parseDiscoverParams({ genre: '0' }).genreId).toBeNull();
expect(parseDiscoverParams({ genre: '-5' }).genreId).toBeNull();
});
it('normalizes country codes and drops orphaned labels', () => {
expect(parseDiscoverParams({ country: 'de' }).countryCode).toBe('DE');
expect(parseDiscoverParams({ country: 'USA' }).countryCode).toBeNull();
const orphanLabel = parseDiscoverParams({
countryLabel: 'United States',
});
expect(orphanLabel.countryCode).toBeNull();
expect(orphanLabel.countryLabel).toBeNull();
});
});
describe('hasDiscoverFacet', () => {
it('is false for a bare type-only param set', () => {
expect(hasDiscoverFacet(parseDiscoverParams({ type: 'movie' }))).toBe(
false
);
});
it('is true when any single facet is present', () => {
expect(hasDiscoverFacet(parseDiscoverParams({ year: '1990' }))).toBe(
true
);
expect(hasDiscoverFacet(parseDiscoverParams({ genre: '18' }))).toBe(
true
);
expect(hasDiscoverFacet(parseDiscoverParams({ country: 'US' }))).toBe(
true
);
});
});
describe('discoverFacetKey', () => {
it('produces distinct keys for distinct facet sets', () => {
const keys = new Set(
[
{ type: 'movie', year: '1990' },
{ type: 'tv', year: '1990' },
{ type: 'movie', year: '1991' },
{ type: 'movie', genre: '18' },
{ type: 'movie', country: 'US' },
].map((params) => discoverFacetKey(parseDiscoverParams(params)))
);
expect(keys.size).toBe(5);
});
it('is stable for equivalent param objects', () => {
expect(
discoverFacetKey(
parseDiscoverParams({ year: '1990', type: 'movie' })
)
).toBe(discoverFacetKey(parseDiscoverParams({ year: '1990' })));
});
});
@@ -0,0 +1,82 @@
import { TmdbMediaType, isTmdbYearFacet } from '@iptvnator/shared/interfaces';
/**
* Facets of a portal Discover page, parsed from route query params.
* Labels are display-only companions of their filter values; a label
* without its value is dropped, so the page never promises a filter it
* cannot send. `type` and `genreId` form an atomic pair — movie and TV
* genre id spaces differ, so a genre never survives a type flip.
*/
export interface DiscoverRouteParams {
type: TmdbMediaType;
year: number | null;
genreId: number | null;
genreLabel: string | null;
countryCode: string | null;
countryLabel: string | null;
}
/** Router-free query-param shape (Angular's `Params` is `any`-valued) */
type RawParams = Record<string, unknown>;
function rawString(params: RawParams, key: string): string | null {
const value = params[key];
return typeof value === 'string' && value.trim() !== ''
? value.trim()
: null;
}
export function parseDiscoverParams(params: RawParams): DiscoverRouteParams {
const type: TmdbMediaType =
rawString(params, 'type') === 'tv' ? 'tv' : 'movie';
// `0000` parses as four digits but filters by nothing — a deep link
// must not reach a state the chips themselves refuse to offer
const rawYear = rawString(params, 'year');
const parsedYear =
rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null;
const year =
parsedYear !== null && isTmdbYearFacet(parsedYear) ? parsedYear : null;
const rawGenre = rawString(params, 'genre');
const parsedGenre = rawGenre && /^\d+$/.test(rawGenre) ? Number(rawGenre) : null;
const genreId = parsedGenre && parsedGenre > 0 ? parsedGenre : null;
const rawCountry = rawString(params, 'country');
const countryCode =
rawCountry && /^[A-Za-z]{2}$/.test(rawCountry)
? rawCountry.toUpperCase()
: null;
return {
type,
year,
genreId,
genreLabel: genreId !== null ? rawString(params, 'genreLabel') : null,
countryCode,
countryLabel:
countryCode !== null ? rawString(params, 'countryLabel') : null,
};
}
export function hasDiscoverFacet(facets: DiscoverRouteParams): boolean {
return (
facets.year !== null ||
facets.genreId !== null ||
facets.countryCode !== null
);
}
/**
* Stable identity of one facet set. Used as the staleness-guard token by
* the route containers (facets change via query params on the same route
* instance, so a late response must be dropped by key, not by instance).
*/
export function discoverFacetKey(facets: DiscoverRouteParams): string {
return [
facets.type,
facets.year ?? '',
facets.genreId ?? '',
facets.countryCode ?? '',
].join('|');
}
@@ -0,0 +1,40 @@
<div class="discover-view">
<button
class="discover-view__back"
type="button"
(click)="backClicked.emit()"
[attr.aria-label]="'BACK' | translate"
>
<mat-icon>arrow_back</mat-icon>
</button>
<header class="discover-view__header">
<h1>{{ 'XTREAM.DISCOVER_TITLE' | translate }}</h1>
<div class="discover-view__facets">
<span class="discover-view__facet-chip">
{{ mediaTypeLabelKey() | translate }}
</span>
@for (label of facetLabels(); track label) {
<span class="discover-view__facet-chip">{{ label }}</span>
}
</div>
</header>
@if (isLoading()) {
<div class="discover-view__loading">
<mat-spinner diameter="48" />
</div>
} @else {
<app-title-results
headingKey="XTREAM.DISCOVER_RESULTS"
emptyMessageKey="XTREAM.DISCOVER_EMPTY"
[items]="items()"
[isMatching]="isMatching()"
[showAvailabilityFilter]="showAvailabilityFilter()"
[showScopeToggle]="showScopeToggle()"
[scope]="scope()"
(scopeChanged)="scopeChanged.emit($event)"
(itemClicked)="itemClicked.emit($event)"
/>
}
</div>
@@ -0,0 +1,88 @@
// Same theme scope as the actor page — cascades into app-title-results.
:host {
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-muted: rgba(26, 26, 26, 0.5);
--chip-bg: rgba(0, 0, 0, 0.06);
--chip-border: rgba(0, 0, 0, 0.12);
--poster-fallback-bg: rgba(0, 0, 0, 0.08);
display: block;
height: 100%;
min-height: 0;
}
:host-context(.dark-theme) {
--surface-bg: #141414;
--text-primary: #fff;
--text-muted: rgba(255, 255, 255, 0.5);
--chip-bg: rgba(255, 255, 255, 0.1);
--chip-border: rgba(255, 255, 255, 0.2);
--poster-fallback-bg: rgba(255, 255, 255, 0.08);
}
.discover-view {
background: var(--surface-bg);
color: var(--text-primary);
display: block;
height: 100%;
overflow-y: auto;
padding: 24px 32px 48px;
position: relative;
&__back {
align-items: center;
background: var(--chip-bg);
border: none;
border-radius: 50%;
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
height: 40px;
justify-content: center;
margin-bottom: 16px;
width: 40px;
&:hover {
background: var(--chip-border);
}
}
&__loading {
align-items: center;
display: flex;
justify-content: center;
min-height: 40vh;
}
&__header {
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
margin-bottom: 24px;
h1 {
color: var(--text-primary);
font-size: 2rem;
font-weight: 700;
margin: 0;
}
}
&__facets {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
&__facet-chip {
background: var(--chip-bg);
border: 1px solid var(--chip-border);
border-radius: 999px;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
padding: 4px 14px;
}
}
@@ -0,0 +1,66 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import {
TitleResultItem,
TitleResultsComponent,
TitleResultsScope,
} from '../title-results/title-results.component';
import { DiscoverRouteParams } from './discover-params';
/**
* Discover page shell: facet header ("Movies · Drama · 1990") over the
* shared title-results grid. Same surface as the actor page minus the
* person profile.
*/
@Component({
selector: 'app-discover-view',
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
],
templateUrl: './discover-view.component.html',
styleUrls: ['./discover-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiscoverViewComponent<T extends TitleResultItem> {
readonly facets = input.required<DiscoverRouteParams>();
readonly items = input<T[]>([]);
readonly isLoading = input(false);
/** Cross-playlist matching in flight (All-portals scope) */
readonly isMatching = input(false);
/** Shows the all/in-library filter (portals with a local catalog) */
readonly showAvailabilityFilter = input(false);
/** Shows the this-portal/all-portals scope switch (Electron only) */
readonly showScopeToggle = input(false);
readonly scope = input<TitleResultsScope>('portal');
readonly itemClicked = output<T>();
readonly backClicked = output<void>();
readonly scopeChanged = output<TitleResultsScope>();
readonly mediaTypeLabelKey = computed(() =>
this.facets().type === 'tv'
? 'XTREAM.DISCOVER_SERIES'
: 'XTREAM.DISCOVER_MOVIES'
);
/** Facet labels rendered as header chips, filter-value order */
readonly facetLabels = computed<string[]>(() => {
const facets = this.facets();
return [
facets.genreLabel,
facets.year !== null ? String(facets.year) : null,
facets.countryLabel ?? facets.countryCode,
].filter((label): label is string => label !== null);
});
}
@@ -0,0 +1,133 @@
<section class="title-results">
<h2>
{{ headingKey() | translate }}
@if (showScopeToggle()) {
<span class="title-results__filter">
<button
type="button"
class="title-results__filter-chip"
[class.title-results__filter-chip--active]="
scope() === 'portal'
"
(click)="scopeChanged.emit('portal')"
>
{{ 'XTREAM.ACTOR_SCOPE_THIS_PORTAL' | translate }}
</button>
<button
type="button"
class="title-results__filter-chip"
[class.title-results__filter-chip--active]="
scope() === 'global'
"
(click)="scopeChanged.emit('global')"
>
{{ 'XTREAM.ACTOR_SCOPE_ALL_PORTALS' | translate }}
</button>
</span>
}
@if (showAvailabilityFilter()) {
<span class="title-results__filter">
<button
type="button"
class="title-results__filter-chip"
[class.title-results__filter-chip--active]="
filterMode() === 'all'
"
(click)="filterMode.set('all')"
>
{{ 'XTREAM.ACTOR_FILTER_ALL' | translate }}
</button>
<button
type="button"
class="title-results__filter-chip"
[class.title-results__filter-chip--active]="
filterMode() === 'available'
"
(click)="filterMode.set('available')"
>
{{ 'XTREAM.ACTOR_IN_LIBRARY' | translate }}
</button>
</span>
}
</h2>
@if (matchingVisible()) {
<div class="title-results__matching">
<mat-spinner diameter="32" />
</div>
} @else if (visibleItems().length === 0) {
<div class="title-results__empty">
<mat-icon>search_off</mat-icon>
<p>{{ emptyMessageKey() | translate }}</p>
</div>
} @else {
<!-- Hidden while matching on purpose: until the matches land every
card still reads as unavailable, and clicking one would open
the portal search for a title the next tick will resolve. -->
<div class="title-results__grid">
@for (
item of visibleItems();
track item.mediaType + '-' + item.tmdbId
) {
<button
type="button"
class="title-results__card"
[class.title-results__card--unavailable]="!item.available"
[title]="
item.character
? item.title + ' — ' + item.character
: item.title
"
(click)="itemClicked.emit(item)"
>
@if (item.posterUrl) {
<img
class="title-results__poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="title-results__poster title-results__poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
@if (item.available && item.availableIn) {
<span class="title-results__badge">
<mat-icon>check_circle</mat-icon>
{{ item.availableIn }}
</span>
} @else if (
item.available && filterMode() !== 'available'
) {
<span class="title-results__badge">
<mat-icon>check_circle</mat-icon>
{{ 'XTREAM.ACTOR_IN_LIBRARY' | translate }}
</span>
} @else if (!item.available) {
<span
class="title-results__badge title-results__badge--search"
>
<mat-icon>search</mat-icon>
{{ 'XTREAM.ACTOR_SEARCH_IN_PORTAL' | translate }}
</span>
}
<span class="title-results__card-title">
{{ item.title }}
</span>
<span class="title-results__card-sub">
@if (item.year) {
{{ item.year }}
}
@if (item.character) {
· {{ item.character }}
} @else if (item.crewJob) {
· {{ crewJobKey(item.crewJob) | translate }}
}
</span>
</button>
}
</div>
}
</section>
@@ -0,0 +1,152 @@
// Theme custom properties (--surface-bg, --text-*, --chip-*,
// --poster-fallback-bg) are defined by the hosting page's :host scope and
// cascade in — see actor-view / discover-view.
.title-results {
h2 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 16px;
}
&__grid {
display: grid;
gap: 20px 16px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
&__card {
background: none;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
padding: 0;
position: relative;
text-align: left;
&:hover .title-results__poster {
transform: scale(1.03);
}
&--unavailable .title-results__poster {
opacity: 0.45;
}
}
&__poster {
aspect-ratio: 2 / 3;
background: var(--poster-fallback-bg);
border-radius: 8px;
object-fit: cover;
transition:
transform 0.15s ease,
opacity 0.15s ease;
width: 100%;
&--fallback {
align-items: center;
color: var(--text-muted);
display: inline-flex;
font-size: 2.5rem;
font-weight: 600;
justify-content: center;
text-transform: uppercase;
}
}
&__matching,
&__empty {
align-items: center;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
padding: 48px 0;
mat-icon {
font-size: 40px;
height: 40px;
width: 40px;
}
p {
font-size: 0.9375rem;
margin: 0;
}
}
&__badge {
align-items: center;
background: rgba(0, 0, 0, 0.72);
border-radius: 999px;
color: #7ddb8a;
display: inline-flex;
font-size: 0.6875rem;
font-weight: 600;
gap: 4px;
left: 8px;
// Playlist names can be long URLs — never overflow the poster
max-width: calc(100% - 16px);
overflow: hidden;
padding: 3px 8px;
position: absolute;
text-overflow: ellipsis;
top: 8px;
white-space: nowrap;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&--search {
// The badge background is always dark, regardless of theme
color: rgba(255, 255, 255, 0.7);
}
}
&__filter {
display: inline-flex;
gap: 8px;
margin-left: 16px;
vertical-align: middle;
}
&__filter-chip {
background: var(--chip-bg);
border: 1px solid var(--chip-border);
border-radius: 999px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
padding: 4px 14px;
&--active {
border-color: transparent;
background: #2f7bff;
color: #fff;
}
}
&__card-title {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__card-sub {
color: var(--text-muted);
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -0,0 +1,88 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ActorCrewJob } from '@iptvnator/services';
/**
* One entry of the shared title-results grid (actor filmography, Discover
* results). `available` marks a confident catalog match (direct
* navigation); the rest open the portal search prefilled with the title.
*/
export interface TitleResultItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
year: number | null;
posterUrl: string | null;
available: boolean;
/** Playlist name shown in the badge (All-portals scope) */
availableIn?: string;
/** Actor-page extras; absent on Discover results */
character?: string | null;
crewJob?: ActorCrewJob | null;
}
export type TitleResultsScope = 'portal' | 'global';
/**
* TMDB-titles grid with the scope (this portal / all portals) and
* availability (all / in library) filter chips, shared by the actor and
* Discover pages. Generic over the item type so hosts get their own item
* shape back from `itemClicked` without casts. Theme custom properties
* (`--surface-bg`, `--chip-*`, ...) are expected from the host's scope.
*/
@Component({
selector: 'app-title-results',
imports: [MatIcon, MatProgressSpinnerModule, TranslatePipe],
templateUrl: './title-results.component.html',
styleUrls: ['./title-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TitleResultsComponent<T extends TitleResultItem> {
readonly headingKey = input.required<string>();
readonly items = input<T[]>([]);
/** Cross-playlist matching in flight (All-portals scope) */
readonly isMatching = input(false);
/** Shows the all/in-library filter (portals with a local catalog) */
readonly showAvailabilityFilter = input(false);
/** Shows the this-portal/all-portals scope switch (Electron only) */
readonly showScopeToggle = input(false);
readonly scope = input<TitleResultsScope>('portal');
readonly emptyMessageKey = input('XTREAM.ACTOR_NO_RESULTS');
readonly itemClicked = output<T>();
readonly scopeChanged = output<TitleResultsScope>();
readonly filterMode = signal<'all' | 'available'>('all');
/** Translated label key for a crew-only credit ("Director"/"Creator") */
crewJobKey(job: ActorCrewJob): string {
return job === 'Creator'
? 'XTREAM.CREW_JOB_CREATOR'
: 'XTREAM.CREW_JOB_DIRECTOR';
}
/**
* The spinner belongs to the global scope only. Gating the grid on the
* raw flag hides valid portal results when the user switches back while
* the worker request is still in flight — a stuck worker would then
* blank the local grid indefinitely.
*/
readonly matchingVisible = computed(
() => this.isMatching() && this.scope() === 'global'
);
readonly visibleItems = computed(() =>
this.showAvailabilityFilter() && this.filterMode() === 'available'
? this.items().filter((item) => item.available)
: this.items()
);
}