fix(portals): a failed pin read must not export as "no pins"

Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 21:10:53 +02:00
1 parent 960a33940f
commit 76804ee1bc
10 files changed
+267 -38

No files matched your search

@@ -25,13 +25,34 @@ describe('PlaylistBackupService Xtream source pins', () => {
localStorage.clear();
});
it('refuses to export a backup whose pin read failed', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService({
playlistsService: collaborators.playlistsService,
databaseService: collaborators.databaseService,
vodSourcePinService: {
listForPlaylistOrThrow: jest
.fn()
.mockRejectedValue(new Error('database is locked')),
set: jest.fn().mockResolvedValue(true),
},
});
// `sourcePins` is authoritative and restore CLEARS the playlist's pins
// before applying it, so a read that failed and reported "none" would
// produce a file that looks complete and wipes every pin the user had
// — through the one feature meant to protect them. Failing the export
// is the only honest outcome.
await expect(service.exportBackup()).rejects.toThrow();
});
it('exports the pins that point at this playlist', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService({
playlistsService: collaborators.playlistsService,
databaseService: collaborators.databaseService,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([
listForPlaylistOrThrow: jest.fn().mockResolvedValue([
{
matchKey: 'tmdb:603',
playlistId: 'xtream-1',
@@ -65,7 +86,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
set: setPin,
clear: jest.fn().mockResolvedValue(true),
clearForPlaylist: jest.fn().mockResolvedValue(true),
@@ -94,7 +115,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
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.
@@ -120,7 +141,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([
listForPlaylistOrThrow: jest.fn().mockResolvedValue([
{
matchKey: 'tmdb:999',
playlistId: 'xtream-1',
@@ -149,7 +170,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
set: jest.fn().mockResolvedValue(true),
clear: jest.fn().mockResolvedValue(true),
clearForPlaylist: jest.fn().mockResolvedValue(false),
@@ -175,7 +196,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
vodSourcePinService: {
// A pin EXISTS, or an empty list would make this pass whether
// or not the clear was skipped.
listForPlaylist: jest.fn().mockResolvedValue([
listForPlaylistOrThrow: jest.fn().mockResolvedValue([
{
matchKey: 'tmdb:999',
playlistId: 'xtream-1',
@@ -202,7 +223,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
set: setPin,
clear: jest.fn().mockResolvedValue(true),
clearForPlaylist: jest.fn().mockResolvedValue(true),
@@ -63,7 +63,7 @@ export function createPlaylistBackupService(
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
},
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
listForPlaylistOrThrow: jest.fn().mockResolvedValue([]),
set: jest.fn().mockResolvedValue(true),
clear: jest.fn().mockResolvedValue(true),
clearForPlaylist: jest.fn().mockResolvedValue(true),
@@ -240,7 +240,7 @@ export function createStatefulBackupCollaborators(
},
},
vodSourcePinService: {
listForPlaylist: async (playlistId: string) =>
listForPlaylistOrThrow: async (playlistId: string) =>
state.sourcePins
.filter((pin) => pin.playlistId === playlistId)
.map((pin) => ({ ...pin })),
@@ -274,7 +274,11 @@ export class PlaylistBackupService {
this.databaseService.getFavorites(playlist._id),
this.databaseService.getRecentItems(playlist._id),
this.playbackPositionService.getAllPlaybackPositions(playlist._id),
this.vodSourcePinService.listForPlaylist(playlist._id),
// 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),
]);
return {
@@ -0,0 +1,71 @@
import { VodSourcePinService } from './vod-source-pin.service';
/**
* The two listing methods differ in ONE way that matters: what a failed read
* means. Backup depends on that difference — its `sourcePins` collection is
* authoritative and restore clears the playlist's pins before applying it, so
* a failure reported as "no pins" writes a backup that silently wipes them.
*/
const electronWindow = window as unknown as {
electron?: Record<string, unknown>;
};
function withBridge(dbListVodSourcePins: unknown) {
electronWindow.electron = {
// `isAvailable` probes for this one.
dbGetVodSourcePin: jest.fn(),
dbListVodSourcePins,
};
}
describe('VodSourcePinService listing', () => {
let service: VodSourcePinService;
let warn: jest.SpyInstance;
beforeEach(() => {
service = new VodSourcePinService();
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
delete electronWindow.electron;
warn.mockRestore();
});
it('returns the pins the bridge reports', async () => {
const pin = {
matchKey: 'tmdb:603',
playlistId: 'p1',
contentId: 501,
portalType: 'xtream' as const,
};
withBridge(jest.fn().mockResolvedValue([pin]));
await expect(service.listForPlaylist('p1')).resolves.toEqual([pin]);
await expect(service.listForPlaylistOrThrow('p1')).resolves.toEqual([
pin,
]);
});
it('reads a failure as "no pins" only for the lenient caller', async () => {
withBridge(
jest.fn().mockRejectedValue(new Error('database is locked'))
);
await expect(service.listForPlaylist('p1')).resolves.toEqual([]);
// The whole point of the second method: backup must be able to tell a
// failed read from an empty one, or it exports a wipe.
await expect(service.listForPlaylistOrThrow('p1')).rejects.toThrow(
'database is locked'
);
});
it('reports no pins, without throwing, outside Electron', async () => {
delete electronWindow.electron;
// Not a failure — there is no pin store in the PWA at all, so an empty
// list is the true answer and a backup built there is complete.
await expect(service.listForPlaylistOrThrow('p1')).resolves.toEqual([]);
});
});
@@ -67,16 +67,13 @@ export class VodSourcePinService {
}
}
/** Every pin pointing at this playlist. Used by playlist backup. */
/**
* Every pin pointing at this playlist, empty when there are none OR when
* the read failed. Only for callers that treat both the same way.
*/
async listForPlaylist(playlistId: string): Promise<VodSourcePin[]> {
if (!this.isAvailable || !playlistId) {
return [];
}
try {
return (
(await window.electron.dbListVodSourcePins(playlistId)) ?? []
);
return await this.readForPlaylist(playlistId);
} catch (error) {
console.warn(
'Listing pinned VOD sources failed:',
@@ -86,6 +83,28 @@ export class VodSourcePinService {
}
}
/**
* As above, but a failed read THROWS instead of reading as "none".
*
* Backup needs this distinction and nothing else does. An export writes
* `sourcePins` as the authoritative set for the playlist, and restore
* clears whatever is there before applying it — so a read that failed and
* returned `[]` produces a backup that looks complete and silently wipes
* every pin the user had. Losing the preferences is bad; losing them via a
* file created to protect them is worse.
*/
async listForPlaylistOrThrow(playlistId: string): Promise<VodSourcePin[]> {
return this.readForPlaylist(playlistId);
}
private async readForPlaylist(playlistId: string): Promise<VodSourcePin[]> {
if (!this.isAvailable || !playlistId) {
return [];
}
return (await window.electron.dbListVodSourcePins(playlistId)) ?? [];
}
/**
* Store the pin, also under `aliasKeys`, retiring `retireKeys` — all in
* the same transaction, because a half-applied change has no honest