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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 08:15:04 +02:00
1 parent 00aa623b83
commit 486d04411f
68 files changed
+2287 -318

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.
+2 -1
View File
@@ -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 gates on a merge-written numeric `tmdb_id`, 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`; facet changes on the same route instance are staleness-guarded by `discoverFacetKey()`. 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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Keresés a portálon",
"ACTOR_BORN": "Születési dátum",
"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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Szukaj w portalu",
"ACTOR_BORN": "Data urodzenia:",
"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
@@ -864,6 +864,11 @@
"ACTOR_SEARCH_IN_PORTAL": "Buscar no portal",
"ACTOR_BORN": "Nascimento:",
"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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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
@@ -864,6 +864,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": "製作中",
+59 -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,63 @@ 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}[]` (from
`details.production_countries`, ISO 3166-1 alpha-2 codes);
`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), and the year chip is clickable only when
`tmdb_id` is a merge-written number — provider payloads ship `tmdb_id`
as untrusted strings, so `typeof === 'number'` is the gate.
**Navigation.** Chip clicks navigate (via each render site's own
`openDiscover*` methods, mirroring `openActor`) 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. Because facets change via
query params on the SAME route instance, async results are
staleness-guarded by `discoverFacetKey()` rather than by instance. 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`
+1
View File
@@ -22,6 +22,7 @@ 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-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,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,
};
}
@@ -0,0 +1,205 @@
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 {
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 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(() => {
const key = this.facetKey();
void this.loadDiscover(key);
});
}
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',
titles: [title.title],
year: title.year,
},
this.globalIndex()
);
}
private async loadGlobalMatches(): Promise<void> {
const requestedKey = this.facetKey();
const titles = this.results().map((title) => title.title);
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (this.facetKey() === requestedKey) {
this.globalMatches.set(matches);
}
} finally {
if (this.facetKey() === requestedKey) {
this.isMatchingGlobal.set(false);
}
}
}
private async loadDiscover(requestedKey: string): Promise<void> {
const facets = this.facets();
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.facetKey() !== requestedKey) {
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,24 @@
}}</span>
}
@if (serial.info.releasedate) {
<span class="details__tag">{{ serial.info.releasedate }}</span>
<span
class="details__tag"
[class.details__tag--clickable]="
canDiscoverByYear(serial.info)
"
(click)="openYearDiscover(serial.info)"
>{{ serial.info.releasedate }}</span
>
}
@if (serial.info.genre) {
@if (serial.info.tmdb_genres?.length) {
@for (genre of serial.info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
>{{ genre.name }}</span
>
}
} @else if (serial.info.genre) {
<span class="details__tag">{{ serial.info.genre }}</span>
}
@if (serial.info.rating_imdb) {
@@ -31,6 +46,18 @@
KP {{ serial.info.rating_kinopoisk }}
</span>
}
@if (serial.info.tmdb_countries?.length) {
@for (
country of serial.info.tmdb_countries;
track country.code
) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
>{{ country.name }}</span
>
}
}
</ng-template>
<!-- Metadata Grid -->
@@ -33,7 +33,10 @@ import {
PlaybackPositionData,
ResolvedPortalPlayback,
seriesStatusLabelKey,
StalkerVodInfo,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamSerieEpisode,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
@@ -46,6 +49,8 @@ import {
PORTAL_PLAYER,
createLogger,
consumeStalkerReturnMarker,
DiscoverFacetClick,
discoverLink,
resolveStalkerBackNavigation,
} from '@iptvnator/portal/shared/util';
import {
@@ -874,6 +879,38 @@ export class StalkerSeriesViewComponent implements OnDestroy {
]);
}
/** Discover needs TMDB; only a matched series can offer facet chips */
canDiscoverByYear(info: StalkerVodInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releasedate;
}
openYearDiscover(info: StalkerVodInfo): void {
const year = Number(info.releasedate?.match(/\d{4}/)?.[0]);
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
const link = discoverLink('stalker', playlistId, 'tv', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
goBack() {
const back = resolveStalkerBackNavigation(
window.history.state,
@@ -0,0 +1,234 @@
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 {
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[]>([]);
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 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(() => {
const key = this.facetKey();
void this.loadDiscover(key);
});
}
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();
return lookupCatalogTitle(index, title.title, title.year);
}
private globalMatchFor(title: DiscoverTitle): CatalogTitleMatch | null {
return pickTitleMatch(
{
type: title.mediaType === 'movie' ? 'movie' : 'series',
titles: [title.title],
year: title.year,
},
this.globalIndex()
);
}
private async loadGlobalMatches(): Promise<void> {
const requestedKey = this.facetKey();
const titles = this.results().map((title) => title.title);
this.isMatchingGlobal.set(true);
try {
const matches = await this.titleMatch.matchTitles(titles);
if (this.facetKey() === requestedKey) {
this.globalMatches.set(matches);
}
} finally {
if (this.facetKey() === requestedKey) {
this.isMatchingGlobal.set(false);
}
}
}
private async loadDiscover(requestedKey: string): Promise<void> {
const facets = this.facets();
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.facetKey() !== requestedKey) {
return;
}
this.results.set(titles ?? []);
this.isLoading.set(false);
if (this.scope() === 'global') {
void this.loadGlobalMatches();
}
}
}
@@ -17,11 +17,22 @@
}}</span>
}
@if (info.releaseDate) {
<span class="details__tag">{{
info.releaseDate | slice: 0 : 4
}}</span>
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear(info)"
(click)="openYearDiscover(info)"
>{{ info.releaseDate | slice: 0 : 4 }}</span
>
}
@if (info.genre) {
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
>{{ genre.name }}</span
>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (info.episode_run_time) {
@@ -34,6 +45,15 @@
⭐ {{ info.rating }}
</span>
}
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
>{{ country.name }}</span
>
}
}
</ng-template>
<ng-template appDetailMeta>
@@ -39,13 +39,19 @@ import {
import {
normalizeTitleKeys,
seriesStatusLabelKey,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamSerieInfo,
} from '@iptvnator/shared/interfaces';
import { buildSeasonDescriptions } from './season-descriptions.util';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
DiscoverFacetClick,
discoverLink,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
@@ -341,6 +347,38 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
]);
}
/** Discover needs TMDB; only a matched series can offer facet chips */
canDiscoverByYear(info: XtreamSerieInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releaseDate;
}
openYearDiscover(info: XtreamSerieInfo): void {
const year = Number(info.releaseDate?.slice(0, 4));
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const link = discoverLink('xtream', playlistId, 'tv', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
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,22 @@
>
<ng-template appDetailTags>
@if (info.releasedate) {
<span class="details__tag">{{
info.releasedate | slice: 0 : 4
}}</span>
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear(info)"
(click)="openYearDiscover(info)"
>{{ info.releasedate | slice: 0 : 4 }}</span
>
}
@if (info.genre) {
@if (info.tmdb_genres?.length) {
@for (genre of info.tmdb_genres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
>{{ genre.name }}</span
>
}
} @else if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (info.duration) {
@@ -310,7 +321,15 @@
⭐ {{ info.rating_imdb }}
</span>
}
@if (info.country) {
@if (info.tmdb_countries?.length) {
@for (country of info.tmdb_countries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
>{{ country.name }}</span
>
}
} @else if (info.country) {
<span class="details__tag">{{ info.country }}</span>
}
@if (isDownloaded()) {
@@ -28,6 +28,8 @@ import {
import { SafePipe } from '@iptvnator/pipes';
import {
createLogger,
DiscoverFacetClick,
discoverLink,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
@@ -50,7 +52,9 @@ import {
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
@@ -487,6 +491,38 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
]);
}
/** Discover needs TMDB; only a matched item can offer facet chips */
canDiscoverByYear(info: XtreamVodInfo): boolean {
return typeof info.tmdb_id === 'number' && !!info.releasedate;
}
openYearDiscover(info: XtreamVodInfo): void {
const year = Number(info.releasedate?.slice(0, 4));
if (!this.canDiscoverByYear(info) || !Number.isInteger(year)) {
return;
}
this.openDiscoverFacet({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscoverFacet({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscoverFacet({ kind: 'country', country });
}
private openDiscoverFacet(facet: DiscoverFacetClick): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
const link = discoverLink('xtream', playlistId, 'movie', facet);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
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,
+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,158 @@
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',
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,46 @@
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;
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);
titles.push({
tmdbId: result.id,
mediaType,
title,
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,34 @@ describe('mergeStalkerInfoWithTmdb', () => {
expect(merged.director).toBe('Создатель Сериала');
});
it('emits facets and the matched media type for Discover chips', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
{
...tmdbMovieRu,
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: 'Провайдерский сюжет' },
@@ -235,6 +235,45 @@ 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('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: ' ' },
],
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 +357,27 @@ 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,
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', () => {
+40
View File
@@ -1,6 +1,8 @@
import {
normalizeSeriesStatus,
StalkerVodInfo,
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
TmdbRecommendation,
XtreamSerieInfo,
@@ -66,6 +68,23 @@ 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 production countries for clickable Discover chips */
function countryFacets(details: TmdbDetails): TmdbCountryFacet[] {
return (details.production_countries ?? [])
.filter((entry) => !!entry.iso_3166_1 && !!entry.name)
.map((entry) => ({
code: entry.iso_3166_1 as string,
name: entry.name 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 +125,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 +161,10 @@ export function mergeVodInfoWithTmdb(
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}),
...(tmdbCountries.length > 0
? { tmdb_countries: tmdbCountries }
: {}),
};
}
@@ -156,6 +181,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 +207,10 @@ export function mergeSerieInfoWithTmdb(
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}),
...(tmdbCountries.length > 0
? { tmdb_countries: tmdbCountries }
: {}),
};
}
@@ -215,6 +246,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 +276,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,
};
}
+19 -1
View File
@@ -101,6 +101,8 @@ 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 }[];
}
export interface TmdbMovieDetails extends TmdbDetailsBase {
@@ -108,7 +110,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 +182,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,26 @@ 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;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
@@ -32,6 +32,11 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
// Provider payloads carry tmdb_id as an untrusted string too; only
// a merge-written number proves an enrichment match
tmdbId: typeof info?.tmdb_id === 'number' ? info.tmdb_id : undefined,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -62,6 +67,10 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
tmdbId: info?.tmdb_id,
tmdbMediaType: info?.tmdb_media_type,
tmdbGenres: info?.tmdb_genres,
tmdbCountries: info?.tmdb_countries,
};
}
@@ -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,14 @@ export interface NormalizedVodMeta {
tmdbDirectors?: TmdbEnrichedCastMember[];
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
tmdbRecommendations?: TmdbRecommendation[];
/** Matched TMDB id — gates the clickable year chip (Discover page) */
tmdbId?: number;
/** 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,18 @@
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
&--clickable {
cursor: pointer;
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));
}
}
}
&__description {
@@ -82,6 +82,18 @@
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
&--clickable {
cursor: pointer;
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));
}
}
}
.about ::ng-deep .details__meta-item {
@@ -12,9 +12,22 @@
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-template appDetailTags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
<span
class="details__tag"
[class.details__tag--clickable]="canDiscoverByYear()"
(click)="openYearDiscover()"
>{{ meta.year }}</span
>
}
@if (meta.genre) {
@if (meta.tmdbGenres?.length) {
@for (genre of meta.tmdbGenres; track genre.id) {
<span
class="details__tag details__tag--clickable"
(click)="openGenreDiscover(genre)"
>{{ genre.name }}</span
>
}
} @else if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
@@ -25,7 +38,15 @@
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
@if (meta.tmdbCountries?.length) {
@for (country of meta.tmdbCountries; track country.code) {
<span
class="details__tag details__tag--clickable"
(click)="openCountryDiscover(country)"
>{{ country.name }}</span
>
}
} @else if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
@if (isDownloaded()) {
@@ -12,8 +12,10 @@ import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
import {
DiscoverFacetClick,
PORTAL_EXTERNAL_PLAYBACK,
createExternalPlaybackButtonState,
discoverLink,
} from '@iptvnator/portal/shared/util';
import {
DetailActionsTemplateDirective,
@@ -26,7 +28,9 @@ import { Router } from '@angular/router';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
VodDetailsItem,
getVodNumericId,
normalizeVodDetails,
@@ -319,6 +323,44 @@ export class VodDetailsComponent {
this.backClicked.emit();
}
// ============ Discover (clickable metadata chips) ============
/** Discover needs TMDB; only a matched item can offer facet chips */
readonly canDiscoverByYear = computed(
() =>
this.normalizedMeta().tmdbId !== undefined &&
!!this.normalizedMeta().year
);
openYearDiscover(): void {
const year = Number(this.normalizedMeta().year);
if (!this.canDiscoverByYear() || !Number.isInteger(year)) {
return;
}
this.openDiscover({ kind: 'year', year });
}
openGenreDiscover(genre: TmdbGenreFacet): void {
this.openDiscover({ kind: 'genre', genre });
}
openCountryDiscover(country: TmdbCountryFacet): void {
this.openDiscover({ kind: 'country', country });
}
private openDiscover(facet: DiscoverFacetClick): void {
const item = this.item();
const link = discoverLink(
item.type === 'stalker' ? 'stalker' : 'xtream',
item.playlistId,
this.normalizedMeta().tmdbMediaType ?? 'movie',
facet
);
void this.router.navigate(link.commands, {
queryParams: link.queryParams,
});
}
/** 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,110 @@
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('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,77 @@
import { TmdbMediaType } 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';
const rawYear = rawString(params, 'year');
const year = rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : 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,127 @@
<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 (isMatching()) {
<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>
}
<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,78 @@
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';
}
readonly visibleItems = computed(() =>
this.showAvailabilityFilter() && this.filterMode() === 'available'
? this.items().filter((item) => item.available)
: this.items()
);
}