mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
4d63f76407
commit
9885178f32
12 files changed
+773
-14
No files matched your search
@@ -55,7 +55,8 @@ export interface RawVodItem {
|
||||
category_id: string;
|
||||
is_series: 0 | 1 | '1';
|
||||
has_files: number;
|
||||
series?: RawEmbeddedEpisode[];
|
||||
/** vclub-style embedded episode numbers, e.g. ['1', '2', '3'] */
|
||||
series?: string[];
|
||||
}
|
||||
|
||||
export interface RawSeriesItem {
|
||||
@@ -95,12 +96,6 @@ export interface RawSeason {
|
||||
series: string[];
|
||||
}
|
||||
|
||||
export interface RawEmbeddedEpisode {
|
||||
id: number;
|
||||
name: string;
|
||||
cmd: string;
|
||||
}
|
||||
|
||||
export interface RawEpgProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -388,19 +383,19 @@ function generateVodItems(
|
||||
};
|
||||
|
||||
if (hasEmbeddedSeries) {
|
||||
item.series = generateEmbeddedEpisodes(id, seasonsPerSeries * episodesPerSeason);
|
||||
// Real vclub portals expose embedded episodes as an array of
|
||||
// episode numbers; playback appends the number to the item cmd.
|
||||
item.series = generateEmbeddedEpisodes(
|
||||
seasonsPerSeries * episodesPerSeason
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function generateEmbeddedEpisodes(parentId: string, count: number): RawEmbeddedEpisode[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: parseInt(parentId) * 100 + i,
|
||||
name: `Episode ${i + 1}`,
|
||||
cmd: `ffrt4://vod/${parentId}/ep${i + 1}/index.m3u8`,
|
||||
}));
|
||||
function generateEmbeddedEpisodes(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => String(i + 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -370,6 +370,36 @@ Navigation rule to preserve:
|
||||
branch.
|
||||
- See [Portal Detail Navigation](./portal-detail-navigation.md).
|
||||
|
||||
### Embedded-series snapshot refresh
|
||||
|
||||
Favorites and recently-viewed rows store the whole Stalker item as a JSON
|
||||
snapshot, so an embedded `series[]` episode list (and its playback `cmd`)
|
||||
freezes at the moment the row was written — newly released episodes would
|
||||
never appear when the item is reopened from favorites, recents, or the
|
||||
dashboard rails. `withStalkerSnapshotRefresh()`
|
||||
(`stores/features/with-stalker-snapshot-refresh.feature.ts`) fixes this with a
|
||||
snapshot-first + background re-fetch contract:
|
||||
|
||||
- The stored snapshot renders immediately; the store method
|
||||
`refreshEmbeddedSeriesSelection()` then re-fetches the item via a portal
|
||||
title search (`get_ordered_list&type=vod&search=<title>`, item matched by
|
||||
id, paginated up to 5 pages, wildcard-category retry) in the background.
|
||||
- When the episode list or `cmd` changed, the selection is patched in place.
|
||||
The guard requires both the item id **and** the active playlist id to be
|
||||
unchanged, because Stalker ids are only unique per portal.
|
||||
- Only the in-memory selection is patched — the stored snapshot row is
|
||||
deliberately left alone. Every entry path into the detail view runs this
|
||||
refresh, so a stale stored episode list is never rendered for longer than
|
||||
one background request, and writing it back would add an uncontrolled
|
||||
background writer to the whole-playlist read-modify-write that every
|
||||
favorite/recent mutation performs (lost-update risk).
|
||||
- Triggers: `stalker-collection-detail.component.ts` (favorites/recent tabs,
|
||||
global collections, dashboard handoffs) and the optional catalog-facade hook
|
||||
`refreshSnapshotSelection()` for snapshot-injected browse detail
|
||||
(`openStalkerItem` navigation state).
|
||||
- Regular `type=series` and Ministra `is_series` favorites are unaffected —
|
||||
their seasons/episodes are always fetched fresh on open.
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
Versioned playlist backups include Stalker connection metadata plus playlist-
|
||||
|
||||
+3
@@ -259,6 +259,9 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.catalog.selectItem(item as CategoryContentItem);
|
||||
// The item came from a stored snapshot, not the live list — let the
|
||||
// provider refresh stale embedded data (e.g. episode lists).
|
||||
this.catalog.refreshSnapshotSelection?.();
|
||||
clearNavigationStateKeys([
|
||||
OPEN_STALKER_ITEM_STATE_KEY,
|
||||
'openFavoriteItem',
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface PortalCatalogFacade<
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void;
|
||||
setMinRating?(value: number | null): void;
|
||||
selectItem(item: TItem): string[] | null;
|
||||
/**
|
||||
* Optional: re-fetches a selection that was injected from a stored
|
||||
* snapshot (navigation state, favorites) so stale embedded data —
|
||||
* e.g. a Stalker embedded-series episode list — is refreshed in the
|
||||
* background. No-op for providers whose selections are always fresh.
|
||||
*/
|
||||
refreshSnapshotSelection?(): void;
|
||||
getItemProgress(item: TItem): PortalCatalogItemProgress;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
withStalkerRecent,
|
||||
withStalkerSelection,
|
||||
withStalkerSeries,
|
||||
withStalkerSnapshotRefresh,
|
||||
} from './stores/features';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ export const StalkerStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withStalkerPortal(),
|
||||
withStalkerSelection(),
|
||||
withStalkerSnapshotRefresh(),
|
||||
withStalkerContent(),
|
||||
withStalkerSeries(),
|
||||
withStalkerPlayer(),
|
||||
|
||||
@@ -6,4 +6,5 @@ export * from './with-stalker-portal.feature';
|
||||
export * from './with-stalker-recent.feature';
|
||||
export * from './with-stalker-selection.feature';
|
||||
export * from './with-stalker-series.feature';
|
||||
export * from './with-stalker-snapshot-refresh.feature';
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerSnapshotRefresh } from './with-stalker-snapshot-refresh.feature';
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'portal-1',
|
||||
title: 'Demo Stalker',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const SNAPSHOT: StalkerVodSource = {
|
||||
id: '20001',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1],
|
||||
category_id: '12',
|
||||
info: { name: 'Embedded Series' },
|
||||
};
|
||||
|
||||
const TestSnapshotRefreshStore = signalStore(
|
||||
withState<{
|
||||
currentPlaylist: PlaylistMeta | undefined;
|
||||
selectedItem: StalkerVodSource | null | undefined;
|
||||
}>({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedItem: SNAPSHOT,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
},
|
||||
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
|
||||
patchState(store, { selectedItem });
|
||||
},
|
||||
})),
|
||||
withStalkerSnapshotRefresh()
|
||||
);
|
||||
|
||||
describe('withStalkerSnapshotRefresh', () => {
|
||||
let store: InstanceType<typeof TestSnapshotRefreshStore>;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
|
||||
const mockPortalRows = (rows: unknown[]) => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: { data: rows } });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = { sendIpcEvent: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestSnapshotRefreshStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: StalkerSessionService, useValue: {} },
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestSnapshotRefreshStore);
|
||||
});
|
||||
|
||||
it('patches the selection when new episodes appeared', async () => {
|
||||
mockPortalRows([
|
||||
{ id: '20001', cmd: '/media/file_20001.mpg', series: [1, 2] },
|
||||
]);
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: 'get_ordered_list',
|
||||
type: 'vod',
|
||||
category: '12',
|
||||
search: 'Embedded Series',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(store.selectedItem()?.series).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('updates a stale playback cmd even when the episode list is unchanged', async () => {
|
||||
mockPortalRows([
|
||||
{ id: '20001', cmd: '/media/file_20001_v2.mpg', series: [1] },
|
||||
]);
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(store.selectedItem()?.cmd).toBe('/media/file_20001_v2.mpg');
|
||||
});
|
||||
|
||||
it('does nothing when the portal returns identical data', async () => {
|
||||
mockPortalRows([
|
||||
{ id: '20001', cmd: '/media/file_20001.mpg', series: [1] },
|
||||
]);
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(store.selectedItem()?.series).toEqual([1]);
|
||||
});
|
||||
|
||||
it('retries with the wildcard category when the snapshot category misses', async () => {
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { data: [] } })
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: '20001',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1, 2, 3],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ category: '*' }),
|
||||
})
|
||||
);
|
||||
expect(store.selectedItem()?.series).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('searches with the wildcard category when the snapshot category is a routing artifact', async () => {
|
||||
store.setSelectedItem({ ...SNAPSHOT, category_id: 'series' });
|
||||
mockPortalRows([
|
||||
{ id: '20001', cmd: '/media/file_20001.mpg', series: [1, 2] },
|
||||
]);
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ category: '*' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the item on a later search page when page 1 is full of other matches', async () => {
|
||||
const filler = Array.from({ length: 14 }, (_, i) => ({
|
||||
id: `9${i}`,
|
||||
series: [1],
|
||||
}));
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
data: filler,
|
||||
total_items: 15,
|
||||
max_page_items: 14,
|
||||
total_pages: 2,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: '20001',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1, 2],
|
||||
},
|
||||
],
|
||||
total_items: 15,
|
||||
max_page_items: 14,
|
||||
total_pages: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ p: 2 }),
|
||||
})
|
||||
);
|
||||
expect(store.selectedItem()?.series).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('never patches after the active portal changed while the request was in flight', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(async () => {
|
||||
// Same item id, but the user switched to another portal
|
||||
store.setCurrentPlaylist({
|
||||
...PLAYLIST,
|
||||
_id: 'portal-2',
|
||||
} as PlaylistMeta);
|
||||
return {
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: '20001',
|
||||
cmd: '/media/other_portal.mpg',
|
||||
series: [1, 2, 3, 4],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(store.selectedItem()?.series).toEqual([1]);
|
||||
expect(store.selectedItem()?.cmd).toBe('/media/file_20001.mpg');
|
||||
});
|
||||
|
||||
it('never patches a selection that changed while the request was in flight', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(async () => {
|
||||
store.setSelectedItem({
|
||||
id: '99999',
|
||||
series: [1],
|
||||
info: { name: 'Другой сериал' },
|
||||
});
|
||||
return {
|
||||
js: {
|
||||
data: [
|
||||
{
|
||||
id: '20001',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(store.selectedItem()?.id).toBe('99999');
|
||||
expect(store.selectedItem()?.series).toEqual([1]);
|
||||
});
|
||||
|
||||
it('skips selections without an embedded episode list', async () => {
|
||||
store.setSelectedItem({
|
||||
id: '555',
|
||||
cmd: '/media/file_555.mpg',
|
||||
info: { name: 'Plain Movie' },
|
||||
});
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves false when the portal request fails', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(new Error('portal down'));
|
||||
|
||||
await expect(store.refreshEmbeddedSeriesSelection()).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(store.selectedItem()?.series).toEqual([1]);
|
||||
});
|
||||
|
||||
});
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
type,
|
||||
withMethods,
|
||||
} from '@ngrx/signals';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
StalkerPortalActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
import { StalkerPortalStoreContract } from '../stalker-store.contracts';
|
||||
import { executeStalkerRequest, StalkerRequestDeps } from '../utils';
|
||||
|
||||
interface StalkerOrderedListResponse {
|
||||
js?: {
|
||||
data?: StalkerVodSource[];
|
||||
total_items?: number | string;
|
||||
max_page_items?: number | string;
|
||||
total_pages?: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Upper bound on paginated search pages scanned per category attempt. */
|
||||
const MAX_SEARCH_PAGES = 5;
|
||||
|
||||
/**
|
||||
* Favorites/recently-viewed rows store embedded-series ("vclub") items as
|
||||
* full JSON snapshots, so their episode list (`series[]`) and playback
|
||||
* `cmd` freeze at the moment the snapshot was written. This feature
|
||||
* re-fetches the item from the portal in the background and patches the
|
||||
* live selection, so newly released episodes appear when a series is
|
||||
* opened from favorites, recents, or the dashboard.
|
||||
*/
|
||||
export function withStalkerSnapshotRefresh() {
|
||||
const logger = createLogger('withStalkerSnapshotRefresh');
|
||||
|
||||
return signalStoreFeature(
|
||||
{
|
||||
state: type<{
|
||||
selectedItem: StalkerVodSource | null | undefined;
|
||||
}>(),
|
||||
},
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerPortalStoreContract;
|
||||
const requestDeps: StalkerRequestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
};
|
||||
|
||||
const findFreshRow = async (
|
||||
playlist: PlaylistMeta,
|
||||
snapshotId: string,
|
||||
title: string,
|
||||
category: string
|
||||
): Promise<StalkerVodSource | null> => {
|
||||
// Short/reused titles can push the item past page 1, so
|
||||
// follow the portal's reported page count (bounded).
|
||||
let totalPages = 1;
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
const response =
|
||||
await executeStalkerRequest<StalkerOrderedListResponse>(
|
||||
requestDeps,
|
||||
playlist,
|
||||
{
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
sortby: 'added',
|
||||
genre: '0',
|
||||
category,
|
||||
search: title,
|
||||
p: page,
|
||||
}
|
||||
);
|
||||
|
||||
const rows = Array.isArray(response?.js?.data)
|
||||
? response.js.data
|
||||
: [];
|
||||
const match = rows.find((row) => {
|
||||
const rowId = normalizeStalkerEntityId(
|
||||
row?.id ?? row?.stream_id
|
||||
);
|
||||
return rowId !== '' && rowId === snapshotId;
|
||||
});
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
totalPages = Math.min(
|
||||
resolveTotalPages(response),
|
||||
MAX_SEARCH_PAGES
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Re-fetches the currently selected embedded-series item
|
||||
* via a portal title search and patches the selection
|
||||
* when the episode list or cmd changed. Safe to fire and
|
||||
* forget; resolves to true when the selection changed.
|
||||
*/
|
||||
async refreshEmbeddedSeriesSelection(): Promise<boolean> {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
const snapshot = store.selectedItem();
|
||||
const snapshotId = normalizeStalkerEntityId(
|
||||
snapshot?.id ?? snapshot?.stream_id
|
||||
);
|
||||
const title = resolveSnapshotTitle(snapshot);
|
||||
|
||||
if (
|
||||
!playlist?._id ||
|
||||
!snapshot ||
|
||||
!snapshotId ||
|
||||
!hasEmbeddedSeries(snapshot) ||
|
||||
!title
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fresh: StalkerVodSource | null = null;
|
||||
try {
|
||||
const category = toSearchCategory(
|
||||
snapshot.category_id
|
||||
);
|
||||
fresh = await findFreshRow(
|
||||
playlist,
|
||||
snapshotId,
|
||||
title,
|
||||
category
|
||||
);
|
||||
if (!fresh && category !== '*') {
|
||||
fresh = await findFreshRow(
|
||||
playlist,
|
||||
snapshotId,
|
||||
title,
|
||||
'*'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Embedded-series snapshot refresh failed',
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fresh || !hasEmbeddedSeries(fresh)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The user may have navigated on while the portal
|
||||
// request was in flight — never patch a selection
|
||||
// that no longer belongs to this snapshot. Stalker
|
||||
// item IDs are only unique per portal, so the active
|
||||
// playlist must still be the one that was queried.
|
||||
const current = store.selectedItem();
|
||||
if (
|
||||
storeContext.currentPlaylist()?._id !==
|
||||
playlist._id ||
|
||||
!current ||
|
||||
normalizeStalkerEntityId(
|
||||
current.id ?? current.stream_id
|
||||
) !== snapshotId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCmd =
|
||||
typeof fresh.cmd === 'string' &&
|
||||
fresh.cmd.trim() !== ''
|
||||
? fresh.cmd
|
||||
: current.cmd;
|
||||
if (
|
||||
nextCmd === current.cmd &&
|
||||
sameEpisodeList(current.series, fresh.series)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only the in-memory selection is patched. The stored
|
||||
// snapshot is deliberately left untouched: every entry
|
||||
// path into the detail view runs this refresh, so a
|
||||
// stale stored episode list is never rendered for
|
||||
// longer than one background request, and writing it
|
||||
// back would add an uncontrolled background writer to
|
||||
// the whole-playlist read-modify-write used by every
|
||||
// favorite/recent mutation.
|
||||
patchState(store, {
|
||||
selectedItem: {
|
||||
...current,
|
||||
series: fresh.series,
|
||||
cmd: nextCmd,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasEmbeddedSeries(
|
||||
item: StalkerVodSource | null | undefined
|
||||
): item is StalkerVodSource & { series: unknown[] } {
|
||||
return Array.isArray(item?.series) && item.series.length > 0;
|
||||
}
|
||||
|
||||
function resolveSnapshotTitle(
|
||||
item: StalkerVodSource | null | undefined
|
||||
): string {
|
||||
const title = String(
|
||||
item?.info?.name ?? item?.name ?? item?.title ?? item?.o_name ?? ''
|
||||
).trim();
|
||||
return title === 'Unknown' ? '' : title;
|
||||
}
|
||||
|
||||
function resolveTotalPages(response: StalkerOrderedListResponse): number {
|
||||
const js = response?.js;
|
||||
const declared = Number(js?.total_pages);
|
||||
if (Number.isFinite(declared) && declared > 0) {
|
||||
return Math.floor(declared);
|
||||
}
|
||||
|
||||
const totalItems = Number(js?.total_items);
|
||||
const pageSize = Number(js?.max_page_items);
|
||||
if (
|
||||
Number.isFinite(totalItems) &&
|
||||
Number.isFinite(pageSize) &&
|
||||
pageSize > 0
|
||||
) {
|
||||
return Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Portals expect a real category id; snapshot ids like 'vod'/'series' are routing artifacts. */
|
||||
function toSearchCategory(categoryId: unknown): string {
|
||||
const raw = String(categoryId ?? '').trim();
|
||||
return /^\d+$/.test(raw) ? raw : '*';
|
||||
}
|
||||
|
||||
function sameEpisodeList(
|
||||
a: unknown[] | undefined,
|
||||
b: unknown[] | undefined
|
||||
): boolean {
|
||||
const left = Array.isArray(a) ? a : [];
|
||||
const right = Array.isArray(b) ? b : [];
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,10 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
return null;
|
||||
}
|
||||
|
||||
refreshSnapshotSelection(): void {
|
||||
void this.stalkerStore.refreshEmbeddedSeriesSelection();
|
||||
}
|
||||
|
||||
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
|
||||
const numericId = Number(item.id);
|
||||
if (Number.isNaN(numericId)) {
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
setSelectedContentType: jest.Mock;
|
||||
setSelectedCategory: jest.Mock;
|
||||
setSelectedItem: jest.Mock;
|
||||
refreshEmbeddedSeriesSelection: jest.Mock;
|
||||
addToFavorites: jest.Mock;
|
||||
removeFromFavorites: jest.Mock;
|
||||
createLinkToPlayVod: jest.Mock;
|
||||
@@ -121,6 +122,7 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
setSelectedItem: jest.fn((value: unknown) => {
|
||||
selectedItem.set(value);
|
||||
}),
|
||||
refreshEmbeddedSeriesSelection: jest.fn(async () => false),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
createLinkToPlayVod: jest.fn(),
|
||||
@@ -311,6 +313,42 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
expect(
|
||||
fixture.componentInstance.inlineDetail().seriesItem?.series
|
||||
).toEqual([1, 2]);
|
||||
// The snapshot renders immediately, but a background portal
|
||||
// re-fetch must be triggered so new episodes can appear.
|
||||
expect(
|
||||
stalkerStore.refreshEmbeddedSeriesSelection
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
buildCollectionItem({
|
||||
contentType: 'series',
|
||||
categoryId: 'series',
|
||||
stalkerItem: {
|
||||
id: '20001',
|
||||
title: 'Embedded Series',
|
||||
category_id: 'series',
|
||||
cmd: '/media/file_20001.mpg',
|
||||
series: [1],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await settleDetail(fixture);
|
||||
expect(
|
||||
fixture.componentInstance.inlineDetail().seriesItem?.series
|
||||
).toEqual([1]);
|
||||
|
||||
// Simulate the store-side refresh completing with a fresh portal row.
|
||||
const current = selectedItem() as Record<string, unknown>;
|
||||
selectedItem.set({ ...current, series: [1, 2] });
|
||||
await settleDetail(fixture);
|
||||
|
||||
expect(
|
||||
fixture.componentInstance.inlineDetail().seriesItem?.series
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => {
|
||||
|
||||
@@ -326,6 +326,14 @@ export class StalkerCollectionDetailComponent {
|
||||
this.stalkerStore.setSelectedItem(itemDetails);
|
||||
this.itemDetails.set(itemDetails);
|
||||
|
||||
if (detailMode.hasEmbeddedSeries) {
|
||||
// Embedded-series snapshots freeze the episode list at
|
||||
// favorite/recent time; re-fetch in the background so new
|
||||
// episodes appear. The store patch flows back into
|
||||
// itemDetails via the selected-item sync effect above.
|
||||
void this.stalkerStore.refreshEmbeddedSeriesSelection();
|
||||
}
|
||||
|
||||
if (
|
||||
detailMode.selectedContentType === 'vod' &&
|
||||
!detailMode.hasEmbeddedSeries &&
|
||||
|
||||
Reference in new issue
Block a user