mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
feat(backup): restore redacted portals safely
This commit is contained in:
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user