fix(xtream): preserve content identity in PWA collections (#1870)

* fix(xtream): invalidate detail initialization on close

* fix(xtream): preserve typed PWA collection identity

* fix(xtream): complete typed live collection actions

* test(xtream): cover PWA collection identity through reloads

* fix(xtream): keep typed collection actions local

* fix(xtream): tolerate quota limits during collection migration

* fix(xtream): prioritize requested collection saves

* fix(xtream): align collection identity with current playback contracts

* test(xtream): confirm playback before collection history assertions
This commit is contained in:
4gray authored and GitHub committed 2026-10-10 12:27:03 +02:00
1 parent 0060330b5e
commit 98784815b1
31 files changed
+2349 -551

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: xtream
---
Xtream favorites and recently viewed in the web app keep movies, live channels, and series separate when providers reuse IDs, including after reloading. Collection changes no longer wait for unrelated catalog loads. Saved items remain readable and removable when storage is full, and new saves take priority over cache updates. Confirmed live playback also appears in recently viewed.
@@ -0,0 +1,368 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
type ContentType = 'movie' | 'series' | 'live';
type CatalogItem = {
category_id: string | number;
stream_id?: number;
series_id?: number;
};
type Detail = {
info: Record<string, unknown>;
movie_data: Record<string, unknown>;
episodes?: Record<string, Array<Record<string, unknown>>>;
};
export const collisionItems = [
{ type: 'movie', label: 'Movies', title: 'Collision Movie' },
{ type: 'series', label: 'Series', title: 'Collision Series' },
{ type: 'live', label: 'Live TV', title: 'Collision Live' },
] as const;
export type CollisionItem = (typeof collisionItems)[number];
/** Keep mock shapes, with colliding IDs and native-playable VOD extensions. */
export async function interceptCollidingXtreamItems(
page: Page,
request: APIRequestContext,
mockServer: string
): Promise<Record<ContentType, string>> {
async function fetchFixture<T>(
action: string,
parameters: Record<string, string> = {}
): Promise<T> {
const url = new URL(`${mockServer}/player_api.php`);
url.search = new URLSearchParams({
username: 'minimal',
password: 'minimal',
action,
...parameters,
}).toString();
const response = await request.get(url.toString());
expect(response.ok()).toBeTruthy();
return response.json();
}
const [[movie], [series], [live]] = await Promise.all([
fetchFixture<CatalogItem[]>('get_vod_streams'),
fetchFixture<CatalogItem[]>('get_series'),
fetchFixture<CatalogItem[]>('get_live_streams'),
]);
const [movieDetails, seriesDetails] = await Promise.all([
fetchFixture<Detail>('get_vod_info', {
vod_id: String(movie.stream_id),
}),
fetchFixture<Detail>('get_series_info', {
series_id: String(series.series_id),
}),
]);
for (const episodes of Object.values(seriesDetails.episodes ?? {})) {
for (const episode of episodes) {
episode['container_extension'] = 'mp4';
}
}
const payloads: Record<string, unknown> = {
get_vod_streams: [
{
...movie,
stream_id: 103,
name: 'Collision Movie',
container_extension: 'mp4',
},
],
get_series: [{ ...series, series_id: 103, name: 'Collision Series' }],
get_live_streams: [{ ...live, stream_id: 103, name: 'Collision Live' }],
get_vod_info: {
...movieDetails,
info: { ...movieDetails.info, name: 'Collision Movie' },
movie_data: {
...movieDetails.movie_data,
stream_id: 103,
name: 'Collision Movie',
container_extension: 'mp4',
},
},
get_series_info: {
...seriesDetails,
info: { ...seriesDetails.info, name: 'Collision Series' },
},
get_short_epg: { epg_listings: [] },
get_simple_data_table: { epg_listings: [] },
};
// Fixtures may include remote artwork. Keep this regression fully local.
await page.route('https://**', (route) => route.abort());
await page.route('**/localhost:3000/xtream**', async (route) => {
const action = new URL(route.request().url()).searchParams.get(
'action'
);
if (!action || !(action in payloads)) {
await route.fallback();
return;
}
await route.fulfill({ json: { action, payload: payloads[action] } });
});
return {
movie: String(movie.category_id),
series: String(series.category_id),
live: String(live.category_id),
};
}
/** Fail initial attempts, then serve only repository-owned synthetic media. */
export async function routeCollisionMedia(
page: Page,
mockServer: string
): Promise<() => void> {
let playable = false;
const webm = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
const transportStream = readFileSync(
join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts')
);
await page.route(
(url) =>
url.origin === mockServer &&
/^\/(live|movie|series)\//.test(url.pathname),
async (route) => {
if (!playable) {
await route.fulfill({
status: 503,
body: 'Fixture unavailable',
});
return;
}
const path = new URL(route.request().url()).pathname;
if (path.endsWith('.m3u8')) {
await route.fulfill({
contentType: 'application/vnd.apple.mpegurl',
body: [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXTINF:6,',
`${mockServer}/live/collection-fixture.ts`,
'#EXT-X-ENDLIST',
'',
].join('\n'),
});
return;
}
if (path.endsWith('.ts')) {
await route.fulfill({
contentType: 'video/mp2t',
body: transportStream,
});
return;
}
// Native media reads use ranges, even when the clip is small.
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = webm.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, webm.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2]
? Math.min(Number(range[2]), last)
: last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
'content-type': 'video/webm',
...(range
? {
'content-range': `bytes ${start}-${end}/${webm.length}`,
}
: {}),
},
body: webm.subarray(start, end + 1),
});
}
);
return () => {
playable = true;
};
}
export async function openCollisionCatalogItem(
page: Page,
item: CollisionItem,
categoryId: string
): Promise<void> {
await page.getByRole('link', { name: item.label, exact: true }).click();
await page
.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
)
.click();
if (item.type !== 'live') {
await page
.locator('.category-content-layout mat-card')
.filter({ hasText: item.title })
.click();
await expect(
page.getByRole('heading', { name: item.title, exact: true })
).toBeVisible();
}
}
export function collisionCollectionItem(page: Page, item: CollisionItem) {
return item.type === 'live'
? page.locator('app-channel-list-item').filter({
has: page.locator('.channel-name', { hasText: item.title }),
})
: page.locator('app-content-card').filter({
has: page.getByRole('heading', { name: item.title, exact: true }),
});
}
export async function addCollisionFavorite(
page: Page,
item: CollisionItem,
categoryId: string
): Promise<void> {
await openCollisionCatalogItem(page, item, categoryId);
if (item.type === 'live') {
const channel = collisionCollectionItem(page, item);
const favorite = channel.locator('.favorite-button').first();
// Other content sharing this ID must not preselect the channel.
await expect(favorite.locator('mat-icon')).toHaveText('star_outline');
await channel.hover();
await favorite.click();
await expect(favorite.locator('mat-icon')).toHaveText('star');
return;
}
const addButton = page.getByRole('button', { name: /add to favorites/i });
await expect(addButton).toBeVisible();
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i })
).toBeVisible();
}
export async function playCollisionItem(
page: Page,
item: CollisionItem,
categoryId: string,
confirmPlayback = true
): Promise<void> {
await openCollisionCatalogItem(page, item, categoryId);
if (item.type === 'live') {
await collisionCollectionItem(page, item).click();
} else {
await page
.getByTestId(
item.type === 'movie'
? 'vod-primary-action'
: 'series-quick-start'
)
.click();
}
await expect(page.locator('app-web-player-view')).toBeVisible();
if (confirmPlayback) {
const video = page.locator('app-web-player-view video').first();
await expect
.poll(
() =>
video.evaluate(
(media: HTMLVideoElement) =>
!media.paused && media.readyState >= 2
),
{ timeout: 15_000 }
)
.toBe(true);
const start = await video.evaluate(
(media: HTMLVideoElement) => media.currentTime
);
// Observe real decoder progress beyond the two-second history gate.
await expect
.poll(
() =>
video.evaluate(
(media: HTMLVideoElement, initial) =>
media.paused ? 0 : media.currentTime - initial,
start
),
{ timeout: 15_000 }
)
.toBeGreaterThanOrEqual(2.5);
}
}
export async function removeCollisionFavorite(
page: Page,
item: CollisionItem,
remainingTypeCount: number
): Promise<void> {
if (remainingTypeCount > 1) {
await selectCollectionType(page, item);
}
const row = collisionCollectionItem(page, item);
await row.hover();
await row
.locator(item.type === 'live' ? '.favorite-button' : '.remove-button')
.first()
.click();
}
export async function selectCollectionType(
page: Page,
item: CollisionItem
): Promise<void> {
await page.getByRole('radio', { name: item.label, exact: true }).click();
}
export async function expectCollisionCollection(
page: Page,
items: readonly CollisionItem[]
): Promise<void> {
// Full navigation/reload passes the startup splash before mounting the list.
await expect(page.locator('app-unified-collection-page')).toBeAttached({
timeout: 15_000,
});
const toggles = page.getByRole('radio', {
name: /^(Movies|Series|Live TV)$/,
});
await expect(toggles).toHaveCount(items.length > 1 ? items.length : 0);
for (const item of items) {
if (items.length > 1) {
await selectCollectionType(page, item);
}
await expect(collisionCollectionItem(page, item)).toBeVisible();
}
for (const removed of collisionItems.filter(
(item) => !items.includes(item)
)) {
await expect(collisionCollectionItem(page, removed)).toHaveCount(0);
await expect(toggles.filter({ hasText: removed.label })).toHaveCount(0);
}
if (items.length === 0) {
await expect(page.locator('app-empty-state')).toBeVisible();
}
}
export async function expectCollectionDetailRoundTrip(
page: Page,
item: CollisionItem
): Promise<void> {
await selectCollectionType(page, item);
const collectionUrl = page.url();
await collisionCollectionItem(page, item).click();
await expect(
page.getByRole('heading', { name: item.title, exact: true })
).toBeVisible();
await expect(page).toHaveURL(collectionUrl);
await page.locator('[data-test-id="workspace-header-back"]').click();
await expect(page).toHaveURL(collectionUrl);
await expect(collisionCollectionItem(page, item)).toBeVisible();
}
+115
View File
@@ -15,7 +15,20 @@ import {
DEFAULT_USERNAME,
interceptXtreamRequests,
MOCK_SERVER,
selectWebPlayer,
} from './xtream-series-playback.fixture';
import {
addCollisionFavorite,
collisionCollectionItem,
collisionItems,
expectCollectionDetailRoundTrip,
expectCollisionCollection,
interceptCollidingXtreamItems,
playCollisionItem,
removeCollisionFavorite,
routeCollisionMedia,
selectCollectionType,
} from './xtream-collection-helpers';
/**
* Xtream Codes E2E Tests
@@ -413,6 +426,108 @@ test('@xtream add portal and see it in the playlist list', async ({ page }) => {
).toBeVisible();
});
test('@xtream @collections PWA favorites keep colliding movie, series and live IDs independent after reload and removal', async ({
page,
request,
}) => {
test.setTimeout(90_000);
const categories = await interceptCollidingXtreamItems(
page,
request,
MOCK_SERVER
);
await addXtreamPortal(page, {
name: 'Collision Favorites Portal',
username: 'minimal',
password: 'minimal',
});
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
for (const item of collisionItems) {
await addCollisionFavorite(page, item, categories[item.type]);
}
await page.goto(`${playlistPath}/favorites`);
await expectCollisionCollection(page, collisionItems);
await page.reload();
await expectCollisionCollection(page, collisionItems);
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
for (let index = 0; index < collisionItems.length; index++) {
const item = collisionItems[index];
await removeCollisionFavorite(
page,
item,
collisionItems.length - index
);
await expect(collisionCollectionItem(page, item)).toHaveCount(0);
await page.reload();
await expectCollisionCollection(page, collisionItems.slice(index + 1));
}
});
test('@xtream @collections PWA recent items require confirmed playback and retain colliding content IDs', async ({
page,
request,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'Synthetic live media uses Chromium codecs'
);
test.setTimeout(90_000);
const categories = await interceptCollidingXtreamItems(
page,
request,
MOCK_SERVER
);
const allowPlayback = await routeCollisionMedia(page, MOCK_SERVER);
await selectWebPlayer(page, 'HTML5 video player');
await page.goto('/');
await addXtreamPortal(page, {
name: 'Collision Recent Portal',
username: 'minimal',
password: 'minimal',
});
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
for (const item of collisionItems) {
const failedMedia = page.waitForResponse(
(response) =>
response.url().startsWith(`${MOCK_SERVER}/${item.type}/`) &&
response.status() === 503
);
await playCollisionItem(page, item, categories[item.type], false);
await failedMedia;
await page.goto(`${playlistPath}/recent`);
await expectCollisionCollection(page, []);
}
allowPlayback();
for (let index = 0; index < collisionItems.length; index++) {
const item = collisionItems[index];
await playCollisionItem(page, item, categories[item.type]);
await page.goto(`${playlistPath}/recent`);
await expectCollisionCollection(
page,
collisionItems.slice(0, index + 1)
);
}
await page.reload();
await expectCollisionCollection(page, collisionItems);
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
await selectCollectionType(page, collisionItems[0]);
const movie = collisionCollectionItem(page, collisionItems[0]);
await movie.hover();
await movie.locator('.remove-button').click();
await expect(movie).toHaveCount(0);
await page.reload();
await expectCollisionCollection(page, collisionItems.slice(1));
});
test('@xtream playlist details edit is retained in the PWA browser context', async ({
page,
}) => {
@@ -37,6 +37,58 @@ See [Nx Workspace Boundaries](./nx-workspace-boundaries.md),
[Portal Detail Navigation](./portal-detail-navigation.md), and
[VOD Multi-Source](./vod-multi-source.md).
## PWA Collection Persistence
Collection operations carry `{ id, type }`: `id` remains the SQLite row ID
on Electron and the provider ID on PWA. Electron unwraps the row ID at its
existing database boundary. Typed lookups, favorite toggles, recent updates,
removals, backdrops and backup/restore retain the content type. A favorite toggle
reads persisted status for its exact typed target instead of borrowing the last
detail's favorite state. Live history uses the same confirmed-playback gate as
VOD and series: inline playback confirms its playlist-scoped session key, while
external playback confirms its URL. The initiating playlist and typed identity
remain captured across delayed confirmation. Selection without playback or a
resolved URL does not update history. See the
[playback-history contract](./embedded-inline-playback.md#recently-viewed-confirmation).
PWA keeps the existing localStorage keys. `xtream-favorites` contains ordered
`type:id` keys (for example `movie:42`); `xtream-recent-items` uses the same
keys in each entry's `id`, retaining `viewedAt` and backdrop metadata.
`xtream-collection-items` stores snapshots under those typed keys while each
snapshot's `id` and `xtream_id` remain numeric provider IDs. Hydration fetches
only the required content types; snapshots support collection reads offline.
Typed add/remove/status operations use synchronous local migration and never
wait for unrelated catalog hydration. `PwaCollectionStorage` owns that local
migration and snapshot persistence; the data source owns provider hydration for
collection loading and legacy numeric callers that still need identity evidence.
Legacy numeric and numeric-string references migrate lazily. A valid legacy
snapshot supplies its saved content type; otherwise all three catalogs must
be loaded successfully and exactly one type must match (repeated rows within
that same type are one identity). A partial catalog,
failed hydration, missing item or colliding ID never chooses a type. Such
references remain stored but hidden until identity can be resolved or the
user explicitly adds a typed item. Legacy numeric snapshot entries remain as
migration evidence for the other collection. Already-overwritten snapshots
cannot recover information lost by an older version. Ambiguous references
cannot be included in typed backup exports; the original browser storage is
retained. Migration merges into current storage after hydration, preserving
concurrent additions/removals, and duplicate recent references keep the newest
timestamp.
Migration writes and snapshot-cache writes are best effort when browser storage
is full. Reads still resolve the retained legacy evidence in memory. Typed
status and mutations match original numeric references through that evidence;
removal filters the original stored keys without first expanding the remaining
keys. Typed favorite-status checks never write storage. Additions save the
requested reference before attempting optional migration or snapshot copies, so
those copies cannot consume space needed by the requested save. Recent aliases
are deduplicated in memory before the 50-item limit, keeping the newest timestamp
and its original saved key until optional migration. Favorite/recent
writes requested by the user still propagate storage failures. A new reference
that fits can be saved without its optional snapshot and hydrated on a later
collection load.
## Connection Input
Xtream server URLs are normalized through
@@ -652,7 +652,7 @@ describe('UnifiedFavoritesDataService', () => {
'movie'
);
expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith(
101,
{ id: 101, type: 'movie' },
'xtream-1',
'movie.png'
);
@@ -678,7 +678,7 @@ describe('UnifiedFavoritesDataService', () => {
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith(
1010,
{ id: 1010, type: 'movie' },
'xtream-1'
);
expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled();
@@ -104,7 +104,7 @@ export class UnifiedFavoritesDataService {
return;
}
await this.xtreamDataSource.removeFavorite(
item.contentId,
{ id: item.contentId, type: item.contentType },
item.playlistId
);
break;
@@ -149,7 +149,7 @@ export class UnifiedFavoritesDataService {
const electron = this.electronActivityBridge;
if (!electron) {
await this.xtreamDataSource.addFavorite(
contentId,
{ id: contentId, type: item.contentType },
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
@@ -365,7 +365,7 @@ export class UnifiedFavoritesDataService {
)
.map((item) =>
this.xtreamDataSource.removeFavorite(
item.contentId,
{ id: item.contentId, type: item.contentType },
item.playlistId
)
)
@@ -849,8 +849,7 @@ export class UnifiedFavoritesDataService {
private getStalkerFavoriteId(
favorite:
| Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'>
| StalkerPortalItem
Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'> | StalkerPortalItem
): string {
if ('uid' in favorite) {
return String(
@@ -305,7 +305,7 @@ describe('UnifiedRecentDataService', () => {
'movie'
);
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
290,
{ id: 290, type: 'movie' },
'xtream-1'
);
expect(dbService.addRecentItem).not.toHaveBeenCalled();
@@ -335,7 +335,7 @@ describe('UnifiedRecentDataService', () => {
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeRecentItem).toHaveBeenCalledWith(
444,
{ id: 444, type: 'movie' },
'xtream-1'
);
expect(dbService.removeRecentItem).not.toHaveBeenCalled();
@@ -74,7 +74,7 @@ export class UnifiedRecentDataService {
}
await this.xtreamDataSource.removeRecentItem(
item.contentId,
{ id: item.contentId, type: item.contentType },
item.playlistId
);
return;
@@ -113,7 +113,11 @@ export class UnifiedRecentDataService {
return;
}
const xtreamBatch: { contentId: number; playlistId: string }[] = [];
const xtreamBatch: {
contentId: number;
playlistId: string;
contentType: UnifiedCollectionItem['contentType'];
}[] = [];
const groupedByPlaylist = new Map<string, (string | number)[]>();
for (const item of items) {
@@ -122,6 +126,7 @@ export class UnifiedRecentDataService {
xtreamBatch.push({
contentId: item.contentId,
playlistId: item.playlistId,
contentType: item.contentType,
});
}
continue;
@@ -145,12 +150,19 @@ export class UnifiedRecentDataService {
if (xtreamBatch.length > 0) {
if (this.hasPortalActivityStorage) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
tasks.push(
this.dbService.removeRecentItemsBatch(
xtreamBatch.map(({ contentId, playlistId }) => ({
contentId,
playlistId,
}))
)
);
} else {
tasks.push(
...xtreamBatch.map((item) =>
this.xtreamDataSource.removeRecentItem(
item.contentId,
{ id: item.contentId, type: item.contentType },
item.playlistId
)
)
@@ -276,7 +288,7 @@ export class UnifiedRecentDataService {
);
} else {
await this.xtreamDataSource.addRecentItem(
contentId,
{ id: contentId, type: item.contentType },
item.playlistId
);
}
@@ -26,6 +26,8 @@ import {
import {
DbCategoryType,
IXtreamDataSource,
XtreamCollectionRef,
xtreamCollectionId,
mapCategoryTypeToDbType,
ProgressCallback,
XtreamOperationOptions,
@@ -471,19 +473,35 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
}
async addFavorite(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
backdropUrl?: string
): Promise<void> {
await this.dbService.addToFavorites(contentId, playlistId, backdropUrl);
await this.dbService.addToFavorites(
xtreamCollectionId(contentId),
playlistId,
backdropUrl
);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
await this.dbService.removeFromFavorites(contentId, playlistId);
async removeFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void> {
await this.dbService.removeFromFavorites(
xtreamCollectionId(contentId),
playlistId
);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
return this.dbService.isFavorite(contentId, playlistId);
async isFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<boolean> {
return this.dbService.isFavorite(
xtreamCollectionId(contentId),
playlistId
);
}
// =========================================================================
@@ -495,18 +513,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
}
async addRecentItem(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
backdropUrl?: string
): Promise<void> {
await this.dbService.addRecentItem(contentId, playlistId, backdropUrl);
await this.dbService.addRecentItem(
xtreamCollectionId(contentId),
playlistId,
backdropUrl
);
}
async removeRecentItem(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void> {
await this.dbService.removeRecentItem(contentId, playlistId);
await this.dbService.removeRecentItem(
xtreamCollectionId(contentId),
playlistId
);
}
async clearRecentItems(playlistId: string): Promise<void> {
@@ -530,12 +555,15 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
}
async setContentMetadataIfMissing(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
patch: ContentMetadataPatch
): Promise<void> {
void playlistId;
await this.dbService.setContentMetadataIfMissing(contentId, patch);
await this.dbService.setContentMetadataIfMissing(
xtreamCollectionId(contentId),
patch
);
}
// =========================================================================
@@ -21,6 +21,55 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
});
describe('favorites and recently viewed', () => {
it.each(['live', 'movie', 'series'] as const)(
'forwards only the SQLite row id for typed %s references',
async (type) => {
const ref = { id: 202, type };
await harness.dataSource.addFavorite(
ref,
playlistId,
'backdrop.jpg'
);
await harness.dataSource.removeFavorite(ref, playlistId);
await harness.dataSource.isFavorite(ref, playlistId);
await harness.dataSource.addRecentItem(
ref,
playlistId,
'backdrop.jpg'
);
await harness.dataSource.removeRecentItem(ref, playlistId);
await harness.dataSource.setContentMetadataIfMissing(
ref,
playlistId,
{ backdropUrl: 'backdrop.jpg' }
);
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
202,
playlistId,
'backdrop.jpg'
);
expect(
harness.dbService.removeFromFavorites
).toHaveBeenCalledWith(202, playlistId);
expect(harness.dbService.isFavorite).toHaveBeenCalledWith(
202,
playlistId
);
expect(harness.dbService.addRecentItem).toHaveBeenCalledWith(
202,
playlistId,
'backdrop.jpg'
);
expect(harness.dbService.removeRecentItem).toHaveBeenCalledWith(
202,
playlistId
);
expect(
harness.dbService.setContentMetadataIfMissing
).toHaveBeenCalledWith(202, { backdropUrl: 'backdrop.jpg' });
}
);
it('delegates favorites operations to the DB', async () => {
harness.dbService.isFavorite.mockResolvedValue(true);
@@ -0,0 +1,159 @@
import {
TypedXtreamCollectionRef,
XTREAM_CONTENT_TYPES,
XtreamContentItem,
} from './xtream-data-source.interface';
export type CollectionKey = number | string;
export interface StoredRecentItem {
readonly id: CollectionKey;
readonly viewedAt: string;
readonly backdropUrl?: string;
}
export function typedCollectionRef(
value: unknown
): TypedXtreamCollectionRef | null {
if (typeof value === 'string') {
const [type, id, extra] = value.split(':');
return extra === undefined && id !== undefined
? typedCollectionRef({ type, id: Number(id) })
: null;
}
if (!value || typeof value !== 'object') return null;
const ref = value as Partial<TypedXtreamCollectionRef>;
return ref.type !== undefined &&
typeof ref.id === 'number' &&
XTREAM_CONTENT_TYPES.includes(ref.type) &&
Number.isSafeInteger(ref.id) &&
ref.id > 0
? { type: ref.type, id: ref.id }
: null;
}
export function collectionKey(value: unknown): CollectionKey | null {
const typed = typedCollectionRef(value);
if (typed) return `${typed.type}:${typed.id}`;
if (typeof value !== 'number' && typeof value !== 'string') return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function itemCollectionKey(
item: XtreamContentItem
): CollectionKey | null {
return item ? collectionKey({ id: item.xtream_id, type: item.type }) : null;
}
export function normalizeFavoriteStorage(
value: unknown
): Record<string, CollectionKey[]> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const normalized: Record<string, CollectionKey[]> = {};
for (const [playlistId, ids] of Object.entries(value)) {
if (!Array.isArray(ids)) continue;
normalized[playlistId] = [
...new Set(
ids
.map(collectionKey)
.filter((id): id is CollectionKey => id !== null)
),
];
}
return normalized;
}
export function normalizeStoredBackdrop(item: {
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
}): Pick<StoredRecentItem, 'backdropUrl'> {
const value = item.backdropUrl ?? item.backdrop_url;
return typeof value === 'string' && value.trim()
? { backdropUrl: value.trim() }
: {};
}
export function normalizeRecentStorage(
value: unknown
): Record<string, StoredRecentItem[]> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const normalized: Record<string, StoredRecentItem[]> = {};
for (const [playlistId, items] of Object.entries(value)) {
if (!Array.isArray(items)) continue;
normalized[playlistId] = [];
for (const item of items as (Partial<StoredRecentItem> | null)[]) {
const id = collectionKey(item?.id);
if (id !== null && typeof item?.viewedAt === 'string') {
normalized[playlistId].push({
id,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
});
}
}
}
return normalized;
}
/** Never infer a legacy number from a partially loaded catalog. */
export function findCollectionItems(
ids: readonly CollectionKey[],
cached: readonly XtreamContentItem[],
snapshots: Record<string, XtreamContentItem>,
completeCatalog: boolean
): Map<CollectionKey, XtreamContentItem> {
const wantedIds = new Set(
ids.map((key) => typedCollectionRef(key)?.id ?? key)
);
const typedItems = new Map<CollectionKey, XtreamContentItem>();
for (const item of [...Object.values(snapshots), ...cached]) {
if (!item || !wantedIds.has(item.xtream_id)) continue;
const key = itemCollectionKey(item);
if (key !== null) typedItems.set(key, item);
}
const uniqueIds = new Map<number, XtreamContentItem | null>();
for (const item of cached) {
if (!wantedIds.has(item.xtream_id)) continue;
const previous = uniqueIds.get(item.xtream_id);
uniqueIds.set(
item.xtream_id,
previous === null || (previous && previous.type !== item.type)
? null
: item
);
}
const results = new Map<CollectionKey, XtreamContentItem>();
for (const key of ids) {
if (typeof key === 'string') {
const item = typedItems.get(key);
if (item) results.set(key, item);
continue;
}
const snapshot = snapshots[String(key)];
const snapshotKey = snapshot ? itemCollectionKey(snapshot) : null;
if (snapshot && snapshot.xtream_id === key && snapshotKey !== null) {
results.set(key, typedItems.get(snapshotKey) ?? snapshot);
} else if (completeCatalog) {
const candidate = uniqueIds.get(key);
if (candidate) results.set(key, candidate);
}
}
return results;
}
export function deduplicateRecentItems(
items: StoredRecentItem[],
keyForItem: (item: StoredRecentItem) => CollectionKey = (item) => item.id
): StoredRecentItem[] {
const unique = new Map<CollectionKey, StoredRecentItem>();
for (const item of items) {
const key = keyForItem(item);
const previous = unique.get(key);
if (
!previous ||
Date.parse(item.viewedAt) > Date.parse(previous.viewedAt)
)
unique.set(key, item);
}
return [...unique.values()];
}
@@ -0,0 +1,271 @@
import {
CollectionKey,
StoredRecentItem,
deduplicateRecentItems,
itemCollectionKey,
normalizeFavoriteStorage,
normalizeRecentStorage,
} from './pwa-collection-identity';
import { XtreamContentItem } from './xtream-data-source.interface';
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
};
/** Synchronous persistence and migration using only snapshots and cached identity evidence. */
export class PwaCollectionStorage {
constructor(
private readonly resolveItems: (
playlistId: string,
ids: readonly CollectionKey[]
) => Map<CollectionKey, XtreamContentItem>
) {}
getLocalFavorites(playlistId: string): XtreamContentItem[] {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
const contentById = this.resolveItems(playlistId, playlistFavorites);
const migrated = playlistFavorites.map((id) => {
const item = contentById.get(id);
return item ? (itemCollectionKey(item) ?? id) : id;
});
if (migrated.some((id, index) => id !== playlistFavorites[index])) {
allFavorites[playlistId] = [...new Set(migrated)];
this.tryStorageMigration(() =>
this.saveFavoritesToStorage(allFavorites)
);
}
this.persistCollectionSnapshots(playlistId, contentById);
return [
...new Map(
Array.from(contentById.values()).map((item) => [
itemCollectionKey(item),
item,
])
).values(),
];
}
getFavoritesFromStorage(): Record<string, CollectionKey[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
}
saveFavoritesToStorage(favorites: Record<string, CollectionKey[]>): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
clearFavoritesForPlaylist(playlistId: string): void {
const allFavorites = this.getFavoritesFromStorage();
delete allFavorites[playlistId];
this.saveFavoritesToStorage(allFavorites);
}
getLocalRecentItems(playlistId: string): XtreamContentItem[] {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
const contentById = this.resolveItems(
playlistId,
playlistRecent.map((item) => item.id)
);
const migrated = deduplicateRecentItems(
playlistRecent.map((entry) => {
const item = contentById.get(entry.id);
return item
? { ...entry, id: itemCollectionKey(item) ?? entry.id }
: entry;
})
);
if (
migrated.length !== playlistRecent.length ||
migrated.some(
(entry, index) => entry.id !== playlistRecent[index].id
)
) {
allRecent[playlistId] = migrated;
this.tryStorageMigration(() =>
this.saveRecentItemsToStorage(allRecent)
);
}
this.persistCollectionSnapshots(playlistId, contentById);
const results: (XtreamContentItem & { viewed_at: string })[] = [];
const migratedContent = this.resolveItems(
playlistId,
migrated.map((item) => item.id)
);
for (const recentEntry of migrated) {
const item = migratedContent.get(recentEntry.id);
if (!item) {
continue;
}
results.push({
...item,
backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
// Sort by viewed_at descending
results.sort(
(a, b) =>
new Date(b.viewed_at).getTime() -
new Date(a.viewed_at).getTime()
);
return results as XtreamContentItem[];
}
getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
}
saveRecentItemsToStorage(
recentItems: Record<string, StoredRecentItem[]>
): void {
localStorage.setItem(
STORAGE_KEYS.RECENT_ITEMS,
JSON.stringify(recentItems)
);
}
clearRecentItemsForPlaylist(playlistId: string): void {
const allRecent = this.getRecentItemsFromStorage();
delete allRecent[playlistId];
this.saveRecentItemsToStorage(allRecent);
}
getCollectionItemsFromStorage(): Record<
string,
Record<string, XtreamContentItem>
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed as Record<string, Record<string, XtreamContentItem>>;
} catch {
return {};
}
}
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
localStorage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
JSON.stringify(items)
);
}
/** Optional migration/cache writes must not block readable legacy collections. */
private tryStorageMigration(write: () => void): void {
try {
write();
} catch (error) {
if (
!(error instanceof DOMException) ||
error.name !== 'QuotaExceededError'
) {
throw error;
}
}
}
private trySaveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
this.tryStorageMigration(() =>
this.saveCollectionItemsToStorage(items)
);
}
clearCollectionItemsForPlaylist(playlistId: string): void {
const allItems = this.getCollectionItemsFromStorage();
delete allItems[playlistId];
this.saveCollectionItemsToStorage(allItems);
}
private persistCollectionSnapshots(
playlistId: string,
items: Map<CollectionKey, XtreamContentItem>
): void {
if (!items.size) return;
const allItems = this.getCollectionItemsFromStorage();
const snapshots = allItems[playlistId] ?? {};
for (const item of items.values()) {
const key = itemCollectionKey(item);
if (key === null) continue;
snapshots[key] = {
...item,
backdrop_url: item.backdrop_url ?? snapshots[key]?.backdrop_url,
};
}
this.trySaveCollectionItemsToStorage({
...allItems,
[playlistId]: snapshots,
});
}
saveCollectionItemSnapshot(
playlistId: string,
contentId: CollectionKey,
backdropUrl?: string
): void {
const item = this.resolveItems(playlistId, [contentId]).get(contentId);
if (!item) {
return;
}
const normalizedBackdropUrl = backdropUrl?.trim();
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId] ?? {};
playlistItems[String(contentId)] = {
...item,
...(normalizedBackdropUrl && !item.backdrop_url
? { backdrop_url: normalizedBackdropUrl }
: {}),
};
this.trySaveCollectionItemsToStorage({
...allItems,
[playlistId]: playlistItems,
});
}
setCollectionItemBackdropIfMissing(
playlistId: string,
contentId: CollectionKey,
backdropUrl: string
): void {
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId];
const item = playlistItems?.[String(contentId)];
if (!item || item.backdrop_url) {
return;
}
this.trySaveCollectionItemsToStorage({
...allItems,
[playlistId]: {
...playlistItems,
[String(contentId)]: {
...item,
backdrop_url: backdropUrl,
},
},
});
}
}
@@ -0,0 +1,611 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { of } from 'rxjs';
import { XtreamApiService } from '../services/xtream-api.service';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
const credentials = {
serverUrl: 'http://localhost:3211',
username: 'demo',
password: 'test',
};
const types = ['live', 'movie', 'series'] as const;
describe('PWA Xtream collection identity', () => {
let source: PwaXtreamDataSource;
const api = { getStreams: jest.fn() };
const playlists = { getPlaylistById: jest.fn() };
const reload = () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: ParentalLockService,
useValue: {
active: () => false,
lockedXtreamIds: () => [],
},
},
{ provide: XtreamApiService, useValue: api },
{ provide: PlaylistsService, useValue: playlists },
],
});
source = TestBed.inject(PwaXtreamDataSource);
};
const loadAll = () =>
Promise.all(
types.map((type) => source.getContent('p1', credentials, type))
);
const read = (key: string) => JSON.parse(localStorage.getItem(key) ?? '{}');
const seedLegacyCollection = (otherIds: number[] = []) => {
const ids = [42, ...otherIds];
localStorage.setItem('xtream-favorites', JSON.stringify({ p1: ids }));
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
p1: ids.map((id) => ({
id,
viewedAt: '2026-10-01T00:00:00.000Z',
})),
})
);
const snapshots: Record<string, unknown> = {};
for (const id of ids.filter((id) => id !== 77)) {
snapshots[id] = {
id,
xtream_id: id,
type: id === 42 ? 'movie' : 'series',
title: 'Saved item '.repeat(100),
};
}
localStorage.setItem(
'xtream-collection-items',
JSON.stringify({ p1: snapshots })
);
};
const limitStorageGrowth = (extraCharacters: number) => {
const storedSize = () =>
Object.keys(localStorage).reduce(
(size, key) =>
size +
key.length +
(localStorage.getItem(key)?.length ?? 0),
0
);
const quota = storedSize() + extraCharacters;
const setItem = Storage.prototype.setItem;
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
this: Storage,
key,
value
) {
const oldValue = this.getItem(key);
const growth =
value.length -
(oldValue?.length ?? 0) +
(oldValue === null ? key.length : 0);
if (storedSize() + growth > quota)
throw new DOMException('Storage full', 'QuotaExceededError');
setItem.call(this, key, value);
});
};
beforeEach(() => {
localStorage.clear();
api.getStreams.mockReset().mockImplementation((_credentials, type) =>
Promise.resolve([
{
stream_id: 42,
series_id: 42,
name: `${type} 42`,
category_id: '1',
},
])
);
playlists.getPlaylistById.mockReturnValue(
of({ _id: 'p1', title: 'Test', ...credentials })
);
reload();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it.each([0, 512])(
'keeps legacy collection reads and status usable with only %i characters of storage headroom',
async (headroom) => {
seedLegacyCollection();
limitStorageGrowth(headroom);
for (let attempt = 0; attempt < 2; attempt++) {
expect(await source.getFavorites('p1')).toEqual([
expect.objectContaining({ xtream_id: 42, type: 'movie' }),
]);
expect(await source.getRecentItems('p1')).toEqual([
expect.objectContaining({ xtream_id: 42, type: 'movie' }),
]);
expect(
await source.isFavorite({ id: 42, type: 'movie' }, 'p1')
).toBe(true);
expect(
await source.isFavorite({ id: 42, type: 'live' }, 'p1')
).toBe(false);
reload();
}
expect(read('xtream-collection-items').p1[42].type).toBe('movie');
expect(api.getStreams).not.toHaveBeenCalled();
}
);
it.each([
['removeFavorite', 0],
['removeFavorite', 512],
['removeRecentItem', 0],
['removeRecentItem', 512],
] as const)(
'%s persists deletion with only %i characters of storage headroom',
async (operation, headroom) => {
seedLegacyCollection([
77,
...Array.from({ length: 12 }, (_, index) => 100 + index),
]);
limitStorageGrowth(headroom);
await source[operation]({ id: 42, type: 'movie' }, 'p1');
expect(api.getStreams).not.toHaveBeenCalled();
const key =
operation === 'removeFavorite'
? 'xtream-favorites'
: 'xtream-recent-items';
const storedIds =
operation === 'removeFavorite'
? read(key).p1
: read(key).p1.map((entry: { id: unknown }) => entry.id);
expect(storedIds).not.toContain(42);
expect(storedIds).not.toContain('movie:42');
expect(storedIds).toContain(77);
expect(read('xtream-collection-items').p1[42].type).toBe('movie');
reload();
const favorites = await source.getFavorites('p1');
const recent = await source.getRecentItems('p1');
expect(favorites.some((item) => item.xtream_id === 42)).toBe(
operation !== 'removeFavorite'
);
expect(recent.some((item) => item.xtream_id === 42)).toBe(
operation !== 'removeRecentItem'
);
}
);
it.each(['addFavorite', 'addRecentItem'] as const)(
'%s reports an authoritative write that cannot fit',
async (operation) => {
seedLegacyCollection();
limitStorageGrowth(0);
await expect(
source[operation]({ id: 99, type: 'series' }, 'p1')
).rejects.toMatchObject({ name: 'QuotaExceededError' });
expect(read('xtream-favorites').p1).toEqual([42]);
expect(
read('xtream-recent-items').p1.map(
(entry: { id: unknown }) => entry.id
)
).toEqual([42]);
}
);
it.each(['addFavorite', 'addRecentItem'] as const)(
'%s succeeds when the reference fits but its optional snapshot does not',
async (operation) => {
seedLegacyCollection();
api.getStreams.mockResolvedValue([
{ series_id: 99, name: 'New series '.repeat(100) },
]);
await source.getContent('p1', credentials, 'series');
limitStorageGrowth(512);
await source[operation]({ id: 99, type: 'series' }, 'p1');
const storedIds =
operation === 'addFavorite'
? read('xtream-favorites').p1
: read('xtream-recent-items').p1.map(
(entry: { id: unknown }) => entry.id
);
expect(storedIds).toContain('series:99');
expect(
read('xtream-collection-items').p1['series:99']
).toBeUndefined();
reload();
const items =
operation === 'addFavorite'
? await source.getFavorites('p1')
: await source.getRecentItems('p1');
expect(items).toContainEqual(
expect.objectContaining({ xtream_id: 99, type: 'series' })
);
}
);
it.each(['addFavorite', 'addRecentItem', 'statusThenAddFavorite'] as const)(
'%s saves the requested reference before optional copies consume its space',
async (operation) => {
seedLegacyCollection();
const snapshots = read('xtream-collection-items');
const copyGrowth =
JSON.stringify({
p1: { ...snapshots.p1, 'movie:42': snapshots.p1[42] },
}).length - JSON.stringify(snapshots).length;
const keyGrowth =
JSON.stringify({ p1: ['movie:42'] }).length -
JSON.stringify({ p1: [42] }).length;
// Both optional writes fit exactly, but then leave no room for an addition.
limitStorageGrowth(copyGrowth + keyGrowth);
const target = { id: 99, type: 'series' } as const;
if (operation === 'statusThenAddFavorite') {
expect(await source.isFavorite(target, 'p1')).toBe(false);
expect(
await source.isFavorite({ id: 42, type: 'movie' }, 'p1')
).toBe(true);
expect(Storage.prototype.setItem).not.toHaveBeenCalled();
}
const mutation =
operation === 'addRecentItem' ? operation : 'addFavorite';
await expect(
source[mutation](target, 'p1')
).resolves.toBeUndefined();
const storedIds =
mutation === 'addFavorite'
? read('xtream-favorites').p1
: read('xtream-recent-items').p1.map(
(entry: { id: unknown }) => entry.id
);
expect(storedIds).toContain('series:99');
expect(read('xtream-collection-items').p1[42]).toEqual(
snapshots.p1[42]
);
expect(api.getStreams).not.toHaveBeenCalled();
}
);
it.each([42, 'movie:42'])(
'deduplicates before the 50-item limit while preserving the newer %s key until the save',
async (newerKey) => {
seedLegacyCollection();
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
p1: [
{
id: newerKey === 42 ? 'movie:42' : 42,
viewedAt: '2026-09-30T00:00:00.000Z',
},
{ id: newerKey, viewedAt: '2026-10-02T00:00:00.000Z' },
...Array.from({ length: 48 }, (_, index) => ({
id: `series:${100 + index}`,
viewedAt: '2026-10-01T00:00:00.000Z',
})),
],
})
);
const writes = jest.spyOn(Storage.prototype, 'setItem');
await source.addRecentItem({ id: 99, type: 'series' }, 'p1');
expect(writes.mock.calls[0][0]).toBe('xtream-recent-items');
expect(JSON.parse(writes.mock.calls[0][1]).p1).toContainEqual({
id: newerKey,
viewedAt: '2026-10-02T00:00:00.000Z',
});
const recent = read('xtream-recent-items').p1;
expect(recent).toHaveLength(50);
expect(recent).toContainEqual({
id: 'series:147',
viewedAt: '2026-10-01T00:00:00.000Z',
});
expect(recent).toContainEqual({
id: 'movie:42',
viewedAt: '2026-10-02T00:00:00.000Z',
});
expect(recent[0].id).toBe('series:99');
expect(api.getStreams).not.toHaveBeenCalled();
}
);
it('preserves restored colliding types through hydration and clear/restore', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
playbackPositions: [],
favorites: types.map((contentType) => ({
contentType,
xtreamId: 42,
})),
recentlyViewed: types.map((contentType, i) => ({
contentType,
xtreamId: 42,
viewedAt: `2026-10-0${i + 1}T12:00:00.000Z`,
})),
});
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(types);
expect(
(await source.getRecentItems('p1')).map((item) => item.type)
).toEqual(['series', 'movie', 'live']);
const backup = await source.clearPlaylistContent('p1');
expect(backup.favorites).toEqual(
types.map((contentType) => ({ contentType, xtreamId: 42 }))
);
expect(backup.recentlyViewed.map((item) => item.contentType)).toEqual(
types
);
reload();
await source.restoreUserData('p1', backup);
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(types);
});
it('preserves ambiguous numeric legacy entries without selecting the last matching type', async () => {
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['42'] })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
p1: [{ id: 42, viewedAt: '2026-10-01T12:00:00.000Z' }],
})
);
await loadAll();
expect(await source.getFavorites('p1')).toEqual([]);
expect(await source.getRecentItems('p1')).toEqual([]);
expect(read('xtream-favorites').p1).toEqual(['42']);
expect(read('xtream-recent-items').p1[0].id).toBe(42);
});
it('does not infer a legacy type from a partially loaded catalog', async () => {
localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] }));
await source.getContent('p1', credentials, 'movie');
expect(await source.getFavorites('p1')).toEqual([]);
expect(api.getStreams).toHaveBeenCalledWith(credentials, 'live');
expect(api.getStreams).toHaveBeenCalledWith(credentials, 'series');
});
it('migrates a numeric reference using its stored typed snapshot offline', async () => {
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['42'] })
);
localStorage.setItem(
'xtream-collection-items',
JSON.stringify({
p1: {
42: {
id: 42,
xtream_id: 42,
type: 'movie',
title: 'Saved Movie',
},
},
})
);
expect(
(await source.getFavorites('p1')).map((item) => item.title)
).toEqual(['Saved Movie']);
expect(read('xtream-favorites').p1).toEqual(['movie:42']);
expect(read('xtream-collection-items').p1['movie:42'].title).toBe(
'Saved Movie'
);
expect(api.getStreams).not.toHaveBeenCalled();
});
it('keeps additions, status, removal and snapshot backdrops isolated after reload', async () => {
await loadAll();
for (const type of types) {
await source.addFavorite({ id: 42, type }, 'p1');
await source.addRecentItem({ id: 42, type }, 'p1');
}
await source.setContentMetadataIfMissing(
{ id: 42, type: 'movie' },
'p1',
{ backdropUrl: 'movie.jpg' }
);
expect(
(await source.getFavorites('p1')).map((item) => [
item.type,
item.backdrop_url,
])
).toEqual([
['live', undefined],
['movie', 'movie.jpg'],
['series', undefined],
]);
reload();
api.getStreams.mockClear();
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(types);
expect(
(await source.getRecentItems('p1')).map((item) => item.type)
).toEqual(['series', 'movie', 'live']);
await source.removeFavorite({ id: 42, type: 'movie' }, 'p1');
await source.removeRecentItem({ id: 42, type: 'series' }, 'p1');
expect(await source.isFavorite({ id: 42, type: 'movie' }, 'p1')).toBe(
false
);
expect(await source.isFavorite({ id: 42, type: 'series' }, 'p1')).toBe(
true
);
expect(
(await source.getRecentItems('p1')).map((item) => item.type)
).toEqual(['movie', 'live']);
expect(api.getStreams).not.toHaveBeenCalled();
});
it('hydrates only the exact typed category for a cold typed favorite', async () => {
await source.addFavorite({ id: 42, type: 'movie' }, 'p1');
reload();
api.getStreams.mockClear();
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(['movie']);
expect(api.getStreams.mock.calls.map((call) => call[1])).toEqual([
'movie',
]);
});
it.each([
'addFavorite',
'removeFavorite',
'isFavorite',
'addRecentItem',
'removeRecentItem',
] as const)(
'%s completes locally while unrelated catalog hydration is pending',
async (operation) => {
const savedIds = [42, 77, 'series:99'];
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ p1: savedIds })
);
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
p1: savedIds.map((id) => ({
id,
viewedAt: '2026-10-01T00:00:00.000Z',
})),
})
);
localStorage.setItem(
'xtream-collection-items',
JSON.stringify({
p1: {
42: {
id: 42,
xtream_id: 42,
type: 'movie',
title: 'Saved Movie',
},
},
})
);
let releaseCatalog!: (items: unknown[]) => void;
const catalog = new Promise((resolve) => {
releaseCatalog = resolve;
});
api.getStreams.mockReturnValue(catalog);
const loading = Promise.all([
source.getFavorites('p1'),
source.getRecentItems('p1'),
]);
for (let i = 0; i < 20; i++) await Promise.resolve();
const requestsBeforeAction = api.getStreams.mock.calls.length;
expect(requestsBeforeAction).toBeGreaterThan(0);
const adding =
operation === 'addFavorite' || operation === 'addRecentItem';
let settled = false;
let result: boolean | void = undefined;
const action = source[operation](
{ id: 42, type: adding ? 'live' : 'movie' },
'p1'
).then((value) => {
result = value;
settled = true;
});
try {
for (let i = 0; i < 20; i++) await Promise.resolve();
expect(settled).toBe(true);
expect(api.getStreams).toHaveBeenCalledTimes(
requestsBeforeAction
);
if (operation === 'isFavorite') expect(result).toBe(true);
const storedIds = operation.includes('Recent')
? read('xtream-recent-items').p1.map(
(entry: { id: unknown }) => entry.id
)
: read('xtream-favorites').p1;
const expectedIds = operation.startsWith('remove')
? [77, 'series:99']
: adding
? ['movie:42', 77, 'series:99', 'live:42']
: [42, 77, 'series:99'];
expect([...storedIds].sort()).toEqual(expectedIds.sort());
} finally {
releaseCatalog([]);
await Promise.all([loading, action]);
}
}
);
it('preserves newer storage changes while legacy hydration is pending', async () => {
localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] }));
let resolveMovie!: (items: unknown[]) => void;
api.getStreams.mockImplementation((_credentials, type) =>
type === 'movie'
? new Promise((resolve) => {
resolveMovie = resolve;
})
: Promise.resolve([])
);
const pending = source.getFavorites('p1');
for (let i = 0; i < 10 && !resolveMovie; i++) await Promise.resolve();
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['series:99'], other: ['live:77'] })
);
resolveMovie([{ stream_id: 42, name: 'Movie' }]);
await pending;
expect(read('xtream-favorites')).toEqual({
p1: ['series:99'],
other: ['live:77'],
});
});
it('deduplicates migrated recents using the most recent timestamp', async () => {
localStorage.setItem(
'xtream-recent-items',
JSON.stringify({
p1: [
{ id: 42, viewedAt: '2026-10-01T00:00:00.000Z' },
{ id: 'movie:42', viewedAt: '2026-10-02T00:00:00.000Z' },
],
})
);
localStorage.setItem(
'xtream-collection-items',
JSON.stringify({
p1: {
42: {
id: 42,
xtream_id: 42,
type: 'movie',
title: 'Saved Movie',
},
},
})
);
expect(await source.getRecentItems('p1')).toEqual([
expect.objectContaining({
type: 'movie',
viewed_at: '2026-10-02T00:00:00.000Z',
}),
]);
expect(read('xtream-recent-items').p1).toEqual([
{ id: 'movie:42', viewedAt: '2026-10-02T00:00:00.000Z' },
]);
});
it('resolves a unique legacy type even when that catalog repeats its provider row', async () => {
localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] }));
api.getStreams.mockImplementation((_credentials, type) =>
Promise.resolve(
type === 'movie'
? [
{ stream_id: 42, name: 'Movie' },
{ stream_id: 42, name: 'Movie' },
]
: []
)
);
expect(
(await source.getFavorites('p1')).map((item) => item.type)
).toEqual(['movie']);
expect(read('xtream-favorites').p1).toEqual(['movie:42']);
});
});
@@ -447,6 +447,9 @@ describe('PwaXtreamDataSource', () => {
);
await dataSource.getContent('playlist-1', credentials, 'movie');
apiService.getStreams.mockResolvedValue([]);
await dataSource.getContent('playlist-1', credentials, 'live');
await dataSource.getContent('playlist-1', credentials, 'series');
await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe(
true
@@ -618,8 +621,14 @@ describe('PwaXtreamDataSource', () => {
})
);
await dataSource.addFavorite(Number(content[0].id), 'playlist-1');
await dataSource.addRecentItem(Number(content[0].id), 'playlist-1');
await dataSource.addFavorite(
{ id: Number(content[0].id), type: 'movie' },
'playlist-1'
);
await dataSource.addRecentItem(
{ id: Number(content[0].id), type: 'movie' },
'playlist-1'
);
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({
@@ -648,24 +657,31 @@ describe('PwaXtreamDataSource', () => {
]);
await dataSource.getContent('playlist-1', credentials, 'movie');
await dataSource.addRecentItem(202, 'playlist-1');
await dataSource.addRecentItem(
{ id: 202, type: 'movie' },
'playlist-1'
);
const before = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0].viewedAt;
await dataSource.setContentMetadataIfMissing(202, 'playlist-1', {
backdropUrl: ' https://example.com/backdrop.png ',
});
await dataSource.setContentMetadataIfMissing(
{ id: 202, type: 'movie' },
'playlist-1',
{
backdropUrl: ' https://example.com/backdrop.png ',
}
);
const stored = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0];
const storedSnapshot = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
)['playlist-1']['202'];
)['playlist-1']['movie:202'];
expect(stored).toEqual(
expect.objectContaining({
id: 202,
id: 'movie:202',
backdropUrl: 'https://example.com/backdrop.png',
viewedAt: before,
})
@@ -705,9 +721,9 @@ describe('PwaXtreamDataSource', () => {
]);
await dataSource.getContent('playlist-1', credentials, 'movie');
await dataSource.addFavorite(202, 'playlist-1');
await dataSource.addFavorite({ id: 202, type: 'movie' }, 'playlist-1');
await dataSource.addRecentItem(
202,
{ id: 202, type: 'movie' },
'playlist-1',
'https://example.com/backdrop.png'
);
@@ -715,7 +731,7 @@ describe('PwaXtreamDataSource', () => {
const storedCollectionItems = JSON.parse(
localStorage.getItem('xtream-collection-items') || '{}'
);
expect(storedCollectionItems['playlist-1']['202']).toEqual(
expect(storedCollectionItems['playlist-1']['movie:202']).toEqual(
expect.objectContaining({
title: 'Movie One',
xtream_id: 202,
@@ -23,6 +23,7 @@ import { firstValueFrom } from 'rxjs';
import {
DbCategoryType,
IXtreamDataSource,
XtreamCollectionRef,
ProgressCallback,
XtreamOperationOptions,
XtreamCategoryFromDb,
@@ -30,13 +31,20 @@ import {
XtreamPlaylistData,
} from './xtream-data-source.interface';
import {
CollectionKey,
collectionKey,
deduplicateRecentItems,
typedCollectionRef,
itemCollectionKey,
findCollectionItems,
} from './pwa-collection-identity';
import { PwaCollectionStorage } from './pwa-collection-storage';
/**
* LocalStorage keys for PWA persistence
*/
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
PLAYLISTS: 'xtream-playlists',
PLAYBACK_POSITIONS: 'xtream-playback-positions',
};
@@ -64,12 +72,6 @@ interface XtreamCachedContentItem {
readonly xtream_id?: number | string;
}
interface StoredRecentItem {
readonly id: number;
readonly viewedAt: string;
readonly backdropUrl?: string;
}
type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
readonly password?: string;
};
@@ -86,6 +88,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly parentalLock = inject(ParentalLockService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
private readonly collectionStorage = new PwaCollectionStorage(
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids)
);
// In-memory cache for the current session
private categoryCache = new Map<string, XtreamCategory[]>();
@@ -165,9 +170,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.savePlaylistsToStorage(filtered);
// Also clear favorites and recent items for this playlist
this.clearCollectionItemsForPlaylist(playlistId);
this.clearFavoritesForPlaylist(playlistId);
this.clearRecentItemsForPlaylist(playlistId);
this.collectionStorage.clearCollectionItemsForPlaylist(playlistId);
this.collectionStorage.clearFavoritesForPlaylist(playlistId);
this.collectionStorage.clearRecentItemsForPlaylist(playlistId);
this.clearPlaybackPositionsForPlaylist(playlistId);
// Clear cache
@@ -682,83 +687,93 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
const contentById = await this.getCollectionItemsWithHydration(
await this.getCollectionItemsWithHydration(
playlistId,
playlistFavorites
this.collectionStorage.getFavoritesFromStorage()[playlistId] || []
);
return Array.from(contentById.values());
return this.collectionStorage.getLocalFavorites(playlistId);
}
async addFavorite(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
backdropUrl?: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
const allFavorites = this.collectionStorage.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
if (!allFavorites[playlistId].includes(normalizedContentId)) {
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
allFavorites[playlistId],
normalizedContentId
);
if (!allFavorites[playlistId].some((id) => matchingKeys.has(id))) {
allFavorites[playlistId].push(normalizedContentId);
}
this.saveFavoritesToStorage(allFavorites);
this.saveCollectionItemSnapshot(
this.collectionStorage.saveFavoritesToStorage(allFavorites);
this.collectionStorage.getLocalFavorites(playlistId);
this.collectionStorage.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
backdropUrl
);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
async removeFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void> {
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
if (normalizedContentId == null) {
return;
}
const allFavorites = this.getFavoritesFromStorage();
const allFavorites = this.collectionStorage.getFavoritesFromStorage();
if (allFavorites[playlistId]) {
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
allFavorites[playlistId],
normalizedContentId
);
allFavorites[playlistId] = allFavorites[playlistId].filter(
(id: number) => id !== normalizedContentId
(id) => !matchingKeys.has(id)
);
}
this.saveFavoritesToStorage(allFavorites);
this.collectionStorage.saveFavoritesToStorage(allFavorites);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const normalizedContentId = this.normalizeStoredId(contentId);
async isFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<boolean> {
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
if (normalizedContentId == null) {
return false;
}
const allFavorites = this.getFavoritesFromStorage();
return (allFavorites[playlistId] || []).includes(normalizedContentId);
}
private getFavoritesFromStorage(): Record<string, number[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
}
private saveFavoritesToStorage(favorites: Record<string, number[]>): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
private clearFavoritesForPlaylist(playlistId: string): void {
const allFavorites = this.getFavoritesFromStorage();
delete allFavorites[playlistId];
this.saveFavoritesToStorage(allFavorites);
const allFavorites = this.collectionStorage.getFavoritesFromStorage();
const ids = allFavorites[playlistId] || [];
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
ids,
normalizedContentId
);
return ids.some((id) => matchingKeys.has(id));
}
// =========================================================================
@@ -950,55 +965,57 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// =========================================================================
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
const contentById = await this.getCollectionItemsWithHydration(
await this.getCollectionItemsWithHydration(
playlistId,
playlistRecent.map((item) => item.id)
(
this.collectionStorage.getRecentItemsFromStorage()[
playlistId
] || []
).map((item) => item.id)
);
const results: (XtreamContentItem & { viewed_at: string })[] = [];
for (const recentEntry of playlistRecent) {
const item = contentById.get(recentEntry.id);
if (!item) {
continue;
}
results.push({
...item,
backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url,
viewed_at: recentEntry.viewedAt,
});
}
// Sort by viewed_at descending
results.sort(
(a, b) =>
new Date(b.viewed_at).getTime() -
new Date(a.viewed_at).getTime()
);
return results as XtreamContentItem[];
return this.collectionStorage.getLocalRecentItems(playlistId);
}
async addRecentItem(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
_backdropUrl?: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
if (normalizedContentId == null) {
return;
}
const normalizedBackdropUrl = _backdropUrl?.trim();
const allRecent = this.getRecentItemsFromStorage();
const allRecent = this.collectionStorage.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
}
// Compare logical identities without expanding legacy keys before the save.
const storedItems = this.getCollectionItemsById(
playlistId,
allRecent[playlistId].map((item) => item.id)
);
allRecent[playlistId] = deduplicateRecentItems(
allRecent[playlistId],
(entry) => {
const item = storedItems.get(entry.id);
return item ? (itemCollectionKey(item) ?? entry.id) : entry.id;
}
);
// Remove existing entry if present
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
allRecent[playlistId].map((item) => item.id),
normalizedContentId
);
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== normalizedContentId
(r) => !matchingKeys.has(r.id)
);
// Add new entry at the beginning
@@ -1013,8 +1030,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Keep only last 50 items
allRecent[playlistId] = allRecent[playlistId].slice(0, 50);
this.saveRecentItemsToStorage(allRecent);
this.saveCollectionItemSnapshot(
this.collectionStorage.saveRecentItemsToStorage(allRecent);
this.collectionStorage.getLocalRecentItems(playlistId);
this.collectionStorage.saveCollectionItemSnapshot(
playlistId,
normalizedContentId,
normalizedBackdropUrl
@@ -1022,250 +1040,91 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
async removeRecentItem(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
if (normalizedContentId == null) {
return;
}
const allRecent = this.getRecentItemsFromStorage();
const allRecent = this.collectionStorage.getRecentItemsFromStorage();
if (allRecent[playlistId]) {
const matchingKeys = this.getMatchingCollectionKeys(
playlistId,
allRecent[playlistId].map((item) => item.id),
normalizedContentId
);
allRecent[playlistId] = allRecent[playlistId].filter(
(r) => r.id !== normalizedContentId
(r) => !matchingKeys.has(r.id)
);
}
this.saveRecentItemsToStorage(allRecent);
this.collectionStorage.saveRecentItemsToStorage(allRecent);
}
async clearRecentItems(playlistId: string): Promise<void> {
this.clearRecentItemsForPlaylist(playlistId);
this.collectionStorage.clearRecentItemsForPlaylist(playlistId);
}
private getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return this.normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
}
}
private saveRecentItemsToStorage(
recentItems: Record<string, StoredRecentItem[]>
): void {
localStorage.setItem(
STORAGE_KEYS.RECENT_ITEMS,
JSON.stringify(recentItems)
);
}
private clearRecentItemsForPlaylist(playlistId: string): void {
const allRecent = this.getRecentItemsFromStorage();
delete allRecent[playlistId];
this.saveRecentItemsToStorage(allRecent);
}
private normalizeStoredId(value: unknown): number | null {
const numericValue = Number(value);
return Number.isFinite(numericValue) && numericValue > 0
? numericValue
: null;
}
private normalizeFavoriteStorage(value: unknown): Record<string, number[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, number[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, ids]) => {
if (!Array.isArray(ids)) {
return;
}
normalized[playlistId] = ids
.map((id) => this.normalizeStoredId(id))
.filter((id): id is number => id !== null);
}
);
return normalized;
}
private normalizeRecentStorage(
value: unknown
): Record<string, StoredRecentItem[]> {
if (!value || typeof value !== 'object') {
return {};
}
const normalized: Record<string, StoredRecentItem[]> = {};
Object.entries(value as Record<string, unknown>).forEach(
([playlistId, items]) => {
if (!Array.isArray(items)) {
return;
}
normalized[playlistId] = items
.map((item) => {
const rawItem = item as {
readonly id?: unknown;
readonly viewedAt?: unknown;
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
};
const id = this.normalizeStoredId(rawItem.id);
if (
id == null ||
typeof rawItem.viewedAt !== 'string'
) {
return null;
}
return {
id,
viewedAt: rawItem.viewedAt,
...this.normalizeStoredBackdrop(rawItem),
};
})
.filter((item): item is StoredRecentItem => item !== null);
}
);
return normalized;
}
private normalizeStoredBackdrop(item: {
readonly backdropUrl?: unknown;
readonly backdrop_url?: unknown;
}): Pick<StoredRecentItem, 'backdropUrl'> | Record<string, never> {
const value = item.backdropUrl ?? item.backdrop_url;
if (typeof value !== 'string') {
return {};
}
const backdropUrl = value.trim();
return backdropUrl ? { backdropUrl } : {};
}
private getCollectionItemsFromStorage(): Record<
string,
Record<string, XtreamContentItem>
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
}
return parsed as Record<string, Record<string, XtreamContentItem>>;
} catch {
return {};
}
}
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
localStorage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
JSON.stringify(items)
);
}
private clearCollectionItemsForPlaylist(playlistId: string): void {
const allItems = this.getCollectionItemsFromStorage();
delete allItems[playlistId];
this.saveCollectionItemsToStorage(allItems);
}
private saveCollectionItemSnapshot(
/** Match original keys even when optional migration could not fit in storage. */
private getMatchingCollectionKeys(
playlistId: string,
contentId: number,
backdropUrl?: string
): void {
const item = this.findCachedContentItemById(playlistId, contentId);
if (!item) {
return;
ids: readonly CollectionKey[],
target: CollectionKey
): Set<CollectionKey> {
const matchingKeys = new Set([target]);
for (const [key, item] of this.getCollectionItemsById(
playlistId,
ids
)) {
if (itemCollectionKey(item) === target) matchingKeys.add(key);
}
const normalizedBackdropUrl = backdropUrl?.trim();
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId] ?? {};
playlistItems[String(contentId)] = {
...item,
...(normalizedBackdropUrl && !item.backdrop_url
? { backdrop_url: normalizedBackdropUrl }
: {}),
};
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: playlistItems,
});
}
private setCollectionItemBackdropIfMissing(
playlistId: string,
contentId: number,
backdropUrl: string
): void {
const allItems = this.getCollectionItemsFromStorage();
const playlistItems = allItems[playlistId];
const item = playlistItems?.[String(contentId)];
if (!item || item.backdrop_url) {
return;
}
this.saveCollectionItemsToStorage({
...allItems,
[playlistId]: {
...playlistItems,
[String(contentId)]: {
...item,
backdrop_url: backdropUrl,
},
},
});
return matchingKeys;
}
private getCollectionItemsById(
playlistId: string,
ids: readonly number[]
): Map<number, XtreamContentItem> {
const idSet = new Set(ids);
const results = new Map<number, XtreamContentItem>();
ids: readonly CollectionKey[]
): Map<CollectionKey, XtreamContentItem> {
if (!ids.length) return new Map();
const cached: XtreamContentItem[] = [];
for (const type of this.contentTypes) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
for (const item of content) {
const itemId = this.getItemIdentity(item, type);
if (idSet.has(itemId)) {
results.set(itemId, item as XtreamContentItem);
}
}
const content =
this.contentCache.get(`${playlistId}-${type}-content`) ?? [];
for (const item of content) cached.push(item as XtreamContentItem);
}
return findCollectionItems(
ids,
cached,
this.collectionStorage.getCollectionItemsFromStorage()[
playlistId
] ?? {},
this.contentTypes.every((type) =>
this.contentCache.has(`${playlistId}-${type}-content`)
)
);
}
const storedItems = this.getCollectionItemsFromStorage()[playlistId];
if (!storedItems) {
return results;
}
for (const id of ids) {
if (!results.has(id) && storedItems[String(id)]) {
results.set(id, storedItems[String(id)]);
}
}
return results;
private async resolveCollectionKey(
playlistId: string,
ref: XtreamCollectionRef
): Promise<CollectionKey | null> {
const key = collectionKey(ref);
if (typeof key !== 'number') return key;
const items = await this.getCollectionItemsWithHydration(playlistId, [
key,
]);
const item = items.get(key);
return item ? itemCollectionKey(item) : null;
}
private async getCollectionItemsWithHydration(
playlistId: string,
ids: readonly number[]
): Promise<Map<number, XtreamContentItem>> {
ids: readonly CollectionKey[]
): Promise<Map<CollectionKey, XtreamContentItem>> {
const contentById = this.getCollectionItemsById(playlistId, ids);
const missingIds = ids.filter((id) => !contentById.has(id));
if (missingIds.length === 0) {
@@ -1278,7 +1137,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private findCachedContentItemById(
playlistId: string,
contentId: number
contentId: CollectionKey
): XtreamContentItem | null {
return (
this.getCollectionItemsById(playlistId, [contentId]).get(
@@ -1289,14 +1148,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private async hydrateStoredCollectionContent(
playlistId: string,
ids: readonly number[]
ids: readonly CollectionKey[]
): Promise<void> {
if (ids.length === 0) {
return;
}
const missingTypes = this.contentTypes.filter(
(type) => !this.contentCache.has(`${playlistId}-${type}-content`)
(type) =>
!this.contentCache.has(`${playlistId}-${type}-content`) &&
ids.some(
(id) =>
typeof id === 'number' ||
typedCollectionRef(id)?.type === type
)
);
if (missingTypes.length === 0) {
return;
@@ -1367,11 +1232,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
* lookups stay on the documented title-only fallback.
*/
async setContentMetadataIfMissing(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
patch: ContentMetadataPatch
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedContentId = await this.resolveCollectionKey(
playlistId,
contentId
);
const normalizedBackdropUrl = patch.backdropUrl?.trim();
if (normalizedContentId == null || !normalizedBackdropUrl) {
return;
@@ -1388,7 +1256,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
cacheKey,
content.map((item) => {
const itemId = this.getItemIdentity(item, type);
if (itemId !== normalizedContentId || item.backdrop_url) {
if (
collectionKey({ id: itemId, type }) !==
normalizedContentId ||
item.backdrop_url
) {
return item;
}
@@ -1400,19 +1272,19 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
);
}
this.setCollectionItemBackdropIfMissing(
this.collectionStorage.setCollectionItemBackdropIfMissing(
playlistId,
normalizedContentId,
normalizedBackdropUrl
);
const allRecent = this.getRecentItemsFromStorage();
const allRecent = this.collectionStorage.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId];
if (!playlistRecent) {
return;
}
this.saveRecentItemsToStorage({
this.collectionStorage.saveRecentItemsToStorage({
...allRecent,
[playlistId]: playlistRecent.map((item) => {
if (item.id !== normalizedContentId || item.backdropUrl) {
@@ -1427,33 +1299,16 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
});
}
private findContentIdentity(
playlistId: string,
xtreamId: number,
contentType?: 'live' | 'movie' | 'series'
): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null {
const types = contentType
? [contentType]
: (['live', 'movie', 'series'] as const);
for (const type of types) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = content.find((item) => {
const itemXtreamId = this.getItemIdentity(item, type);
return itemXtreamId === xtreamId;
});
if (found) {
return {
contentType: type,
xtreamId,
};
}
}
return null;
private findContentIdentity(playlistId: string, key: CollectionKey) {
const ref = typedCollectionRef(key);
if (ref) return { contentType: ref.type, xtreamId: ref.id };
const item = this.findCachedContentItemById(playlistId, key);
const identity = item
? typedCollectionRef({ id: item.xtream_id, type: item.type })
: null;
return identity
? { contentType: identity.type, xtreamId: identity.id }
: null;
}
// =========================================================================
@@ -1463,9 +1318,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async clearPlaylistContent(
playlistId: string
): Promise<XtreamPendingRestoreState> {
// Get current favorites and recent items
const favorites = this.getFavoritesFromStorage();
const recentItems = this.getRecentItemsFromStorage();
// Resolve only unambiguous legacy references before producing typed backup data.
await this.getFavorites(playlistId);
await this.getRecentItems(playlistId);
const favorites = this.collectionStorage.getFavoritesFromStorage();
const recentItems = this.collectionStorage.getRecentItemsFromStorage();
const playbackPositions =
await this.getAllPlaybackPositions(playlistId);
@@ -1521,19 +1378,26 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
): Promise<void> {
void options;
// Restore favorites
const favorites = this.getFavoritesFromStorage();
favorites[playlistId] = restoreState.favorites.map(
(item) => item.xtreamId
);
this.saveFavoritesToStorage(favorites);
const favorites = this.collectionStorage.getFavoritesFromStorage();
favorites[playlistId] = restoreState.favorites
.map((item) =>
collectionKey({ id: item.xtreamId, type: item.contentType })
)
.filter((key): key is CollectionKey => key !== null);
this.collectionStorage.saveFavoritesToStorage(favorites);
// Restore recent items
const recentItems = this.getRecentItemsFromStorage();
recentItems[playlistId] = restoreState.recentlyViewed.map((item) => ({
id: item.xtreamId,
viewedAt: item.viewedAt,
}));
this.saveRecentItemsToStorage(recentItems);
const recentItems = this.collectionStorage.getRecentItemsFromStorage();
recentItems[playlistId] = [];
for (const item of restoreState.recentlyViewed) {
const id = collectionKey({
id: item.xtreamId,
type: item.contentType,
});
if (id !== null)
recentItems[playlistId].push({ id, viewedAt: item.viewedAt });
}
this.collectionStorage.saveRecentItemsToStorage(recentItems);
// Restore playback positions
this.clearPlaybackPositionsForPlaylist(playlistId);
@@ -18,6 +18,20 @@ import {
// Re-export for backward compatibility
export type { PlaybackPositionData };
export const XTREAM_CONTENT_TYPES = ['live', 'movie', 'series'] as const;
export type XtreamContentType = (typeof XTREAM_CONTENT_TYPES)[number];
/** SQLite row id on Electron; provider id on PWA. Always carry type for PWA. */
export interface TypedXtreamCollectionRef {
readonly id: number;
readonly type: XtreamContentType;
}
export type XtreamCollectionRef = number | TypedXtreamCollectionRef;
export function xtreamCollectionId(ref: XtreamCollectionRef): number {
return typeof ref === 'number' ? ref : ref.id;
}
/**
* Playlist representation in the data source
*/
@@ -327,7 +341,7 @@ export interface IXtreamDataSource {
* a cinematic backdrop without a separate round-trip.
*/
addFavorite(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
backdropUrl?: string
): Promise<void>;
@@ -335,12 +349,18 @@ export interface IXtreamDataSource {
/**
* Remove content from favorites
*/
removeFavorite(contentId: number, playlistId: string): Promise<void>;
removeFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void>;
/**
* Check if content is favorited
*/
isFavorite(contentId: number, playlistId: string): Promise<boolean>;
isFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<boolean>;
// =========================================================================
// Recently Viewed Operations
@@ -355,7 +375,7 @@ export interface IXtreamDataSource {
* Add item to recently viewed. See `addFavorite` for `backdropUrl`.
*/
addRecentItem(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
backdropUrl?: string
): Promise<void>;
@@ -363,7 +383,10 @@ export interface IXtreamDataSource {
/**
* Remove item from recently viewed
*/
removeRecentItem(contentId: number, playlistId: string): Promise<void>;
removeRecentItem(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void>;
/**
* Clear recently viewed for a playlist
@@ -391,7 +414,7 @@ export interface IXtreamDataSource {
* ordering, and never overwrites a column that already has a value.
*/
setContentMetadataIfMissing(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string,
patch: ContentMetadataPatch
): Promise<void>;
@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { from, map, Observable } from 'rxjs';
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import {
XTREAM_DATA_SOURCE,
XtreamCollectionRef,
} from '../data-sources/xtream-data-source.interface';
import { FavoriteItem } from './favorite-item.interface';
function normalizeCategoryId(categoryId: string | number): number {
@@ -15,7 +18,7 @@ export class FavoritesService {
private dataSource = inject(XTREAM_DATA_SOURCE);
async addToFavorites(item: {
content_id: number;
content_id: XtreamCollectionRef;
playlist_id: string;
backdrop_url?: string;
}): Promise<void> {
@@ -27,13 +30,16 @@ export class FavoritesService {
}
async removeFromFavorites(
contentId: number,
contentId: XtreamCollectionRef,
playlistId: string
): Promise<void> {
await this.dataSource.removeFavorite(contentId, playlistId);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
async isFavorite(
contentId: XtreamCollectionRef,
playlistId: string
): Promise<boolean> {
return await this.dataSource.isFavorite(contentId, playlistId);
}
@@ -1,3 +1,4 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
@@ -19,6 +20,7 @@ const TestFavoritesStore = signalStore(
describe('withFavorites', () => {
const originalElectron = window.electron;
const runtime = { supportsXtreamSqliteDataSource: false };
let store: InstanceType<typeof TestFavoritesStore>;
let dataSource: {
addFavorite: jest.Mock;
@@ -28,6 +30,7 @@ describe('withFavorites', () => {
};
beforeEach(() => {
runtime.supportsXtreamSqliteDataSource = false;
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
@@ -38,6 +41,7 @@ describe('withFavorites', () => {
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
@@ -56,6 +60,31 @@ describe('withFavorites', () => {
});
});
it.each([false, true])(
'toggles the typed target using its persisted status %s rather than the previous detail state',
async (persistedStatus) => {
patchState(store, { isFavorite: !persistedStatus });
dataSource.getContentByXtreamId.mockResolvedValue({
id: 42,
xtream_id: 42,
type: 'live',
});
dataSource.isFavorite.mockResolvedValue(persistedStatus);
expect(await store.toggleFavorite(42, 'playlist-1', 'live')).toBe(
!persistedStatus
);
expect(dataSource.isFavorite).toHaveBeenCalledWith(
{ id: 42, type: 'live' },
'playlist-1'
);
expect(
persistedStatus
? dataSource.removeFavorite
: dataSource.addFavorite
).toHaveBeenCalled();
}
);
it('looks favorites up with the requested content type before adding one', async () => {
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
@@ -72,7 +101,7 @@ describe('withFavorites', () => {
'series'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
3941697,
{ id: 3941697, type: 'series' },
'playlist-1',
undefined
);
@@ -95,7 +124,7 @@ describe('withFavorites', () => {
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
{ id: 1767451, type: 'movie' },
'playlist-1',
undefined
);
@@ -123,7 +152,7 @@ describe('withFavorites', () => {
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
1767451,
{ id: 1767451, type: 'movie' },
'playlist-1',
undefined
);
@@ -138,6 +167,7 @@ describe('withFavorites', () => {
xtream_id: 290,
});
patchState(store, { isFavorite: true });
dataSource.isFavorite.mockResolvedValue(true);
const result = await store.toggleFavorite(290, 'playlist-1', 'live');
@@ -147,7 +177,7 @@ describe('withFavorites', () => {
'live'
);
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
3867578,
{ id: 3867578, type: 'live' },
'playlist-1'
);
expect(result).toBe(false);
@@ -171,7 +201,7 @@ describe('withFavorites', () => {
'series'
);
expect(dataSource.isFavorite).toHaveBeenCalledWith(
3829429,
{ id: 3829429, type: 'series' },
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
@@ -189,7 +219,7 @@ describe('withFavorites', () => {
await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie');
expect(dataSource.isFavorite).toHaveBeenCalledWith(
1767451,
{ id: 1767451, type: 'movie' },
'playlist-1'
);
expect(store.isFavorite()).toBe(true);
@@ -211,6 +241,7 @@ describe('withFavorites', () => {
});
it('does not toggle Electron favorites when the cached content is missing', async () => {
runtime.supportsXtreamSqliteDataSource = true;
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
@@ -237,6 +268,7 @@ describe('withFavorites', () => {
});
it('resets Electron favorite state when the cached content is missing', async () => {
runtime.supportsXtreamSqliteDataSource = true;
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
@@ -1,3 +1,4 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { inject } from '@angular/core';
import {
patchState,
@@ -14,94 +15,110 @@ export const withFavorites = function () {
withState({
isFavorite: false,
}),
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
async toggleFavorite(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
withMethods(
(
store,
dataSource = inject(XTREAM_DATA_SOURCE),
runtime = inject(RuntimeCapabilitiesService)
) => ({
async toggleFavorite(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
) {
return false;
}
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
return false;
}
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
if (contentId == null) {
logger.error(
'Content not found for xtream ID',
normalizedXtreamId
);
return false;
}
const currentStatus = store.isFavorite();
if (currentStatus) {
// Remove from favorites
await dataSource.removeFavorite(contentId, playlistId);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
contentId,
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
backdropUrl
contentType
);
patchState(store, { isFavorite: true });
return true;
}
},
const contentId =
content?.id ??
(!runtime.supportsXtreamSqliteDataSource
? normalizedXtreamId
: null);
async checkFavoriteStatus(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
if (contentId == null) {
logger.error(
'Content not found for xtream ID',
normalizedXtreamId
);
return false;
}
const currentStatus = await dataSource.isFavorite(
{ id: contentId, type: contentType },
playlistId
);
if (currentStatus) {
// Remove from favorites
await dataSource.removeFavorite(
{ id: contentId, type: contentType },
playlistId
);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
{ id: contentId, type: contentType },
playlistId,
backdropUrl
);
patchState(store, { isFavorite: true });
return true;
}
},
async checkFavoriteStatus(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
patchState(store, { isFavorite: false });
return;
}
const normalizedXtreamId = Number(xtreamId);
if (
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!playlistId
) {
patchState(store, { isFavorite: false });
return;
}
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!window.electron ? normalizedXtreamId : null);
const content = await dataSource.getContentByXtreamId(
normalizedXtreamId,
playlistId,
contentType
);
const contentId =
content?.id ??
(!runtime.supportsXtreamSqliteDataSource
? normalizedXtreamId
: null);
if (contentId == null) {
patchState(store, { isFavorite: false });
return;
}
if (contentId == null) {
patchState(store, { isFavorite: false });
return;
}
const isFavorite = await dataSource.isFavorite(
contentId,
playlistId
);
const isFavorite = await dataSource.isFavorite(
{ id: contentId, type: contentType },
playlistId
);
patchState(store, { isFavorite });
},
}))
patchState(store, { isFavorite });
},
})
)
);
};
@@ -2,7 +2,11 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withRecentItems } from './with-recent-items';
@@ -19,6 +23,7 @@ const TestRecentItemsStore = signalStore(withRecentItems());
describe('withRecentItems', () => {
const originalElectron = window.electron;
const runtime = { supportsXtreamSqliteDataSource: false };
let store: InstanceType<typeof TestRecentItemsStore>;
let databaseService: {
clearGlobalRecentlyViewed: jest.Mock;
@@ -42,6 +47,7 @@ describe('withRecentItems', () => {
};
beforeEach(() => {
runtime.supportsXtreamSqliteDataSource = false;
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
@@ -95,6 +101,7 @@ describe('withRecentItems', () => {
TestBed.configureTestingModule({
providers: [
TestRecentItemsStore,
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: DatabaseService,
useValue: databaseService,
@@ -142,7 +149,7 @@ describe('withRecentItems', () => {
'series'
);
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
{ id: 3941697, type: 'series' },
'playlist-1',
undefined
);
@@ -172,7 +179,7 @@ describe('withRecentItems', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
{ id: 1767451, type: 'movie' },
'playlist-1',
undefined
);
@@ -199,7 +206,7 @@ describe('withRecentItems', () => {
'movie'
);
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
1767451,
{ id: 1767451, type: 'movie' },
'playlist-1',
undefined
);
@@ -222,7 +229,7 @@ describe('withRecentItems', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
{ id: 3941697, type: 'series' },
'playlist-1',
'https://example.com/krypton-backdrop.png'
);
@@ -251,7 +258,7 @@ describe('withRecentItems', () => {
'series'
);
expect(dataSource.setContentMetadataIfMissing).toHaveBeenCalledWith(
3941697,
{ id: 3941697, type: 'series' },
'playlist-1',
{ backdropUrl: 'https://example.com/krypton-backdrop.png' }
);
@@ -322,11 +329,15 @@ describe('withRecentItems', () => {
configurable: true,
});
store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' });
store.removeRecentItem({
itemId: 3941697,
contentType: 'series',
playlistId: 'playlist-1',
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.removeRecentItem).toHaveBeenCalledWith(
3941697,
{ id: 3941697, type: 'series' },
'playlist-1'
);
expect(databaseService.removeRecentItem).not.toHaveBeenCalled();
@@ -7,7 +7,11 @@ import {
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { firstValueFrom, pipe, switchMap, tap } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
buildPlaylistRecentItems,
ContentMetadataPatch,
@@ -78,7 +82,8 @@ export const withRecentItems = function () {
store,
dbService = inject(DatabaseService),
playlistsService = inject(PlaylistsService),
dataSource = inject(XTREAM_DATA_SOURCE)
dataSource = inject(XTREAM_DATA_SOURCE),
runtime = inject(RuntimeCapabilitiesService)
) => ({
addRecentItem: rxMethod<{
xtreamId: number | string;
@@ -112,13 +117,13 @@ export const withRecentItems = function () {
);
const contentId =
content?.id ??
(!window.electron
(!runtime.supportsXtreamSqliteDataSource
? normalizedXtreamId
: null);
if (contentId != null) {
await dataSource.addRecentItem(
contentId,
{ id: contentId, type: contentType },
playlistId,
backdropUrl
);
@@ -172,7 +177,7 @@ export const withRecentItems = function () {
}
await dataSource.setContentMetadataIfMissing(
content.id,
{ id: content.id, type: contentType },
playlistId,
normalized
);
@@ -180,36 +185,35 @@ export const withRecentItems = function () {
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
if (window.electron) {
await dbService.clearPlaylistRecentItems(
playlist.id
);
} else {
await dataSource.clearRecentItems(playlist.id);
}
await dataSource.clearRecentItems(playlist.id);
patchState(store, { recentItems: [] });
})
)
),
removeRecentItem: rxMethod<{
itemId: number;
contentType?: 'live' | 'movie' | 'series';
playlistId: string;
}>(
pipe(
switchMap(async ({ itemId, playlistId }) => {
await dataSource.removeRecentItem(
itemId,
playlistId
);
// Reload recent items to update UI
const items =
await dataSource.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
})
switchMap(
async ({ itemId, playlistId, contentType }) => {
await dataSource.removeRecentItem(
contentType
? { id: itemId, type: contentType }
: itemId,
playlistId
);
// Reload recent items to update UI
const items =
await dataSource.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
}
)
)
),
async loadGlobalRecentItems() {
@@ -0,0 +1,70 @@
import { TestBed } from '@angular/core/testing';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { playlist, sampleChannel } from './live-stream-layout-stubs.spec-data';
interface LiveHistoryContext {
component: LiveStreamLayoutComponent;
xtreamStore: {
constructStreamUrl: jest.Mock<string, []>;
addRecentItem: jest.Mock;
};
portalPlayer: { isEmbeddedPlayer: jest.Mock };
}
/** Collection-history cases share the main live host's component fixture. */
export function liveHistoryTestCases(context: () => LiveHistoryContext): void {
it('records typed live history only after its own playback session confirms', () => {
const { component, xtreamStore } = context();
component.playLive(sampleChannel, true);
const gate = TestBed.inject(PlaybackHistoryGate);
expect(xtreamStore.addRecentItem).not.toHaveBeenCalled();
gate.confirm({
sessionKey: 'other-playlist',
streamUrls: ['https://example.com/live.ts'],
});
expect(xtreamStore.addRecentItem).not.toHaveBeenCalled();
gate.confirm({ sessionKey: component.playbackSessionKey() });
expect(xtreamStore.addRecentItem).toHaveBeenCalledWith(
expect.objectContaining({
xtreamId: 101,
contentType: 'live',
})
);
expect(xtreamStore.addRecentItem.mock.calls[0][0].playlist()).toEqual(
playlist
);
});
it('records external live playback after URL confirmation', () => {
const { component, xtreamStore, portalPlayer } = context();
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
component.playLive(sampleChannel, true);
expect(xtreamStore.addRecentItem).not.toHaveBeenCalled();
TestBed.inject(PlaybackHistoryGate).confirm({
streamUrls: ['https://example.com/live.ts'],
});
expect(xtreamStore.addRecentItem).toHaveBeenCalledWith(
expect.objectContaining({
xtreamId: 101,
contentType: 'live',
})
);
});
it('does not record a live start when no stream URL can be resolved', () => {
const { component, xtreamStore } = context();
xtreamStore.constructStreamUrl.mockReturnValueOnce('');
component.playLive(sampleChannel, true);
expect(xtreamStore.addRecentItem).not.toHaveBeenCalled();
});
it('does not record a live channel selected without starting playback', () => {
const { component, xtreamStore } = context();
component.playLive(sampleChannel, false);
TestBed.inject(PlaybackHistoryGate).confirm({
sessionKey: component.playbackSessionKey(),
});
expect(xtreamStore.addRecentItem).not.toHaveBeenCalled();
});
}
@@ -42,6 +42,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { liveHistoryTestCases } from './live-stream-layout-history.spec-data';
import {
sampleChannel,
@@ -97,6 +98,7 @@ describe('LiveStreamLayoutComponent', () => {
]),
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
openPlayer: jest.fn(),
addRecentItem: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
@@ -147,6 +149,7 @@ describe('LiveStreamLayoutComponent', () => {
router = { events: routerEvents, navigate: jest.fn() };
xtreamStore.constructStreamUrl.mockClear();
xtreamStore.openPlayer.mockClear();
xtreamStore.addRecentItem.mockClear();
xtreamStore.setSelectedItem.mockClear();
xtreamStore.setSelectedCategory.mockClear();
xtreamStore.loadMoreContent.mockClear();
@@ -344,6 +347,8 @@ describe('LiveStreamLayoutComponent', () => {
settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests
});
liveHistoryTestCases(() => ({ component, xtreamStore, portalPlayer }));
it('hides the EPG panel in browser/PWA playback', () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
@@ -104,6 +104,7 @@ import {
} from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import {
XtreamLiveChannelNavigationService,
@@ -155,6 +156,7 @@ export class LiveStreamLayoutComponent
private readonly router = inject(Router);
private readonly favoritesService = inject(FavoritesService);
private readonly xtreamStore = inject(XtreamStore);
private readonly recordRecentItem = injectXtreamRecentHistory();
readonly archiveContextKey = computed(() =>
JSON.stringify([
this.xtreamStore.currentPlaylist()?.id,
@@ -694,6 +696,15 @@ export class LiveStreamLayoutComponent
thumbnail: item.poster_url ?? item.stream_icon ?? null,
isLive: true,
});
if (startPlayback && streamUrl) {
this.recordRecentItem(
streamUrl,
{ xtreamId: item.xtream_id, contentType: 'live' },
this.usesEmbeddedPlayer()
? this.playbackSessionKey()
: undefined
);
}
if (this.usesEmbeddedPlayer() || !startPlayback) {
return;
}
@@ -63,7 +63,12 @@ describe('SerialDetailsMenuService', () => {
},
{
provide: XTREAM_DATA_SOURCE,
useValue: { removeRecentItem: jest.fn() },
useValue: {
removeRecentItem: jest.fn(),
getContentByXtreamId: jest
.fn()
.mockResolvedValue({ id: 103 }),
},
},
{
provide: RuntimeCapabilitiesService,
@@ -161,6 +166,17 @@ describe('SerialDetailsMenuService', () => {
});
});
it('removes only the typed series when hiding it from Continue Watching', async () => {
await service.run(SERIES_MENU_ACTION.HideFromContinueWatching);
expect(
TestBed.inject(XTREAM_DATA_SOURCE).getContentByXtreamId
).toHaveBeenCalledWith(103, 'xtream-1', 'series');
expect(
TestBed.inject(XTREAM_DATA_SOURCE).removeRecentItem
).toHaveBeenCalledWith({ id: 103, type: 'series' }, 'xtream-1');
});
it('is offered once an episode is finished while the next one remains, as the rail lists the series with it', () => {
episodePositionsSignal.set(new Map([watched(1001)]));
@@ -330,7 +330,10 @@ export class SerialDetailsMenuService {
if (!content?.id) {
return;
}
await this.dataSource.removeRecentItem(content.id, playlistId);
await this.dataSource.removeRecentItem(
{ id: content.id, type: 'series' },
playlistId
);
// The refreshed list drops the row, and brings it back once the
// series is played again.
this.xtreamStore.loadRecentItems({ id: playlistId });
@@ -277,7 +277,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
'movie'
);
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
77,
{ id: 77, type: 'movie' },
ROUTE_PLAYLIST,
undefined
);
@@ -311,7 +311,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves);
if (saves) {
expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith(
ROUTE_VOD_ID,
{ id: ROUTE_VOD_ID, type: 'movie' },
ROUTE_PLAYLIST,
undefined
);
@@ -33,9 +33,34 @@ describe('XtreamCollectionDetailComponent', () => {
let cancelDetailsRequest: jest.Mock;
let routerNavigate: jest.Mock;
const seriesItem: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 103,
categoryId: 3,
};
const detailPlaylist = {
_id: 'xtream-1',
title: 'Xtream Portal',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
} as Playlist;
beforeEach(async () => {
playlistId = signal('');
currentPlaylist = signal<XtreamPlaylistData | null>(null);
playlistId = signal('original-playlist');
currentPlaylist = signal<XtreamPlaylistData | null>({
id: 'original-playlist',
name: 'Original Portal',
type: 'xtream',
serverUrl: 'http://original.example',
username: 'original-user',
password: 'original-pass',
});
selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
selectedCategoryId = signal<number | null>(null);
selectedItem = signal<unknown>(null);
@@ -93,15 +118,7 @@ describe('XtreamCollectionDetailComponent', () => {
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() =>
of({
_id: 'xtream-1',
title: 'Xtream Portal',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
} as Playlist)
),
getPlaylistById: jest.fn(() => of(detailPlaylist)),
},
},
],
@@ -127,16 +144,7 @@ describe('XtreamCollectionDetailComponent', () => {
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentRef.setInput('item', {
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem);
fixture.componentRef.setInput('item', seriesItem);
fixture.componentRef.setInput('seriesResume', seriesResume);
fixture.detectChanges();
@@ -174,6 +182,37 @@ describe('XtreamCollectionDetailComponent', () => {
).toEqual(seriesResume);
});
it('keeps the newer selection when an earlier playlist resolves last', fakeAsync(() => {
const pendingPlaylist = new Subject<Playlist>();
jest.spyOn(TestBed.inject(PlaylistsService), 'getPlaylistById')
.mockReturnValueOnce(pendingPlaylist)
.mockReturnValueOnce(of({ ...detailPlaylist, _id: 'xtream-2' }));
fixture.componentRef.setInput('item', seriesItem);
fixture.detectChanges();
fixture.componentRef.setInput('item', {
...seriesItem,
uid: 'xtream::xtream-2::series:203',
playlistId: 'xtream-2',
xtreamId: 203,
categoryId: 6,
} satisfies UnifiedCollectionItem);
fixture.detectChanges();
flushMicrotasks();
const detailInjector = fixture.componentInstance.detailInjector();
pendingPlaylist.next(detailPlaylist);
flushMicrotasks();
expect(playlistId()).toBe('xtream-2');
expect(currentPlaylist()?.id).toBe('xtream-2');
expect(selectedCategoryId()).toBe(6);
expect(fixture.componentInstance.detailInjector()).toBe(detailInjector);
expect(detailInjector?.get(ActivatedRoute).snapshot.params).toEqual({
categoryId: '6',
serialId: '203',
});
}));
it('invalidates detail loading before restoring the underlying store', () => {
fixture.componentInstance.ngOnDestroy();
@@ -9,12 +9,12 @@ import {
export interface XtreamRecentItemRequest {
readonly xtreamId: number | string;
readonly contentType: 'movie' | 'series';
readonly contentType: 'live' | 'movie' | 'series';
readonly backdropUrl?: string;
}
/**
* Records a movie or series as recently viewed once `streamUrl` has really
* Records Xtream content as recently viewed once `streamUrl` has really
* played — inline for a couple of seconds, or launched in MPV/VLC — so a
* source that fails straight away never reaches history or the dashboard
* hero. The playlist is captured when playback starts, so navigating to
@@ -29,15 +29,16 @@ export interface XtreamRecentItemRequest {
*/
export function injectXtreamRecentHistory(): (
streamUrl: string,
request: XtreamRecentItemRequest
request: XtreamRecentItemRequest,
sessionKey?: string
) => void {
const gate = inject(PlaybackHistoryGate);
const store = inject(XtreamStore);
const injector = inject(Injector);
return (streamUrl, request) => {
return (streamUrl, request, sessionKey) => {
const playlist = signal(store.currentPlaylist()).asReadonly();
gate.defer({ streamUrls: [streamUrl] }, () => {
gate.defer({ sessionKey, streamUrls: [streamUrl] }, () => {
const playlistId = playlist()?.id;
if (store.currentPlaylist()?.id === playlistId) {
store.addRecentItem({ ...request, playlist });
@@ -80,6 +81,10 @@ async function saveWithoutListRefresh(
);
const contentId = content?.id ?? (keysByXtreamId ? id : null);
if (contentId != null) {
await dataSource.addRecentItem(contentId, playlistId, backdropUrl);
await dataSource.addRecentItem(
{ id: contentId, type: contentType },
playlistId,
backdropUrl
);
}
}
@@ -880,7 +880,7 @@ describe('DashboardDataService', () => {
await service.removeGlobalFavorite(item);
expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith(
51,
{ id: 51, type: 'movie' },
'xtream-1'
);
expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled();
@@ -2784,7 +2784,7 @@ describe('DashboardDataService', () => {
await service.removeGlobalRecentItem(item);
expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith(
91,
{ id: 91, type: 'movie' },
'xtream-1'
);
expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled();
@@ -1046,7 +1046,10 @@ export class DashboardDataService {
);
} else {
await this.xtreamDataSource.removeRecentItem(
item.id as number,
{
id: Number(item.id),
type: item.type as 'live' | 'movie' | 'series',
},
item.playlist_id
);
}
@@ -1121,7 +1124,10 @@ export class DashboardDataService {
);
} else {
await this.xtreamDataSource.removeFavorite(
item.id as number,
{
id: Number(item.id),
type: item.type as 'live' | 'movie' | 'series',
},
item.playlist_id
);
}