diff --git a/.changes/portals-clickable-metadata-discover.md b/.changes/portals-clickable-metadata-discover.md new file mode 100644 index 000000000..12527db7f --- /dev/null +++ b/.changes/portals-clickable-metadata-discover.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 43b1c1aae..0f8259282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a23101009..397e44b05 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "قيد الإنتاج", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 18cd628c8..254dd7914 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "كيتصور", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index fec01d55a..09e2ad20f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "У вытворчасці", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d960ed27a..3bbd8e09d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 9e39d676f..7d0621afb 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Σε παραγωγή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 862a168be..0ca17570d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9e3d89d09..d007010e1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 16954c80c..5358fedd5 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index bd555d290..618010608 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c9c7587ce..e436c0bae 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ac0398a4a..c1d6362bb 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "製作中", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index aed2310ed..69a933533 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "제작 중", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8c90564e4..f78987bbc 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2ca107f06..376f9a646 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 14c72ad87..a7a651efc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index b0fb55b61..59167daab 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "В производстве", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 285eb2b12..8f172bb2e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a2cd294e5..81ece9f82 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "制作中", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4f47add1c..21ebc4f9d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "製作中", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index d4365357d..adc78a468 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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=&genreLabel=&country=&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` diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index e4be18af0..b8283af84 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/navigation/discover-link.util.ts b/libs/portal/shared/util/src/lib/navigation/discover-link.util.ts new file mode 100644 index 000000000..55be009d0 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/discover-link.util.ts @@ -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; +} + +export function discoverLink( + portal: 'xtream' | 'stalker', + playlistId: string, + type: TmdbMediaType, + facet: DiscoverFacetClick +): DiscoverLink { + const queryParams: Record = { 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, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts new file mode 100644 index 000000000..f93a1ab41 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -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: ``, + 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([]); + readonly isLoading = signal(true); + + readonly showScopeToggle = this.titleMatch.isAvailable; + readonly scope = signal('portal'); + readonly isMatchingGlobal = signal(false); + private readonly globalMatches = signal(null); + private readonly globalIndex = computed(() => + groupTitleMatchesByKey(this.globalMatches() ?? []) + ); + + readonly items = computed(() => { + 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 { + 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 { + 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(); + } + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index 52d484ea4..d500a0784 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index a78d8c7dc..aeaedd796 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -16,9 +16,24 @@ }} } @if (serial.info.releasedate) { - {{ serial.info.releasedate }} + {{ serial.info.releasedate }} } - @if (serial.info.genre) { + @if (serial.info.tmdb_genres?.length) { + @for (genre of serial.info.tmdb_genres; track genre.id) { + {{ genre.name }} + } + } @else if (serial.info.genre) { {{ serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -31,6 +46,18 @@ KP {{ serial.info.rating_kinopoisk }} } + @if (serial.info.tmdb_countries?.length) { + @for ( + country of serial.info.tmdb_countries; + track country.code + ) { + {{ country.name }} + } + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 4a5fea93c..2478657db 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts new file mode 100644 index 000000000..5a09c2a7e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -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: ``, + 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([]); + readonly isLoading = signal(true); + + readonly showScopeToggle = this.titleMatch.isAvailable; + readonly scope = signal('portal'); + readonly isMatchingGlobal = signal(false); + private readonly globalMatches = signal(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(() => { + 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 { + 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 { + 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(); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 7c0e995b8..88e12e24a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -17,11 +17,22 @@ }} } @if (info.releaseDate) { - {{ - info.releaseDate | slice: 0 : 4 - }} + {{ info.releaseDate | slice: 0 : 4 }} } - @if (info.genre) { + @if (info.tmdb_genres?.length) { + @for (genre of info.tmdb_genres; track genre.id) { + {{ genre.name }} + } + } @else if (info.genre) { {{ info.genre }} } @if (info.episode_run_time) { @@ -34,6 +45,15 @@ ⭐ {{ info.rating }} } + @if (info.tmdb_countries?.length) { + @for (country of info.tmdb_countries; track country.code) { + {{ country.name }} + } + } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 3b22d682a..714883f1b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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. diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 77f1fcdd8..51a25f88e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -295,11 +295,22 @@ > @if (info.releasedate) { - {{ - info.releasedate | slice: 0 : 4 - }} + {{ info.releasedate | slice: 0 : 4 }} } - @if (info.genre) { + @if (info.tmdb_genres?.length) { + @for (genre of info.tmdb_genres; track genre.id) { + {{ genre.name }} + } + } @else if (info.genre) { {{ info.genre }} } @if (info.duration) { @@ -310,7 +321,15 @@ ⭐ {{ info.rating_imdb }} } - @if (info.country) { + @if (info.tmdb_countries?.length) { + @for (country of info.tmdb_countries; track country.code) { + {{ country.name }} + } + } @else if (info.country) { {{ info.country }} } @if (isDownloaded()) { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 0e10b7aad..de7854809 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts index 4af1c064f..b31bda4d4 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts @@ -14,6 +14,8 @@ describe('createXtreamRoutes', () => { 'vod', 'series', 'search', + 'actor/:personId', + 'discover', 'recently-added', ]) ); diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index 6407a92e5..1ac729eb7 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -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, diff --git a/libs/services/src/lib/tmdb/index.ts b/libs/services/src/lib/tmdb/index.ts index caf910eeb..59e62e49b 100644 --- a/libs/services/src/lib/tmdb/index.ts +++ b/libs/services/src/lib/tmdb/index.ts @@ -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'; diff --git a/libs/services/src/lib/tmdb/tmdb-api.service.ts b/libs/services/src/lib/tmdb/tmdb-api.service.ts index b2b6555b0..10d4b2e0c 100644 --- a/libs/services/src/lib/tmdb/tmdb-api.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-api.service.ts @@ -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 { + const yearParam = + mediaType === 'movie' + ? 'primary_release_year' + : 'first_air_date_year'; + return this.request( + `/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, diff --git a/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts new file mode 100644 index 000000000..b91afc6bb --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts @@ -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); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-discover.service.ts b/libs/services/src/lib/tmdb/tmdb-discover.service.ts new file mode 100644 index 000000000..16af91f71 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.service.ts @@ -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(); + + async discoverTitles( + mediaType: TmdbMediaType, + filters: TmdbDiscoverFilters + ): Promise { + 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; + } + } +} diff --git a/libs/services/src/lib/tmdb/tmdb-discover.ts b/libs/services/src/lib/tmdb/tmdb-discover.ts new file mode 100644 index 000000000..2a67da2ab --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.ts @@ -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(); + 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; +} diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts index 6d5ecae41..db7245c51 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.spec.ts @@ -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( diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index c39645500..328d37072 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -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 { + return this.discover.discoverTitles(mediaType, filters); + } + private async enrich( mediaType: TmdbMediaType, query: TmdbEnrichmentQuery diff --git a/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts index 61b6b3846..a13781ce1 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge-stalker.spec.ts @@ -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: 'Провайдерский сюжет' }, diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 69f9ee58a..83e6e5b61 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -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', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index d46849147..c94bf0dc1 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -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, }; } diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index ccb5087af..dd30dd45e 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 247911423..32c7d5aef 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -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; } diff --git a/libs/shared/interfaces/src/lib/tmdb.interface.ts b/libs/shared/interfaces/src/lib/tmdb.interface.ts index a00db1381..f723d997a 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -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: diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index 608b3b772..bf0788579 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -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, }; } diff --git a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts index 1bb8b607a..eb7a8f6b0 100644 --- a/libs/shared/interfaces/src/lib/vod-details-item.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-details-item.interface.ts @@ -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[]; } /** diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index 583ae4d9a..8052c481d 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -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 { diff --git a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts index f31067bcb..3bcf9b027 100644 --- a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts @@ -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 { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index e7cc676f3..e5abface0 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss index 0c45e378d..d14b301e8 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss @@ -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 { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 06e89030a..203e0545f 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -12,9 +12,22 @@ @if (meta.year) { - {{ meta.year }} + {{ meta.year }} } - @if (meta.genre) { + @if (meta.tmdbGenres?.length) { + @for (genre of meta.tmdbGenres; track genre.id) { + {{ genre.name }} + } + } @else if (meta.genre) { {{ meta.genre }} } @if (meta.duration) { @@ -25,7 +38,15 @@ ⭐ {{ meta.ratingImdb }} } - @if (meta.country) { + @if (meta.tmdbCountries?.length) { + @for (country of meta.tmdbCountries; track country.code) { + {{ country.name }} + } + } @else if (meta.country) { {{ meta.country }} } @if (isDownloaded()) { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 7512cc347..10ace5f6f 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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()); diff --git a/libs/ui/shared-portals/src/index.ts b/libs/ui/shared-portals/src/index.ts index 483fefe5f..24287f1ae 100644 --- a/libs/ui/shared-portals/src/index.ts +++ b/libs/ui/shared-portals/src/index.ts @@ -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'; diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html index 92bed9a46..408626567 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html @@ -51,136 +51,15 @@ -
-

- {{ 'XTREAM.ACTOR_FILMOGRAPHY' | translate }} - @if (showScopeToggle()) { - - - - - } - @if (showAvailabilityFilter()) { - - - - - } -

- @if (isMatching()) { -
- -
- } @else if (visibleItems().length === 0) { -
- search_off -

{{ 'XTREAM.ACTOR_NO_RESULTS' | translate }}

-
- } -
- @for ( - item of visibleItems(); - track item.mediaType + '-' + item.tmdbId - ) { - - } -
-
+ } diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.scss b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.scss index fc3aa0309..42d21dae4 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.scss +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.scss @@ -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; - } } diff --git a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts index 0c6b993b7..ed253b563 100644 --- a/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts +++ b/libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts @@ -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(); readonly backClicked = output(); readonly scopeChanged = output(); - - 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() - ); } diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts new file mode 100644 index 000000000..7f45ab8ee --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts @@ -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' }))); + }); +}); diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts new file mode 100644 index 000000000..ad869365c --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts @@ -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; + +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('|'); +} diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.html b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.html new file mode 100644 index 000000000..e56cbb033 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.html @@ -0,0 +1,40 @@ +
+ + +
+

{{ 'XTREAM.DISCOVER_TITLE' | translate }}

+
+ + {{ mediaTypeLabelKey() | translate }} + + @for (label of facetLabels(); track label) { + {{ label }} + } +
+
+ + @if (isLoading()) { +
+ +
+ } @else { + + } +
diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.scss b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.scss new file mode 100644 index 000000000..a9b37d245 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.scss @@ -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; + } +} diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.ts new file mode 100644 index 000000000..d15231544 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-view.component.ts @@ -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 { + readonly facets = input.required(); + readonly items = input([]); + 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('portal'); + + readonly itemClicked = output(); + readonly backClicked = output(); + readonly scopeChanged = output(); + + 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(() => { + 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); + }); +} diff --git a/libs/ui/shared-portals/src/lib/title-results/title-results.component.html b/libs/ui/shared-portals/src/lib/title-results/title-results.component.html new file mode 100644 index 000000000..72fd642bb --- /dev/null +++ b/libs/ui/shared-portals/src/lib/title-results/title-results.component.html @@ -0,0 +1,127 @@ +
+

+ {{ headingKey() | translate }} + @if (showScopeToggle()) { + + + + + } + @if (showAvailabilityFilter()) { + + + + + } +

+ @if (isMatching()) { +
+ +
+ } @else if (visibleItems().length === 0) { +
+ search_off +

{{ emptyMessageKey() | translate }}

+
+ } +
+ @for ( + item of visibleItems(); + track item.mediaType + '-' + item.tmdbId + ) { + + } +
+
diff --git a/libs/ui/shared-portals/src/lib/title-results/title-results.component.scss b/libs/ui/shared-portals/src/lib/title-results/title-results.component.scss new file mode 100644 index 000000000..31ac894b9 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/title-results/title-results.component.scss @@ -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; + } +} diff --git a/libs/ui/shared-portals/src/lib/title-results/title-results.component.ts b/libs/ui/shared-portals/src/lib/title-results/title-results.component.ts new file mode 100644 index 000000000..20803a447 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/title-results/title-results.component.ts @@ -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 { + readonly headingKey = input.required(); + readonly items = input([]); + /** 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('portal'); + readonly emptyMessageKey = input('XTREAM.ACTOR_NO_RESULTS'); + + readonly itemClicked = output(); + readonly scopeChanged = output(); + + 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() + ); +}