fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)

Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.

Closes #1017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 20:32:45 +02:00
1 parent bd07e17857
commit b4c0cce741
16 files changed
+1424 -151

No files matched your search

@@ -0,0 +1,98 @@
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
describe('normalizeXtreamPendingRestoreState', () => {
const emptyState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
};
it.each([null, undefined, 'text', 42, []])(
'returns an empty state for non-object input %p',
(value) => {
expect(normalizeXtreamPendingRestoreState(value)).toEqual(
emptyState
);
}
);
it('falls back to empty arrays for missing or non-array fields', () => {
expect(
normalizeXtreamPendingRestoreState({
hiddenCategories: 'broken',
favorites: null,
})
).toEqual(emptyState);
});
it('keeps hidden categories with a numeric xtream ID and drops the rest', () => {
const state = normalizeXtreamPendingRestoreState({
hiddenCategories: [
{ categoryType: 'live', xtreamId: 101 },
// Entries exported by builds affected by issue #1017 carry
// no ID at all and must not survive normalization.
{ categoryType: 'live' },
{ categoryType: 'movies', xtreamId: 'not-a-number' },
{ categoryType: 'unknown', xtreamId: 5 },
{ categoryType: 'series', xtreamId: '301' },
null,
],
});
expect(state.hiddenCategories).toEqual([
{ categoryType: 'live', xtreamId: 101 },
{ categoryType: 'series', xtreamId: 301 },
]);
});
it('drops favorites and recently viewed entries without a numeric xtream ID', () => {
const state = normalizeXtreamPendingRestoreState({
favorites: [
{
contentType: 'movie',
xtreamId: 7,
addedAt: '2026-07-01T00:00:00.000Z',
},
{ contentType: 'movie' },
],
recentlyViewed: [
{
contentType: 'live',
xtreamId: '9',
viewedAt: '2026-07-01T00:00:00.000Z',
},
{ contentType: 'live', xtreamId: Number.NaN },
],
});
expect(state.favorites).toEqual([
{
contentType: 'movie',
xtreamId: 7,
addedAt: '2026-07-01T00:00:00.000Z',
},
]);
expect(state.recentlyViewed).toEqual([
{
contentType: 'live',
xtreamId: 9,
viewedAt: '2026-07-01T00:00:00.000Z',
},
]);
});
it('keeps playback position objects and drops primitives', () => {
const position = {
contentXtreamId: 12,
contentType: 'vod',
positionSeconds: 30,
};
const state = normalizeXtreamPendingRestoreState({
playbackPositions: [position, 'broken', null],
});
expect(state.playbackPositions).toEqual([position]);
});
});
@@ -1,4 +1,5 @@
import {
XtreamBackupCategoryType,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
@@ -15,3 +16,125 @@ export interface XtreamPendingRestoreState {
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
return `xtream-restore-${playlistId}`;
}
const XTREAM_BACKUP_CATEGORY_TYPES: readonly XtreamBackupCategoryType[] = [
'live',
'movies',
'series',
];
interface RestoreStateCandidate {
hiddenCategories?: unknown;
favorites?: unknown;
recentlyViewed?: unknown;
playbackPositions?: unknown;
}
interface RestoreEntryCandidate {
categoryType?: unknown;
xtreamId?: unknown;
}
function normalizeXtreamBackupId(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
// The web build compiles this lib with lib=es2018, so Array#flatMap is not
// available here; stick to filter/map/push.
function withNumericXtreamId<T extends { xtreamId: number }>(
items: unknown[]
): T[] {
const result: T[] = [];
for (const item of items) {
if (!isRecord(item)) {
continue;
}
const xtreamId = normalizeXtreamBackupId(
(item as RestoreEntryCandidate).xtreamId
);
if (xtreamId === null) {
continue;
}
result.push({ ...item, xtreamId } as T);
}
return result;
}
/**
* Normalizes restore state coming from untrusted sources: user-supplied
* backup files and persisted localStorage entries. Backups exported by
* broken builds (issue #1017) contain hiddenCategories entries without an
* xtreamId; matching such entries against real category rows would compare
* `undefined === undefined` and hide every category of that type, so any
* entry without a usable numeric xtreamId is dropped instead of restored.
*/
export function normalizeXtreamPendingRestoreState(
value: unknown
): XtreamPendingRestoreState {
if (!isRecord(value)) {
return {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
};
}
const candidate = value as RestoreStateCandidate;
const hiddenCategories: XtreamBackupHiddenCategory[] = [];
for (const item of toArray(candidate.hiddenCategories)) {
if (!isRecord(item)) {
continue;
}
const entry = item as RestoreEntryCandidate;
const xtreamId = normalizeXtreamBackupId(entry.xtreamId);
const categoryType = entry.categoryType as XtreamBackupCategoryType;
if (
xtreamId === null ||
!XTREAM_BACKUP_CATEGORY_TYPES.includes(categoryType)
) {
continue;
}
hiddenCategories.push({ categoryType, xtreamId });
}
return {
hiddenCategories,
favorites: withNumericXtreamId<XtreamBackupFavoriteItem>(
toArray(candidate.favorites)
),
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
toArray(candidate.recentlyViewed)
),
playbackPositions: toArray(candidate.playbackPositions).filter(
(item): item is PlaybackPositionData => isRecord(item)
),
};
}