fix(server-storage): read migration maps by own playlist ids only

The browser's legacy maps and the server's user-state entries are read
into null-prototype maps, so a playlist id such as `toString` is not
taken for an entry and a missing server value no longer resolves to an
inherited function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 20:54:56 +02:00
1 parent a94673b4ab
commit 61c3f844d6
2 files changed
+40 -5

No files matched your search

@@ -418,4 +418,34 @@ describe('ServerMigrationService', () => {
expect.objectContaining({ _id: 's1', isFullStalkerPortal: true })
);
});
it("does not take an inherited name for a playlist id's data", async () => {
TestBed.overrideProvider(NgxIndexedDBService, {
useValue: {
getAll: () =>
of([{ _id: 'toString', title: 'Odd id', url: 'http://x' }]),
},
});
localStorage.setItem(
'xtream-favorites',
JSON.stringify({ toString: ['live:1'] })
);
// The server has the namespace, but no entry for that id.
session.api.getJson.mockResolvedValue({
entries: { 'xtream-favorites': {} },
tags: { 'xtream-favorites': {} },
});
const result = await TestBed.inject(
ServerMigrationService
).moveBrowserData();
expect(result.failed).toBe(0);
expect(session.api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/toString',
{ value: ['live:1'] },
{ 'If-None-Match': '*' }
);
});
});
@@ -16,7 +16,8 @@ import {
unionItems,
} from './server-migration-merge';
import { planServerMigration } from './server-migration-plan';
import { preconditionHeaders } from './server-user-data-entries';
import { withoutDeleted } from './server-user-data-edit';
import { idMap, preconditionHeaders } from './server-user-data-entries';
import {
typeLegacyXtreamRefs,
type XtreamUserData,
@@ -205,10 +206,14 @@ export class ServerMigrationService {
if (targets.size === 0) {
return 0;
}
const { entries, tags } =
// Maps keyed by playlist id without a prototype: an id such as
// `toString` is not mistaken for an entry.
const { entries, tags } = withoutDeleted(
await this.session.api.getJson<ServerUserStateResponse>(
'/user-state'
);
),
[]
);
// A map this browser has but cannot read fails the move, so it
// stays on offer instead of being marked done without it.
let unreadable = 0;
@@ -369,12 +374,12 @@ function readLegacyMap(key: string): Record<string, unknown> | null {
return null;
}
if (value === null) {
return {};
return idMap();
}
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
? idMap(parsed as Record<string, unknown>)
: null;
} catch {
return null;