Files
iptvnator/libs/services/src/lib/playlists.service.ts
T
4grayandClaude Opus 5.5 94570bc387 refactor(storage): seams for server-backed playlist storage (#1896)
* docs(storage): server storage design and implementation plans

Design for keeping playlists and user data on the self-hosted server
(#305), revised against master, plus three implementation plans: storage
seams, the web-backend storage API, and the PWA server mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(database): share the playlist row codec

Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers
from the Electron database operations into libs/shared/database
(@iptvnator/shared/database/app-playlist) so the self-hosted web backend
can write the same rows. The Electron modules re-export them; behavior is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(database): read the data root from IPTVNATOR_DATA_DIR

Deployments that keep the database on a mounted volume, such as the
self-hosted web backend in Docker, can now set the data root. The E2E
override still wins; without either variable the root stays ~/.iptvnator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(services): add the playlist storage backend resolver

PlaylistStorageBackend becomes the single place that decides whether
playlists persist through the Electron bridge or IndexedDB, and defines
the PlaylistDocumentStore contract a server store will implement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(services): resolve playlist storage through one backend

PlaylistsService asks PlaylistStorageBackend for its document store
instead of reading window.electron and supportsSqlite directly. Paths that
only make sense on the desktop (legacy IndexedDB import, worker deletion
with progress, the favorites-channel query) check for the Electron kind
explicitly. Behavior is unchanged: the backend still resolves to the
Electron bridge or IndexedDB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(xtream): store PWA user data through USER_DATA_STORAGE

PwaXtreamDataSource and PwaCollectionStorage read and write favorites,
recently viewed items, collection snapshots and playback positions through
the new USER_DATA_STORAGE token (default: localStorage) instead of calling
localStorage directly, so a deployment can substitute a server-synced
storage. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(pwa): document the storage seams

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(database): bracket access in the shared playlist codec

libs/shared/database compiles with noPropertyAccessFromIndexSignature,
which the Electron backend did not enforce on the moved code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(electron): alias the shared playlist codec in the worker bundle

The database worker is bundled by esbuild with an explicit alias table;
without the new entry, @iptvnator/shared/database/app-playlist resolved
under the library's index file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): read localStorage lazily in USER_DATA_STORAGE

When the browser denies storage access, reading localStorage itself
throws. The default factory returned the global, so injecting the token
failed and PwaXtreamDataSource could not be constructed. The adapter now
reads the global inside each call, where the data source already guards
its reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(database): describe /data as an example data root

The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with
the server storage backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:03:58 +02:00

1556 lines
55 KiB
TypeScript

import {
DatabaseService,
type DbOperationOptions,
} from './database-electron.service';
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
import { DBMode, NgxIndexedDBService } from 'ngx-indexed-db';
import {
combineLatest,
defer,
firstValueFrom,
from,
map,
Observable,
of,
switchMap,
tap,
} from 'rxjs';
import {
Channel,
DbStores,
extractStalkerItemId,
isFullStalkerPortalUrl,
isM3uRecentlyViewedItem,
M3uFavoriteChannel,
M3uRecentlyViewedItem,
Playlist,
PlaylistMetaUpdate,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
normalizeStalkerDate,
} from '@iptvnator/shared/interfaces';
import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token';
import {
runWithPlaylistAuthorityMutation,
runWithPlaylistAuthorityReset,
} from './playlist-cross-context-lock';
import {
PlaylistDocumentStore,
PlaylistStorageBackend,
} from './playlist-storage-backend';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
'm3u-playlists-stalker-metadata-v1';
type PortalFavoriteItem = StalkerPortalItem & {
category_id?: string;
raw?: string;
[key: string]: unknown;
};
type PlaylistRawItem = {
raw?: string;
};
type AddManyPlaylistsResult = Playlist[] | IDBValidKey[];
type PlaylistParserModule = Partial<typeof import('iptv-playlist-parser')> & {
default?: Partial<typeof import('iptv-playlist-parser')>;
};
export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
const parse = parserModule.parse ?? parserModule.default?.parse;
if (!parse) {
throw new Error('iptv-playlist-parser parse export was not found');
}
return parse;
}
@Injectable({
providedIn: 'root',
})
export class PlaylistsService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true });
private readonly dbService = inject(NgxIndexedDBService);
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly storageBackend = inject(PlaylistStorageBackend);
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
// Startup reads the inventory twice at once (the playlist effect and the
// XMLTV source reconciliation); both share one worker round trip. Only the
// first read is shared, while the startup screen still hides every action
// that writes playlists (other services write them too, e.g. the settings
// reset); it ends when that read settles or a write here starts (null).
private startupMetas?: Promise<Playlist[]> | null;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
/** Electron bridge or server store; `null` means IndexedDB. */
private get documentStore(): PlaylistDocumentStore | null {
return this.storageBackend.documentStore();
}
private get usesDocumentStore(): boolean {
return this.storageBackend.kind() !== 'indexeddb';
}
/** Desktop-only paths: legacy IndexedDB import, worker deletion. */
private get usesElectronStorage(): boolean {
return this.storageBackend.kind() === 'electron';
}
private runOnSqlite<T>(operation: () => Promise<T>) {
return from(
this.ensureElectronPlaylistMigrations().then(() => operation())
);
}
private runOnIndexedDb<T>(operation: () => Promise<T>) {
return from(
this.ensureIndexedDbPlaylistMigrations().then(() => operation())
);
}
private async ensureElectronPlaylistMigrations(): Promise<void> {
if (!this.usesElectronStorage) {
return;
}
if (!this.electronMigrationPromise) {
this.electronMigrationPromise = (async () => {
await this.migrateIndexedDbPlaylistsToSqlite();
await this.documentStore?.dbRecoverLegacyPlaylists?.();
await this.migrateStalkerPlaylistMetadataInSqlite();
})().catch((error) => {
this.electronMigrationPromise = null;
throw error;
});
}
return this.electronMigrationPromise;
}
private async ensureIndexedDbPlaylistMigrations(): Promise<void> {
if (this.usesDocumentStore) {
return;
}
if (!this.indexedDbMigrationPromise) {
this.indexedDbMigrationPromise =
this.migrateStalkerPlaylistMetadataInIndexedDb();
}
return this.indexedDbMigrationPromise;
}
private toPlaylistMeta(playlist: Playlist): Playlist {
const playlistMeta = { ...playlist };
delete playlistMeta.playlist;
delete playlistMeta.items;
delete playlistMeta.header;
return playlistMeta;
}
private toAutoUpdatePlaylistMeta(playlist: Playlist): Playlist {
const playlistMeta = this.toPlaylistMeta(playlist);
delete playlistMeta.favorites;
return playlistMeta;
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
const store = this.documentStore;
if (!store?.dbMigrateAppPlaylists) return;
if ((await store.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) === '1')
return;
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (playlists.length) {
// The worker commits rows and the receipt atomically. Keep the
// original IndexedDB as a recovery source, even after success.
await store.dbMigrateAppPlaylists(playlists);
} else {
await store.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
}
}
private createSqliteFallbackPlaylist(
playlist: Partial<Playlist> & { _id?: string; id?: string }
): Playlist {
const id = String(playlist._id ?? playlist.id ?? '');
return {
...playlist,
_id: id,
title: playlist.title ?? '',
count: Number(playlist.count ?? 0),
importDate: playlist.importDate ?? new Date().toISOString(),
lastUsage: playlist.lastUsage ?? new Date().toISOString(),
favorites: playlist.favorites ?? [],
recentlyViewed: playlist.recentlyViewed ?? [],
autoRefresh: Boolean(playlist.autoRefresh),
playlist: playlist.playlist,
url: playlist.url,
filePath: playlist.filePath,
epgUrls: playlist.epgUrls ?? [],
detectedEpgUrls: playlist.detectedEpgUrls ?? playlist.epgUrls ?? [],
manualEpgUrls: playlist.manualEpgUrls ?? [],
disabledEpgUrls: playlist.disabledEpgUrls ?? [],
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
updateDate: playlist.updateDate,
updateState: playlist.updateState,
position: playlist.position,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
macAddress: playlist.macAddress,
portalUrl: playlist.portalUrl,
stalkerSerialNumber: playlist.stalkerSerialNumber,
stalkerDeviceId1: playlist.stalkerDeviceId1,
stalkerDeviceId2: playlist.stalkerDeviceId2,
stalkerSignature1: playlist.stalkerSignature1,
stalkerSignature2: playlist.stalkerSignature2,
isFullStalkerPortal: playlist.isFullStalkerPortal,
stalkerToken: playlist.stalkerToken,
// Without these the Electron cold read loses the persisted
// cadence AND the identity the token was negotiated for, so the
// mismatch check cannot run and the watchdog falls back to the
// default.
stalkerSessionIdentity: playlist.stalkerSessionIdentity,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
stalkerAccountInfo: playlist.stalkerAccountInfo,
} as Playlist;
}
private withExplicitLegacyStalkerPortalFlag(playlist: Playlist): Playlist {
if (
!playlist?.macAddress ||
playlist.isFullStalkerPortal !== undefined
) {
return playlist;
}
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
return {
...playlist,
isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl),
};
}
private collectStalkerMetadataMigrationUpdates(
playlists: Playlist[]
): Playlist[] {
return playlists.reduce<Playlist[]>((updates, playlist) => {
const migratedPlaylist =
this.withExplicitLegacyStalkerPortalFlag(playlist);
if (migratedPlaylist !== playlist) {
updates.push(migratedPlaylist);
}
return updates;
}, []);
}
private readIndexedDbMigrationFlag(key: string): string | null {
try {
const value = localStorage.getItem(key);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
private writeIndexedDbMigrationFlag(key: string): void {
try {
localStorage.setItem(key, '1');
} catch {
// Ignore storage write failures.
}
}
private async migrateStalkerPlaylistMetadataInSqlite(): Promise<void> {
try {
const store = this.documentStore;
if (!store) {
return;
}
const alreadyMigrated = await store.dbGetAppState(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const storedPlaylists =
(await store.dbGetAppPlaylists()) as Playlist[];
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
await store.dbUpsertAppPlaylists(updates);
}
await store.dbSetAppState(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
'1'
);
} catch (error) {
console.error(
'Failed to migrate Stalker playlist metadata in SQLite:',
error
);
}
}
private async migrateStalkerPlaylistMetadataInIndexedDb(): Promise<void> {
try {
if (
this.readIndexedDbMigrationFlag(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
) === '1'
) {
return;
}
await new Promise<void>((resolve, reject) => {
this.dbService
.openCursor<Playlist>({
storeName: DbStores.Playlists,
mode: DBMode.readwrite,
})
.subscribe({
next: (cursor) => {
try {
const migratedPlaylist =
this.withExplicitLegacyStalkerPortalFlag(
cursor.value
);
if (migratedPlaylist !== cursor.value) {
cursor.update(migratedPlaylist);
}
cursor.continue();
} catch (error) {
cursor.request.transaction?.abort();
reject(error);
}
},
error: reject,
complete: resolve,
});
});
this.writeIndexedDbMigrationFlag(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
);
} catch (error) {
console.error(
'Failed to migrate Stalker playlist metadata in IndexedDB:',
error
);
}
}
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
return this.runOnSqlite(async () => {
const store = this.documentStore;
if (!store) {
return playlist;
}
this.startupMetas = null;
if (operationId === undefined) {
await store.dbUpsertAppPlaylist(playlist);
} else {
await store.dbUpsertAppPlaylist(playlist, operationId);
}
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
return playlist;
});
}
private upsertManySqlitePlaylists(playlists: Playlist[]) {
return this.runOnSqlite(async () => {
const store = this.documentStore;
if (!store) {
return playlists;
}
this.startupMetas = null;
await store.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
});
}
// Every playlist mutation reads the row, patches it in memory, and writes
// the whole row back. Overlapping mutations on the same playlist would be
// last-write-wins, so all of them are chained per playlist id here.
private serializePlaylistWrite<T>(
playlistId: string,
operation: () => Promise<T>
): Observable<T> {
return defer(() => {
const previous =
this.playlistWriteQueues.get(playlistId) ?? Promise.resolve();
const next = previous.then(() => operation());
const tail = next.then(
() => undefined,
() => undefined
);
this.playlistWriteQueues.set(playlistId, tail);
void tail.then(() => {
if (this.playlistWriteQueues.get(playlistId) === tail) {
this.playlistWriteQueues.delete(playlistId);
}
});
return next;
});
}
private persistPlaylistMutation(
nextPlaylist: Playlist,
operationId?: string
) {
if (this.usesDocumentStore) {
return firstValueFrom(
this.upsertSqlitePlaylist(nextPlaylist, operationId)
);
}
return firstValueFrom(
this.dbService.update(DbStores.Playlists, nextPlaylist)
);
}
private transformIndexedDbPlaylistMeta(
playlistId: string,
transform: (current: Playlist) => Playlist | null
): Promise<Playlist | null> {
return new Promise((resolve, reject) => {
let nextPlaylist: Playlist | null = null;
this.dbService
.openCursor<Playlist>({
storeName: DbStores.Playlists,
query: playlistId,
mode: DBMode.readwrite,
})
.subscribe({
next: (cursor) => {
try {
nextPlaylist = transform(cursor.value);
if (nextPlaylist !== null) {
cursor.update(nextPlaylist);
}
} catch (error) {
cursor.request.transaction?.abort();
reject(error);
}
},
error: reject,
complete: () => resolve(nextPlaylist),
});
});
}
getAllPlaylists() {
if (this.usesDocumentStore) {
const shared = this.startupMetas;
// A joining caller gets its own copy of the shared result.
if (shared) return from(shared.then((p) => structuredClone(p)));
const read = firstValueFrom(
this.runOnSqlite(async () => {
const store = this.documentStore;
const playlists = store
? await (store.dbGetAppPlaylistMetas?.() ??
store.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
})
);
if (shared === undefined) {
this.startupMetas = read;
const end = () => (this.startupMetas = null);
read.then(end, end);
}
return from(read);
}
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(DbStores.Playlists))
).pipe(
map((data) => data.map((playlist) => this.toPlaylistMeta(playlist)))
);
}
addPlaylist(playlist: Playlist) {
return this.serializePlaylistWrite(playlist._id, () =>
runWithPlaylistAuthorityMutation([playlist._id], async () => {
if (this.usesDocumentStore) {
return firstValueFrom(this.upsertSqlitePlaylist(playlist));
}
await firstValueFrom(
this.dbService.add(DbStores.Playlists, playlist)
);
return playlist;
})
);
}
getPlaylist(id: string) {
if (id === 'global-favorites') {
return this.getPlaylistWithGlobalFavorites();
}
// Refresh publishes its store action before its asynchronous write
// finishes. A route opened in that interval must not replace the new
// channels with the previous persisted snapshot. Join the same queue;
// mutation-internal reads keep using getPlaylistById to avoid re-entry.
return this.serializePlaylistWrite(id, () =>
firstValueFrom(this.getPlaylistById(id))
);
}
deletePlaylist(
playlistId: string,
options?: DbOperationOptions
): Observable<{ success: boolean; cleanupWarnings?: number }> {
// Deletion goes through the SAME per-playlist queue as every write:
// a queued mutation (e.g. the Stalker portal repair's conditional
// transform) landing after an unserialized delete would upsert the
// row back and resurrect the playlist.
const delete$: Observable<unknown> = this.serializePlaylistWrite(
playlistId,
() =>
runWithPlaylistAuthorityMutation([playlistId], async () => {
if (this.usesDocumentStore) {
await this.ensureElectronPlaylistMigrations();
const store = this.documentStore;
if (store) {
this.startupMetas = null;
if (options && this.usesElectronStorage) {
const deleted =
await this.databaseService.deletePlaylist(
playlistId,
options
);
if (!deleted)
throw new Error(
'Playlist deletion did not complete'
);
} else await store.dbDeletePlaylist(playlistId);
}
return undefined;
}
return firstValueFrom(
this.dbService.delete(DbStores.Playlists, playlistId)
);
})
);
return delete$.pipe(
tap(() => this.healthEvidence?.connections.next({ id: playlistId })),
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
map((cleanupWarnings) =>
cleanupWarnings
? { success: true, cleanupWarnings }
: { success: true }
)
);
}
private async runPlaylistDeleteCleanups(
playlistId: string
): Promise<number> {
if (this.playlistDeleteCleanups.length === 0) {
return 0;
}
const failures = (
await Promise.all(
this.playlistDeleteCleanups.map(async (cleanup) => {
try {
await cleanup(playlistId);
return null;
} catch (error) {
return error;
}
})
)
).filter((error) => error !== null);
for (const failure of failures) {
console.warn(
'Playlist cleanup failed after playlist deletion:',
failure
);
}
return failures.length;
}
/**
* Canonical refresh merge shared by the single-playlist update flow and
* the auto-refresh batch. The refreshed payload only contributes
* refresh-owned data (parsed content, count, EPG detection); user-owned
* state on the freshly read row — favorites, recently viewed, ordering,
* hidden groups, curated EPG sources — must survive the refresh write.
*/
private mergeRefreshedPlaylist(
currentPlaylist: Playlist | undefined,
updatedPlaylist: Playlist,
playlistId: string
): Playlist {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist?.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? currentPlaylist?.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ?? currentPlaylist?.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist?.disabledEpgUrls,
});
return {
...currentPlaylist,
...updatedPlaylist,
_id: playlistId,
count:
updatedPlaylist.playlist?.items?.length ??
currentPlaylist?.count ??
updatedPlaylist.count,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
...(currentPlaylist
? {
favorites: currentPlaylist.favorites,
recentlyViewed: currentPlaylist.recentlyViewed,
position: currentPlaylist.position,
}
: {}),
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist?.autoRefresh ?? updatedPlaylist.autoRefresh,
};
}
updatePlaylist(
playlistId: string,
updatedPlaylist: Playlist,
operationId?: string
) {
return this.serializePlaylistWrite(playlistId, async () => {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId, operationId)
);
if (!currentPlaylist) throw new Error('Playlist no longer exists');
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
updatedPlaylist,
playlistId
);
return this.persistPlaylistMutation(mergedPlaylist, operationId);
});
}
getPlaylistById(id: string, operationId?: string) {
if (this.usesDocumentStore) {
return this.runOnSqlite(async () => {
const store = this.documentStore;
let playlist: Playlist | null = null;
if (store) {
playlist =
operationId === undefined
? await store.dbGetAppPlaylist(id)
: await store.dbGetAppPlaylist(id, operationId);
}
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
: (undefined as unknown as Playlist);
});
}
return this.runOnIndexedDb(() =>
firstValueFrom(
this.dbService.getByID<Playlist>(DbStores.Playlists, id)
)
);
}
updatePlaylistMeta(updatedPlaylist: PlaylistMetaUpdate) {
return this.updatePlaylistMetaInQueue(updatedPlaylist);
}
/** Applies a meta update only while the storage-current row still matches. */
updatePlaylistMetaIfCurrent(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent: (playlist: Playlist) => boolean
) {
return this.updatePlaylistMetaInQueue(updatedPlaylist, isCurrent);
}
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate
): Observable<Playlist>;
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent: (playlist: Playlist) => boolean
): Observable<Playlist | null>;
private updatePlaylistMetaInQueue(
updatedPlaylist: PlaylistMetaUpdate,
isCurrent?: (playlist: Playlist) => boolean
): Observable<Playlist | null> {
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
if (isCurrent && !this.usesDocumentStore) {
await this.ensureIndexedDbPlaylistMigrations();
return this.transformIndexedDbPlaylistMeta(
updatedPlaylist._id,
(current) =>
isCurrent(current)
? this.mergePlaylistMeta(current, updatedPlaylist)
: null
);
}
const playlist = await firstValueFrom(
this.getPlaylistById(updatedPlaylist._id)
);
if (isCurrent && !isCurrent(playlist)) {
return null;
}
return this.persistPlaylistMutation(
this.mergePlaylistMeta(playlist, updatedPlaylist)
);
});
}
private mergePlaylistMeta(
playlist: Playlist,
updatedPlaylist: PlaylistMetaUpdate
): Playlist {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ?? playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ?? playlist.disabledEpgUrls,
});
return {
...playlist,
...(updatedPlaylist.title != null
? { title: updatedPlaylist.title }
: {}),
...(updatedPlaylist.autoRefresh != null
? { autoRefresh: updatedPlaylist.autoRefresh }
: {}),
...(updatedPlaylist.userAgent != null
? { userAgent: updatedPlaylist.userAgent }
: {}),
...(updatedPlaylist.referrer !== undefined
? { referrer: updatedPlaylist.referrer }
: {}),
...(updatedPlaylist.origin !== undefined
? { origin: updatedPlaylist.origin }
: {}),
...(updatedPlaylist.serverUrl != null
? { serverUrl: updatedPlaylist.serverUrl }
: {}),
...(updatedPlaylist.portalUrl != null
? { portalUrl: updatedPlaylist.portalUrl }
: {}),
// A learned panel clock belongs to the panel it was learned
// from: an update that points the source at another server
// drops it until the next account-info check, unless the
// update itself supplies one (issue #1562).
...(updatedPlaylist.serverTimezone != null
? { serverTimezone: updatedPlaylist.serverTimezone }
: updatedPlaylist.serverUrl != null &&
updatedPlaylist.serverUrl !== playlist.serverUrl
? { serverTimezone: undefined }
: {}),
...(updatedPlaylist.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal: updatedPlaylist.isFullStalkerPortal,
}
: {}),
...(updatedPlaylist.macAddress != null
? { macAddress: updatedPlaylist.macAddress }
: {}),
...(updatedPlaylist.username != null
? { username: updatedPlaylist.username }
: {}),
...(updatedPlaylist.password != null
? { password: updatedPlaylist.password }
: {}),
...(updatedPlaylist.favorites != null
? { favorites: updatedPlaylist.favorites }
: {}),
...(updatedPlaylist.recentlyViewed != null
? { recentlyViewed: updatedPlaylist.recentlyViewed }
: {}),
...(updatedPlaylist.hiddenGroupTitles != null
? {
hiddenGroupTitles: updatedPlaylist.hiddenGroupTitles,
}
: {}),
...(updatedPlaylist.detectedEpgUrls !== undefined
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(updatedPlaylist.manualEpgUrls !== undefined
? { manualEpgUrls: epgSourceState.manualEpgUrls }
: {}),
...(updatedPlaylist.disabledEpgUrls !== undefined
? { disabledEpgUrls: epgSourceState.disabledEpgUrls }
: {}),
...(updatedPlaylist.epgUrls !== undefined ||
updatedPlaylist.detectedEpgUrls !== undefined ||
updatedPlaylist.manualEpgUrls !== undefined ||
updatedPlaylist.disabledEpgUrls !== undefined
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
...(updatedPlaylist.stalkerSerialNumber !== undefined
? {
stalkerSerialNumber: updatedPlaylist.stalkerSerialNumber,
}
: {}),
...(updatedPlaylist.stalkerDeviceId1 !== undefined
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
: {}),
...(updatedPlaylist.stalkerDeviceId2 !== undefined
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
: {}),
...(updatedPlaylist.stalkerSignature1 !== undefined
? {
stalkerSignature1: updatedPlaylist.stalkerSignature1,
}
: {}),
...(updatedPlaylist.stalkerSignature2 !== undefined
? {
stalkerSignature2: updatedPlaylist.stalkerSignature2,
}
: {}),
...(updatedPlaylist.stalkerSessionPatch !== undefined
? {
stalkerToken:
updatedPlaylist.stalkerSessionPatch?.stalkerToken,
stalkerSessionIdentity:
updatedPlaylist.stalkerSessionPatch
?.stalkerSessionIdentity,
stalkerWatchdogTimeout:
updatedPlaylist.stalkerSessionPatch
?.stalkerWatchdogTimeout,
stalkerTimeslot:
updatedPlaylist.stalkerSessionPatch?.stalkerTimeslot,
stalkerAccountInfo:
updatedPlaylist.stalkerSessionPatch
?.stalkerAccountInfo,
}
: {}),
};
}
/**
* Persists a freshly negotiated Stalker session on the playlist so the
* next app start can re-present the token (the portal handshake is
* idempotent) and keep the portal's own watchdog cadence — reusing a
* token skips the `get_profile` that carries the cadence, so it has to
* survive with the token. No-ops when the playlist row does not exist
* yet: the import flow saves both with the playlist itself.
*/
updateStalkerSession(
playlistId: string,
session: {
stalkerToken: string;
stalkerSessionIdentity?: string;
stalkerWatchdogTimeout?: number;
stalkerTimeslot?: number;
}
) {
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
// The cadence is written unconditionally, including as
// `undefined`: a portal that stops advertising one must not leave
// a stale value behind for the next restart to re-apply, since
// reusing the token skips the profile that would correct it.
return this.persistPlaylistMutation({
...playlist,
stalkerToken: session.stalkerToken,
stalkerSessionIdentity: session.stalkerSessionIdentity,
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
stalkerTimeslot: session.stalkerTimeslot,
});
});
}
updateFavorites(id: string, favorites: string[]) {
return this.serializePlaylistWrite(id, async () => {
const playlist = await firstValueFrom(this.getPlaylistById(id));
return this.persistPlaylistMutation({
...playlist,
favorites,
});
});
}
/**
* Applies an atomic favorites update: the current favorites are read
* inside the per-playlist write queue, so overlapping calls cannot work
* from stale snapshots. Callers should pass a pure transform instead of
* precomputing the next favorites array from an earlier read.
*/
transformPlaylistFavorites(
playlistId: string,
transform: (
currentFavorites: NonNullable<Playlist['favorites']>
) => NonNullable<Playlist['favorites']>
): Observable<Playlist> {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
throw new Error(`Playlist not found: ${playlistId}`);
}
const currentFavorites = Array.isArray(playlist.favorites)
? playlist.favorites
: [];
const nextPlaylist: Playlist = {
...playlist,
favorites: transform(currentFavorites),
};
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
});
}
/**
* Applies an atomic, conditional meta mutation. Electron uses the
* per-playlist write queue; IndexedDB additionally performs the read,
* predicate, and cursor update in one readwrite transaction so another
* browser context cannot interleave a replacement. The transform may
* return null to abort without writing.
*/
transformPlaylistMeta(
playlistId: string,
transform: (current: Playlist) => Playlist | null
): Observable<Playlist | null> {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
if (!this.usesDocumentStore) {
await this.ensureIndexedDbPlaylistMigrations();
return this.transformIndexedDbPlaylistMeta(
playlistId,
transform
);
}
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
const nextPlaylist = transform(playlist);
if (nextPlaylist === null) {
return null;
}
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
});
}
updateManyPlaylists(playlists: Playlist[]) {
if (playlists.length === 0) {
return of([]);
}
// Auto-refresh payloads are snapshots taken before the refresh ran,
// so each row goes through the same per-playlist queue and canonical
// refresh merge as the single-playlist update flow: a batch write
// cannot clobber a concurrent collection or metadata mutation.
return combineLatest(
playlists.map((playlist) =>
this.serializePlaylistWrite(playlist._id, async () => {
const current = await firstValueFrom(
this.getPlaylistById(playlist._id)
);
if (!current) return null;
// The merge takes autoRefresh from the current row first,
// so disabling auto-refresh while a refresh is in flight
// is not reverted by the completing batch write.
const nextPlaylist = this.mergeRefreshedPlaylist(
current,
playlist,
playlist._id
);
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
})
)
).pipe(map((rows) => rows.filter((row): row is Playlist => row !== null)));
}
getFavoriteChannels(playlistId: string) {
return this.getPlaylistById(playlistId).pipe(
map((data) =>
(data.playlist?.items ?? []).filter((channel: Channel) =>
data.favorites?.includes(channel.id)
)
)
);
}
getM3uFavoriteChannels(
playlistId: string
): Observable<M3uFavoriteChannel[] | null> {
const store = this.documentStore;
const getFavoriteChannels = store?.dbGetAppPlaylistFavoriteChannels;
if (
!store ||
!this.usesElectronStorage ||
typeof getFavoriteChannels !== 'function'
) {
return of(null);
}
return from(
(async () => {
// A favorite toggled just before this read may still be queued;
// the queue tail never rejects.
await this.playlistWriteQueues.get(playlistId);
const alreadyMigrated = await store.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
return getFavoriteChannels(playlistId);
})()
);
}
getPortalFavorites(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return item.favorites as PortalFavoriteItem[];
}),
map((favorites) =>
favorites.sort(
(a, b) =>
new Date(b.added_at ?? '').getTime() -
new Date(a.added_at ?? '').getTime()
)
)
);
}
getPortalLiveStreamFavorites(portalId: string) {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return (item.favorites as PortalFavoriteItem[]).filter(
(itm) =>
itm && itm.stream_type && itm.stream_type === 'live'
);
})
);
}
addPortalFavorite(portalId: string, item: PortalFavoriteItem) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
return this.persistPlaylistMutation({
...portal,
favorites: [...(portal.favorites ?? []), item],
});
});
}
setPortalFavorites(portalId: string, favorites: StalkerPortalItem[]) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
return this.persistPlaylistMutation({
...portal,
favorites,
});
});
}
removeFromPortalFavorites(portalId: string, favoriteId: number | string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
return this.persistPlaylistMutation({
...portal,
favorites: portal.favorites?.filter((i) => {
const expectedId = String(favoriteId);
const favorite = i as PortalFavoriteItem;
const streamId = String(favorite.stream_id ?? '');
const seriesId = String(favorite.series_id ?? '');
const movieId = String(favorite.movie_id ?? '');
const itemId = String(favorite.id ?? '');
return (
streamId !== expectedId &&
seriesId !== expectedId &&
movieId !== expectedId &&
itemId !== expectedId
);
}),
});
});
}
updatePlaylistPositions(
positionUpdates: {
id: string;
changes: { position: number };
}[]
) {
if (positionUpdates.length === 0) {
return of([]);
}
return combineLatest(
positionUpdates.map((item) =>
this.serializePlaylistWrite(item.id, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(item.id)
);
if (!playlist) {
return null;
}
const nextPlaylist: Playlist = {
...playlist,
position: item.changes.position,
};
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
})
)
);
}
async handlePlaylistParsing(
uploadType: 'FILE' | 'URL' | 'TEXT',
playlist: string,
title: string,
path?: string
) {
try {
// Dynamic import keeps the parser out of the eager bundle;
// parse() only runs on user-triggered imports.
const parserModule = await import('iptv-playlist-parser');
const parse = resolvePlaylistParser(parserModule);
const parsedPlaylist = parse(playlist);
return createPlaylistObject(
title,
parsedPlaylist,
path,
uploadType
);
} catch (error) {
this.snackBar.open(
this.translateService.instant('HOME.PARSING_ERROR'),
undefined,
{ duration: 2000 }
);
throw new Error(`Parsing failed, not a valid playlist: ${error}`);
}
}
getPlaylistWithGlobalFavorites() {
return this.getAllData().pipe(
map((playlists: Playlist[]) => {
const favoriteChannels = aggregateFavoriteChannels(playlists);
const favPlaylist = createFavoritesPlaylist(favoriteChannels);
return favPlaylist;
})
);
}
addManyPlaylists(
playlists: Playlist[]
): Observable<AddManyPlaylistsResult> {
return defer(() =>
runWithPlaylistAuthorityMutation(
playlists.map((playlist) => playlist._id),
async () => {
if (this.usesDocumentStore) {
return firstValueFrom(
this.upsertManySqlitePlaylists(playlists)
);
}
return firstValueFrom(
this.dbService.bulkAdd(
DbStores.Playlists,
playlists as unknown as Playlist[]
)
);
}
)
);
}
getPlaylistsForAutoUpdate() {
return this.getAllData().pipe(
map((playlists: Playlist[]) => {
return playlists
.filter((item) => item.autoRefresh)
.map((playlist) => this.toAutoUpdatePlaylistMeta(playlist));
})
);
}
setFavorites(playlistId: string, favorites: string[]) {
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
return this.persistPlaylistMutation({
...playlist,
favorites,
});
});
}
getRawPlaylistById(id: string) {
return this.getPlaylistById(id).pipe(
map((playlist) => {
return (
`${playlist.playlist?.header?.raw ?? ''}` +
'\n' +
(playlist.playlist?.items ?? [])
.map((item: PlaylistRawItem) => item.raw)
.join('\n')
);
})
);
}
getAllData() {
if (this.usesDocumentStore) {
return this.runOnSqlite(async () => {
const store = this.documentStore;
return store
? ((await store.dbGetAppPlaylists()) as Playlist[])
: [];
});
}
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(DbStores.Playlists))
);
}
removeAll(): Observable<void> {
return defer(() =>
runWithPlaylistAuthorityReset(async () => {
if (this.usesDocumentStore) {
await this.ensureElectronPlaylistMigrations();
const store = this.documentStore;
if (store) {
this.startupMetas = null;
await store.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
return undefined;
}
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
return undefined;
})
);
}
private normalizePortalRecentIdentity(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) {
return '';
}
return raw.includes(':') ? raw.split(':')[0] : raw;
}
private getPlaylistRecentIdentity(
item: PlaylistRecentlyViewedItem
): string {
if (isM3uRecentlyViewedItem(item)) {
return String(item.url ?? item.id ?? '').trim();
}
return this.normalizePortalRecentIdentity(
extractStalkerItemId(item ?? {})
);
}
private matchesPlaylistRecentIdentity(
item: PlaylistRecentlyViewedItem,
expectedIdentity: string | number
): boolean {
const expectedRaw = String(expectedIdentity ?? '').trim();
if (!expectedRaw) {
return false;
}
if (isM3uRecentlyViewedItem(item)) {
return this.getPlaylistRecentIdentity(item) === expectedRaw;
}
return (
this.getPlaylistRecentIdentity(item) ===
this.normalizePortalRecentIdentity(expectedRaw)
);
}
private sortPlaylistRecentItems(
items: PlaylistRecentlyViewedItem[]
): PlaylistRecentlyViewedItem[] {
return [...items].sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() -
new Date(normalizeStalkerDate(a.added_at ?? '')).getTime()
);
}
getPlaylistRecentlyViewed(playlistId: string) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
map((item) => {
if (!item || !Array.isArray(item.recentlyViewed)) {
return [];
}
return item.recentlyViewed as PlaylistRecentlyViewedItem[];
}),
map((items) => this.sortPlaylistRecentItems(items))
);
}
addPlaylistRecentlyViewed(
playlistId: string,
item: PlaylistRecentlyViewedItem
) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
const nowIso = new Date().toISOString();
const recentItems = Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
const existingIndex = recentItems.findIndex((recentItem) =>
this.matchesPlaylistRecentIdentity(
recentItem,
this.getPlaylistRecentIdentity(item)
)
);
const existingItem =
existingIndex >= 0 ? recentItems[existingIndex] : null;
const nextItem: PlaylistRecentlyViewedItem = {
...(existingItem ?? {}),
...item,
added_at: nowIso,
};
const remainingItems = recentItems.filter(
(_, index) => index !== existingIndex
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: [nextItem, ...remainingItems],
});
});
}
removeFromPlaylistRecentlyViewed(
playlistId: string,
identity: string | number
) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!this.matchesPlaylistRecentIdentity(item, identity)
),
});
});
}
removeFromPlaylistRecentlyViewedBatch(
playlistId: string,
identities: ReadonlyArray<string | number>
) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
if (identities.length === 0) {
return this.getPlaylistById(playlistId);
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!identities.some((identity) =>
this.matchesPlaylistRecentIdentity(item, identity)
)
),
});
});
}
clearPlaylistRecentlyViewed(playlistId: string) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: [],
});
});
}
getPortalRecentlyViewed(portalId: string) {
return this.getPlaylistRecentlyViewed(portalId).pipe(
map((items) =>
items.filter(
(item): item is StalkerPortalItem =>
!isM3uRecentlyViewedItem(item)
)
)
);
}
addPortalRecentlyViewed(
portalId: string,
item: StalkerPortalItem & { id: string | number; title: string }
) {
return this.addPlaylistRecentlyViewed(portalId, item);
}
addM3uRecentlyViewed(playlistId: string, item: M3uRecentlyViewedItem) {
return this.addPlaylistRecentlyViewed(playlistId, item);
}
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
return this.removeFromPlaylistRecentlyViewed(portalId, id);
}
removeFromM3uRecentlyViewed(playlistId: string, channelUrl: string) {
return this.removeFromPlaylistRecentlyViewed(playlistId, channelUrl);
}
clearPortalRecentlyViewed(portalId: string) {
return this.clearPlaylistRecentlyViewed(portalId);
}
clearM3uRecentlyViewed(playlistId: string) {
return this.clearPlaylistRecentlyViewed(playlistId);
}
}