fix(portals): gate facet chips on enrichment, keep portal results visible

- `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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 09:40:00 +02:00
1 parent 11744dd389
commit b9dfc12994
14 files changed
+77 -65

No files matched your search

+2 -2
View File
@@ -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
+2
View File
@@ -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)
@@ -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();
});
@@ -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 });
@@ -16,21 +16,11 @@
}}</span>
}
@if (serial.info.releasedate) {
@if (
discover.canOpenYear(
serial.info.tmdb_id,
serial.info.releasedate
)
) {
@if (discover.canOpenYear(serial.info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(
serial.info.tmdb_id,
serial.info.releasedate
)
"
(click)="discover.openYear(serial.info.releasedate)"
>
{{ serial.info.releasedate }}
</button>
@@ -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;
});
@@ -17,13 +17,11 @@
}}</span>
}
@if (info.releaseDate) {
@if (discover.canOpenYear(info.tmdb_id, info.releaseDate)) {
@if (discover.canOpenYear(info.releaseDate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(info.tmdb_id, info.releaseDate)
"
(click)="discover.openYear(info.releaseDate)"
>
{{ info.releaseDate | slice: 0 : 4 }}
</button>
@@ -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;
});
@@ -295,13 +295,11 @@
>
<ng-template appDetailTags>
@if (info.releasedate) {
@if (discover.canOpenYear(info.tmdb_id, info.releasedate)) {
@if (discover.canOpenYear(info.releasedate)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="
discover.openYear(info.tmdb_id, info.releasedate)
"
(click)="discover.openYear(info.releasedate)"
>
{{ info.releasedate | slice: 0 : 4 }}
</button>
@@ -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;
});
@@ -12,11 +12,11 @@
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-template appDetailTags>
@if (meta.year) {
@if (discover.canOpenYear(meta.tmdbId, meta.year)) {
@if (discover.canOpenYear(meta.year)) {
<button
type="button"
class="details__tag details__tag--clickable"
(click)="discover.openYear(meta.tmdbId, meta.year)"
(click)="discover.openYear(meta.year)"
>
{{ meta.year }}
</button>
@@ -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
@@ -50,7 +50,7 @@
</span>
}
</h2>
@if (isMatching()) {
@if (matchingVisible()) {
<div class="title-results__matching">
<mat-spinner diameter="32" />
</div>
@@ -70,6 +70,16 @@ export class TitleResultsComponent<T extends TitleResultItem> {
: '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)