fix(xtream): preserve backup metadata and recent favorite identity

This commit is contained in:
4gray committed 2026-10-14 01:49:40 +02:00
1 parent a9955ce781
commit 1ed192833f
9 files changed
+295 -13

No files matched your search

@@ -1,3 +1,4 @@
import { createServer, get as httpGet } from 'node:http';
import {
addXtreamPortal,
clickCategoryByNameExact,
@@ -16,7 +17,57 @@ import {
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures';
import { readSavedFavorites } from './support/xtream-favorites-catalog';
import {
createFavoritesCatalogProxy,
readSavedFavorites,
} from './support/xtream-favorites-catalog';
test('@xtream catalog proxy keeps absolute request targets on the mock upstream', async () => {
let redirectedRequests = 0;
const trap = createServer((_request, response) => {
redirectedRequests++;
response.end('{"unexpected":"upstream"}');
});
await new Promise<void>((resolve) => trap.listen(0, '127.0.0.1', resolve));
const address = trap.address();
if (!address || typeof address === 'string')
throw new Error('Expected TCP address');
const proxy = await createFavoritesCatalogProxy();
try {
const target = new URL(proxy.origin);
const response = await new Promise<{ status: number; body: string }>(
(resolve, reject) => {
const request = httpGet(
{
hostname: target.hostname,
port: target.port,
path: `http://127.0.0.1:${address.port}/player_api.php?username=minimal&password=minimal`,
},
(incoming) => {
let body = '';
incoming.setEncoding('utf8');
incoming.on('data', (chunk) => {
body += chunk;
});
incoming.on('end', () =>
resolve({ status: incoming.statusCode ?? 0, body })
);
incoming.on('error', reject);
}
);
request.on('error', reject);
}
);
expect(response.status).toBe(200);
expect(redirectedRequests).toBe(0);
expect(JSON.parse(response.body)).toHaveProperty('user_info');
} finally {
await proxy.close();
await new Promise<void>((resolve, reject) =>
trap.close((error) => (error ? reject(error) : resolve()))
);
}
});
test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({
dataDir,
@@ -27,7 +27,12 @@ export async function createFavoritesCatalogProxy(): Promise<FavoritesCatalogPro
const responses = new Map<string, number>();
const pending = new Set<AbortController>();
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', xtreamMockServer);
const incoming = new URL(request.url ?? '/', xtreamMockServer);
// Only path/query may vary: an absolute request target must never select
// a different upstream host for this local test proxy.
const url = new URL(xtreamMockServer);
url.pathname = incoming.pathname;
url.search = incoming.search;
const action = url.searchParams.get('action') ?? 'get_account_info';
const requestMode = mode;
response.once('finish', () => {
@@ -63,6 +63,9 @@ Adding a conflicting replacement returns an explicit failure and retains the
older favorite. Detail and recent-collection toggles stay inactive and explain
that the older entry must be removed from Favorites before the replacement can
be saved. Collection hearts change only after the storage write succeeds.
Recent collections also check the current catalog row's favorite status through
the data source when loading: a retained favorite's matching provider key alone
cannot mark an incompatible replacement as saved.
`catalog_status` is `present`, `missing`, or `unknown`. Only a completed import
of the relevant type can prove absence. Failed, cancelled and in-progress
@@ -94,6 +97,10 @@ unfiltered read; a failed favorites read fails export rather than producing an
empty collection. New backup imports materialize favorites before waiting for
an online catalog. Existing history, hidden-category and playback-position
restore contracts remain separate.
Browser imports convert desktop favorite snapshots into typed collection-cache
rows so saved titles and artwork remain visible offline. Browser exports include
the normalized nested favorite snapshots as well as their optional collection
cache, preserving metadata when the archive returns to Electron.
Both Xtream data-source and portal-activity capabilities require the pending
favorite conversion bridge; activity reads also require playlist lookup. The
@@ -89,7 +89,7 @@ export class UnifiedCollectionDataService {
const favoriteUids =
params.mode === 'favorites'
? new Set(items.map((item) => item.uid))
: await this.loadFavoriteUidSet(params);
: await this.loadFavoriteUidSet(params, items);
if (requestId !== this.requestId) {
return null;
}
@@ -188,7 +188,8 @@ export class UnifiedCollectionDataService {
}
private async loadFavoriteUidSet(
params: CollectionLoadRequest
params: CollectionLoadRequest,
recentItems: UnifiedCollectionItem[]
): Promise<ReadonlySet<string>> {
try {
const favorites = await this.favoritesData.getFavorites(
@@ -196,7 +197,20 @@ export class UnifiedCollectionDataService {
params.playlistId,
params.portalType
);
return new Set(favorites.map((item) => item.uid));
const uids = new Set(favorites.map((item) => item.uid));
await Promise.all(
recentItems.map(async (item) => {
if (item.sourceType !== 'xtream' || !uids.has(item.uid))
return;
// Favorites can retain an older, incompatible movie with this UID.
// Let the owning data source check this exact recent catalog row.
const matches = await this.favoritesData
.isXtreamFavorite(item)
.catch(() => false);
if (!matches) uids.delete(item.uid);
})
);
return uids;
} catch {
return new Set<string>();
}
@@ -36,11 +36,13 @@ describe('UnifiedFavoritesDataService', () => {
dbRemoveRecentItemsBatch: jest.Mock;
};
let databaseService: {
isFavorite: jest.Mock;
getAllGlobalFavorites: jest.Mock;
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
};
let xtreamDataSource: {
isFavorite: jest.Mock;
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
getFavorites: jest.Mock;
@@ -166,11 +168,13 @@ describe('UnifiedFavoritesDataService', () => {
),
};
databaseService = {
isFavorite: jest.fn().mockResolvedValue(true),
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
};
xtreamDataSource = {
isFavorite: jest.fn().mockResolvedValue(true),
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getFavorites: jest.fn().mockResolvedValue([]),
@@ -205,7 +209,10 @@ describe('UnifiedFavoritesDataService', () => {
providers: [
UnifiedFavoritesDataService,
UnifiedCollectionDataService,
{ provide: UnifiedRecentDataService, useValue: {} },
{
provide: UnifiedRecentDataService,
useValue: { getRecentItems: jest.fn() },
},
{
provide: Store,
useValue: store,
@@ -710,6 +717,56 @@ describe('UnifiedFavoritesDataService', () => {
expect(collection.favoriteUidSet().has(item.uid)).toBe(true);
});
it.each([undefined, 99])(
'checks the recent row identity against a saved favorite resolved to %s before setting its heart',
async (savedContentId) => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::movie:101',
name: 'Replacement movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 101,
contentId: 42,
};
jest.spyOn(service, 'getFavorites').mockResolvedValue([
{
...item,
name: 'Older favorite',
contentId: savedContentId,
favoriteId: 5,
},
]);
jest.spyOn(
TestBed.inject(UnifiedRecentDataService),
'getRecentItems'
).mockResolvedValue([item]);
databaseService.isFavorite.mockResolvedValue(false);
electronApi.dbAddFavorite.mockResolvedValue({
success: false,
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
});
const collection = TestBed.inject(UnifiedCollectionDataService);
const request = { mode: 'recent', scope: 'all' } as const;
await collection.load(request);
expect(collection.favoriteUidSet().has(item.uid)).toBe(false);
expect(databaseService.isFavorite).toHaveBeenCalledWith(
42,
'xtream-1'
);
await expect(collection.toggleFavorite(item)).rejects.toThrow(
'XTREAM_FAVORITE_IDENTITY_CONFLICT'
);
expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled();
await collection.load(request);
expect(collection.favoriteUidSet().has(item.uid)).toBe(false);
databaseService.isFavorite.mockResolvedValue(true);
await collection.load(request);
expect(collection.favoriteUidSet().has(item.uid)).toBe(true);
}
);
it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
@@ -80,6 +80,20 @@ export class UnifiedFavoritesDataService {
}
}
/** A reused provider ID alone cannot determine a recent row's favorite status. */
async isXtreamFavorite(item: UnifiedCollectionItem): Promise<boolean> {
const contentId =
item.contentId ?? (await this.resolveXtreamContentId(item));
if (contentId == null) return false;
if (this.electronActivityBridge) {
return this.dbService.isFavorite(contentId, item.playlistId);
}
return this.xtreamDataSource.isFavorite(
{ id: contentId, type: item.contentType },
item.playlistId
);
}
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
switch (item.sourceType) {
case 'm3u': {
@@ -145,6 +145,7 @@ describe('UnifiedCollectionPageComponent', () => {
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
isXtreamFavorite: jest.fn().mockResolvedValue(true),
addFavorite: jest.fn().mockResolvedValue(undefined),
getFavorites: jest.fn().mockResolvedValue([]),
clearFavorites: jest.fn().mockResolvedValue(undefined),
@@ -159,8 +159,20 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'series', xtreamId: 77 },
{
contentType: 'movie',
xtreamId: 501,
snapshot: {
title: heat.title,
posterUrl: heat.poster_url,
backdropUrl: heat.backdrop_url,
},
},
{
contentType: 'series',
xtreamId: 77,
snapshot: { title: legacyShow.title },
},
],
// One row per title, its newest view.
recentlyViewed: [
@@ -272,6 +284,60 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
});
describe('import', () => {
it('preserves desktop favorite snapshots offline and includes them in browser re-exports', async () => {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
const favorites = [
{
contentType: 'movie' as const,
xtreamId: 501,
addedAt: '2026-01-02',
position: 3,
snapshot: {
title: 'Offline desktop movie',
posterUrl: 'https://img.example.com/saved.jpg',
backdropUrl: 'https://img.example.com/saved-wide.jpg',
tmdbId: 949,
releaseYear: 1995,
originalTitle: 'Original movie title',
},
},
];
entry.userState = {
hiddenCategories: [],
favorites,
recentlyViewed: [],
playbackPositions: [],
};
localStorage.clear();
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
expect(
await service.importBackup(xtreamOnly(manifest))
).toMatchObject({ merged: 1, failed: 0 });
expect(
read(KEYS.collectionItems)?.['xtream-1']?.['movie:501']
).toMatchObject({
id: 501,
xtream_id: 501,
type: 'movie',
title: favorites[0].snapshot.title,
poster_url: favorites[0].snapshot.posterUrl,
backdrop_url: favorites[0].snapshot.backdropUrl,
tmdb_id: 949,
release_year: 1995,
original_title: 'Original movie title',
});
const { manifest: reexport } = await service.exportBackup();
expect(xtreamEntry(reexport).userState.favorites).toEqual(
favorites
);
expect(
xtreamEntry(reexport).userState.collectionItems
).toHaveLength(1);
});
it('restores every list under the id the playlist is imported as', async () => {
const manifest = await exportSeeded();
localStorage.clear();
@@ -361,6 +427,9 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
entry.userState.favorites = entry.userState.favorites.map(
({ contentType, xtreamId }) => ({ contentType, xtreamId })
);
const before = read(KEYS.collectionItems);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import {
PlaybackPositionData,
normalizeXtreamPendingRestoreState,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
@@ -42,11 +43,16 @@ export class PwaXtreamBackupStateService {
const playlistId = entry.exportedId;
const stored = this.readMap('collectionItems')[playlistId];
const snapshots = isRecord(stored) ? stored : {};
const byKey = snapshotsByKey(Object.values(snapshots));
const favorites = new Map<string, XtreamBackupFavoriteItem>();
for (const key of this.listFor('favorites', playlistId)) {
const ref = resolveKey(key, snapshots);
if (ref) favorites.set(ref.key, ref.item);
if (ref)
favorites.set(
ref.key,
favoriteWithSnapshot(ref, byKey[ref.key])
);
}
// Newest view wins, artwork included, as in the data source.
@@ -66,7 +72,6 @@ export class PwaXtreamBackupStateService {
}
}
const byKey = snapshotsByKey(Object.values(snapshots));
const collectionItems = [
...new Set([...favorites.keys(), ...recent.keys()]),
]
@@ -94,9 +99,10 @@ export class PwaXtreamBackupStateService {
*
* Lists first, so the optional cache never takes space they need. A
* failed write throws with earlier ones applied, and the caller keeps the
* pending snapshot for the Xtream store to replay on the next open. The
* replay carries no titles (they come from `entry`, not `state`), so they
* are still merged then, unless storage is full and they would crowd it.
* pending snapshot for the Xtream store to replay on the next open.
* Browser-only cache rows come from `entry`; desktop favorite snapshots
* also travel in `state`. Merge both unless a full store would crowd out
* the lists that still need to be restored.
*/
async restore(
playlistId: string,
@@ -108,6 +114,39 @@ export class PwaXtreamBackupStateService {
const snapshots = snapshotsByKey(
Array.isArray(archived) ? archived : []
);
for (const favorite of state.favorites) {
const saved = favorite.snapshot;
const key = typedKey(favorite.contentType, favorite.xtreamId);
if (!saved || key === null) continue;
snapshots[key] = {
...snapshots[key],
id: favorite.xtreamId,
xtream_id: favorite.xtreamId,
type: favorite.contentType,
title: saved.title,
...(saved.posterUrl !== undefined
? { poster_url: saved.posterUrl }
: {}),
...(saved.backdropUrl !== undefined
? { backdrop_url: saved.backdropUrl }
: {}),
...(saved.tmdbId !== undefined
? { tmdb_id: saved.tmdbId }
: {}),
...(saved.releaseYear !== undefined
? { release_year: saved.releaseYear }
: {}),
...(saved.originalTitle !== undefined
? { original_title: saved.originalTitle }
: {}),
...(favorite.addedAt !== undefined
? { added_at: favorite.addedAt }
: {}),
...(favorite.position !== undefined
? { position: favorite.position }
: {}),
};
}
try {
this.replaceLists(playlistId, state);
} catch (error) {
@@ -222,6 +261,31 @@ export class PwaXtreamBackupStateService {
}
}
/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */
function favoriteWithSnapshot(
ref: ResolvedKey,
saved?: XtreamBackupCollectionItem
): XtreamBackupFavoriteItem {
if (!saved) return ref.item;
return normalizeXtreamPendingRestoreState({
favorites: [
{
...ref.item,
addedAt: saved['added_at'],
position: saved['position'],
snapshot: {
title: saved['title'],
posterUrl: saved['poster_url'],
backdropUrl: saved['backdrop_url'],
tmdbId: saved['tmdb_id'],
releaseYear: saved['release_year'],
originalTitle: saved['original_title'],
},
},
],
}).favorites[0];
}
/**
* A typed key carries its own identity. A bare provider id resolves only
* through the snapshot stored under it, as the data source does without a