From df8962969e1fa167afdf68c7f1ac05d7f2357556 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:59:58 +0200 Subject: [PATCH] feat(portals): make year, genre and country metadata clickable (#1449) (#1453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(portals): make year, genre and country metadata clickable (#1449) Year, genre and country chips on movie and series detail pages now open a Discover page inside the portal: popular TMDB titles for that facet, matched against the user's own catalog. Generalizes the existing actor-page pattern (TMDB list -> what's in my library -> else portal search) to metadata facets. - All three TMDB merges emit structured `tmdb_genres`/`tmdb_countries` (+ `tmdb_media_type` on Stalker, whose embedded-VOD series route as movies). Cached details payloads already carry both, so existing rows need no refetch. - Chips are clickable only with TMDB backing, like person chips today; the year chip gates on a merge-written numeric `tmdb_id`, since provider payloads ship junk string ids. - `TmdbDiscoverService` fetches up to 5 `/discover` pages by popularity and caches them in memory only — popularity rankings are volatile and must not reach the persisted `tmdb_metadata` table. - New `discover` route in both portals; containers clone the actor route, staleness-guarded by a facet key because facets change via query params on the same route instance. - The grid, filter chips and badges move out of `ActorViewComponent` into a shared `TitleResultsComponent` used by both pages. Co-Authored-By: Claude Fable 5 * refactor(portals): share the discover facet navigation across detail pages The four render sites each carried their own copy of the year/genre/country click handlers, which also pushed serial-details.component.ts past the 400-line limit. `createDiscoverFacetNavigation()` now owns the navigation, the numeric-tmdb_id gate and the year parsing. Year parsing moves from a fixed 4-char slice to the first four-digit run, so a day-first provider date resolves instead of producing NaN. Co-Authored-By: Claude Fable 5 * fix(portals): address review findings on the Discover pages - The matching indicator was keyed on the request's subject, so an obsolete response skipped clearing it while no replacement request ever ran — the results grid then sat under the spinner forever. `createLatestRequestGuard()` now owns the indicator: the newest request always clears it, and the subject check keeps deciding whether the RESULT is still wanted. The actor pages carried the same latent bug and use the same guard now. - Country chips came from `production_countries` while Discover filters by `with_origin_country`, so clicking a co-production partner returned titles originating there instead of titles it produced. Chips are now built from `origin_country` and labelled from `production_countries`; a code TMDB does not name is dropped rather than shown as a bare code. - A cold load of an `actor` or `discover` route never initialized the catalog, so every result claimed to be missing from a library that actually holds it. Both are import-driven sections now. Co-Authored-By: Claude Fable 5 * refactor(portals): extract the series Similar rail into its own service Rebasing onto master pushed serial-details.component.ts back over the 400-line limit. The "Similar" rail moves into SerialDetailsSimilarService, mirroring VodDetailsSimilarService next to it: same two sources, same component-provided lifetime so a cross-portal lookup dies with the page. Co-Authored-By: Claude Fable 5 * fix(portals): make facet chips keyboard-operable and reject zero years - The chips were plain spans with a click handler, so the whole feature was mouse-only. Actionable chips are + } @else { + {{ + discover.yearLabel(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) { + + } + } @else if (serial.info.genre) { {{ serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -31,6 +54,20 @@ KP {{ serial.info.rating_kinopoisk }} } + @if (serial.info.tmdb_countries?.length) { + @for ( + country of serial.info.tmdb_countries; + track country.code + ) { + + } + } 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..b3eb1a709 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 @@ -46,6 +46,7 @@ import { PORTAL_PLAYER, createLogger, consumeStalkerReturnMarker, + createDiscoverFacetNavigation, resolveStalkerBackNavigation, } from '@iptvnator/portal/shared/util'; import { @@ -77,6 +78,7 @@ import { CrossPortalSimilarItem, CrossPortalSimilarService, PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, } from '@iptvnator/services'; import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; import { @@ -874,6 +876,18 @@ export class StalkerSeriesViewComponent implements OnDestroy { ]); } + /** Clickable year/genre/country chips (Discover pages) */ + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + + readonly discover = createDiscoverFacetNavigation(() => { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return playlistId && this.tmdbEnrichment.isEnabled() + ? { portal: 'stalker', mediaType: 'tv', playlistId } + : null; + }); + goBack() { const back = resolveStalkerBackNavigation( window.history.state, diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 6a940bfd4..1cc02c73c 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -21,6 +21,7 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -78,6 +79,7 @@ export class XtreamActorRouteComponent { readonly scope = signal('portal'); readonly isMatchingGlobal = signal(false); private readonly globalMatches = signal(null); + private readonly matchRequest = createLatestRequestGuard(); private readonly vodIndex = computed(() => buildCatalogTitleIndex(this.xtreamStore.vodStreams()) @@ -195,14 +197,21 @@ export class XtreamActorRouteComponent { // previous person must not overwrite the current one's results const requestedPersonId = this.personId(); const titles = this.filmography().map((credit) => credit.title); + const matchToken = this.matchRequest.start(); this.isMatchingGlobal.set(true); try { const matches = await this.titleMatch.matchTitles(titles); - if (this.personId() === requestedPersonId) { + if ( + this.matchRequest.isLatest(matchToken) && + this.personId() === requestedPersonId + ) { this.globalMatches.set(matches); } } finally { - if (this.personId() === requestedPersonId) { + // Only the newest request may clear the indicator. Keying this + // on the subject instead strands the spinner when the user + // leaves the scope and no replacement request ever runs. + if (this.matchRequest.isLatest(matchToken)) { this.isMatchingGlobal.set(false); } } diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts new file mode 100644 index 000000000..b4c2090a0 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts @@ -0,0 +1,165 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CatalogTitleMatchService, + DiscoverTitle, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { XtreamDiscoverRouteComponent } from './xtream-discover-route.component'; + +/** + * The cold-load contract: a Discover page must not state that a title is + * missing from a catalog that has not finished loading. TMDB routinely + * answers before a cold import does, and the content gate renders this + * route while that runs. + */ +describe('XtreamDiscoverRouteComponent — catalog readiness', () => { + const isLoadingContent = signal(false); + const isLoadingCategories = signal(false); + const isImporting = signal(false); + const vodStreams = signal([]); + const serialStreams = signal([]); + + let resolveDiscover: (titles: DiscoverTitle[] | null) => void; + let discoverTitles: jest.Mock; + + /** Creates the component and flushes the effect that starts the load */ + function createComponent(): XtreamDiscoverRouteComponent { + const component = TestBed.runInInjectionContext( + () => new XtreamDiscoverRouteComponent() + ); + TestBed.tick(); + return component; + } + + /** Lets the pending TMDB request settle and effects flush */ + async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); + TestBed.tick(); + } + + beforeEach(() => { + isLoadingContent.set(true); + isLoadingCategories.set(true); + isImporting.set(false); + vodStreams.set([]); + serialStreams.set([]); + + discoverTitles = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveDiscover = resolve; + }) + ); + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + queryParams: of({ type: 'movie', year: '1990' }), + snapshot: { + queryParams: { type: 'movie', year: '1990' }, + pathFromRoot: [], + }, + }, + }, + { + provide: XtreamStore, + useValue: { + isLoadingContent, + isLoadingCategories, + isImporting, + vodStreams, + serialStreams, + currentPlaylist: () => ({ id: 'pl-1' }), + }, + }, + { + provide: TmdbEnrichmentService, + useValue: { discoverTitles }, + }, + { + provide: CatalogTitleMatchService, + useValue: { isAvailable: false, matchTitles: jest.fn() }, + }, + ], + }); + }); + + it('keeps loading while the catalog is still importing', async () => { + const component = createComponent(); + + // TMDB wins the race a cold import always loses + resolveDiscover([ + { + tmdbId: 1, + mediaType: 'movie', + title: 'Goodfellas', + originalTitle: null, + year: 1990, + posterUrl: null, + }, + ]); + await settle(); + + // Before the fix the spinner dropped here and every card claimed + // the title was missing from the (still empty) catalog + expect(component.isLoading()).toBe(true); + }); + + it('publishes results once the catalog finishes loading', async () => { + const component = createComponent(); + resolveDiscover([ + { + tmdbId: 1, + mediaType: 'movie', + title: 'Goodfellas', + originalTitle: null, + year: 1990, + posterUrl: null, + }, + ]); + await settle(); + + isLoadingContent.set(false); + isLoadingCategories.set(false); + await settle(); + + expect(component.isLoading()).toBe(false); + expect(component.items()).toHaveLength(1); + }); + + it('settles when the catalog load fails instead of spinning forever', async () => { + const component = createComponent(); + resolveDiscover(null); + await settle(); + + // A failed import clears the in-flight flags without ever marking + // the catalog initialized — the page must still settle + isLoadingContent.set(false); + isLoadingCategories.set(false); + isImporting.set(false); + await settle(); + + expect(component.isLoading()).toBe(false); + }); + + it('keeps loading while an import is still running', async () => { + const component = createComponent(); + resolveDiscover([]); + await settle(); + + isLoadingContent.set(false); + isLoadingCategories.set(false); + isImporting.set(true); + await settle(); + + expect(component.isLoading()).toBe(true); + }); +}); 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..6b6f26c5d --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -0,0 +1,285 @@ +import { Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CatalogTitleMatchService, + DiscoverTitle, + TmdbEnrichmentService, + groupTitleMatchesByKey, + pickTitleMatch, +} from '@iptvnator/services'; +import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; +import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { + DiscoverViewComponent, + TitleResultsScope, + discoverFacetKey, + hasDiscoverFacet, + parseDiscoverParams, +} from '@iptvnator/ui/shared-portals'; +import { + buildCatalogTitleIndex, + lookupCatalogTitle, +} from '../tmdb-similar.util'; + +/** One Discover result annotated with catalog availability */ +interface DiscoverItem extends DiscoverTitle { + available: boolean; + availableIn?: string; +} + +/** + * Discover page inside an Xtream portal: popular TMDB titles for one + * metadata facet (year, genre or country, from the detail-page chips). + * Scope "This portal" matches against the loaded catalog; "All portals" + * (Electron only) matches against every imported Xtream playlist via the + * DB worker. Matched titles navigate straight to their detail view, the + * rest open the current portal's search prefilled. + */ +@Component({ + template: ``, + 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([]); + private readonly isLoadingResults = signal(true); + + /** + * TMDB usually answers before a cold catalog finishes importing, and + * the content gate renders this route while that runs. Publishing + * results against an empty catalog would state that titles the user + * owns are missing, so availability waits for the catalog too. + */ + readonly isLoading = computed( + () => this.isLoadingResults() || !this.isCatalogReady() + ); + + // Keyed on what is in flight rather than on isContentInitialized, so + // a failed import settles the page instead of spinning forever + private readonly isCatalogReady = computed( + () => + !this.xtreamStore.isLoadingContent() && + !this.xtreamStore.isLoadingCategories() && + !this.xtreamStore.isImporting() + ); + + readonly showScopeToggle = this.titleMatch.isAvailable; + readonly scope = signal('portal'); + readonly isMatchingGlobal = signal(false); + private readonly globalMatches = signal(null); + private readonly matchRequest = createLatestRequestGuard(); + private readonly discoverRequest = createLatestRequestGuard(); + + private readonly vodIndex = computed(() => + buildCatalogTitleIndex(this.xtreamStore.vodStreams()) + ); + private readonly serialIndex = computed(() => + buildCatalogTitleIndex(this.xtreamStore.serialStreams()) + ); + private readonly globalIndex = computed(() => + groupTitleMatchesByKey(this.globalMatches() ?? []) + ); + + readonly items = computed(() => { + if (this.scope() === 'global') { + return this.results().map((title) => { + const match = this.globalMatchFor(title); + return { + ...title, + available: match !== null, + ...(match ? { availableIn: match.playlistName } : {}), + }; + }); + } + + return this.results().map((title) => ({ + ...title, + available: this.portalMatchFor(title) !== null, + })); + }); + + constructor() { + effect(() => { + this.facetKey(); + void this.loadDiscover(); + }); + } + + onScopeChanged(scope: TitleResultsScope): void { + this.scope.set(scope); + if (scope === 'global' && this.globalMatches() === null) { + void this.loadGlobalMatches(); + } + } + + openItem(item: DiscoverItem): void { + if (this.scope() === 'global') { + const match = this.globalMatchFor(item); + if (match) { + void this.router.navigate([ + '/workspace/xtreams', + match.playlistId, + match.type === 'movie' ? 'vod' : 'series', + match.categoryId, + match.xtreamId, + ]); + return; + } + this.openPortalSearch(item.title); + return; + } + + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const match = this.portalMatchFor(item); + if (playlistId && match) { + void this.router.navigate([ + '/workspace/xtreams', + playlistId, + item.mediaType === 'movie' ? 'vod' : 'series', + match.categoryId, + match.id, + ]); + return; + } + + this.openPortalSearch(item.title); + } + + goBack(): void { + this.location.back(); + } + + private openPortalSearch(title: string): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + void this.router.navigate( + ['/workspace/xtreams', playlistId, 'search'], + { queryParams: { q: title } } + ); + } + + private portalMatchFor(title: DiscoverTitle) { + const index = + title.mediaType === 'movie' ? this.vodIndex() : this.serialIndex(); + // The localized title first, then TMDB's original: the catalog + // stores whatever the panel named the file + return ( + lookupCatalogTitle(index, title.title, title.year) ?? + (title.originalTitle + ? lookupCatalogTitle(index, title.originalTitle, title.year) + : null) + ); + } + + private globalMatchFor(title: DiscoverTitle): CatalogTitleMatch | null { + return pickTitleMatch( + { + type: title.mediaType === 'movie' ? 'movie' : 'series', + // The localized title first, then TMDB's original: the + // catalog stores whatever the panel named the file + titles: title.originalTitle + ? [title.title, title.originalTitle] + : [title.title], + year: title.year, + }, + this.globalIndex() + ); + } + + private async loadGlobalMatches(): Promise { + const requestedKey = this.facetKey(); + // Both names go to the worker so its FTS can hit either one + const titles: string[] = []; + for (const result of this.results()) { + titles.push(result.title); + if (result.originalTitle) { + titles.push(result.originalTitle); + } + } + const matchToken = this.matchRequest.start(); + this.isMatchingGlobal.set(true); + try { + const matches = await this.titleMatch.matchTitles(titles); + if ( + this.matchRequest.isLatest(matchToken) && + this.facetKey() === requestedKey + ) { + this.globalMatches.set(matches); + } + } finally { + // Only the newest request may clear the indicator. Keying this + // on the subject instead strands the spinner when the user + // leaves the scope and no replacement request ever runs. + if (this.matchRequest.isLatest(matchToken)) { + this.isMatchingGlobal.set(false); + } + } + } + + private async loadDiscover(): Promise { + const facets = this.facets(); + // A facet change to B and back to A leaves two in-flight requests + // with the SAME key, so recency — not the key — decides who may + // commit: otherwise the older one's failure blanks valid results + const token = this.discoverRequest.start(); + this.isLoadingResults.set(true); + this.globalMatches.set(null); + if (!hasDiscoverFacet(facets)) { + this.results.set([]); + this.isLoadingResults.set(false); + return; + } + const titles = await this.tmdbEnrichment.discoverTitles(facets.type, { + year: facets.year, + genreId: facets.genreId, + countryCode: facets.countryCode, + }); + if (!this.discoverRequest.isLatest(token)) { + return; + } + this.results.set(titles ?? []); + this.isLoadingResults.set(false); + if (this.scope() === 'global') { + void this.loadGlobalMatches(); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts new file mode 100644 index 000000000..eacfe5799 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-similar.service.ts @@ -0,0 +1,99 @@ +import { + Injectable, + Signal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; +import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; +import { + SimilarCatalogItem, + matchRecommendationsToCatalog, +} from '../tmdb-similar.util'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; + +/** + * The "Similar" rail on the series details page — the series counterpart of + * {@link VodDetailsSimilarService}, with the same two sources: TMDB + * recommendations matched against this playlist's loaded catalog, and — in + * Electron — the same recommendations matched across every OTHER imported + * playlist, filtered against the first list so one show cannot appear twice. + * + * Component-provided: the cross-portal lookup is per-series and must die with + * the page rather than leak into the next show. + */ +@Injectable() +export class SerialDetailsSimilarService { + private readonly xtreamStore = inject(XtreamStore); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + + private selectedItem: Signal = signal(null); + + bind(bindings: { + selectedItem: Signal; + }): void { + this.selectedItem = bindings.selectedItem; + } + + readonly similarItems = computed(() => { + const item = this.selectedItem(); + const recommendations = item?.info?.tmdb_recommendations; + if (!recommendations?.length) { + return []; + } + return matchRecommendationsToCatalog( + recommendations, + this.xtreamStore.serialStreams(), + { excludeId: Number(item?.series_id) } + ); + }); + + /** Recommendations found in the user's OTHER portals (Electron only) */ + private readonly crossPortalItems = signal([]); + + readonly similarInPortals = computed(() => { + const localTitles = new Set( + this.similarItems().map( + (item) => normalizeTitleKeys(item.title).exact + ) + ); + return this.crossPortalItems().filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); + }); + + private readonly loadCrossPortalSimilar = effect(() => { + const recommendations = this.selectedItem()?.info?.tmdb_recommendations; + const playlistId = this.xtreamStore.currentPlaylist()?.id; + untracked(() => { + this.crossPortalItems.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'series', { + excludePlaylistId: playlistId, + }) + .then((items) => { + // Staleness guard: a slow lookup for the previous series + // must not populate the rail of the one now on screen + if ( + this.selectedItem()?.info?.tmdb_recommendations === + recommendations + ) { + this.crossPortalItems.set(items); + } + }); + }); + }); +} 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..7d6eb43c8 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,32 @@ }} } @if (info.releaseDate) { - {{ - info.releaseDate | slice: 0 : 4 - }} + @if (discover.canOpenYear(info.releaseDate)) { + + } @else { + {{ + discover.yearLabel(info.releaseDate) ?? + (info.releaseDate | slice: 0 : 4) + }} + } } - @if (info.genre) { + @if (info.tmdb_genres?.length) { + @for (genre of info.tmdb_genres; track genre.id) { + + } + } @else if (info.genre) { {{ info.genre }} } @if (info.episode_run_time) { @@ -34,6 +55,17 @@ ⭐ {{ info.rating }} } + @if (info.tmdb_countries?.length) { + @for (country of info.tmdb_countries; track country.code) { + + } + } 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..da97b2282 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 @@ -37,7 +37,6 @@ import { type UpNextRailItem, } from '@iptvnator/ui/playback'; import { - normalizeTitleKeys, seriesStatusLabelKey, TmdbEnrichedCastMember, XtreamSerieDetails, @@ -45,20 +44,22 @@ import { XtreamSerieInfo, } from '@iptvnator/shared/interfaces'; import { buildSeasonDescriptions } from './season-descriptions.util'; -import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util'; +import { + createDiscoverFacetNavigation, + isProviderOnlyDetailState, +} from '@iptvnator/portal/shared/util'; import { CrossPortalSimilarItem, CrossPortalSimilarService, + TmdbEnrichmentService, } from '@iptvnator/services'; import { SerialDetailsPlaybackService, type XtreamSerieDetailsView, } from './serial-details-playback.service'; import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service'; -import { - SimilarCatalogItem, - matchRecommendationsToCatalog, -} from '../tmdb-similar.util'; +import { SerialDetailsSimilarService } from './serial-details-similar.service'; +import { SimilarCatalogItem } from '../tmdb-similar.util'; import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; @@ -79,7 +80,11 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; } `, ], - providers: [SerialDetailsPlaybackService, SerialDetailsSeasonWatchService], + providers: [ + SerialDetailsPlaybackService, + SerialDetailsSeasonWatchService, + SerialDetailsSimilarService, + ], imports: [ DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -186,61 +191,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { buildSeasonDescriptions(this.selectedItem()) ); - /** TMDB recommendations matched against the loaded series catalog */ - readonly similarItems = computed(() => { - const item = this.selectedItem(); - const recommendations = item?.info?.tmdb_recommendations; - if (!recommendations?.length) { - return []; - } - return matchRecommendationsToCatalog( - recommendations, - this.xtreamStore.serialStreams(), - { excludeId: Number(item?.series_id) } - ); - }); - - /** Recommendations found in the user's OTHER portals (Electron only) */ - private readonly crossPortalItems = signal([]); - readonly similarInPortals = computed(() => { - const localTitles = new Set( - this.similarItems().map( - (item) => normalizeTitleKeys(item.title).exact - ) - ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); - }); - - private readonly loadCrossPortalSimilar = effect(() => { - const recommendations = this.selectedItem()?.info?.tmdb_recommendations; - const playlistId = this.xtreamStore.currentPlaylist()?.id; - untracked(() => { - this.crossPortalItems.set([]); - if ( - !recommendations?.length || - !this.crossPortalSimilar.isAvailable - ) { - return; - } - void this.crossPortalSimilar - .matchRecommendations(recommendations, 'series', { - excludePlaylistId: playlistId, - }) - .then((items) => { - if ( - this.selectedItem()?.info?.tmdb_recommendations === - recommendations - ) { - this.crossPortalItems.set(items); - } - }); - }); - }); + /** The "Similar" rail: catalog matches plus cross-portal matches */ + private readonly similar = inject(SerialDetailsSimilarService); + readonly similarItems = this.similar.similarItems; + readonly similarInPortals = this.similar.similarInPortals; constructor() { this.playback.bind({ selectedItem: this.selectedItem }); + this.similar.bind({ selectedItem: this.selectedItem }); // TMDB season enrichment, keyed on (tmdb_id, selected season). With // season tabs the first seasonSelected fires as soon as seasons load — @@ -341,6 +299,18 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { ]); } + /** Clickable year/genre/country chips (Discover pages) */ + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + + readonly discover = createDiscoverFacetNavigation(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return playlistId && this.tmdbEnrichment.isEnabled() + ? { portal: 'xtream', mediaType: 'tv', playlistId } + : null; + }); + onSeasonSelected(seasonKey: string): void { // The enrichment call itself runs from the constructor effect keyed // on (tmdb_id, selectedSeasonKey) — see the race note there. 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..407855726 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,32 @@ > @if (info.releasedate) { - {{ - info.releasedate | slice: 0 : 4 - }} + @if (discover.canOpenYear(info.releasedate)) { + + } @else { + {{ + discover.yearLabel(info.releasedate) ?? + (info.releasedate | slice: 0 : 4) + }} + } } - @if (info.genre) { + @if (info.tmdb_genres?.length) { + @for (genre of info.tmdb_genres; track genre.id) { + + } + } @else if (info.genre) { {{ info.genre }} } @if (info.duration) { @@ -310,7 +331,17 @@ ⭐ {{ info.rating_imdb }} } - @if (info.country) { + @if (info.tmdb_countries?.length) { + @for (country of info.tmdb_countries; track country.code) { + + } + } @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..8ddeba81e 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 @@ -27,6 +27,7 @@ import { } from '@iptvnator/ui/components'; import { SafePipe } from '@iptvnator/pipes'; import { + createDiscoverFacetNavigation, createLogger, isProviderOnlyDetailState, } from '@iptvnator/portal/shared/util'; @@ -44,6 +45,7 @@ import { CrossPortalSimilarService, DownloadsService, SettingsStore, + TmdbEnrichmentService, } from '@iptvnator/services'; import { getXtreamVodInfo, @@ -487,6 +489,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ]); } + /** Clickable year/genre/country chips (Discover pages) */ + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + + readonly discover = createDiscoverFacetNavigation(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return playlistId && this.tmdbEnrichment.isEnabled() + ? { portal: 'xtream', mediaType: 'movie', playlistId } + : null; + }); + ngOnDestroy(): void { if (this.favoritePulseTimer) { clearTimeout(this.favoritePulseTimer); 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/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 0b48352a7..63247e585 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -56,13 +56,25 @@ function toXtreamPlaylistData( }; } -function isImportDrivenSection(section: PortalRailSection | null): boolean { +/** + * Sections whose content comes from the imported catalog, so a cold load + * of one has to initialize it first. + * + * The parameter is a raw URL segment, not a rail section: `actor` and + * `discover` have no rail entry, yet both read the catalog to decide + * whether a TMDB title is "In your library". Without initialization a + * direct load of either answers "no" for everything the user owns — + * a wrong claim rather than a missing one. + */ +function isImportDrivenSection(section: string | null): boolean { return ( section === 'vod' || section === 'live' || section === 'series' || section === 'search' || - section === 'recently-added' + section === 'recently-added' || + section === 'actor' || + section === 'discover' ); } @@ -80,7 +92,7 @@ function toContentInitBlockReason( } function toCachedContentScope( - section: PortalRailSection | null + section: string | null ): XtreamCachedContentScope | null { switch (section) { case 'live': @@ -89,6 +101,13 @@ function toCachedContentScope( case 'search': case 'recently-added': return section; + // Neither reads one content type: both match TMDB titles against + // the whole catalog, so they take the aggregate scope search uses. + // Without it an expired or offline portal skips hydration and the + // page answers "not in your library" from an empty catalog. + case 'actor': + case 'discover': + return 'search'; default: return null; } 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..0fae1efed --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.service.spec.ts @@ -0,0 +1,159 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { TmdbApiService } from './tmdb-api.service'; +import { TmdbDiscoverService } from './tmdb-discover.service'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbDiscoverResponse } from './tmdb.types'; + +describe('TmdbDiscoverService', () => { + let discoverTitles: jest.Mock; + let isEnabled: boolean; + + function page( + start: number, + count: number, + totalPages: number + ): TmdbDiscoverResponse { + return { + total_pages: totalPages, + results: Array.from({ length: count }, (_, index) => ({ + id: start + index, + title: `Movie ${start + index}`, + release_date: '1990-06-01', + poster_path: null, + })), + }; + } + + // The services Jest target has no @angular/core/testing — build the + // service in a plain injection context instead of TestBed. + function createService(): TmdbDiscoverService { + const injector = Injector.create({ + providers: [ + { + provide: TmdbRuntimeService, + useValue: { + isEnabled: () => isEnabled, + apiKey: () => 'key', + language: () => 'en-US', + appLanguage: () => 'en', + }, + }, + { provide: TmdbApiService, useValue: { discoverTitles } }, + ], + }); + return runInInjectionContext( + injector, + () => new TmdbDiscoverService() + ); + } + + beforeEach(() => { + isEnabled = true; + discoverTitles = jest + .fn() + .mockImplementation((_type, _filters, pageNumber: number) => + Promise.resolve(page(pageNumber * 100, 20, 2)) + ); + }); + + it('returns null without a request when enrichment is disabled', async () => { + isEnabled = false; + const service = createService(); + + expect(await service.discoverTitles('movie', { year: 1990 })).toBeNull(); + expect(discoverTitles).not.toHaveBeenCalled(); + }); + + it('fetches all pages up to total_pages', async () => { + const service = createService(); + + const titles = await service.discoverTitles('movie', { year: 1990 }); + + expect(discoverTitles).toHaveBeenCalledTimes(2); + expect(discoverTitles).toHaveBeenNthCalledWith( + 1, + 'movie', + { year: 1990 }, + 1, + 'en-US', + 'key' + ); + expect(discoverTitles).toHaveBeenNthCalledWith( + 2, + 'movie', + { year: 1990 }, + 2, + 'en-US', + 'key' + ); + expect(titles).toHaveLength(40); + expect(titles?.[0]).toEqual({ + tmdbId: 100, + mediaType: 'movie', + title: 'Movie 100', + originalTitle: null, + year: 1990, + posterUrl: null, + }); + }); + + it('caps the page fan-out at five pages', async () => { + discoverTitles.mockImplementation((_t, _f, pageNumber: number) => + Promise.resolve(page(pageNumber * 100, 20, 500)) + ); + const service = createService(); + + await service.discoverTitles('movie', { genreId: 18 }); + + expect(discoverTitles).toHaveBeenCalledTimes(5); + }); + + it('serves repeated facet lookups from the session cache', async () => { + const service = createService(); + + const first = await service.discoverTitles('movie', { year: 1990 }); + const second = await service.discoverTitles('movie', { year: 1990 }); + + expect(discoverTitles).toHaveBeenCalledTimes(2); + expect(second).toBe(first); + }); + + it('keys the cache by media type and facets', async () => { + const service = createService(); + + await service.discoverTitles('movie', { year: 1990 }); + await service.discoverTitles('tv', { year: 1990 }); + await service.discoverTitles('movie', { year: 1991 }); + + expect(discoverTitles).toHaveBeenCalledTimes(6); + }); + + it('returns null on failure and does not cache the error', async () => { + discoverTitles.mockRejectedValue(new Error('offline')); + const service = createService(); + + expect( + await service.discoverTitles('movie', { countryCode: 'US' }) + ).toBeNull(); + + discoverTitles.mockImplementation((_t, _f, pageNumber: number) => + Promise.resolve(page(pageNumber * 100, 20, 1)) + ); + const retried = await service.discoverTitles('movie', { + countryCode: 'US', + }); + expect(retried).toHaveLength(20); + }); + + it('deduplicates titles repeated across pages', async () => { + // Both pages return the same ids — popularity shifted mid-fetch + discoverTitles.mockImplementation(() => + Promise.resolve(page(100, 20, 2)) + ); + const service = createService(); + + const titles = await service.discoverTitles('movie', { year: 1990 }); + + expect(titles).toHaveLength(20); + }); +}); 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.spec.ts b/libs/services/src/lib/tmdb/tmdb-discover.spec.ts new file mode 100644 index 000000000..d4616064d --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.spec.ts @@ -0,0 +1,55 @@ +import { mapDiscoverResults } from './tmdb-discover'; + +describe('mapDiscoverResults', () => { + it('keeps the original title as a matching alias', () => { + const [movie] = mapDiscoverResults( + [ + { + id: 1, + title: 'Ирония судьбы', + original_title: 'Ирония судьбы, или С лёгким паром!', + release_date: '1976-01-01', + }, + ], + 'movie' + ); + + // TMDB localizes `title` while the catalog stores whatever the + // panel named the file, which is often the original + expect(movie.title).toBe('Ирония судьбы'); + expect(movie.originalTitle).toBe( + 'Ирония судьбы, или С лёгким паром!' + ); + }); + + it('reads the original name for tv results', () => { + const [show] = mapDiscoverResults( + [{ id: 2, name: 'Тьма', original_name: 'Dark' }], + 'tv' + ); + + expect(show.originalTitle).toBe('Dark'); + }); + + it('has no alias when the original matches the localized title', () => { + const [movie] = mapDiscoverResults( + [{ id: 3, title: 'Dune', original_title: 'Dune' }], + 'movie' + ); + + expect(movie.originalTitle).toBeNull(); + }); + + it('drops untitled results and deduplicates by id', () => { + const mapped = mapDiscoverResults( + [ + { id: 4, title: 'Alien' }, + { id: 4, title: 'Alien' }, + { id: 5, title: ' ' }, + ], + 'movie' + ); + + expect(mapped.map((entry) => entry.tmdbId)).toEqual([4]); + }); +}); 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..2d3b14ec9 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-discover.ts @@ -0,0 +1,59 @@ +import { tmdbPosterUrl } from './tmdb-config'; +import { extractYear } from './tmdb-matcher'; +import { TmdbSearchResult } from './tmdb.types'; + +/** + * View-friendly projection of a `/discover` result, shared by the portal + * Discover pages. Structurally a subset of `ActorFilmographyCredit`, so + * both feed the same title-results grid. + */ +export interface DiscoverTitle { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; + /** + * TMDB's original-language title, kept as a matching alias: `title` + * is localized to the app language while the provider catalog stores + * whatever the panel named the file, which is often the original. + */ + originalTitle: string | null; + year: number | null; + posterUrl: string | null; +} + +/** + * Maps raw discover results, dropping untitled entries and deduplicating + * by TMDB id (concatenated pages can repeat entries when popularity + * shifts between page fetches). + */ +export function mapDiscoverResults( + results: readonly TmdbSearchResult[], + mediaType: 'movie' | 'tv' +): DiscoverTitle[] { + const seen = new Set(); + const titles: DiscoverTitle[] = []; + + for (const result of results) { + const title = (result.title ?? result.name ?? '').trim(); + if (!title || seen.has(result.id)) { + continue; + } + seen.add(result.id); + const originalTitle = ( + result.original_title ?? + result.original_name ?? + '' + ).trim(); + titles.push({ + tmdbId: result.id, + mediaType, + title, + originalTitle: + originalTitle && originalTitle !== title ? originalTitle : null, + year: extractYear(result.release_date ?? result.first_air_date), + posterUrl: tmdbPosterUrl(result.poster_path), + }); + } + + return titles; +} 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..3e8b9cc87 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,35 @@ describe('mergeStalkerInfoWithTmdb', () => { expect(merged.director).toBe('Создатель Сериала'); }); + it('emits facets and the matched media type for Discover chips', () => { + const merged = mergeStalkerInfoWithTmdb( + providerStalkerInfo, + { + ...tmdbMovieRu, + origin_country: ['RU'], + production_countries: [ + { iso_3166_1: 'RU', name: 'Russia' }, + ], + }, + 'movie' + ); + + expect(merged.tmdb_genres).toEqual([{ id: 35, name: 'комедия' }]); + expect(merged.tmdb_countries).toEqual([ + { code: 'RU', name: 'Russia' }, + ]); + expect(merged.tmdb_media_type).toBe('movie'); + }); + + it('marks embedded-VOD series as tv so Discover routes correctly', () => { + const tv: TmdbTvDetails = { id: 1, overview: 'tv overview' }; + const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv'); + + expect(merged.tmdb_media_type).toBe('tv'); + expect(merged.tmdb_genres).toBeUndefined(); + expect(merged.tmdb_countries).toBeUndefined(); + }); + it('keeps provider values for missing TMDB fields', () => { const merged = mergeStalkerInfoWithTmdb( { ...providerStalkerInfo, description: 'Провайдерский сюжет' }, diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index 69f9ee58a..9513e96fe 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -51,6 +51,7 @@ const tmdbMovie: TmdbMovieDetails = { poster_path: '/matrix-poster.jpg', backdrop_path: '/matrix-backdrop.jpg', production_countries: [{ iso_3166_1: 'US', name: 'United States' }], + origin_country: ['US'], credits: { cast: [ { @@ -235,6 +236,76 @@ describe('mergeVodInfoWithTmdb', () => { expect(merged.tmdb_supplied_release_date).toBeUndefined(); }); + it('emits structured genre and country facets for Discover chips', () => { + const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie); + + expect(merged.tmdb_genres).toEqual([ + { id: 28, name: 'Action' }, + { id: 878, name: 'Science Fiction' }, + ]); + expect(merged.tmdb_countries).toEqual([ + { code: 'US', name: 'United States' }, + ]); + }); + + it('keeps country facets to the origin countries the filter can query', () => { + // A co-production lists every partner in production_countries, but + // Discover filters by origin — offering "Canada" here would return + // Canadian-origin films rather than ones Canada co-produced + const merged = mergeVodInfoWithTmdb(providerVodInfo(), { + id: 476299, + origin_country: ['FR'], + production_countries: [ + { iso_3166_1: 'CA', name: 'Canada' }, + { iso_3166_1: 'FR', name: 'France' }, + ], + }); + + expect(merged.tmdb_countries).toEqual([ + { code: 'FR', name: 'France' }, + ]); + }); + + it('drops an origin country TMDB does not name', () => { + const merged = mergeVodInfoWithTmdb(providerVodInfo(), { + id: 603, + origin_country: ['US', 'XX'], + production_countries: [{ iso_3166_1: 'US', name: 'United States' }], + }); + + expect(merged.tmdb_countries).toEqual([ + { code: 'US', name: 'United States' }, + ]); + }); + + it('omits facet fields when TMDB states none', () => { + const merged = mergeVodInfoWithTmdb(providerVodInfo(), { id: 603 }); + + expect(merged.tmdb_genres).toBeUndefined(); + expect(merged.tmdb_countries).toBeUndefined(); + }); + + it('drops facet entries with missing ids or names', () => { + const merged = mergeVodInfoWithTmdb(providerVodInfo(), { + id: 603, + genres: [ + { id: 28, name: 'Action' }, + { id: 12, name: ' ' }, + ], + origin_country: ['US', 'ZZ'], + production_countries: [ + { iso_3166_1: 'US', name: 'United States' }, + { iso_3166_1: '', name: 'Nowhere' }, + { name: 'No Code' }, + ], + }); + + expect(merged.tmdb_genres).toEqual([{ id: 28, name: 'Action' }]); + expect(merged.tmdb_countries).toEqual([ + { code: 'US', name: 'United States' }, + ]); + }); + it('does not mutate the provider object', () => { const info = providerVodInfo(); const snapshot = JSON.parse(JSON.stringify(info)); @@ -318,6 +389,28 @@ describe('mergeSerieInfoWithTmdb', () => { expect(merged.rating_5based).toBe(3.5); expect(merged.cover).toBe('http://provider/cover.jpg'); }); + + it('emits structured genre and country facets for Discover chips', () => { + const merged = mergeSerieInfoWithTmdb(providerSerieInfo, { + ...tmdbTv, + origin_country: ['DE'], + production_countries: [{ iso_3166_1: 'DE', name: 'Germany' }], + }); + + expect(merged.tmdb_genres).toEqual([{ id: 9648, name: 'Mystery' }]); + expect(merged.tmdb_countries).toEqual([ + { code: 'DE', name: 'Germany' }, + ]); + }); + + it('omits facet fields when TMDB states none', () => { + const merged = mergeSerieInfoWithTmdb(providerSerieInfo, { + id: 70523, + }); + + expect(merged.tmdb_genres).toBeUndefined(); + expect(merged.tmdb_countries).toBeUndefined(); + }); }); describe('series status', () => { diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index d46849147..aacd1be90 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,34 @@ function genreNames(details: TmdbDetails): string { .join(', '); } +/** Structured genres for clickable Discover chips */ +function genreFacets(details: TmdbDetails): TmdbGenreFacet[] { + return (details.genres ?? []) + .filter((genre) => Number.isInteger(genre.id) && !!genre.name?.trim()) + .map((genre) => ({ id: genre.id, name: genre.name })); +} + +/** + * Structured countries for clickable Discover chips. + * + * Built from `origin_country`, never from the fuller + * `production_countries`: the Discover page filters by + * `with_origin_country`, so a co-production's other countries would + * promise "titles from here" and deliver a different set. Names come + * from `production_countries` (the only place TMDB states them), and a + * code it does not name is dropped rather than rendered as a bare "FR". + */ +function countryFacets(details: TmdbDetails): TmdbCountryFacet[] { + const namesByCode = new Map( + (details.production_countries ?? []) + .filter((entry) => !!entry.iso_3166_1 && !!entry.name) + .map((entry) => [entry.iso_3166_1 as string, entry.name as string]) + ); + return (details.origin_country ?? []) + .filter((code) => namesByCode.has(code)) + .map((code) => ({ code, name: namesByCode.get(code) as string })); +} + function tmdbRating(details: TmdbDetails): number | null { const { vote_average: average, vote_count: count } = details; return average && average > 0 && count && count > 0 @@ -106,6 +136,8 @@ export function mergeVodInfoWithTmdb( const cast = castNames(movieCast); const director = directorNames(details.credits); const genre = genreNames(details); + const tmdbGenres = genreFacets(details); + const tmdbCountries = countryFacets(details); const rating = tmdbRating(details); const poster = tmdbPosterUrl(details.poster_path); const country = (details.production_countries ?? []) @@ -140,6 +172,10 @@ export function mergeVodInfoWithTmdb( ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } : {}), + ...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}), + ...(tmdbCountries.length > 0 + ? { tmdb_countries: tmdbCountries } + : {}), }; } @@ -156,6 +192,8 @@ export function mergeSerieInfoWithTmdb( const cast = castNames(seriesCast); const creators = creatorNames(details); const genre = genreNames(details); + const tmdbGenres = genreFacets(details); + const tmdbCountries = countryFacets(details); const rating = tmdbRating(details); const poster = tmdbPosterUrl(details.poster_path); @@ -180,6 +218,10 @@ export function mergeSerieInfoWithTmdb( ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } : {}), + ...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}), + ...(tmdbCountries.length > 0 + ? { tmdb_countries: tmdbCountries } + : {}), }; } @@ -215,6 +257,8 @@ export function mergeStalkerInfoWithTmdb( ? directorNames(details.credits) : creatorNames(details as TmdbTvDetails); const genre = genreNames(details); + const tmdbGenres = genreFacets(details); + const tmdbCountries = countryFacets(details); const rating = tmdbRating(details); const poster = tmdbPosterUrl(details.poster_path); const backdrop = tmdbBackdropUrl(details.backdrop_path); @@ -243,5 +287,12 @@ export function mergeStalkerInfoWithTmdb( ...(recommendations.length > 0 ? { tmdb_recommendations: recommendations } : {}), + ...(tmdbGenres.length > 0 ? { tmdb_genres: tmdbGenres } : {}), + ...(tmdbCountries.length > 0 + ? { tmdb_countries: tmdbCountries } + : {}), + // Lets the shared detail view route Discover clicks to the media + // type this item actually matched as (embedded-VOD series → tv) + tmdb_media_type: mediaType, }; } diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index ccb5087af..7fecc3389 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -101,6 +101,15 @@ interface TmdbDetailsBase { credits?: TmdbCredits; videos?: { results?: TmdbVideo[] }; recommendations?: { results?: TmdbSearchResult[] }; + /** Present on both /movie and /tv details payloads */ + production_countries?: { iso_3166_1?: string; name?: string }[]; + /** + * ISO 3166-1 codes of the countries the title ORIGINATES from — a + * subset of `production_countries` for co-productions. This is the + * dimension `/discover`'s `with_origin_country` filters on, so it is + * what a clickable country facet must be built from. + */ + origin_country?: string[]; } export interface TmdbMovieDetails extends TmdbDetailsBase { @@ -108,7 +117,6 @@ export interface TmdbMovieDetails extends TmdbDetailsBase { original_title?: string; release_date?: string; runtime?: number; - production_countries?: { iso_3166_1?: string; name?: string }[]; } export interface TmdbTvDetails extends TmdbDetailsBase { @@ -181,6 +189,23 @@ export interface TmdbPersonDetails { export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails; +/** `/discover/{movie,tv}` page; extends the search shape with paging */ +export interface TmdbDiscoverResponse extends TmdbSearchResponse { + page?: number; + total_pages?: number; +} + +/** + * Facet filters for `/discover`. All optional — a single chip click sends + * exactly one, but the shape supports combined queries for later versions. + */ +export interface TmdbDiscoverFilters { + year?: number | null; + genreId?: number | null; + /** ISO 3166-1 alpha-2 */ + countryCode?: string | null; +} + /** Input for the enrichment orchestrator */ export interface TmdbEnrichmentQuery { /** Provider-supplied TMDB id — trusted fully when valid */ 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..7a183a2be 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -99,6 +99,55 @@ export interface TmdbRecommendation { posterUrl: string | null; } +/** + * Genre as stated by TMDB for one title. The id drives the Discover-page + * filter (`with_genres`), so it must never be mixed across media types — + * movie and TV genre id spaces differ. + */ +export interface TmdbGenreFacet { + id: number; + name: string; +} + +/** + * Production country as stated by TMDB. `code` is ISO 3166-1 alpha-2 and + * drives the Discover-page filter (`with_origin_country`); `name` is the + * TMDB display name (English regardless of request language). + */ +export interface TmdbCountryFacet { + code: string; + name: string; +} + +/** + * Whether a number is a year the Discover page can actually filter by. + * + * Providers ship `0000-00-00` as their "no date" placeholder, which reads + * as a four-digit year and would otherwise produce a `0000` chip that + * filters by nothing. Shared by the chip side and the route-param side so + * a deep link cannot smuggle in what a chip refuses to offer. + */ +export function isTmdbYearFacet(year: number): boolean { + return Number.isInteger(year) && year >= 1000 && year <= 9999; +} + +/** + * The year stated by a provider date field, whatever shape it arrives in + * — `1976`, `1999-03-31` and `31-03-1999` all resolve to the same year. + * + * Reads the first four-digit run rather than a fixed prefix: slicing + * turns a day-first date into `31-0`, which is both the wrong label and + * an unusable filter. Lives here so the Discover chips and the detail + * adapters cannot drift into disagreeing about what a date says. + */ +export function parseFacetYear( + releaseDate: string | null | undefined +): number | null { + const match = releaseDate?.match(/\d{4}/); + const year = match ? Number(match[0]) : null; + return year !== null && isTmdbYearFacet(year) ? year : null; +} + /** * One cached TMDB lookup. Two kinds of rows share the table, discriminated * by the `lookupKey` prefix: diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.spec.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.spec.ts index e4da637e5..790d9d049 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.spec.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.spec.ts @@ -1,4 +1,75 @@ -import { youtubeEmbedUrl } from './vod-details-adapters'; +import { + normalizeStalkerVod, + normalizeXtreamVod, + youtubeEmbedUrl, +} from './vod-details-adapters'; + +describe('normalized release year', () => { + const stalker = (releasedate: string) => + normalizeStalkerVod({ + id: '1', + cmd: '', + info: { + movie_image: '', + description: '', + name: 'Film', + actors: '', + director: '', + releasedate, + genre: '', + rating_imdb: '', + rating_kinopoisk: '', + }, + }).year; + + it('reads the year out of a day-first provider date', () => { + // A fixed prefix renders '31-0' here, which is both the wrong + // label and a year the Discover chip cannot filter by + expect(stalker('31-03-1999')).toBe('1999'); + }); + + it('keeps reading ordinary date shapes', () => { + expect(stalker('1976')).toBe('1976'); + expect(stalker('1999-03-31')).toBe('1999'); + expect( + normalizeXtreamVod({ + info: { + kinopoisk_url: '', + tmdb_id: 0, + name: 'Film', + o_name: '', + cover_big: '', + movie_image: '', + releasedate: '2018-05-01', + episode_run_time: 0, + youtube_trailer: '', + director: '', + actors: '', + cast: '', + description: '', + plot: '', + age: '', + mpaa_rating: '', + rating_count_kinopoisk: 0, + country: '', + genre: '', + backdrop_path: [], + duration_secs: 0, + duration: '', + video: [], + audio: [], + bitrate: 0, + rating: 0, + }, + }).year + ).toBe('2018'); + }); + + it('states no year for a placeholder or unusable date', () => { + expect(stalker('0000-00-00')).toBeUndefined(); + expect(stalker('')).toBeUndefined(); + }); +}); describe('youtubeEmbedUrl', () => { it('builds an embed URL from a plain video id (TMDB format)', () => { diff --git a/libs/shared/interfaces/src/lib/vod-details-adapters.ts b/libs/shared/interfaces/src/lib/vod-details-adapters.ts index 608b3b772..4334dee27 100644 --- a/libs/shared/interfaces/src/lib/vod-details-adapters.ts +++ b/libs/shared/interfaces/src/lib/vod-details-adapters.ts @@ -1,5 +1,6 @@ import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface'; import { StalkerVodDetails } from './stalker-vod-details.interface'; +import { parseFacetYear } from './tmdb.interface'; import { NormalizedVodMeta, VodDetailsItem, @@ -32,6 +33,8 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta { tmdbCast: info?.tmdb_cast, tmdbDirectors: info?.tmdb_directors, tmdbRecommendations: info?.tmdb_recommendations, + tmdbGenres: info?.tmdb_genres, + tmdbCountries: info?.tmdb_countries, }; } @@ -62,6 +65,9 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta tmdbCast: info?.tmdb_cast, tmdbDirectors: info?.tmdb_directors, tmdbRecommendations: info?.tmdb_recommendations, + tmdbMediaType: info?.tmdb_media_type, + tmdbGenres: info?.tmdb_genres, + tmdbCountries: info?.tmdb_countries, }; } @@ -165,24 +171,28 @@ export function getVodNumericId(item: VodDetailsItem): number { // ============ Helper Functions ============ /** - * Extracts 4-digit year from various date formats. + * Extracts the 4-digit year from the various date formats providers send. + * + * Delegates to the shared facet parser so the displayed year and the + * Discover chip built from it cannot disagree — the old fixed-prefix + * fallback turned a day-first `31-03-1999` into `31-0`, which is both + * the wrong label and an unusable filter. The date-parse fallback stays + * for shapes stating no four-digit run of their own. */ function extractYear(dateString?: string): string | undefined { if (!dateString) return undefined; - // Try to extract 4-digit year from beginning - const yearMatch = dateString.match(/^(\d{4})/); - if (yearMatch) { - return yearMatch[1]; + const facetYear = parseFacetYear(dateString); + if (facetYear !== null) { + return String(facetYear); } - // Try to parse as date and extract year const date = new Date(dateString); if (!isNaN(date.getTime())) { return date.getFullYear().toString(); } - return dateString.slice(0, 4); + return undefined; } /** 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..00f72b07f 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,12 @@ export interface NormalizedVodMeta { tmdbDirectors?: TmdbEnrichedCastMember[]; /** TMDB recommendations (drives the cross-portal "Similar" rail) */ tmdbRecommendations?: TmdbRecommendation[]; + /** Media type the enrichment matched as; Discover click routing */ + tmdbMediaType?: TmdbMediaType; + /** Per-entry clickable genre chips (Discover page) */ + tmdbGenres?: TmdbGenreFacet[]; + /** Per-entry clickable country chips (Discover page) */ + tmdbCountries?: TmdbCountryFacet[]; } /** 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..24e42bc79 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,30 @@ color: #ffd700; border-color: rgba(255, 215, 0, 0.3); } + + // Rendered as a + } @else { + {{ meta.year }} + } } - @if (meta.genre) { + @if (meta.tmdbGenres?.length) { + @for (genre of meta.tmdbGenres; track genre.id) { + + } + } @else if (meta.genre) { {{ meta.genre }} } @if (meta.duration) { @@ -25,7 +45,17 @@ ⭐ {{ meta.ratingImdb }} } - @if (meta.country) { + @if (meta.tmdbCountries?.length) { + @for (country of meta.tmdbCountries; track country.code) { + + } + } @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..8cec49d96 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 @@ -13,6 +13,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { SafePipe } from '@iptvnator/pipes'; import { PORTAL_EXTERNAL_PLAYBACK, + createDiscoverFacetNavigation, createExternalPlaybackButtonState, } from '@iptvnator/portal/shared/util'; import { @@ -36,6 +37,7 @@ import { CrossPortalSimilarItem, CrossPortalSimilarService, DownloadsService, + TmdbEnrichmentService, } from '@iptvnator/services'; import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component'; @@ -319,6 +321,24 @@ export class VodDetailsComponent { this.backClicked.emit(); } + /** Clickable year/genre/country chips (Discover pages) */ + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + + readonly discover = createDiscoverFacetNavigation(() => { + const item = this.item(); + // Discover reads its results from TMDB, so a chip must not offer a + // page that enrichment cannot fill + return item.playlistId && this.tmdbEnrichment.isEnabled() + ? { + portal: item.type === 'stalker' ? 'stalker' : 'xtream', + // Stalker embedded-VOD series render here but are matched + // as tv, so the merge's verdict decides — not the route + mediaType: this.normalizedMeta().tmdbMediaType ?? 'movie', + playlistId: item.playlistId, + } + : null; + }); + /** Handle download request */ onDownload(): void { this.downloadRequested.emit(this.item()); 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..a47693c57 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts @@ -0,0 +1,119 @@ +import { + discoverFacetKey, + hasDiscoverFacet, + parseDiscoverParams, +} from './discover-params'; + +describe('parseDiscoverParams', () => { + it('parses a full valid facet set', () => { + const facets = parseDiscoverParams({ + type: 'tv', + year: '1990', + genre: '18', + genreLabel: 'Drama', + country: 'us', + countryLabel: 'United States', + }); + + expect(facets).toEqual({ + type: 'tv', + year: 1990, + genreId: 18, + genreLabel: 'Drama', + countryCode: 'US', + countryLabel: 'United States', + }); + }); + + it('defaults an unknown media type to movie', () => { + expect(parseDiscoverParams({ type: 'series' }).type).toBe('movie'); + expect(parseDiscoverParams({}).type).toBe('movie'); + expect(parseDiscoverParams({ type: ['tv'] }).type).toBe('movie'); + }); + + it('rejects malformed years', () => { + expect(parseDiscoverParams({ year: '199' }).year).toBeNull(); + expect(parseDiscoverParams({ year: '19901' }).year).toBeNull(); + expect(parseDiscoverParams({ year: 'abcd' }).year).toBeNull(); + expect(parseDiscoverParams({ year: '1990' }).year).toBe(1990); + }); + + it('rejects a zero year a deep link could smuggle in', () => { + // The chips refuse '0000'; the route must refuse it too, or the + // request drops the filter and returns unfiltered popular titles + expect(parseDiscoverParams({ year: '0000' }).year).toBeNull(); + expect(hasDiscoverFacet(parseDiscoverParams({ year: '0000' }))).toBe( + false + ); + }); + + it('drops a genre label without a valid genre id', () => { + const facets = parseDiscoverParams({ + genre: 'drama', + genreLabel: 'Drama', + }); + + expect(facets.genreId).toBeNull(); + expect(facets.genreLabel).toBeNull(); + }); + + it('rejects zero and negative genre ids', () => { + expect(parseDiscoverParams({ genre: '0' }).genreId).toBeNull(); + expect(parseDiscoverParams({ genre: '-5' }).genreId).toBeNull(); + }); + + it('normalizes country codes and drops orphaned labels', () => { + expect(parseDiscoverParams({ country: 'de' }).countryCode).toBe('DE'); + expect(parseDiscoverParams({ country: 'USA' }).countryCode).toBeNull(); + + const orphanLabel = parseDiscoverParams({ + countryLabel: 'United States', + }); + expect(orphanLabel.countryCode).toBeNull(); + expect(orphanLabel.countryLabel).toBeNull(); + }); +}); + +describe('hasDiscoverFacet', () => { + it('is false for a bare type-only param set', () => { + expect(hasDiscoverFacet(parseDiscoverParams({ type: 'movie' }))).toBe( + false + ); + }); + + it('is true when any single facet is present', () => { + expect(hasDiscoverFacet(parseDiscoverParams({ year: '1990' }))).toBe( + true + ); + expect(hasDiscoverFacet(parseDiscoverParams({ genre: '18' }))).toBe( + true + ); + expect(hasDiscoverFacet(parseDiscoverParams({ country: 'US' }))).toBe( + true + ); + }); +}); + +describe('discoverFacetKey', () => { + it('produces distinct keys for distinct facet sets', () => { + const keys = new Set( + [ + { type: 'movie', year: '1990' }, + { type: 'tv', year: '1990' }, + { type: 'movie', year: '1991' }, + { type: 'movie', genre: '18' }, + { type: 'movie', country: 'US' }, + ].map((params) => discoverFacetKey(parseDiscoverParams(params))) + ); + + expect(keys.size).toBe(5); + }); + + it('is stable for equivalent param objects', () => { + expect( + discoverFacetKey( + parseDiscoverParams({ year: '1990', type: 'movie' }) + ) + ).toBe(discoverFacetKey(parseDiscoverParams({ year: '1990' }))); + }); +}); 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..47ea52486 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts @@ -0,0 +1,82 @@ +import { TmdbMediaType, isTmdbYearFacet } from '@iptvnator/shared/interfaces'; + +/** + * Facets of a portal Discover page, parsed from route query params. + * Labels are display-only companions of their filter values; a label + * without its value is dropped, so the page never promises a filter it + * cannot send. `type` and `genreId` form an atomic pair — movie and TV + * genre id spaces differ, so a genre never survives a type flip. + */ +export interface DiscoverRouteParams { + type: TmdbMediaType; + year: number | null; + genreId: number | null; + genreLabel: string | null; + countryCode: string | null; + countryLabel: string | null; +} + +/** Router-free query-param shape (Angular's `Params` is `any`-valued) */ +type RawParams = Record; + +function rawString(params: RawParams, key: string): string | null { + const value = params[key]; + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : null; +} + +export function parseDiscoverParams(params: RawParams): DiscoverRouteParams { + const type: TmdbMediaType = + rawString(params, 'type') === 'tv' ? 'tv' : 'movie'; + + // `0000` parses as four digits but filters by nothing — a deep link + // must not reach a state the chips themselves refuse to offer + const rawYear = rawString(params, 'year'); + const parsedYear = + rawYear && /^\d{4}$/.test(rawYear) ? Number(rawYear) : null; + const year = + parsedYear !== null && isTmdbYearFacet(parsedYear) ? parsedYear : null; + + const rawGenre = rawString(params, 'genre'); + const parsedGenre = rawGenre && /^\d+$/.test(rawGenre) ? Number(rawGenre) : null; + const genreId = parsedGenre && parsedGenre > 0 ? parsedGenre : null; + + const rawCountry = rawString(params, 'country'); + const countryCode = + rawCountry && /^[A-Za-z]{2}$/.test(rawCountry) + ? rawCountry.toUpperCase() + : null; + + return { + type, + year, + genreId, + genreLabel: genreId !== null ? rawString(params, 'genreLabel') : null, + countryCode, + countryLabel: + countryCode !== null ? rawString(params, 'countryLabel') : null, + }; +} + +export function hasDiscoverFacet(facets: DiscoverRouteParams): boolean { + return ( + facets.year !== null || + facets.genreId !== null || + facets.countryCode !== null + ); +} + +/** + * Stable identity of one facet set. Used as the staleness-guard token by + * the route containers (facets change via query params on the same route + * instance, so a late response must be dropped by key, not by instance). + */ +export function discoverFacetKey(facets: DiscoverRouteParams): string { + return [ + facets.type, + facets.year ?? '', + facets.genreId ?? '', + facets.countryCode ?? '', + ].join('|'); +} 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..697bdf7d4 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/title-results/title-results.component.html @@ -0,0 +1,133 @@ +
+

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

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

{{ emptyMessageKey() | translate }}

+
+ } @else { + +
+ @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..e39d29aa1 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/title-results/title-results.component.ts @@ -0,0 +1,88 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ActorCrewJob } from '@iptvnator/services'; + +/** + * One entry of the shared title-results grid (actor filmography, Discover + * results). `available` marks a confident catalog match (direct + * navigation); the rest open the portal search prefilled with the title. + */ +export interface TitleResultItem { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; + year: number | null; + posterUrl: string | null; + available: boolean; + /** Playlist name shown in the badge (All-portals scope) */ + availableIn?: string; + /** Actor-page extras; absent on Discover results */ + character?: string | null; + crewJob?: ActorCrewJob | null; +} + +export type TitleResultsScope = 'portal' | 'global'; + +/** + * TMDB-titles grid with the scope (this portal / all portals) and + * availability (all / in library) filter chips, shared by the actor and + * Discover pages. Generic over the item type so hosts get their own item + * shape back from `itemClicked` without casts. Theme custom properties + * (`--surface-bg`, `--chip-*`, ...) are expected from the host's scope. + */ +@Component({ + selector: 'app-title-results', + imports: [MatIcon, MatProgressSpinnerModule, TranslatePipe], + templateUrl: './title-results.component.html', + styleUrls: ['./title-results.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TitleResultsComponent { + 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'; + } + + /** + * The spinner belongs to the global scope only. Gating the grid on the + * raw flag hides valid portal results when the user switches back while + * the worker request is still in flight — a stuck worker would then + * blank the local grid indefinitely. + */ + readonly matchingVisible = computed( + () => this.isMatching() && this.scope() === 'global' + ); + + readonly visibleItems = computed(() => + this.showAvailabilityFilter() && this.filterMode() === 'available' + ? this.items().filter((item) => item.available) + : this.items() + ); +}