feat(backup): restore redacted portals safely

This commit is contained in:
4gray committed 2026-07-27 10:12:22 +02:00
1 parent 581ad6fe8b
commit 04e9027840
4 files changed
+831 -107

No files matched your search

@@ -247,4 +247,102 @@ describe('PlaylistBackupService secret policy', () => {
service.importBackup(JSON.stringify(manifest))
).rejects.toBeInstanceOf(PlaylistBackupError);
});
it('keeps old version-1 manifests without includeSecrets importable', async () => {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
const service = createPlaylistBackupService({ playlistsService });
const legacyManifest = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
playlists: [
{
portalType: 'stalker',
exportedId: 'legacy-stalker',
title: 'Legacy Stalker',
autoRefresh: false,
connection: {
portalUrl: 'https://stalker.example/portal.php',
macAddress: '00:1A:79:AA:BB:CC',
username: 'legacy-user',
password: 'legacy-password',
},
userState: {
favorites: [],
recentlyViewed: [],
},
},
],
};
const summary = await service.importBackup(
JSON.stringify(legacyManifest)
);
expect(summary.imported).toBe(1);
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
username: 'legacy-user',
password: 'legacy-password',
})
);
});
it('rejects duplicate or blank exported IDs before any restore mutation', async () => {
const addPlaylist = jest.fn((playlist: Playlist) => of(playlist));
const updateSettings = jest.fn().mockResolvedValue(undefined);
const service = createPlaylistBackupService({
playlistsService: {
addPlaylist,
getAllData: jest.fn(() => of([])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
},
settingsStore: {
getSettings: jest.fn(() => ({ epgUrl: [] })),
updateSettings,
},
});
const baseEntry = {
portalType: 'stalker' as const,
exportedId: 'duplicate-id',
title: 'Stalker',
autoRefresh: false,
connection: {
portalUrl: 'https://stalker.example/portal.php',
macAddress: '00:1A:79:AA:BB:CC',
},
userState: {
favorites: [],
recentlyViewed: [],
},
};
const duplicateManifest: PlaylistBackupManifestV1 = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
includeSecrets: true,
settings: { epgUrls: ['https://epg.example/guide.xml'] },
playlists: [baseEntry, { ...baseEntry }],
};
await expect(
service.importBackup(JSON.stringify(duplicateManifest))
).rejects.toThrow(/duplicate exported playlist IDs/);
const blankIdManifest = {
...duplicateManifest,
playlists: [{ ...baseEntry, exportedId: ' ' }],
};
await expect(
service.importBackup(JSON.stringify(blankIdManifest))
).rejects.toThrow(/required metadata/);
expect(addPlaylist).not.toHaveBeenCalled();
expect(updateSettings).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,241 @@
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
StalkerPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { createPlaylistBackupService } from './playlist-backup.service.test-helpers';
describe('PlaylistBackupService Stalker restore safety', () => {
function entry(
connection: StalkerPlaylistBackupEntry['connection'],
exportedId = 'stalker-backup'
): StalkerPlaylistBackupEntry {
return {
portalType: 'stalker',
exportedId,
title: 'Restored Stalker',
autoRefresh: false,
connection,
userState: {
favorites: [],
recentlyViewed: [],
},
};
}
function manifest(
backupEntry: StalkerPlaylistBackupEntry,
includeSecrets: boolean
): PlaylistBackupManifestV1 {
return {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
includeSecrets,
playlists: [backupEntry],
};
}
function existing(overrides: Partial<Playlist> = {}): Playlist {
return {
_id: 'stalker-backup',
title: 'Local Stalker',
count: 1,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-01T00:00:00.000Z',
autoRefresh: false,
portalUrl: 'https://portal.test/server/load.php',
stalkerSourceUrl: 'https://portal.test/c/',
macAddress: '00:1A:79:AA:BB:CC',
stalkerProfilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
username: 'local-user',
password: 'local-password',
stalkerIdentityOverrides: {
serialNumber: 'LOCAL-SERIAL',
},
stalkerDeviceId1: 'LOCAL-DEVICE',
stalkerToken: 'obsolete-token',
...overrides,
} as Playlist;
}
function serviceWith(playlists: Playlist[]) {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of(playlists)),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
return {
playlistsService,
service: createPlaylistBackupService({ playlistsService }),
};
}
it('preserves local secrets only for an exact redacted exportedId/source/MAC/profile match', async () => {
const local = existing();
const { playlistsService, service } = serviceWith([local]);
const backup = entry(
{
portalUrl: 'https://portal.test/server/load.php',
sourceUrl: 'https://portal.test/c/',
macAddress: '00-1a-79-aa-bb-cc',
profilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
},
local._id
);
const summary = await service.importBackup(
JSON.stringify(manifest(backup, false))
);
expect(summary).toEqual({
imported: 0,
merged: 1,
skipped: 0,
failed: 0,
errors: [],
});
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: local._id,
username: 'local-user',
password: 'local-password',
stalkerIdentityOverrides: {
serialNumber: 'LOCAL-SERIAL',
},
stalkerToken: undefined,
})
);
});
it('creates a separate credential-less row when a redacted source does not exactly match', async () => {
const local = existing();
const { playlistsService, service } = serviceWith([local]);
const backup = entry(
{
portalUrl: 'https://other.test/server/load.php',
sourceUrl: 'https://other.test/c/',
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
},
local._id
);
const summary = await service.importBackup(
JSON.stringify(manifest(backup, false))
);
expect(summary).toEqual(
expect.objectContaining({ imported: 1, merged: 0, failed: 0 })
);
const restored = playlistsService.addPlaylist.mock
.calls[0][0] as Playlist;
expect(restored._id).not.toBe(local._id);
expect(restored.stalkerSourceUrl).toBe('https://other.test/c/');
expect(restored).not.toHaveProperty('username');
expect(restored).not.toHaveProperty('password');
expect(restored).not.toHaveProperty('stalkerIdentityOverrides');
});
it('matches included identities by source, MAC, profile, explicit identity, and username', async () => {
const local = existing({ _id: 'local-id' });
const { playlistsService, service } = serviceWith([local]);
const backup = entry({
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: '00-1a-79-aa-bb-cc',
profilePreset: local.stalkerProfilePreset,
identityOverrides: {
serialNumber: 'LOCAL-SERIAL',
},
username: 'local-user',
password: 'replacement-password',
});
const summary = await service.importBackup(
JSON.stringify(manifest(backup, true))
);
expect(summary.merged).toBe(1);
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'local-id',
password: 'replacement-password',
})
);
});
it('does not merge an ambiguous legacy portalUrl and MAC pair', async () => {
const first = existing({
_id: 'first',
stalkerSourceUrl: undefined,
username: 'one',
});
const second = existing({
_id: 'second',
stalkerSourceUrl: undefined,
username: 'two',
});
const { playlistsService, service } = serviceWith([first, second]);
const backup = entry({
portalUrl: first.portalUrl as string,
macAddress: first.macAddress as string,
});
const summary = await service.importBackup(
JSON.stringify(manifest(backup, true))
);
expect(summary.imported).toBe(1);
expect(summary.merged).toBe(0);
const restored = playlistsService.addPlaylist.mock
.calls[0][0] as Playlist;
expect(restored._id).toBe('stalker-backup');
expect(restored).not.toHaveProperty('username');
});
it('uses patch semantics: present empty clears while omitted secrets survive a merge', async () => {
const local = existing({ _id: 'local-id' });
const { playlistsService, service } = serviceWith([local]);
const backup = entry(
{
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
identityOverrides: local.stalkerIdentityOverrides,
username: '',
password: '',
stalkerSerialNumber: '',
},
local._id
);
await service.importBackup(JSON.stringify(manifest(backup, true)));
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'local-id',
username: '',
password: '',
stalkerSerialNumber: '',
stalkerDeviceId1: 'LOCAL-DEVICE',
})
);
const restored = playlistsService.addPlaylist.mock
.calls[0][0] as Playlist;
expect(restored.stalkerIdentityOverrides).toEqual({
serialNumber: 'LOCAL-SERIAL',
});
});
});
+350 -102
View File
@@ -35,6 +35,23 @@ export interface PlaylistBackupExportOptions {
includeSecrets?: boolean;
}
export interface PlaylistBackupXtreamCredentialsRequest {
exportedId: string;
title: string;
serverUrl: string;
}
export interface PlaylistBackupXtreamCredentials {
username: string;
password: string;
}
export interface PlaylistBackupImportOptions {
resolveXtreamCredentials?: (
request: PlaylistBackupXtreamCredentialsRequest
) => Promise<PlaylistBackupXtreamCredentials | null>;
}
export interface PlaylistBackupImportSummary {
imported: number;
merged: number;
@@ -100,7 +117,10 @@ export class PlaylistBackupService {
};
}
async importBackup(json: string): Promise<PlaylistBackupImportSummary> {
async importBackup(
json: string,
options: PlaylistBackupImportOptions = {}
): Promise<PlaylistBackupImportSummary> {
const manifest = this.parseManifest(json);
const existingPlaylists = await firstValueFrom(
this.playlistsService.getAllData()
@@ -108,9 +128,6 @@ export class PlaylistBackupService {
const existingIds = new Set(
existingPlaylists.map((playlist) => playlist._id)
);
const fingerprintMap =
await this.buildExistingFingerprintMap(existingPlaylists);
const seenBackupFingerprints = new Set<string>();
const summary: PlaylistBackupImportSummary = {
imported: 0,
merged: 0,
@@ -126,27 +143,29 @@ export class PlaylistBackupService {
entry.title || entry.exportedId || entry.portalType;
try {
const fingerprint = this.getEntryFingerprint(entry);
if (seenBackupFingerprints.has(fingerprint)) {
const importEntry = await this.resolveImportEntry(
entry,
existingPlaylists,
options
);
if (importEntry === null) {
summary.skipped += 1;
summary.errors.push(
`${entryLabel}: duplicate backup entry skipped`
);
continue;
}
seenBackupFingerprints.add(fingerprint);
const existingMatch = fingerprintMap.get(fingerprint);
const existingMatch = await this.findExistingMatch(
importEntry,
existingPlaylists,
manifest.includeSecrets
);
const targetId = this.resolveTargetPlaylistId(
entry,
importEntry,
existingMatch?._id,
existingIds
);
const isMerge = existingMatch != null;
const nextPlaylist = await this.buildImportedPlaylist(
entry,
importEntry,
targetId,
existingMatch ?? null
);
@@ -155,15 +174,22 @@ export class PlaylistBackupService {
this.playlistsService.addPlaylist(nextPlaylist)
);
if (entry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, entry);
if (importEntry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, importEntry);
}
if (isMerge) {
summary.merged += 1;
const index = existingPlaylists.findIndex(
(playlist) => playlist._id === targetId
);
if (index >= 0) {
existingPlaylists[index] = nextPlaylist;
}
} else {
summary.imported += 1;
existingIds.add(targetId);
existingPlaylists.push(nextPlaylist);
}
} catch (error) {
summary.failed += 1;
@@ -519,7 +545,12 @@ export class PlaylistBackupService {
);
}
if (!entry.exportedId || !entry.title) {
if (
typeof entry.exportedId !== 'string' ||
entry.exportedId.trim().length === 0 ||
typeof entry.title !== 'string' ||
entry.title.trim().length === 0
) {
throw new PlaylistBackupError(
'Backup contains a playlist entry without required metadata.'
);
@@ -593,90 +624,229 @@ export class PlaylistBackupService {
}
}
private async buildExistingFingerprintMap(
playlists: Playlist[]
): Promise<Map<string, Playlist>> {
const map = new Map<string, Playlist>();
for (const playlist of playlists) {
const portalType = this.getPlaylistPortalType(playlist);
if (portalType === 'm3u' && !playlist.url) {
const rawM3u = await firstValueFrom(
this.playlistsService.getRawPlaylistById(playlist._id)
);
map.set(this.buildM3uRawFingerprint(rawM3u), playlist);
continue;
}
map.set(this.getPlaylistFingerprint(playlist), playlist);
private async resolveImportEntry(
entry: PlaylistBackupEntry,
existingPlaylists: readonly Playlist[],
options: PlaylistBackupImportOptions
): Promise<PlaylistBackupEntry | null> {
if (
entry.portalType !== 'xtream' ||
entry.connection.credentialsOmitted !== true
) {
return entry;
}
return map;
}
private getPlaylistFingerprint(playlist: Playlist): string {
const portalType = this.getPlaylistPortalType(playlist);
switch (portalType) {
case 'xtream':
return [
'xtream',
this.normalizeUrlIdentity(playlist.serverUrl ?? ''),
this.normalizeIdentityValue(playlist.username ?? ''),
].join('|');
case 'stalker':
return [
'stalker',
this.normalizeUrlIdentity(
playlist.portalUrl ?? playlist.url ?? ''
),
this.normalizeIdentityValue(
playlist.macAddress ?? '',
true
),
].join('|');
case 'm3u':
default:
if (playlist.url) {
return this.buildM3uUrlFingerprint(playlist.url);
}
throw new PlaylistBackupError(
`Unable to fingerprint M3U playlist "${playlist.title}".`
);
const exactLocalMatch = existingPlaylists.some(
(playlist) =>
playlist._id === entry.exportedId &&
this.getPlaylistPortalType(playlist) === 'xtream' &&
this.normalizeUrlIdentity(playlist.serverUrl ?? '') ===
this.normalizeUrlIdentity(entry.connection.serverUrl)
);
if (exactLocalMatch) {
return entry;
}
const credentials = await options.resolveXtreamCredentials?.({
exportedId: entry.exportedId,
serverUrl: entry.connection.serverUrl,
title: entry.title,
});
if (credentials == null) {
return null;
}
const username = credentials.username.trim();
const password = credentials.password.trim();
if (!username || !password) {
throw new PlaylistBackupError(
'Xtream credentials must include a username and password.'
);
}
return {
...entry,
connection: {
password,
serverUrl: entry.connection.serverUrl,
username,
},
};
}
private getEntryFingerprint(entry: PlaylistBackupEntry): string {
private async findExistingMatch(
entry: PlaylistBackupEntry,
playlists: readonly Playlist[],
includeSecrets: boolean
): Promise<Playlist | null> {
switch (entry.portalType) {
case 'xtream':
return [
'xtream',
this.normalizeUrlIdentity(entry.connection.serverUrl),
this.normalizeIdentityValue(
entry.connection.username ?? ''
),
].join('|');
return this.findXtreamMatch(entry, playlists);
case 'stalker':
return [
'stalker',
this.normalizeUrlIdentity(entry.connection.portalUrl),
this.normalizeIdentityValue(
entry.connection.macAddress,
true
),
].join('|');
return this.findStalkerMatch(entry, playlists, includeSecrets);
case 'm3u':
default:
if (entry.source.url) {
return this.buildM3uUrlFingerprint(entry.source.url);
}
return this.buildM3uRawFingerprint(entry.source.rawM3u);
return this.findM3uMatch(entry, playlists);
}
}
private findXtreamMatch(
entry: XtreamPlaylistBackupEntry,
playlists: readonly Playlist[]
): Playlist | null {
const candidates = playlists.filter(
(playlist) =>
this.getPlaylistPortalType(playlist) === 'xtream' &&
this.normalizeUrlIdentity(playlist.serverUrl ?? '') ===
this.normalizeUrlIdentity(entry.connection.serverUrl)
);
if (entry.connection.credentialsOmitted === true) {
return (
candidates.find(
(playlist) => playlist._id === entry.exportedId
) ?? null
);
}
const username = this.normalizeIdentityValue(
entry.connection.username ?? ''
);
const principalMatches = candidates.filter(
(playlist) =>
this.normalizeIdentityValue(playlist.username ?? '') ===
username
);
return this.chooseUnambiguousMatch(principalMatches, entry.exportedId);
}
private findStalkerMatch(
entry: StalkerPlaylistBackupEntry,
playlists: readonly Playlist[],
includeSecrets: boolean
): Playlist | null {
const candidates = playlists.filter(
(playlist) => this.getPlaylistPortalType(playlist) === 'stalker'
);
if (!includeSecrets) {
return (
candidates.find(
(playlist) =>
playlist._id === entry.exportedId &&
this.hasSameStalkerSourceMacAndProfile(entry, playlist)
) ?? null
);
}
if (this.isLegacyStalkerBackupEntry(entry)) {
const legacyMatches = candidates.filter(
(playlist) =>
this.normalizeUrlIdentity(
playlist.portalUrl ?? playlist.url ?? ''
) ===
this.normalizeUrlIdentity(entry.connection.portalUrl) &&
this.normalizeMacIdentity(playlist.macAddress ?? '') ===
this.normalizeMacIdentity(entry.connection.macAddress)
);
return legacyMatches.length === 1 ? legacyMatches[0] : null;
}
const identityMatches = candidates.filter(
(playlist) =>
this.hasSameStalkerSourceMacAndProfile(entry, playlist) &&
this.stableObjectIdentity(playlist.stalkerIdentityOverrides) ===
this.stableObjectIdentity(
entry.connection.identityOverrides
)
);
const exportedIdMatch = identityMatches.find(
(playlist) => playlist._id === entry.exportedId
);
if (exportedIdMatch) {
return exportedIdMatch;
}
const principalMatches = identityMatches.filter(
(playlist) =>
this.normalizeIdentityValue(playlist.username ?? '') ===
this.normalizeIdentityValue(entry.connection.username ?? '')
);
return principalMatches.length === 1 ? principalMatches[0] : null;
}
private async findM3uMatch(
entry: M3uPlaylistBackupEntry,
playlists: readonly Playlist[]
): Promise<Playlist | null> {
const entryFingerprint = entry.source.url
? this.buildM3uUrlFingerprint(entry.source.url)
: this.buildM3uRawFingerprint(entry.source.rawM3u);
const matches: Playlist[] = [];
for (const playlist of playlists) {
if (this.getPlaylistPortalType(playlist) !== 'm3u') {
continue;
}
const fingerprint = playlist.url
? this.buildM3uUrlFingerprint(playlist.url)
: this.buildM3uRawFingerprint(
await firstValueFrom(
this.playlistsService.getRawPlaylistById(playlist._id)
)
);
if (fingerprint === entryFingerprint) {
matches.push(playlist);
}
}
return this.chooseUnambiguousMatch(matches, entry.exportedId);
}
private hasSameStalkerSourceMacAndProfile(
entry: StalkerPlaylistBackupEntry,
playlist: Playlist
): boolean {
const entrySource =
entry.connection.sourceUrl ?? entry.connection.portalUrl;
const playlistSource =
playlist.stalkerSourceUrl ??
playlist.portalUrl ??
playlist.url ??
'';
return (
this.normalizeUrlIdentity(entrySource) ===
this.normalizeUrlIdentity(playlistSource) &&
this.normalizeMacIdentity(entry.connection.macAddress) ===
this.normalizeMacIdentity(playlist.macAddress ?? '') &&
this.stableObjectIdentity(entry.connection.profilePreset) ===
this.stableObjectIdentity(playlist.stalkerProfilePreset)
);
}
private isLegacyStalkerBackupEntry(
entry: StalkerPlaylistBackupEntry
): boolean {
return (
!hasOwn(entry.connection, 'sourceUrl') &&
!hasOwn(entry.connection, 'profilePreset') &&
!hasOwn(entry.connection, 'identityOverrides')
);
}
private chooseUnambiguousMatch(
candidates: readonly Playlist[],
exportedId: string
): Playlist | null {
const idMatch = candidates.find(
(playlist) => playlist._id === exportedId
);
if (idMatch) {
return idMatch;
}
return candidates.length === 1 ? candidates[0] : null;
}
private buildM3uUrlFingerprint(url: string): string {
return ['m3u', 'url', this.normalizeUrlIdentity(url)].join('|');
}
@@ -803,8 +973,12 @@ export class PlaylistBackupService {
autoRefresh: entry.autoRefresh,
position: entry.position,
serverUrl: entry.connection.serverUrl,
username: entry.connection.username,
password: entry.connection.password,
...(hasOwn(entry.connection, 'username')
? { username: entry.connection.username }
: {}),
...(hasOwn(entry.connection, 'password')
? { password: entry.connection.password }
: {}),
favorites: [],
recentlyViewed: [],
};
@@ -829,22 +1003,79 @@ export class PlaylistBackupService {
position: entry.position,
portalUrl: entry.connection.portalUrl,
url: entry.connection.portalUrl,
...(hasOwn(entry.connection, 'sourceUrl')
? { stalkerSourceUrl: entry.connection.sourceUrl }
: {}),
macAddress: entry.connection.macAddress,
username: entry.connection.username,
password: entry.connection.password,
userAgent: entry.connection.userAgent,
referrer: entry.connection.referrer,
origin: entry.connection.origin,
isFullStalkerPortal: entry.connection.isFullStalkerPortal,
...(hasOwn(entry.connection, 'username')
? { username: entry.connection.username }
: {}),
...(hasOwn(entry.connection, 'password')
? { password: entry.connection.password }
: {}),
...(hasOwn(entry.connection, 'userAgent')
? { userAgent: entry.connection.userAgent }
: {}),
...(hasOwn(entry.connection, 'referrer')
? { referrer: entry.connection.referrer }
: {}),
...(hasOwn(entry.connection, 'origin')
? { origin: entry.connection.origin }
: {}),
...(hasOwn(entry.connection, 'isFullStalkerPortal')
? {
isFullStalkerPortal: entry.connection.isFullStalkerPortal,
}
: {}),
...(hasOwn(entry.connection, 'profilePreset')
? {
stalkerProfilePreset:
entry.connection.profilePreset === undefined
? undefined
: { ...entry.connection.profilePreset },
}
: {}),
...(hasOwn(entry.connection, 'transportConfiguration')
? {
stalkerTransportConfiguration:
entry.connection.transportConfiguration === undefined
? undefined
: {
...entry.connection.transportConfiguration,
},
}
: {}),
...(hasOwn(entry.connection, 'identityOverrides')
? {
stalkerIdentityOverrides:
entry.connection.identityOverrides === undefined
? undefined
: {
...entry.connection.identityOverrides,
},
}
: {}),
favorites: entry.userState.favorites.map((item) => ({ ...item })),
recentlyViewed: entry.userState.recentlyViewed.map((item) => ({
...item,
})),
stalkerSerialNumber: entry.connection.stalkerSerialNumber,
stalkerDeviceId1: entry.connection.stalkerDeviceId1,
stalkerDeviceId2: entry.connection.stalkerDeviceId2,
stalkerSignature1: entry.connection.stalkerSignature1,
stalkerSignature2: entry.connection.stalkerSignature2,
...(hasOwn(entry.connection, 'stalkerSerialNumber')
? {
stalkerSerialNumber: entry.connection.stalkerSerialNumber,
}
: {}),
...(hasOwn(entry.connection, 'stalkerDeviceId1')
? { stalkerDeviceId1: entry.connection.stalkerDeviceId1 }
: {}),
...(hasOwn(entry.connection, 'stalkerDeviceId2')
? { stalkerDeviceId2: entry.connection.stalkerDeviceId2 }
: {}),
...(hasOwn(entry.connection, 'stalkerSignature1')
? { stalkerSignature1: entry.connection.stalkerSignature1 }
: {}),
...(hasOwn(entry.connection, 'stalkerSignature2')
? { stalkerSignature2: entry.connection.stalkerSignature2 }
: {}),
stalkerToken: undefined,
stalkerAccountInfo: undefined,
};
@@ -1130,6 +1361,23 @@ export class PlaylistBackupService {
return toLowerCase ? trimmed.toLowerCase() : trimmed;
}
private normalizeMacIdentity(value: string): string {
return value.trim().replace(/-/g, ':').toUpperCase();
}
private stableObjectIdentity(value: Readonly<object> | undefined): string {
if (value === undefined) {
return '';
}
return JSON.stringify(
Object.fromEntries(
Object.entries(value)
.filter(([, field]) => field !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
)
);
}
private canonicalizeM3u(rawM3u: string): string {
return rawM3u.replace(/\r\n?/g, '\n').trim();
}
@@ -140,8 +140,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
const backup = await service.exportBackup();
const entry = backup.manifest
.playlists[0] as XtreamPlaylistBackupEntry;
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
const expectedHiddenCategories = [
{ categoryType: 'live', xtreamId: 101 },
{ categoryType: 'movies', xtreamId: 201 },
@@ -198,9 +197,8 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
// treated as an authoritative "empty" state: the merge path would
// unhide every category and delete favorites/recent/positions.
const manifest = createXtreamManifest([]);
delete (
manifest.playlists[0] as unknown as { userState?: unknown }
).userState;
delete (manifest.playlists[0] as unknown as { userState?: unknown })
.userState;
await expect(
service.importBackup(JSON.stringify(manifest))
@@ -241,3 +239,142 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
);
});
});
describe('PlaylistBackupService redacted Xtream restore', () => {
function redactedManifest(): PlaylistBackupManifestV1 {
return {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
includeSecrets: false,
playlists: [
{
portalType: 'xtream',
exportedId: 'xtream-redacted',
title: 'Redacted Xtream',
autoRefresh: false,
connection: {
credentialsOmitted: true,
serverUrl: 'https://portal.test/base/',
},
userState: {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
},
},
],
};
}
function serviceWith(playlists: Playlist[]) {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of(playlists)),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
return {
playlistsService,
service: createPlaylistBackupService({ playlistsService }),
};
}
it('preserves local credentials only for the exact exportedId and normalized server URL', async () => {
const local = {
_id: 'xtream-redacted',
title: 'Local Xtream',
count: 1,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://portal.test/base',
username: 'local-user',
password: 'local-password',
} as Playlist;
const { playlistsService, service } = serviceWith([local]);
const summary = await service.importBackup(
JSON.stringify(redactedManifest())
);
expect(summary.merged).toBe(1);
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: local._id,
username: 'local-user',
password: 'local-password',
})
);
});
it('keeps an unmatched entry in memory for the credential resolver and persists nothing when skipped', async () => {
const { playlistsService, service } = serviceWith([]);
const resolveXtreamCredentials = jest.fn().mockResolvedValue(null);
const summary = await service.importBackup(
JSON.stringify(redactedManifest()),
{ resolveXtreamCredentials }
);
expect(resolveXtreamCredentials).toHaveBeenCalledWith({
exportedId: 'xtream-redacted',
serverUrl: 'https://portal.test/base/',
title: 'Redacted Xtream',
});
expect(summary).toEqual({
imported: 0,
merged: 0,
skipped: 1,
failed: 0,
errors: [],
});
expect(playlistsService.addPlaylist).not.toHaveBeenCalled();
});
it('creates the row only after the resolver supplies valid credentials', async () => {
const { playlistsService, service } = serviceWith([]);
const summary = await service.importBackup(
JSON.stringify(redactedManifest()),
{
resolveXtreamCredentials: async () => ({
username: ' restored-user ',
password: ' restored-password ',
}),
}
);
expect(summary.imported).toBe(1);
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: 'https://portal.test/base/',
username: 'restored-user',
password: 'restored-password',
})
);
});
it('rejects blank resolved credentials before creating an unusable row', async () => {
const { playlistsService, service } = serviceWith([]);
const summary = await service.importBackup(
JSON.stringify(redactedManifest()),
{
resolveXtreamCredentials: async () => ({
username: ' ',
password: 'password',
}),
}
);
expect(summary).toEqual(
expect.objectContaining({ failed: 1, imported: 0 })
);
expect(summary.errors[0]).toContain(
'must include a username and password'
);
expect(playlistsService.addPlaylist).not.toHaveBeenCalled();
});
});