From b9dfc129940871f3eaf0a8e60c0de2b0460fbbe4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Aug 2026 09:40:00 +0200 Subject: [PATCH] fix(portals): gate facet chips on enrichment, keep portal results visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `typeof tmdb_id === 'number'` was the wrong proof that enrichment ran: XtreamVodInfo.tmdb_id allows a provider-sent JSON number, so with TMDB disabled the year chip stayed clickable and opened a Discover page that cannot load anything. The target now answers the real question — can a facet click land anywhere — and returns null when enrichment is off, so the id argument is gone from the chip API entirely. - Hiding the grid on the raw matching flag blanked valid portal results when the user switched back to "This portal" mid-request; a stuck worker would have blanked them indefinitely. The spinner belongs to the global scope, so it only replaces the grid while that scope is active. - CLAUDE.md and docs/architecture/stalker-portal.md list the portal child routes explicitly; both now include `discover`. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +-- docs/architecture/stalker-portal.md | 2 ++ .../discover-facet-navigation.spec.ts | 32 ++++++++--------- .../navigation/discover-facet-navigation.ts | 34 ++++++++----------- .../stalker-series-view.component.html | 14 ++------ .../stalker-series-view.component.ts | 7 +++- .../serial-details.component.html | 6 ++-- .../serial-details.component.ts | 7 +++- .../vod-details-route.component.html | 6 ++-- .../vod-details-route.component.ts | 7 +++- .../vod-details/vod-details.component.html | 4 +-- .../lib/vod-details/vod-details.component.ts | 7 +++- .../title-results.component.html | 2 +- .../title-results/title-results.component.ts | 10 ++++++ 14 files changed, 77 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f8259282..43390df11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -537,8 +537,8 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. - Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources` - M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player` -- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` -- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` +- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `discover`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` +- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `discover`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` - Global collections: `/workspace/global-favorites`, `/workspace/global-recent` - Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`) - Downloads: `/workspace/downloads` with focused diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 31852d521..2eee0188c 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -42,6 +42,8 @@ below is reached as `/workspace/stalker/:id/…`. - `/workspace/stalker/:id/recent` - `/workspace/stalker/:id/search` - `/workspace/stalker/:id/actor/:personId` +- `/workspace/stalker/:id/discover` (facet query params; see + `docs/architecture/tmdb-metadata-enrichment.md`) - `/workspace/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`) - `/workspace/stalker/:id/downloads/:downloadId` (focused local movie/series detail with no category context panel) diff --git a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts index 24eeace49..426cfd253 100644 --- a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.spec.ts @@ -71,34 +71,32 @@ describe('createDiscoverFacetNavigation', () => { ['31-03-1999', '1999'], ]) { navigate.mockClear(); - discover.openYear(603, date); + discover.openYear(date); expect(navigate).toHaveBeenCalledWith(expect.anything(), { queryParams: { type: 'movie', year }, }); } }); - it('refuses the year facet without a merge-written numeric tmdb id', () => { - const discover = create(xtreamMovie); + it('refuses the year facet when the target cannot be reached', () => { + // Hosts return null when enrichment is off, and Discover reads its + // results from TMDB — a year chip must not promise an empty page + const discover = create(() => null); - // Providers ship tmdb_id as an untrusted string; only the merge - // writes a number, and only a matched item has a facet identity - expect(discover.canOpenYear('603', '1999-03-31')).toBe(false); - expect(discover.canOpenYear(undefined, '1999-03-31')).toBe(false); - expect(discover.canOpenYear(603, '1999-03-31')).toBe(true); + expect(discover.canOpenYear('1999-03-31')).toBe(false); - discover.openYear('603', '1999-03-31'); + discover.openYear('1999-03-31'); expect(navigate).not.toHaveBeenCalled(); }); it('refuses the year facet without a parsable year', () => { const discover = create(xtreamMovie); - expect(discover.canOpenYear(603, '')).toBe(false); - expect(discover.canOpenYear(603, undefined)).toBe(false); - expect(discover.canOpenYear(603, 'unknown')).toBe(false); + expect(discover.canOpenYear('')).toBe(false); + expect(discover.canOpenYear(undefined)).toBe(false); + expect(discover.canOpenYear('unknown')).toBe(false); - discover.openYear(603, 'unknown'); + discover.openYear('unknown'); expect(navigate).not.toHaveBeenCalled(); }); @@ -106,10 +104,10 @@ describe('createDiscoverFacetNavigation', () => { const discover = create(xtreamMovie); // '0000-00-00' reads as a four-digit year but filters by nothing - expect(discover.canOpenYear(603, '0000-00-00')).toBe(false); - expect(discover.canOpenYear(603, '0000')).toBe(false); + expect(discover.canOpenYear('0000-00-00')).toBe(false); + expect(discover.canOpenYear('0000')).toBe(false); - discover.openYear(603, '0000-00-00'); + discover.openYear('0000-00-00'); expect(navigate).not.toHaveBeenCalled(); }); @@ -118,7 +116,7 @@ describe('createDiscoverFacetNavigation', () => { discover.openGenre({ id: 18, name: 'Drama' }); discover.openCountry({ code: 'US', name: 'United States' }); - discover.openYear(603, '1999'); + discover.openYear('1999'); expect(navigate).not.toHaveBeenCalled(); }); diff --git a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts index 673f6776f..c20b1be81 100644 --- a/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/discover-facet-navigation.ts @@ -9,8 +9,9 @@ import { import { discoverLink } from './discover-link.util'; /** - * Where a detail page's facet chips should navigate. `null` means the page - * cannot resolve its own playlist yet, so chips must not navigate at all. + * Where a detail page's facet chips should navigate. `null` means the + * page cannot send a facet anywhere — it has no playlist yet, or TMDB + * enrichment is off, which is what Discover reads its results from. */ export interface DiscoverFacetTarget { portal: 'xtream' | 'stalker'; @@ -19,11 +20,8 @@ export interface DiscoverFacetTarget { } export interface DiscoverFacetNavigation { - canOpenYear( - tmdbId: unknown, - releaseDate: string | null | undefined - ): boolean; - openYear(tmdbId: unknown, releaseDate: string | null | undefined): void; + canOpenYear(releaseDate: string | null | undefined): boolean; + openYear(releaseDate: string | null | undefined): void; openGenre(genre: TmdbGenreFacet): void; openCountry(country: TmdbCountryFacet): void; } @@ -45,10 +43,11 @@ function facetYear(releaseDate: string | null | undefined): number | null { * Shared behavior behind the clickable year/genre/country chips on every * detail page. Call it from a field initializer (injection context). * - * The year gate is deliberately `typeof tmdbId === 'number'`: provider - * payloads ship `tmdb_id` as an untrusted string, so only a value written - * by the TMDB merge proves the item was actually matched — and without a - * match there is no facet identity to discover by. + * A chip is offered only when its click can actually land somewhere, which + * is what the target answers. Genre and country chips are inherently safe + * (they exist only on an enriched item), but the year chip renders from + * provider data, so the target is what keeps it from promising a Discover + * page that enrichment cannot fill. */ export function createDiscoverFacetNavigation( target: () => DiscoverFacetTarget | null @@ -73,19 +72,16 @@ export function createDiscoverFacetNavigation( }); }; - const canOpenYear = ( - tmdbId: unknown, - releaseDate: string | null | undefined - ): boolean => typeof tmdbId === 'number' && facetYear(releaseDate) !== null; + const canOpenYear = (releaseDate: string | null | undefined): boolean => + target() !== null && facetYear(releaseDate) !== null; return { canOpenYear, - openYear(tmdbId, releaseDate) { + openYear(releaseDate) { const year = facetYear(releaseDate); - if (year === null || !canOpenYear(tmdbId, releaseDate)) { - return; + if (year !== null) { + navigate({ kind: 'year', year }); } - navigate({ kind: 'year', year }); }, openGenre(genre) { navigate({ kind: 'genre', genre }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 9105cdddf..bd2097927 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -16,21 +16,11 @@ }} } @if (serial.info.releasedate) { - @if ( - discover.canOpenYear( - serial.info.tmdb_id, - serial.info.releasedate - ) - ) { + @if (discover.canOpenYear(serial.info.releasedate)) { 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 1dc57913d..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 @@ -78,6 +78,7 @@ import { CrossPortalSimilarItem, CrossPortalSimilarService, PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, } from '@iptvnator/services'; import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; import { @@ -876,9 +877,13 @@ 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; - return playlistId + // 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; }); 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 24d943a74..5e4cbf481 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,13 +17,11 @@ }} } @if (info.releaseDate) { - @if (discover.canOpenYear(info.tmdb_id, info.releaseDate)) { + @if (discover.canOpenYear(info.releaseDate)) { 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 32fb4f248..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 @@ -51,6 +51,7 @@ import { import { CrossPortalSimilarItem, CrossPortalSimilarService, + TmdbEnrichmentService, } from '@iptvnator/services'; import { SerialDetailsPlaybackService, @@ -299,9 +300,13 @@ 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; - return playlistId + // 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; }); 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 3d177f1a6..9718aa4e4 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,13 +295,11 @@ > @if (info.releasedate) { - @if (discover.canOpenYear(info.tmdb_id, info.releasedate)) { + @if (discover.canOpenYear(info.releasedate)) { 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 ff308254d..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 @@ -45,6 +45,7 @@ import { CrossPortalSimilarService, DownloadsService, SettingsStore, + TmdbEnrichmentService, } from '@iptvnator/services'; import { getXtreamVodInfo, @@ -489,9 +490,13 @@ 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; - return playlistId + // 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; }); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index b04dcbde4..cf00a8dc2 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -12,11 +12,11 @@ @if (meta.year) { - @if (discover.canOpenYear(meta.tmdbId, meta.year)) { + @if (discover.canOpenYear(meta.year)) { 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 065782afd..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 @@ -37,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'; @@ -321,9 +322,13 @@ export class VodDetailsComponent { } /** Clickable year/genre/country chips (Discover pages) */ + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + readonly discover = createDiscoverFacetNavigation(() => { const item = this.item(); - return item.playlistId + // 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 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 index 89a1db829..697bdf7d4 100644 --- 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 @@ -50,7 +50,7 @@ } - @if (isMatching()) { + @if (matchingVisible()) {
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 index 20803a447..e39d29aa1 100644 --- 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 @@ -70,6 +70,16 @@ export class TitleResultsComponent { : '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)