fix(stalker): refresh stale embedded-series snapshots from favorites and dashboard (#1253)

Favorites and recently-viewed rows store Stalker items as full JSON
snapshots, so a vclub-style embedded series[] episode list froze at the
moment the row was written: a series favorited when only episode 1 was out
kept showing one episode forever when opened from favorites, recents,
Continue Watching, or any dashboard rail.

New withStalkerSnapshotRefresh() store feature renders the stored snapshot
immediately and re-fetches the item from the portal in the background via a
title search (get_ordered_list&type=vod&search=..., matched by id, paginated
up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into
the active selection. The patch is guarded on both the item id and the active
playlist id, since Stalker ids are only unique per portal.

Only the in-memory selection is patched — the stored snapshot row is
deliberately left alone, because every entry path into the detail view runs
this refresh and writing it back would add an uncontrolled background writer
to the whole-playlist read-modify-write that every favorite/recent mutation
performs.

Also fixes the stalker-mock-server embedded-series scenario, which generated
series[] as objects the app's vclub adapters filter out instead of the
episode-number arrays real portals send.

Regular type=series and Ministra is_series items are unaffected; Xtream is
unaffected (get_series_info is never cached).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-25 18:03:33 +02:00
1 parent 4d63f76407
commit 9885178f32
12 files changed
+773 -14

No files matched your search

+112
View File
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
@@ -565,6 +568,115 @@ test('@stalker mock server returns radio categories and stations', async ({
);
});
test('@stalker favorites — embedded-series favorite refreshes newly released episodes', async ({
page,
request,
}) => {
// Find an embedded-series VOD item in the mock catalog first
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
await addStalkerPortal(page, {
name: 'Embedded Series Portal',
mac: EMBEDDED_SERIES_MAC,
});
// Open the embedded-series item from its category and favorite it —
// this persists a snapshot with the current episode list
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Add to favorites' }).click();
// Wait for the async favorite persistence before navigating away
await expect(
page.getByRole('button', { name: 'Remove from favorites' })
).toBeVisible({ timeout: 10_000 });
// From now on the portal has "released" one more episode: extend
// series[] in every search response (the background snapshot refresh
// re-fetches the item via a title search)
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
if (!originalUrl.searchParams.get('search')) {
await route.fallback();
return;
}
const mockUrl = new URL(BACKEND_PROXY);
const targetId = originalUrl.searchParams.get('targetId');
const providerUrl = targetId
? Buffer.from(targetId, 'base64url').toString()
: originalUrl.searchParams.get('url');
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = await response.json();
const rows: { series?: string[] }[] =
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [
...row.series,
String(row.series.length + 1),
];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
});
// Open the item from the Favorites view: the stored snapshot renders
// first, then the background refresh patches in the new episode
const favoritesUrl = new URL(page.url());
favoritesUrl.pathname = favoritesUrl.pathname.replace(
/\/vod.*$/,
'/favorites'
);
favoritesUrl.search = '';
await page.goto(favoritesUrl.toString());
const favoriteCard = page.getByText(embeddedItem.name).first();
await expect(favoriteCard).toBeVisible({ timeout: 10_000 });
await favoriteCard.click();
// Snapshot episodes are visible immediately…
await expect(
page.getByRole('heading', { name: '1. Episode 1', exact: true })
).toBeVisible({ timeout: 10_000 });
// …and the newly released episode appears after the background refresh
await expect(
page.getByRole('heading', {
name: `${episodeCount + 1}. Episode ${episodeCount + 1}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
});
test('@stalker series — seasons load for a series item', async ({
request,
}) => {