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

@@ -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();
}