fix(portals): keep lazy Stalker VOD series on the VOD route

`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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 17:13:10 +02:00
1 parent db1ec7cf5e
commit fa97aa1add
3 files changed
+197 -14

No files matched your search

+38 -1
View File
@@ -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)
@@ -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(
{
@@ -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();
}