mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): retain favorites through storage and metadata updates
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user