mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
db1ec7cf5e
commit
fa97aa1add
3 files changed
+197
-14
No files matched your search
@@ -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)
|
||||
|
||||
+87
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user