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)