mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): preserve backup metadata and recent favorite identity
This commit is contained in:
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
|
||||
|
||||
+17
-3
@@ -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>();
|
||||
}
|
||||
|
||||
+58
-1
@@ -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': {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user