mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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": "قيد الإنتاج",
|
||||
|
||||
@@ -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": "كيتصور",
|
||||
|
||||
@@ -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": "У вытворчасці",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Σε παραγωγή",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "製作中",
|
||||
|
||||
@@ -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": "제작 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "В производстве",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "制作中",
|
||||
|
||||
@@ -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": "製作中",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
-2
@@ -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 -->
|
||||
|
||||
+37
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user