From fa97aa1add4b82b04df8bf0b462fa47834d01908 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 12 Aug 2026 17:13:10 +0200 Subject: [PATCH] fix(portals): keep lazy Stalker VOD series on the VOD route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractStalkerItemType()` reports `series` for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but both belong in the VOD catalog. Passing that type straight through sent "View in portal" to `/stalker/.../series/...`, where `StalkerCatalogFacadeService.selectItem()` gates the lazy season/episode fetch on the VOD content type — so the detail opened unable to load its episodes. Mirror `StalkerCollectionDetailComponent.resolveDetailMode()` to pick the section from the raw Stalker shape, and `resolveSelectedCategory()` to normalize the virtual `series` category to `vod`. Three regression tests cover both shapes plus the virtual category; each fails on the previous behavior. Also document the collection handoff in CLAUDE.md, which previously described `View in portal` as download-manager-only. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 39 ++++++++- ...ollection-detail-portal-navigation.spec.ts | 87 +++++++++++++++++++ .../collection-detail-portal-navigation.ts | 85 +++++++++++++++--- 3 files changed, 197 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d96f2fa6..c250ef18c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1198,7 +1198,9 @@ engine` (restart required) or form a metadata-only target; unproven episode and legacy-movie handoffs stay unavailable. The normal detail uses one-shot `provider-only` presentation: it exposes provider content/playback it can resolve while hiding - Offline/local/download actions. + Offline/local/download actions. A second, independent `View in portal` + bridge exists for inline collection details — see **Collection Detail + Portal Handoff** below; it deliberately does NOT use `provider-only`. - Download rows and local files survive source deletion. The global offline library remains visible with no playlists; only provider handoff is disabled until the source exists again. @@ -1208,6 +1210,41 @@ engine` (restart required) or - Canonical contract: `docs/architecture/download-manager.md`; provider handoff: `docs/architecture/portal-detail-navigation.md`. +**Collection Detail Portal Handoff** (`View in portal` for inline details): + +- Details opened outside portal category context — `/workspace/global-favorites`, + `/workspace/global-recent` (which also receive the dashboard hero, Continue + Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent` + tabs — render full-width with no category sidebar. They expose a separate-row + hero action that jumps to the item inside its owning portal. +- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action` + (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host + provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are + `XtreamCollectionDetailComponent` (through its dynamic detail injector) and + `StalkerCollectionDetailComponent` (component providers), which exist only in + collection contexts — so router-mounted category details need no opt-out. When + hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim + a phantom row in the hero action container. +- Targets come from `getUnifiedCollectionDetailNavigation()` + (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike + `getUnifiedCollectionNavigation` it NEVER degrades to a category- or + section-only route: an Xtream item without a resolvable category and positive + item id keeps the action hidden rather than promising a jump to the title and + landing in a list. +- Stalker section resolution mirrors + `StalkerCollectionDetailComponent.resolveDetailMode()` and must not be + simplified to `item.contentType`: `extractStalkerItemType()` reports `series` + for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but + both belong in the VOD catalog — the lazy season/episode fetch in + `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so + a `/series` route leaves the detail unable to load episodes. The virtual + `series` category is normalized to `vod` the same way + `resolveSelectedCategory()` does. Stalker also carries `stalkerReturnTo`. +- Unlike the download handoff this bridge does NOT pass + `detailPresentation: 'provider-only'` — the item exists in the provider + catalog, so the full normal detail (downloads included) is wanted. +- Contract: `docs/architecture/portal-detail-navigation.md`. + **VOD/Series Detail Pages (two-state layout)**: - Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts index e33af15f3..c46dc19a4 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts @@ -102,6 +102,93 @@ describe('getUnifiedCollectionDetailNavigation', () => { }); }); + it('keeps a lazy Ministra VOD is_series item on the VOD route', () => { + // extractStalkerItemType() reports `series` for the is_series flag, but + // the lazy season/episode fetch only runs in the VOD catalog. + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-77', + name: 'Lazy VOD Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-77', + categoryId: '12', + stalkerItem: { + id: 'vod-77', + title: 'Lazy VOD Series', + category_id: '12', + is_series: '1', + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', '12']); + }); + + it('keeps an embedded series[] snapshot on the VOD route', () => { + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-88', + name: 'Embedded Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-88', + categoryId: '12', + stalkerItem: { + id: 'vod-88', + title: 'Embedded Series', + category_id: '12', + series: [1, 2, 3], + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', '12']); + }); + + it('normalizes the virtual series category for a VOD-catalog item', () => { + // Persisted from the series view, so it carries category_id 'series' + // while still belonging to the VOD catalog. + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-99', + name: 'Lazy VOD Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-99', + categoryId: 'series', + stalkerItem: { + id: 'vod-99', + title: 'Lazy VOD Series', + category_id: 'series', + is_series: true, + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', 'vod']); + }); + + it('still routes a regular Stalker series to the series catalog', () => { + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::series-9', + name: 'Regular Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'series-9', + categoryId: '44', + stalkerItem: { + id: 'series-9', + title: 'Regular Series', + category_id: '44', + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'series', '44']); + }); + it('carries stalkerReturnTo when a returnTo option is passed', () => { const navigation = getUnifiedCollectionDetailNavigation( { diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts index a2610baa6..4eb39f8b9 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts @@ -5,7 +5,10 @@ import { buildXtreamNavigationTarget, WorkspaceNavigationTarget, } from './workspace-portal-navigation'; -import { StalkerPortalItem } from '@iptvnator/shared/interfaces'; +import { + isStalkerSeriesFlag, + StalkerPortalItem, +} from '@iptvnator/shared/interfaces'; /** * Builds the portal-detail target for a collection item, or `null` when no @@ -41,20 +44,25 @@ export function getUnifiedCollectionDetailNavigation( } if (item.sourceType === 'stalker') { + const stalkerItem = item.stalkerItem as StalkerPortalItem | undefined; + const type = resolveStalkerDetailType(item, stalkerItem); + const categoryId = resolveStalkerDetailCategoryId( + item, + stalkerItem, + type + ); + return buildStalkerDetailNavigationTarget({ playlistId: item.playlistId, - type: item.contentType, - categoryId: item.categoryId, - item: buildStalkerStateItem( - item.stalkerItem as StalkerPortalItem | undefined, - { - id: item.stalkerId ?? lastUidSegment(item.uid) ?? '', - title: item.name, - type: item.contentType, - category_id: item.categoryId, - poster_url: item.posterUrl ?? item.logo ?? undefined, - } - ), + type, + categoryId, + item: buildStalkerStateItem(stalkerItem, { + id: item.stalkerId ?? lastUidSegment(item.uid) ?? '', + title: item.name, + type, + category_id: categoryId, + poster_url: item.posterUrl ?? item.logo ?? undefined, + }), returnTo: options?.returnTo ?? null, }); } @@ -62,6 +70,57 @@ export function getUnifiedCollectionDetailNavigation( return null; } +/** + * Mirrors `StalkerCollectionDetailComponent.resolveDetailMode()`: only a + * regular `/series` item belongs in the series catalog. Embedded `series[]` + * snapshots and lazy Ministra VOD `is_series` items normalize to `series` in + * `extractStalkerItemType()` but must stay in the VOD catalog — the lazy + * season/episode fetch in `StalkerCatalogFacadeService.selectItem()` is gated + * on the VOD content type, so routing them to `/series` would leave the detail + * unable to load its episodes. + */ +function resolveStalkerDetailType( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem | undefined +): 'movie' | 'series' { + const embeddedSeries = (stalkerItem as { series?: unknown[] } | undefined) + ?.series; + const hasEmbeddedSeries = + Array.isArray(embeddedSeries) && embeddedSeries.length > 0; + const isVodSeries = isStalkerSeriesFlag( + (stalkerItem as { is_series?: unknown } | undefined)?.is_series + ); + + return item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries + ? 'series' + : 'movie'; +} + +/** + * Mirrors `StalkerCollectionDetailComponent.resolveSelectedCategory()`: a + * VOD-catalog item persisted from the series view carries the virtual + * `series` category, which would otherwise form a `/vod/series` route. + */ +function resolveStalkerDetailCategoryId( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem | undefined, + type: 'movie' | 'series' +): string | number | undefined { + const categoryId = + item.categoryId ?? + (stalkerItem as { category_id?: string | number } | undefined) + ?.category_id; + + if ( + type === 'movie' && + String(categoryId ?? '').toLowerCase() === 'series' + ) { + return 'vod'; + } + + return categoryId; +} + function toTrimmedSegment(value: unknown): string { return String(value ?? '').trim(); }