mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
11744dd389
commit
b9dfc12994
14 files changed
+77
-65
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+2
-12
@@ -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>
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user