mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(portals): stop two backup paths from deleting pins they never read
Two data-loss paths, both P1, both mine. A web export wrote `sourcePins: []`. Pins are Electron-only, so out there we cannot read them — which is not the same as knowing there are none, and restore treats the collection as authoritative. A backup made in the browser was therefore an instruction to delete every pin the moment it was imported on the desktop. There are three answers here, not two: pins exist, there are none, and "could not look". The last omits the field, exactly as an archive written before pins existed does. The same rule now covers Electron with the bridge method missing. I had written a test asserting the unreachable-store case resolves to an empty list "because a backup made there is complete". That reasoning was wrong: empty was true of what the runtime could see, never of the playlist. Restore also cleared the playlist's pins and then wrote the archive's one by one. A write failing partway left the previous pins already gone and only a prefix applied — a state belonging to neither, reported as a failure the user could not undo. `DB_REPLACE_VOD_SOURCE_PINS` does the clear and every write in one transaction, so the playlist ends up as the archive describes it or exactly as it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76804ee1bc
commit
390188f0ce
12 files changed
+306
-84
No files matched your search
@@ -433,6 +433,14 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
// separate calls that can half-apply.
|
||||
forwardedArgs: [vodSourcePin, ['title:dune:'], ['title:dune:2021']],
|
||||
},
|
||||
{
|
||||
method: 'dbReplaceVodSourcePins',
|
||||
args: ['playlist-1', [vodSourcePin]],
|
||||
channel: 'DB_REPLACE_VOD_SOURCE_PINS',
|
||||
// One statement for the whole playlist: restore cannot afford a clear
|
||||
// and a write that can half-apply.
|
||||
forwardedArgs: ['playlist-1', [vodSourcePin]],
|
||||
},
|
||||
{
|
||||
method: 'dbClearVodSourcePin',
|
||||
args: [vodSourceMatchKeys],
|
||||
|
||||
@@ -916,6 +916,8 @@ const electronApi: ElectronBridgeApi = {
|
||||
retireKeys ?? [],
|
||||
aliasKeys ?? []
|
||||
),
|
||||
dbReplaceVodSourcePins: (playlistId: string, pins: VodSourcePin[]) =>
|
||||
ipcRenderer.invoke('DB_REPLACE_VOD_SOURCE_PINS', playlistId, pins),
|
||||
dbClearVodSourcePin: (matchKeys: string[]) =>
|
||||
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
|
||||
// Playback Positions
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getVodSourcePin,
|
||||
setVodSourcePin,
|
||||
clearVodSourcePinsForPlaylist,
|
||||
replaceVodSourcePinsForPlaylist,
|
||||
} from './vod-source-pin.operations';
|
||||
|
||||
const tmdbRow = {
|
||||
@@ -299,6 +300,62 @@ describe('vod-source-pin.operations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceVodSourcePinsForPlaylist', () => {
|
||||
const pin: VodSourcePin = {
|
||||
matchKey: 'tmdb:603',
|
||||
playlistId: 'playlist-1',
|
||||
contentId: 501,
|
||||
portalType: 'xtream',
|
||||
};
|
||||
|
||||
it('clears and rewrites inside ONE transaction', async () => {
|
||||
const { db, transaction, deleteRun, insertRun } =
|
||||
createUpsertDbMock();
|
||||
|
||||
await expect(
|
||||
replaceVodSourcePinsForPlaylist(db, 'playlist-1', [pin, pin])
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
// Split into a clear and per-pin writes, a write failing partway
|
||||
// leaves the old pins gone and the archive half-applied — a state
|
||||
// belonging to neither, which the user cannot undo.
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deleteRun).toHaveBeenCalledTimes(1);
|
||||
expect(insertRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clears without writing for an empty set', async () => {
|
||||
const { db, deleteRun, insertRun } = createUpsertDbMock();
|
||||
|
||||
// Present-but-empty means the user had none; the clear still runs.
|
||||
await replaceVodSourcePinsForPlaylist(db, 'playlist-1', []);
|
||||
|
||||
expect(deleteRun).toHaveBeenCalledTimes(1);
|
||||
expect(insertRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a blank playlist id rather than clearing everything', async () => {
|
||||
const { db, transaction } = createUpsertDbMock();
|
||||
|
||||
await expect(
|
||||
replaceVodSourcePinsForPlaylist(db, '', [pin])
|
||||
).resolves.toEqual({ success: false });
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops rows that name no film', async () => {
|
||||
const { db, insertRun } = createUpsertDbMock();
|
||||
|
||||
await replaceVodSourcePinsForPlaylist(db, 'playlist-1', [
|
||||
{ ...pin, matchKey: '' },
|
||||
{ ...pin, contentId: undefined as unknown as number },
|
||||
pin,
|
||||
]);
|
||||
|
||||
expect(insertRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearVodSourcePinsForPlaylist', () => {
|
||||
it('deletes by playlist, with no key cap to truncate', async () => {
|
||||
const { db, deleteFn, deleteWhere } = createDbMock();
|
||||
|
||||
@@ -198,6 +198,69 @@ export async function clearVodSourcePinsForPlaylist(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this playlist's pins exactly `pins`, in ONE transaction.
|
||||
*
|
||||
* Restore cannot do this as a clear followed by writes. If a write fails
|
||||
* partway the user's previous pins are already gone and only a prefix of the
|
||||
* archive has landed — the import reports failure, and the state it leaves
|
||||
* behind belongs to neither the backup nor what was there before. One
|
||||
* statement group means the playlist either ends up as the archive describes
|
||||
* it or exactly as it was.
|
||||
*/
|
||||
export async function replaceVodSourcePinsForPlaylist(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
pins: VodSourcePin[]
|
||||
): Promise<{ success: boolean }> {
|
||||
if (typeof playlistId !== 'string' || playlistId === '') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const rows = (pins ?? [])
|
||||
.filter(
|
||||
(pin) =>
|
||||
typeof pin?.matchKey === 'string' &&
|
||||
pin.matchKey !== '' &&
|
||||
Number.isInteger(pin?.contentId)
|
||||
)
|
||||
.map((pin) => ({
|
||||
matchKey: pin.matchKey,
|
||||
playlistId,
|
||||
contentId: pin.contentId,
|
||||
portalType: pin.portalType,
|
||||
updatedAt: pin.updatedAt ?? updatedAt,
|
||||
}));
|
||||
|
||||
const inserts = rows.map((row) =>
|
||||
db
|
||||
.insert(schema.vodSourcePins)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.vodSourcePins.matchKey,
|
||||
set: {
|
||||
playlistId: row.playlistId,
|
||||
contentId: row.contentId,
|
||||
portalType: row.portalType,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await db.transaction(() => {
|
||||
db.delete(schema.vodSourcePins)
|
||||
.where(eq(schema.vodSourcePins.playlistId, playlistId))
|
||||
.run();
|
||||
|
||||
for (const insert of inserts) {
|
||||
insert.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function toPin(row: schema.VodSourcePinRow): VodSourcePin {
|
||||
return {
|
||||
matchKey: row.matchKey,
|
||||
|
||||
@@ -41,3 +41,8 @@ handleWorkerRequest(
|
||||
handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
|
||||
matchKeys,
|
||||
}));
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_REPLACE_VOD_SOURCE_PINS',
|
||||
(playlistId: string, pins: VodSourcePin[] = []) => ({ playlistId, pins })
|
||||
);
|
||||
@@ -379,6 +379,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
aliasKeys: ['title:dune:2021'],
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: 'DB_REPLACE_VOD_SOURCE_PINS',
|
||||
args: ['playlist-1', [vodSourcePin]],
|
||||
payload: { playlistId: 'playlist-1', pins: [vodSourcePin] },
|
||||
},
|
||||
{
|
||||
operation: 'DB_CLEAR_VOD_SOURCE_PIN',
|
||||
args: [vodSourceMatchKeys],
|
||||
|
||||
@@ -62,6 +62,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST',
|
||||
'DB_SET_VOD_SOURCE_PIN',
|
||||
'DB_CLEAR_VOD_SOURCE_PIN',
|
||||
'DB_REPLACE_VOD_SOURCE_PINS',
|
||||
] as const;
|
||||
|
||||
export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number];
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
clearVodSourcePin,
|
||||
getVodSourcePin,
|
||||
clearVodSourcePinsForPlaylist,
|
||||
replaceVodSourcePinsForPlaylist,
|
||||
listVodSourcePinsForPlaylist,
|
||||
setVodSourcePin,
|
||||
} from '../database/operations/vod-source-pin.operations';
|
||||
@@ -830,6 +831,18 @@ async function executeRequest(
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_REPLACE_VOD_SOURCE_PINS': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
pins: VodSourcePin[];
|
||||
};
|
||||
return replaceVodSourcePinsForPlaylist(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.pins ?? []
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_CLEAR_VOD_SOURCE_PIN': {
|
||||
const payload = message.payload as { matchKeys: string[] };
|
||||
return clearVodSourcePin(db, payload.matchKeys);
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
playlistsService: collaborators.playlistsService,
|
||||
databaseService: collaborators.databaseService,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('database is locked')),
|
||||
@@ -52,6 +53,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
playlistsService: collaborators.playlistsService,
|
||||
databaseService: collaborators.databaseService,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([
|
||||
{
|
||||
matchKey: 'tmdb:603',
|
||||
@@ -82,14 +84,13 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
|
||||
it('restores pins against the imported playlist, not the exported one', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const setPin = jest.fn().mockResolvedValue(true);
|
||||
const replacePins = jest.fn().mockResolvedValue(true);
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
set: setPin,
|
||||
clear: jest.fn().mockResolvedValue(true),
|
||||
clearForPlaylist: jest.fn().mockResolvedValue(true),
|
||||
replaceForPlaylist: replacePins,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,26 +101,32 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
|
||||
await service.importBackup(JSON.stringify(manifest));
|
||||
|
||||
// The match key identifies the film and carries over as-is; the
|
||||
// playlist id is this installation's, not the archive's.
|
||||
expect(setPin).toHaveBeenCalledWith({
|
||||
matchKey: 'tmdb:603',
|
||||
playlistId: 'xtream-1',
|
||||
contentId: 501,
|
||||
portalType: 'xtream',
|
||||
});
|
||||
// One call, not a clear plus writes: a write failing partway would
|
||||
// leave the old pins gone and the archive half-applied. The match key
|
||||
// identifies the film and carries over as-is; the playlist id is this
|
||||
// installation's, not the archive's.
|
||||
expect(replacePins).toHaveBeenCalledTimes(1);
|
||||
expect(replacePins).toHaveBeenCalledWith('xtream-1', [
|
||||
{
|
||||
matchKey: 'tmdb:603',
|
||||
playlistId: 'xtream-1',
|
||||
contentId: 501,
|
||||
portalType: 'xtream',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a pin that could not be written', async () => {
|
||||
it('reports pins that could not be written', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
// `set` reports failure rather than throwing, so ignoring the
|
||||
// result would drop the preference while the summary claims
|
||||
// the import succeeded.
|
||||
set: jest.fn().mockResolvedValue(false),
|
||||
// Reported rather than thrown, so ignoring the result would
|
||||
// drop every preference while the summary claims the import
|
||||
// succeeded.
|
||||
replaceForPlaylist: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -141,6 +148,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([
|
||||
{
|
||||
matchKey: 'tmdb:999',
|
||||
@@ -149,43 +157,41 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
portalType: 'xtream',
|
||||
},
|
||||
]),
|
||||
set: jest.fn().mockResolvedValue(true),
|
||||
clear: jest.fn().mockResolvedValue(true),
|
||||
clearForPlaylist: clearPins,
|
||||
replaceForPlaylist: clearPins,
|
||||
},
|
||||
});
|
||||
|
||||
// Present-but-empty is an answer, like the playback positions cleared
|
||||
// beside it: leaving the current pins would resurrect preferences the
|
||||
// archive deliberately does not contain.
|
||||
await service.importBackup(JSON.stringify(createXtreamManifest([], [])));
|
||||
|
||||
// By playlist, not by key list: the keyed clear caps its input and
|
||||
// would have left the surplus behind while reporting success.
|
||||
expect(clearPins).toHaveBeenCalledWith('xtream-1');
|
||||
});
|
||||
|
||||
it('fails the entry when the existing pins cannot be cleared', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
set: jest.fn().mockResolvedValue(true),
|
||||
clear: jest.fn().mockResolvedValue(true),
|
||||
clearForPlaylist: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(
|
||||
await service.importBackup(
|
||||
JSON.stringify(createXtreamManifest([], []))
|
||||
);
|
||||
|
||||
// Writing the archive's pins over pins that are still there produces a
|
||||
// union of both, which is neither state the user asked for.
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({ merged: 0, failed: 1 })
|
||||
);
|
||||
// By playlist and in one statement, so the surplus cannot survive and
|
||||
// a partial application cannot be left behind.
|
||||
expect(clearPins).toHaveBeenCalledWith('xtream-1', []);
|
||||
});
|
||||
|
||||
it('omits the collection entirely when it cannot read pins', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService: collaborators.playlistsService,
|
||||
databaseService: collaborators.databaseService,
|
||||
vodSourcePinService: {
|
||||
// The PWA, where pins do not exist at all.
|
||||
isAvailable: false,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const backup = await service.exportBackup();
|
||||
|
||||
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
|
||||
// NOT `[]`. "Could not look" is a third answer, and writing it as
|
||||
// "there are none" turns a web-made archive into an instruction that
|
||||
// deletes every pin when imported on the desktop.
|
||||
expect(entry.userState.sourcePins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves pins alone for an archive that has no opinion', async () => {
|
||||
@@ -223,6 +229,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
const service = createPlaylistBackupService({
|
||||
...collaborators,
|
||||
vodSourcePinService: {
|
||||
isAvailable: true,
|
||||
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
|
||||
set: setPin,
|
||||
clear: jest.fn().mockResolvedValue(true),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
XtreamBackupCategoryType,
|
||||
XtreamBackupContentType,
|
||||
XtreamPlaylistBackupEntry,
|
||||
XtreamBackupSourcePin,
|
||||
XtreamPendingRestoreState,
|
||||
createRandomId,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -254,7 +255,14 @@ export class PlaylistBackupService {
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
sourcePins: [],
|
||||
// NOT `[]`. Pins are Electron-only, so out here we cannot
|
||||
// read them — which is not the same as knowing there are
|
||||
// none. Restore treats the collection as authoritative and
|
||||
// clears the playlist's pins before applying it, so an
|
||||
// empty array exported from the web would wipe them the
|
||||
// moment the archive was imported on the desktop. Omitted,
|
||||
// the archive says "no opinion", exactly as one written
|
||||
// before pins existed does.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -277,8 +285,9 @@ export class PlaylistBackupService {
|
||||
// Throws rather than reading a failure as "no pins": restore
|
||||
// treats this collection as authoritative and clears the
|
||||
// playlist's pins before applying it, so an empty list born of a
|
||||
// failed read would wipe them.
|
||||
this.vodSourcePinService.listForPlaylistOrThrow(playlist._id),
|
||||
// failed read would wipe them. `null` where the store is not
|
||||
// reachable at all, which is a different answer again — see below.
|
||||
this.readSourcePinsForExport(playlist._id),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -317,15 +326,43 @@ export class PlaylistBackupService {
|
||||
// Carried under the playlist they point AT, so a restore that
|
||||
// does not include that portal cannot resurrect a preference
|
||||
// for something the archive never had.
|
||||
sourcePins: sourcePins.map((pin) => ({
|
||||
matchKey: pin.matchKey,
|
||||
contentId: pin.contentId,
|
||||
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
|
||||
})),
|
||||
//
|
||||
// Present ONLY when this runtime can actually read them. The
|
||||
// collection is authoritative on restore, so stating `[]`
|
||||
// where the answer is "could not look" turns the archive into
|
||||
// an instruction to delete.
|
||||
...(sourcePins ? { sourcePins } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlist's pins for the archive, or `null` when this runtime cannot
|
||||
* read them at all.
|
||||
*
|
||||
* Three outcomes, and conflating any two of them loses data: pins exist,
|
||||
* there are none, and "could not look". Only the first two belong in an
|
||||
* archive — the third has to leave `sourcePins` out entirely, so restore
|
||||
* treats it as no opinion rather than as an instruction to delete. A read
|
||||
* that FAILS is neither: it throws, and the export fails with it.
|
||||
*/
|
||||
private async readSourcePinsForExport(
|
||||
playlistId: string
|
||||
): Promise<XtreamBackupSourcePin[] | null> {
|
||||
if (!this.vodSourcePinService.isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pins =
|
||||
await this.vodSourcePinService.listForPlaylistOrThrow(playlistId);
|
||||
|
||||
return pins.map((pin) => ({
|
||||
matchKey: pin.matchKey,
|
||||
contentId: pin.contentId,
|
||||
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry {
|
||||
return {
|
||||
portalType: 'stalker',
|
||||
@@ -778,21 +815,6 @@ export class PlaylistBackupService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every pin currently pointing at this playlist.
|
||||
*
|
||||
* One statement rather than list-then-clear-by-key: the keyed clear caps
|
||||
* its input, so a playlist with more pinned movies than the cap kept the
|
||||
* surplus while reporting success — and a failed list read silently
|
||||
* became "there was nothing to clear".
|
||||
*/
|
||||
private async clearPinsForPlaylist(playlistId: string): Promise<void> {
|
||||
if (!(await this.vodSourcePinService.clearForPlaylist(playlistId))) {
|
||||
throw new PlaylistBackupError(
|
||||
`Clearing the existing pinned sources for "${playlistId}" failed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreXtreamEntry(
|
||||
playlistId: string,
|
||||
@@ -889,29 +911,32 @@ export class PlaylistBackupService {
|
||||
// so leaving the current ones would resurrect preferences the archive
|
||||
// deliberately does not contain. Absent (an older archive) means "no
|
||||
// opinion", and those are left alone.
|
||||
if (state.sourcePins) {
|
||||
await this.clearPinsForPlaylist(playlistId);
|
||||
if (!state.sourcePins) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The match key identifies the film and survives untouched; only the
|
||||
// playlist has a new id in this installation.
|
||||
for (const pin of state.sourcePins ?? []) {
|
||||
const written = await this.vodSourcePinService.set({
|
||||
// ONE call, not a clear followed by writes. Split up, a write that
|
||||
// fails partway leaves the previous pins already deleted and only part
|
||||
// of the archive applied — a state belonging to neither, reported as a
|
||||
// failure the user has no way to undo. The match key identifies the
|
||||
// film and survives untouched; only the playlist has a new id here.
|
||||
const replaced = await this.vodSourcePinService.replaceForPlaylist(
|
||||
playlistId,
|
||||
state.sourcePins.map((pin) => ({
|
||||
matchKey: pin.matchKey,
|
||||
playlistId,
|
||||
contentId: pin.contentId,
|
||||
portalType: 'xtream',
|
||||
portalType: 'xtream' as const,
|
||||
...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}),
|
||||
});
|
||||
}))
|
||||
);
|
||||
|
||||
// `set` reports a failed write rather than throwing, so ignoring
|
||||
// it would drop the preference while the summary claims the
|
||||
// import succeeded.
|
||||
if (!written) {
|
||||
throw new PlaylistBackupError(
|
||||
`Restoring the pinned source for "${pin.matchKey}" failed.`
|
||||
);
|
||||
}
|
||||
// Reported rather than thrown, so ignoring it would drop every
|
||||
// preference while the summary claims the import succeeded.
|
||||
if (!replaced) {
|
||||
throw new PlaylistBackupError(
|
||||
`Restoring the pinned sources for "${playlistId}" failed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,37 @@ export class VodSourcePinService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this playlist's pins exactly `pins`, in one transaction.
|
||||
*
|
||||
* Restore needs the clear and the writes to be indivisible. Done in steps,
|
||||
* a write that fails partway leaves the user's previous pins already gone
|
||||
* and only part of the archive applied — a state belonging to neither, and
|
||||
* reported as a failure the user cannot act on.
|
||||
*/
|
||||
async replaceForPlaylist(
|
||||
playlistId: string,
|
||||
pins: VodSourcePin[]
|
||||
): Promise<boolean> {
|
||||
if (!this.isAvailable || !playlistId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.dbReplaceVodSourcePins(
|
||||
playlistId,
|
||||
pins
|
||||
);
|
||||
return result?.success === true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Replacing pinned VOD sources failed:',
|
||||
redactSensitiveData(error)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the pin under every alias, so unpinning cannot be undone by a
|
||||
* stale row stored under the other key form. */
|
||||
async clear(matchKeys: string[]): Promise<boolean> {
|
||||
|
||||
@@ -923,6 +923,11 @@ export interface ElectronBridgeApi {
|
||||
retireKeys?: string[],
|
||||
aliasKeys?: string[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
/** Makes the playlist's pins exactly `pins`, in one transaction. */
|
||||
dbReplaceVodSourcePins: (
|
||||
playlistId: string,
|
||||
pins: VodSourcePin[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbClearVodSourcePin: (
|
||||
matchKeys: string[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
|
||||
Reference in new issue
Block a user