Files
iptvnator/libs/services/src/lib/server-storage/server-migration.service.ts
T
4grayandClaude Opus 5.5 115465755b fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes
- A served copy carries the version it came from (a symbol property that
  spreads keep and JSON drops), so a mutation of a portal document names
  the version it was built on, not the latest read.
- A 401 for a token replaced since (a new sign-in) neither clears the new
  token nor reports unauthorized, in the API client, the HttpClient
  interceptor and PwaService's proxy calls.
- Moving browser data counts a browser user-data map that cannot be read
  as a failure, so the move stays on offer.
- An edit made before the first read on top of this tab's recovered
  unsent write is applied to that write, not to the server's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:26 +02:00

379 lines
13 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { firstValueFrom } from 'rxjs';
import {
DbStores,
SERVER_USER_STATE_NAMESPACES,
type Playlist,
type ServerUserStateNamespace,
type ServerUserStateResponse,
} from '@iptvnator/shared/interfaces';
import {
combineUserData,
remapChannelReferences,
retargetUserData,
unionItems,
} from './server-migration-merge';
import { planServerMigration } from './server-migration-plan';
import { preconditionHeaders } from './server-user-data-entries';
import {
typeLegacyXtreamRefs,
type XtreamUserData,
} from './server-migration-xtream';
import { ParentalLockService } from '../parental-lock/parental-lock.service';
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. New playlists keep their
* ids; one the server already has (same id, or same provider source) is
* merged into the server's copy, adding the browser's favorites and
* history to it. The legacy Xtream favorites, history, collections and
* playback positions follow under the server id, combined with what is
* there (`combineUserData`). Browser data is never deleted, and the move
* can run again after a failure.
*/
@Injectable({ providedIn: 'root' })
export class ServerMigrationService {
private readonly dbService = inject(NgxIndexedDBService);
private readonly session = inject(ServerStorageSession);
private readonly parentalLock = inject(ParentalLockService);
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> {
// An edit still unsent could land after the combined values below.
await this.session.userData.flush();
if (this.session.userData.hasUnsentWrites()) {
throw new Error('Unsent user data; try again when online');
}
const [browser, server] = await Promise.all([
this.readBrowserPlaylists(),
this.session.documentStore.dbGetAppPlaylistMetas(),
]);
const byId = new Map(
browser.map((playlist) => [playlist._id, playlist])
);
/** Browser playlist id → its id on the server. */
const targets = new Map<string, string>();
let imported = 0;
let merged = 0;
let failed = 0;
const onServer = new Set(server.map((playlist) => playlist._id));
for (const step of planServerMigration(browser, server)) {
try {
if (step.kind === 'merge') {
// A duplicate of a browser playlist whose upload failed.
if (!onServer.has(step.serverId)) {
throw new Error('Merge target was not uploaded');
}
const playlist = byId.get(step.browserId);
if (playlist) {
await this.mergeDocumentUserData(
playlist,
step.serverId
);
}
merged += 1;
targets.set(step.browserId, step.serverId);
} else {
await this.session.documentStore.dbUpsertAppPlaylist(
step.playlist
);
imported += 1;
onServer.add(step.playlist._id);
targets.set(step.playlist._id, step.playlist._id);
}
} catch {
failed += 1;
}
}
failed += await this.copyXtreamUserData(targets);
failed += await this.moveParentalLocks(targets);
await this.session.userData.hydrate();
if (failed === 0) {
// A partial move stays on offer, in Settings → Server.
this.writeMarker('done');
}
return { imported, merged, failed };
}
/** Adds a browser document's favorites and history to the server's. */
private async mergeDocumentUserData(
playlist: Playlist,
serverId: string
): Promise<void> {
const favorites = playlist.favorites ?? [];
const recentlyViewed = playlist.recentlyViewed ?? [];
if (favorites.length === 0 && recentlyViewed.length === 0) {
return;
}
const stored =
await this.session.documentStore.dbGetAppPlaylist(serverId);
if (!stored) {
throw new Error('The server playlist disappeared');
}
const browserChannels = channelsOf(playlist);
const serverChannels = channelsOf(stored);
const next = {
...stored,
favorites: unionItems(
stored.favorites ?? [],
remapChannelReferences(
favorites,
browserChannels,
serverChannels
)
),
recentlyViewed: unionItems(
stored.recentlyViewed ?? [],
remapChannelReferences(
recentlyViewed,
browserChannels,
serverChannels
)
),
};
if (
next.favorites.length !== (stored.favorites ?? []).length ||
next.recentlyViewed.length !== (stored.recentlyViewed ?? []).length
) {
await this.session.documentStore.dbUpsertAppPlaylist(next);
}
}
/**
* Copies the legacy maps, one write per server entry: each copy's
* legacy ids are typed first, then the browser copies of one source
* are combined with each other and with the server's value. Returns
* how many writes failed.
*/
private async copyXtreamUserData(
targets: ReadonlyMap<string, string>
): Promise<number> {
if (targets.size === 0) {
return 0;
}
const { entries, tags } =
await this.session.api.getJson<ServerUserStateResponse>(
'/user-state'
);
// A map this browser has but cannot read fails the move, so it
// stays on offer instead of being marked done without it.
let unreadable = 0;
const legacy = new Map<
ServerUserStateNamespace,
Record<string, unknown>
>();
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const map = readLegacyMap(namespace);
if (map) {
legacy.set(namespace, map);
} else {
unreadable += 1;
}
}
const copies = new Map<string, XtreamUserData[]>();
for (const [browserId, serverId] of targets) {
const copy: XtreamUserData = {};
for (const [namespace, map] of legacy) {
if (browserId in map) {
copy[namespace] = retargetUserData(
map[browserId],
browserId,
serverId
);
}
}
if (Object.keys(copy).length > 0) {
copies.set(serverId, [...(copies.get(serverId) ?? []), copy]);
}
}
let failed = unreadable;
for (const [serverId, browserCopies] of copies) {
const stored: XtreamUserData = {};
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const value = entries[namespace]?.[serverId];
if (value !== undefined) {
stored[namespace] = value;
}
}
const typed = [stored, ...browserCopies].map(typeLegacyXtreamRefs);
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const value = combineUserData(
typed.map((copy) => copy[namespace])
);
if (
value === undefined ||
JSON.stringify(value) === JSON.stringify(stored[namespace])
) {
continue;
}
try {
// On the version read above: a device that changed it
// meanwhile wins, and the move stays on offer.
await this.session.api.sendJson(
'PUT',
`/user-state/${namespace}/${encodeURIComponent(serverId)}`,
{ value },
tags
? preconditionHeaders(
tags[namespace]?.[serverId] ?? null
)
: {}
);
} catch {
failed += 1;
}
}
}
return failed;
}
/**
* Parental locks stay in this browser, keyed by playlist id: a playlist
* merged under another id takes its locks along, or its locked
* categories would show after the move. Returns how many failed.
*/
private async moveParentalLocks(
targets: ReadonlyMap<string, string>
): Promise<number> {
const moving = [...targets].filter(([from, to]) => from !== to);
// An unreadable lock store reads as empty: keep the move on offer.
if (
moving.length > 0 &&
!(await this.parentalLock.ensureLocksReadable())
) {
return moving.length;
}
let failed = 0;
for (const [browserId, serverId] of targets) {
const from = this.parentalLock.locksFor(browserId);
if (
browserId === serverId ||
from.xtream.length + from.stalker.length + from.m3u.length === 0
) {
continue;
}
const to = this.parentalLock.locksFor(serverId);
const moved = await this.parentalLock.replacePlaylistLocks(
serverId,
{
xtream: unionItems(to.xtream, from.xtream),
stalker: unionItems(to.stalker, from.stalker),
m3u: unionItems(to.m3u, from.m3u),
}
);
if (!moved) {
failed += 1;
}
}
return 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.
}
}
}
/** The M3U channels of a full playlist document. */
function channelsOf(playlist: Playlist): { id?: unknown; url?: unknown }[] {
const items = (playlist as { playlist?: { items?: unknown } }).playlist
?.items;
return Array.isArray(items) ? items : [];
}
function readLocal(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
/** A browser map, `{}` when absent, `null` when it cannot be read. */
function readLegacyMap(key: string): Record<string, unknown> | null {
let value: string | null;
try {
value = localStorage.getItem(key);
} catch {
return null;
}
if (value === null) {
return {};
}
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}