diff --git a/libs/services/src/lib/server-storage/index.ts b/libs/services/src/lib/server-storage/index.ts index 8debd0d92..8d321c337 100644 --- a/libs/services/src/lib/server-storage/index.ts +++ b/libs/services/src/lib/server-storage/index.ts @@ -4,3 +4,4 @@ export * from './server-playlist-document-store'; export * from './server-user-data-storage'; export * from './server-access-token.interceptor'; export * from './server-storage-session'; +export * from './server-migration.service'; diff --git a/libs/services/src/lib/server-storage/server-migration.service.spec.ts b/libs/services/src/lib/server-storage/server-migration.service.spec.ts new file mode 100644 index 000000000..4f52b042d --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration.service.spec.ts @@ -0,0 +1,128 @@ +import { TestBed } from '@angular/core/testing'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { of } from 'rxjs'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { PlaylistBackupService } from '../playlist-backup.service'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerStorageSession } from './server-storage-session'; +import { ServerMigrationService } from './server-migration.service'; + +describe('ServerMigrationService', () => { + const browserPlaylists = [ + { _id: 'm', title: 'Family' }, + { _id: 'x', title: 'Provider', serverUrl: 'http://panel.test' }, + ]; + let session: { + fetchStatus: jest.Mock; + documentStore: { dbUpsertAppPlaylist: jest.Mock }; + api: { sendJson: jest.Mock }; + userData: { hydrate: jest.Mock }; + }; + let backup: { exportBackup: jest.Mock; importBackup: jest.Mock }; + + beforeEach(() => { + session = { + fetchStatus: jest.fn().mockResolvedValue({ playlists: 0 }), + documentStore: { + dbUpsertAppPlaylist: jest + .fn() + .mockResolvedValue({ success: true }), + }, + api: { sendJson: jest.fn().mockResolvedValue(undefined) }, + userData: { hydrate: jest.fn().mockResolvedValue(undefined) }, + }; + backup = { + exportBackup: jest.fn().mockResolvedValue({ json: '{}' }), + importBackup: jest.fn().mockResolvedValue({ + imported: 1, + merged: 1, + skipped: 0, + failed: 0, + errors: [], + }), + }; + TestBed.configureTestingModule({ + providers: [ + { + provide: NgxIndexedDBService, + useValue: { getAll: () => of(browserPlaylists) }, + }, + { provide: ServerStorageSession, useValue: session }, + { provide: PlaylistBackupService, useValue: backup }, + { + provide: PlaylistStorageBackend, + useValue: { + runWithBrowserStorage: (task: () => Promise) => + task(), + }, + }, + { + provide: SERVER_STORAGE_CONFIG, + useValue: { + baseUrl: '/api', + instance: { instanceId: 'i-1' }, + }, + }, + ], + }); + }); + + afterEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + it('offers the move on an empty server and summarizes browser playlists', async () => { + const offer = await TestBed.inject( + ServerMigrationService + ).offerOnStartup(); + + expect(offer).toEqual([ + { id: 'm', title: 'Family', kind: 'm3u' }, + { id: 'x', title: 'Provider', kind: 'xtream' }, + ]); + }); + + it('does not offer after a decision or when the server has playlists', async () => { + const migration = TestBed.inject(ServerMigrationService); + session.fetchStatus.mockResolvedValueOnce({ playlists: 3 }); + expect(await migration.offerOnStartup()).toBeNull(); + + migration.skip(); + expect(await migration.offerOnStartup()).toBeNull(); + }); + + it('copies playlists with their ids and their Xtream user data to an empty server', async () => { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ x: ['live:1'], other: ['live:2'] }) + ); + const migration = TestBed.inject(ServerMigrationService); + + const result = await migration.moveBrowserData(); + + expect(result).toEqual({ imported: 2, merged: 0, failed: 0 }); + expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledTimes( + 2 + ); + expect(session.api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/x', + { value: ['live:1'] } + ); + expect(session.api.sendJson).toHaveBeenCalledTimes(1); + expect(migration.browserDataMoved()).toBe(true); + }); + + it('merges through the backup format when the server already has playlists', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 4 }); + + const result = await TestBed.inject( + ServerMigrationService + ).moveBrowserData(); + + expect(backup.exportBackup).toHaveBeenCalled(); + expect(backup.importBackup).toHaveBeenCalledWith('{}'); + expect(result).toEqual({ imported: 1, merged: 1, failed: 0 }); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-migration.service.ts b/libs/services/src/lib/server-storage/server-migration.service.ts new file mode 100644 index 000000000..08dbe10bc --- /dev/null +++ b/libs/services/src/lib/server-storage/server-migration.service.ts @@ -0,0 +1,169 @@ +import { inject, Injectable } from '@angular/core'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { firstValueFrom } from 'rxjs'; +import { + DbStores, + SERVER_USER_STATE_NAMESPACES, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { PlaylistBackupService } from '../playlist-backup.service'; +import { PlaylistStorageBackend } from '../playlist-storage-backend'; +import { SERVER_STORAGE_CONFIG } from './server-storage.config'; +import { ServerStorageSession } from './server-storage-session'; + +const MARKER_PREFIX = 'iptvnator-server:migration:'; + +export interface ServerMigrationPlaylist { + readonly id: string; + readonly title: string; + readonly kind: 'm3u' | 'xtream' | 'stalker'; +} + +export interface ServerMigrationResult { + readonly imported: number; + readonly merged: number; + readonly failed: number; +} + +/** + * Moves this browser's playlists to the server. An empty server receives + * them with their ids plus the legacy Xtream user-data maps; a server that + * already has playlists goes through the backup export/import, whose + * fingerprints merge duplicates. Browser data is never deleted. + */ +@Injectable({ providedIn: 'root' }) +export class ServerMigrationService { + private readonly dbService = inject(NgxIndexedDBService); + private readonly session = inject(ServerStorageSession); + private readonly backend = inject(PlaylistStorageBackend); + private readonly backup = inject(PlaylistBackupService); + private readonly instanceId = inject(SERVER_STORAGE_CONFIG).instance + .instanceId; + + async browserPlaylists(): Promise { + const playlists = await this.readBrowserPlaylists(); + return playlists.map((playlist) => ({ + id: playlist._id, + title: playlist.title, + kind: playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', + })); + } + + /** The startup offer: only for an empty server and an undecided browser. */ + async offerOnStartup(): Promise { + if (this.readMarker() !== null) { + return null; + } + const playlists = await this.browserPlaylists(); + if (playlists.length === 0) { + return null; + } + const { playlists: serverCount } = await this.session.fetchStatus(); + return serverCount === 0 ? playlists : null; + } + + browserDataMoved(): boolean { + return this.readMarker() === 'done'; + } + + skip(): void { + this.writeMarker('skipped'); + } + + async moveBrowserData(): Promise { + const { playlists: serverCount } = await this.session.fetchStatus(); + const result = + serverCount === 0 + ? await this.copyWithIds() + : await this.mergeThroughBackup(); + this.writeMarker('done'); + return result; + } + + private async copyWithIds(): Promise { + const playlists = await this.readBrowserPlaylists(); + let imported = 0; + let failed = 0; + for (const playlist of playlists) { + try { + await this.session.documentStore.dbUpsertAppPlaylist(playlist); + imported += 1; + } catch { + failed += 1; + } + } + await this.copyXtreamUserData(new Set(playlists.map((p) => p._id))); + return { imported, merged: 0, failed }; + } + + private async copyXtreamUserData( + playlistIds: ReadonlySet + ): Promise { + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const legacy = parseMap(readLocal(namespace)); + for (const [playlistId, value] of Object.entries(legacy)) { + if (playlistIds.has(playlistId)) { + await this.session.api.sendJson( + 'PUT', + `/user-state/${namespace}/${encodeURIComponent(playlistId)}`, + { value } + ); + } + } + } + await this.session.userData.hydrate(); + } + + private async mergeThroughBackup(): Promise { + const { json } = await this.backend.runWithBrowserStorage(() => + this.backup.exportBackup() + ); + const summary = await this.backup.importBackup(json); + return { + imported: summary.imported, + merged: summary.merged, + failed: summary.failed, + }; + } + + private readBrowserPlaylists(): Promise { + return firstValueFrom( + this.dbService.getAll(DbStores.Playlists) + ); + } + + private readMarker(): string | null { + return readLocal(MARKER_PREFIX + this.instanceId); + } + + private writeMarker(value: 'done' | 'skipped'): void { + try { + localStorage.setItem(MARKER_PREFIX + this.instanceId, value); + } catch { + // Without storage the offer simply returns next time. + } + } +} + +function readLocal(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function parseMap(value: string | null): Record { + try { + const parsed: unknown = value ? JSON.parse(value) : {}; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +}