mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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)
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user