diff --git a/libs/services/src/lib/playlist-backup.service.secrets.spec.ts b/libs/services/src/lib/playlist-backup.service.secrets.spec.ts index 2afcef231..12c4e3fe0 100644 --- a/libs/services/src/lib/playlist-backup.service.secrets.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.secrets.spec.ts @@ -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(); + }); }); diff --git a/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts new file mode 100644 index 000000000..a06d237fe --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.stalker-restore.spec.ts @@ -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 { + 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', + }); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 6d1806cd7..37c17a27b 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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; +} + export interface PlaylistBackupImportSummary { imported: number; merged: number; @@ -100,7 +117,10 @@ export class PlaylistBackupService { }; } - async importBackup(json: string): Promise { + async importBackup( + json: string, + options: PlaylistBackupImportOptions = {} + ): Promise { 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(); 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> { - const map = new Map(); - - 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 { + 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 { 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 { + 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 | 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(); } diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index dcfa79507..066e786da 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -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(); + }); +});