mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user