fix(xtream): recover Similar VOD provider categories

This commit is contained in:
4gray committed 2026-07-29 00:57:52 +02:00
1 parent c7cafee01e
commit 3e5ed268af
7 files changed
+253 -80

No files matched your search

+1 -1
View File
@@ -861,7 +861,7 @@ engine` (restart required) or
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the SQLite route category through all persisted categories, including hidden ones; PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
@@ -104,14 +104,18 @@ merged detail response and cached row still cannot resolve a source, the detail
loader requests the exact VOD from its provider category and merges that raw
catalog row in memory. An Electron route category uses the SQLite row id, so
the loader maps it through the complete persisted category set, including
hidden categories, before sending the provider `category_id`; PWA falls back
to its API-backed visible categories, and an unresolved database id is never
sent as though it were a provider id. This recovery request is skipped when
the detail response or owner-valid cached catalog fields are already
sufficient. Recovery is best-effort and never gates the detail page: the
initial sparse selection is published and the loading shell ends before the
category lookup completes, then a successful result upgrades that same
fallback reactively with playback actions. Detail, recovered, and cached
hidden categories, before sending the provider `category_id`. Cross-portal
Similar links already carry that provider id, so recovery accepts either the
SQLite `id` or `xtream_id` representation while preserving local-id lookup
precedence. If the numeric representations collide, candidate provider ids are
deduplicated and tried in that order until the exact VOD is found. PWA falls
back to its API-backed visible categories, and an unresolved database id is
never sent as though it were a provider id. This recovery request is skipped
when the detail response or owner-valid cached catalog fields are already
sufficient. Recovery is best-effort and never gates the detail page: the initial
sparse selection is published and the loading shell ends before the category
lookup completes, then a successful result upgrades that same fallback
reactively with playback actions. Detail, recovered, and cached
playback fields remain separate candidates: the first complete pair wins, so
two incomplete rows can never synthesize a source. A failed lookup leaves the
already-rendered item safely unplayable. Detail requests are generation- and
@@ -0,0 +1,97 @@
import { XtreamVodStream } from '@iptvnator/shared/interfaces';
import { recoverXtreamVodCatalogItem } from './xtream-details-request';
describe('Xtream VOD category recovery', () => {
const credentials = {
serverUrl: 'http://demo.example',
username: 'demo',
password: 'secret',
};
it('tries a local category mapping before a colliding provider id', async () => {
const recoveredItem = {
stream_id: 42,
container_extension: 'mp4',
} as XtreamVodStream;
const getVodStream = jest.fn(
async (
_credentials: typeof credentials,
_vodId: string | number,
categoryId: string | number
) => (Number(categoryId) === 7 ? recoveredItem : null)
);
const getAllCategories = jest.fn();
const getCategories = jest.fn();
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [
{ id: 7, xtream_id: 701 },
{ id: 8, xtream_id: 7 },
],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 7,
vodId: 42,
})
).resolves.toBe(recoveredItem);
expect(getVodStream.mock.calls).toEqual([
[credentials, 42, 701],
[credentials, 42, 7],
]);
expect(getAllCategories).not.toHaveBeenCalled();
expect(getCategories).not.toHaveBeenCalled();
});
it('deduplicates one provider category across all category sources', async () => {
const getVodStream = jest.fn().mockResolvedValue(null);
const getAllCategories = jest
.fn()
.mockResolvedValue([{ id: 7, xtream_id: 7 }]);
const getCategories = jest
.fn()
.mockResolvedValue([{ category_id: '7' }]);
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [{ id: 7, xtream_id: 7 }],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 7,
vodId: 42,
})
).resolves.toBeNull();
expect(getVodStream).toHaveBeenCalledTimes(1);
expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 7);
});
it('does not request a VOD for an unresolved route category', async () => {
const getVodStream = jest.fn();
const getAllCategories = jest
.fn()
.mockResolvedValue([{ id: 8, xtream_id: 701 }]);
const getCategories = jest
.fn()
.mockResolvedValue([{ category_id: '702' }]);
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 7,
vodId: 42,
})
).resolves.toBeNull();
expect(getVodStream).not.toHaveBeenCalled();
});
});
@@ -30,38 +30,44 @@ describe('Xtream details request coordination', () => {
expect(thirdRequestIsCurrent()).toBe(false);
});
it('recovers a provider category id from hidden Electron categories', async () => {
const getAllCategories = jest.fn().mockResolvedValue([
{
id: 7,
xtream_id: 701,
},
]);
const getCategories = jest.fn().mockResolvedValue([]);
const getVodStream = jest.fn().mockResolvedValue({
stream_id: 42,
container_extension: 'mp4',
});
it.each([7, 701])(
'recovers from Electron route category id %s',
async (routeCategoryId) => {
const getAllCategories = jest.fn().mockResolvedValue([
{
id: 7,
xtream_id: 701,
},
]);
const getCategories = jest.fn().mockResolvedValue([]);
const getVodStream = jest.fn().mockResolvedValue({
stream_id: 42,
container_extension: 'mp4',
});
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId: 7,
vodId: 42,
})
).resolves.toEqual({
stream_id: 42,
container_extension: 'mp4',
});
expect(getAllCategories).toHaveBeenCalledWith('playlist-a', 'movies');
expect(getCategories).not.toHaveBeenCalled();
expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701);
});
await expect(
recoverXtreamVodCatalogItem({
apiService: { getVodStream },
currentCategories: [],
credentials,
dataSource: { getAllCategories, getCategories },
isCurrent: () => true,
playlistId: 'playlist-a',
routeCategoryId,
vodId: 42,
})
).resolves.toEqual({
stream_id: 42,
container_extension: 'mp4',
});
expect(getAllCategories).toHaveBeenCalledWith(
'playlist-a',
'movies'
);
expect(getCategories).not.toHaveBeenCalled();
expect(getVodStream).toHaveBeenCalledWith(credentials, 42, 701);
}
);
it('falls back to the visible API categories when PWA has no persisted categories', async () => {
const getAllCategories = jest.fn().mockResolvedValue([]);
@@ -7,7 +7,7 @@ import { resolveXtreamVodPlaybackSource } from '../services/xtream-vod-playback-
import {
buildXtreamVodSelection,
findXtreamVodCatalogItem,
resolveXtreamVodCatalogCategoryId,
resolveXtreamVodCatalogCategoryIds,
XtreamVodCatalogCategory,
XtreamVodSelection,
} from './xtream-vod-selection';
@@ -122,49 +122,67 @@ export async function recoverXtreamVodCatalogItem({
return null;
}
let providerCategoryId = resolveXtreamVodCatalogCategoryId(
currentCategories,
routeCategoryId
);
if (providerCategoryId === null) {
const persistedCategories = await dataSource.getAllCategories(
playlistId,
'movies'
);
if (!isCurrent()) {
return null;
}
providerCategoryId = resolveXtreamVodCatalogCategoryId(
persistedCategories,
routeCategoryId
);
}
if (providerCategoryId === null) {
const categories = await dataSource.getCategories(
playlistId,
credentials,
'vod'
);
if (!isCurrent()) {
return null;
}
providerCategoryId = resolveXtreamVodCatalogCategoryId(
const attemptedCategoryIds = new Set<number>();
const findInCategories = async (
categories: readonly XtreamVodCatalogCategory[]
): Promise<XtreamVodStream | null> => {
const providerCategoryIds = resolveXtreamVodCatalogCategoryIds(
categories,
routeCategoryId
);
for (const providerCategoryId of providerCategoryIds) {
const normalizedProviderId = Number(providerCategoryId);
if (attemptedCategoryIds.has(normalizedProviderId)) {
continue;
}
attemptedCategoryIds.add(normalizedProviderId);
if (!isCurrent()) {
return null;
}
const item = await apiService.getVodStream(
credentials,
vodId,
providerCategoryId
);
if (!isCurrent()) {
return null;
}
if (item) {
return item;
}
}
return null;
};
let item = await findInCategories(currentCategories);
if (item || !isCurrent()) {
return item;
}
if (providerCategoryId === null || !isCurrent()) {
const persistedCategories = await dataSource.getAllCategories(
playlistId,
'movies'
);
if (!isCurrent()) {
return null;
}
item = await findInCategories(persistedCategories);
if (item || !isCurrent()) {
return item;
}
const categories = await dataSource.getCategories(
playlistId,
credentials,
'vod'
);
if (!isCurrent()) {
return null;
}
const item = await apiService.getVodStream(
credentials,
vodId,
providerCategoryId
);
return isCurrent() ? item : null;
return findInCategories(categories);
}
export function resolveXtreamVodDetailsSelection({
@@ -133,6 +133,18 @@ describe('Xtream VOD selection helpers', () => {
).toBe('702');
});
it('accepts a provider category id from cross-portal detail links', () => {
expect(
resolveXtreamVodCatalogCategoryId([{ id: 7, xtream_id: 701 }], 701)
).toBe(701);
});
it('does not treat an optional PWA id as a database category id', () => {
expect(
resolveXtreamVodCatalogCategoryId([{ id: 7, category_id: 701 }], 7)
).toBeNull();
});
it('does not treat an unresolved database category id as a provider id', () => {
expect(resolveXtreamVodCatalogCategoryId([], 7)).toBeNull();
});
@@ -67,9 +67,45 @@ export function resolveXtreamVodCatalogCategoryId(
categories: readonly XtreamVodCatalogCategory[],
categoryId: string | number
): string | number | null {
const category = categories.find(
(item) => Number(item.id ?? item.category_id) === Number(categoryId)
return (
resolveXtreamVodCatalogCategoryIds(categories, categoryId)[0] ?? null
);
return category?.xtream_id ?? category?.category_id ?? null;
}
export function resolveXtreamVodCatalogCategoryIds(
categories: readonly XtreamVodCatalogCategory[],
categoryId: string | number
): (string | number)[] {
const normalizedCategoryId = Number(categoryId);
if (!Number.isFinite(normalizedCategoryId)) {
return [];
}
const candidates: (string | number)[] = [];
const seen = new Set<number>();
const addProviderId = (category: XtreamVodCatalogCategory): void => {
const providerId = category.xtream_id ?? category.category_id;
if (providerId === undefined || providerId === null) {
return;
}
const normalizedProviderId = Number(providerId);
if (
Number.isFinite(normalizedProviderId) &&
!seen.has(normalizedProviderId)
) {
seen.add(normalizedProviderId);
candidates.push(providerId);
}
};
const matches = (value: string | number | undefined): boolean =>
value !== undefined && Number(value) === normalizedCategoryId;
categories
.filter((item) => item.xtream_id !== undefined && matches(item.id))
.forEach(addProviderId);
categories
.filter((item) => matches(item.xtream_id) || matches(item.category_id))
.forEach(addProviderId);
return candidates;
}