fix(xtream): retain favorites through storage and metadata updates

This commit is contained in:
4gray committed 2026-10-14 01:12:34 +02:00
1 parent 844aaa5329
commit fcbf57db3e
7 files changed
+284 -48

No files matched your search

@@ -9,6 +9,7 @@ import {
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
@@ -148,3 +149,111 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
if (app) await closeElectronApp(app);
}
});
test('@persistence @xtream @electron retains a late-confirmed snapshot through catalog loss and unavailable legacy storage', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const item = fixture.items[0];
const title = getXtreamTitle(item);
const oldTitle = 'Snapshot before identity confirmation';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: 'Late confirmed favorite',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const playlistId = /\/xtreams\/([^/]+)/.exec(
new URL(app.mainWindow.url()).pathname
)?.[1];
if (!playlistId) throw new Error('Expected a playlist route');
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const returned = await app.mainWindow.evaluate(
async ({ playlistId, xtreamId, oldTitle }) => {
const row = await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId,
'movie'
);
if (!row) throw new Error('Expected imported content');
const identity = { tmdbId: 99999999, releaseYear: 2000 };
const restored = await window.electron.dbRestoreXtreamFavorites(
playlistId,
[
{
xtreamId,
contentType: 'movie',
snapshot: {
title: oldTitle,
posterUrl: 'https://art.invalid/old.jpg',
...identity,
},
},
]
);
if (!restored.success)
throw new Error('Could not seed favorite');
await window.electron.dbSetAppState(
`xtream-import-status:${playlistId}:movie`,
'completed'
);
const [unconfirmed] =
await window.electron.dbGetFavoritesForBackup(playlistId);
if (unconfirmed.title !== oldTitle)
throw new Error('Unconfirmed snapshot overwritten');
const enriched =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
identity
);
if (!enriched.success)
throw new Error('Could not confirm identity');
// No second import completion is allowed to repair the snapshot.
await window.electron.dbDeleteXtreamContent(playlistId);
return {
poster: row.poster_url,
backdrop: row.backdrop_url ?? null,
};
},
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
);
const [saved] = await readSavedFavorites(app.mainWindow, playlistId);
expect(saved).toMatchObject({
id: null,
title,
poster_url: returned.poster,
backdrop_url: returned.backdrop,
});
await app.mainWindow.evaluate((playlistId) => {
const key = `xtream-restore-${playlistId}`;
localStorage.setItem(key, JSON.stringify({ favorites: [] }));
const getItem = Storage.prototype.getItem;
Storage.prototype.getItem = function (requestedKey) {
if (requestedKey === key)
throw new Error('Storage unavailable');
return getItem.call(this, requestedKey);
};
}, playlistId);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible();
await expect(contentCardByTitle(app.mainWindow, oldTitle)).toHaveCount(
0
);
app = await restartElectronApp(app, dataDir);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible();
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([
saved,
]);
} finally {
if (app) await closeElectronApp(app);
}
});
@@ -7,6 +7,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
favoriteMatchesContentId,
favoriteSnapshotFromContent,
favoriteSnapshotMatchesContent,
} from './favorite-catalog';
import type { AppDatabase } from '../database.types';
@@ -110,17 +111,11 @@ export async function persistContentMetadataIfMissing(
.where(and(eq(schema.content.id, contentId), anyAbsent));
// Favorites own their metadata beyond the lifetime of this catalog row.
// Capture enrichment when learned, before a later refresh can delete it.
// Once enrichment confirms identity, capture the complete returned snapshot,
// including title/artwork that arrived before the confirming metadata.
await db
.update(schema.favorites)
.set(
Object.fromEntries(
columns.map(({ key, column }) => [
key,
sql`COALESCE(NULLIF(${schema.favorites[key]}, ''), (SELECT ${column} FROM content WHERE id = ${contentId}))`,
])
)
)
.set(favoriteSnapshotFromContent(contentId))
.where(
and(
favoriteMatchesContentId(contentId),
@@ -1,4 +1,4 @@
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { isParentalLockActive } from '../parental-lock-state';
@@ -25,6 +25,18 @@ export function favoriteSnapshotMatchesContent() {
AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`;
}
/** Use only after confirming identity; empty catalog fields retain the saved value. */
export function favoriteSnapshotFromContent(contentId: number | SQL) {
return {
title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = ${contentId}), favorites.title)`,
posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = ${contentId}), favorites.poster_url)`,
backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = ${contentId}), favorites.backdrop_url)`,
tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = ${contentId}), favorites.tmdb_id)`,
releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = ${contentId}), favorites.release_year)`,
originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = ${contentId}), favorites.original_title)`,
};
}
export function favoriteCatalogStatus() {
return sql<'present' | 'missing' | 'unknown'>`CASE
WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown'
@@ -90,14 +102,7 @@ export async function reconcileCompletedFavoriteCatalog(
)
.run();
tx.update(schema.favorites)
.set({
title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = favorites.content_id), favorites.title)`,
posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = favorites.content_id), favorites.poster_url)`,
backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = favorites.content_id), favorites.backdrop_url)`,
tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = favorites.content_id), favorites.tmdb_id)`,
releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = favorites.content_id), favorites.release_year)`,
originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = favorites.content_id), favorites.original_title)`,
})
.set(favoriteSnapshotFromContent(sql`favorites.content_id`))
.where(
and(
sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`,
@@ -54,7 +54,12 @@ export async function verifyFavoriteSnapshot(db: AppDatabase) {
assert.equal((await getFavorites(db, 'p'))[0].catalog_status, 'missing');
assert.equal((await getFavorites(db, 'p'))[0].title, original.title);
await deleteXtreamContent(db, 'p');
await catalog(3, 7, 'ABC renamed', 'movie', 'p', { tmdbId: 10 });
await catalog(3, 7, 'ABC renamed', 'movie', 'p', {
tmdbId: 10,
posterUrl: 'https://art.invalid/returned.jpg',
backdropUrl: 'https://art.invalid/returned-wide.jpg',
originalTitle: 'ABC original renamed',
});
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
@@ -62,11 +67,34 @@ export async function verifyFavoriteSnapshot(db: AppDatabase) {
'partial identity is insufficient to replace the saved fields'
);
await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 });
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.equal(
(await db.select().from(schema.favorites))[0].title,
'ABC renamed',
'confirmed compatible identity may refresh the snapshot'
const confirmed = {
...snapshot(original),
title: 'ABC renamed',
poster: 'https://art.invalid/returned.jpg',
backdrop: 'https://art.invalid/returned-wide.jpg',
original: 'ABC original renamed',
};
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
confirmed,
'detail enrichment must refresh the snapshot as soon as identity is confirmed'
);
await deleteXtreamContent(db, 'p');
const [missing] = await getFavoritesForBackup(db, 'p');
assert.equal(missing.title, confirmed.title);
assert.equal(missing.poster_url, confirmed.poster);
assert.equal(missing.backdrop_url, confirmed.backdrop);
await catalog(3, 7, ' ', 'movie', 'p', {
tmdbId: 10,
posterUrl: ' ',
backdropUrl: '',
originalTitle: '',
});
await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 });
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
confirmed,
'blank fields on a confirmed return must preserve the saved snapshot'
);
// A replacement's year/artwork must not fill gaps before its TMDB identity is known.
await removeFavorite(db, 3, 'p');
@@ -54,8 +54,9 @@ imports reconnect valid identities. Display snapshots are replaced only after
the returning row confirms every saved TMDB ID/year; incomplete identity keeps
the saved title/artwork in collections and backups. Restore applies the same
confirmation guard before filling gaps in saved metadata from catalog rows.
Metadata learned in detail
views is copied into the saved snapshot before catalog loss.
When detail enrichment confirms the remaining identity fields, it immediately
copies the returning title, artwork and metadata into the saved snapshot without
waiting for another import. Blank catalog fields retain their saved values.
Conflicting known TMDB IDs or release years prevent automatic reconnection;
changed provider IDs never reconnect by title.
Adding a conflicting replacement returns an explicit failure and retains the
@@ -80,8 +81,9 @@ unfiltered.
Legacy pending favorites are converted before destructive refresh, import
requests and ordinary collection reads, even if portal-status preflight is
offline. Ordinary collection reads isolate failed conversions per playlist and
still read healthy database favorites, retaining pending state for retry. Backup
reads remain strict and fail if conversion fails. Backup merges convert any
still read healthy database favorites, retaining pending state for retry. This
also applies when localStorage access or key enumeration fails. Backup reads
remain strict and fail if storage access or conversion fails. Backup merges convert any
older pending favorites before parking the incoming archive. Favorite inserts and a replay receipt commit together;
retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a
fresh receipt ID, which stays internal and is never exported. Identity-only old
@@ -1,4 +1,5 @@
import { DatabaseService } from './database-electron.service';
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
describe('DatabaseService browser guards', () => {
const originalElectron = window.electron;
@@ -20,6 +21,7 @@ describe('DatabaseService browser guards', () => {
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
consoleErrorSpy.mockRestore();
Object.defineProperty(window, 'electron', {
@@ -144,6 +146,91 @@ describe('DatabaseService browser guards', () => {
}
);
it.each(['storage', 'length', 'key', 'getItem'] as const)(
'reads SQLite despite a failing localStorage %s, but rejects strict conversion',
async (failure) => {
localStorage.setItem(
'xtream-restore-broken',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7 }],
})
);
localStorage.setItem(
'xtream-restore-healthy',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 9 }],
})
);
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
const read = jest.fn().mockResolvedValue(rows);
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
dbGetFavorites: read,
dbGetGlobalFavorites: read,
dbGetAllGlobalFavorites: read,
dbGetFavoritesForBackup: jest.fn(),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
const unavailable = () => {
throw new Error('Storage unavailable');
};
if (failure === 'storage') {
jest.spyOn(window, 'localStorage', 'get').mockImplementation(
unavailable
);
} else if (failure === 'length') {
jest.spyOn(
Storage.prototype,
'length',
'get'
).mockImplementation(unavailable);
} else if (failure === 'key') {
jest.spyOn(Storage.prototype, 'key').mockImplementation(
unavailable
);
} else {
const getItem = Storage.prototype.getItem;
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(
function (key) {
if (key === 'xtream-restore-broken')
return unavailable();
return getItem.call(this, key);
}
);
}
for (const method of [
'getGlobalFavorites',
'getAllGlobalFavorites',
'getFavorites',
] as const) {
await expect(service[method]('broken')).resolves.toEqual(rows);
}
expect(read).toHaveBeenCalledTimes(3);
if (failure === 'getItem') {
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
'healthy',
[{ contentType: 'movie', xtreamId: 9 }],
undefined
);
}
await expect(
materializePendingXtreamFavorites(bridge)
).rejects.toThrow('Storage unavailable');
if (failure === 'storage' || failure === 'getItem') {
await expect(
service.getFavoritesOrThrow('broken')
).rejects.toThrow('Storage unavailable');
expect(bridge.dbGetFavoritesForBackup).not.toHaveBeenCalled();
}
}
);
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
@@ -14,29 +14,39 @@ export async function materializePendingXtreamFavorites(
failureMode: 'throw' | 'continue' = 'throw'
): Promise<void> {
const prefix = getXtreamPendingRestoreStorageKey('');
const ids = playlistId
? [playlistId]
: Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index)
)
.filter(
(key): key is string => key !== null && key.startsWith(prefix)
let ids: string[];
try {
ids = playlistId
? [playlistId]
: Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index)
)
.map((key) => key.slice(prefix.length));
.filter(
(key): key is string =>
key !== null && key.startsWith(prefix)
)
.map((key) => key.slice(prefix.length));
} catch (error) {
if (failureMode === 'throw') throw error;
console.warn('Pending favorites storage could not be read.');
return;
}
for (const id of ids) {
const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id));
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const state = normalizeXtreamPendingRestoreState(parsed);
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
try {
const raw = localStorage.getItem(
getXtreamPendingRestoreStorageKey(id)
);
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const state = normalizeXtreamPendingRestoreState(parsed);
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(