feat(services): move browser playlists to the server

An empty server gets the browser playlists with their ids plus the
legacy Xtream user-data maps; a server that already has playlists goes
through the backup export and import, so duplicates merge. Browser data
is never deleted; the decision is remembered per server instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent fadb6e5ae2
commit 57f2a33396
3 files changed
+298

No files matched your search

@@ -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';
@@ -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<unknown>) =>
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 });
});
});
@@ -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<ServerMigrationPlaylist[]> {
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<ServerMigrationPlaylist[] | null> {
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<ServerMigrationResult> {
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<ServerMigrationResult> {
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<string>
): Promise<void> {
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<ServerMigrationResult> {
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<Playlist[]> {
return firstValueFrom(
this.dbService.getAll<Playlist>(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<string, unknown> {
try {
const parsed: unknown = value ? JSON.parse(value) : {};
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}