mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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 {};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user