Files
iptvnator/libs/services/src/lib/playlists.service.ts
T
4gray e801028005 feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
2026-06-21 22:53:07 +02:00

1253 lines
42 KiB
TypeScript

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 { NgxIndexedDBService } from 'ngx-indexed-db';
import {
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
switchMap,
} from 'rxjs';
import {
Channel,
DbStores,
extractStalkerItemId,
isM3uRecentlyViewedItem,
M3uFavoriteChannel,
M3uRecentlyViewedItem,
Playlist,
PlaylistMeta,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
normalizeStalkerDate,
} from '@iptvnator/shared/interfaces';
import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
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 PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
dbGetAppPlaylistMetas?: () => Promise<Playlist[]>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<unknown>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
type PlaylistStorageWindow = Window & {
electron?: PlaylistStorageElectronApi;
};
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 dbService = inject(NgxIndexedDBService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
private indexedDbMigrationPromise: Promise<void> | null = null;
private get electronApi(): PlaylistStorageElectronApi | null {
if (typeof window === 'undefined') {
return null;
}
return (window as PlaylistStorageWindow).electron ?? null;
}
private get isElectronStorageAvailable(): boolean {
return this.runtime.supportsSqlite;
}
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.isElectronStorageAvailable) {
return;
}
if (!this.electronMigrationPromise) {
this.electronMigrationPromise = (async () => {
await this.migrateIndexedDbPlaylistsToSqlite();
await this.migrateStalkerPlaylistMetadataInSqlite();
})();
}
return this.electronMigrationPromise;
}
private async ensureIndexedDbPlaylistMigrations(): Promise<void> {
if (this.isElectronStorageAvailable) {
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> {
try {
const electron = this.electronApi;
if (!electron) {
return;
}
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const indexedDbPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (indexedDbPlaylists.length > 0) {
await electron.dbUpsertAppPlaylists(indexedDbPlaylists);
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
}
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
} catch (error) {
console.error(
'Failed to migrate IndexedDB playlists to SQLite:',
error
);
}
}
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,
stalkerAccountInfo: playlist.stalkerAccountInfo,
} as Playlist;
}
private withExplicitLegacyStalkerPortalFlag(playlist: Playlist): Playlist {
if (
!playlist?.macAddress ||
playlist.isFullStalkerPortal !== undefined
) {
return playlist;
}
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
const isFullPortal =
portalUrl.includes('/stalker_portal') ||
portalUrl.includes('/server/load.php');
return {
...playlist,
isFullStalkerPortal: isFullPortal,
};
}
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 electron = this.electronApi;
if (!electron) {
return;
}
const alreadyMigrated = await electron.dbGetAppState(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const storedPlaylists =
(await electron.dbGetAppPlaylists()) as Playlist[];
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
await electron.dbUpsertAppPlaylists(updates);
}
await electron.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;
}
const storedPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
await firstValueFrom(
combineLatest(
updates.map((playlist) =>
this.dbService.update(DbStores.Playlists, playlist)
)
)
);
}
this.writeIndexedDbMigrationFlag(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
);
} catch (error) {
console.error(
'Failed to migrate Stalker playlist metadata in IndexedDB:',
error
);
}
}
private upsertSqlitePlaylist(playlist: Playlist) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return playlist;
}
await electron.dbUpsertAppPlaylist(playlist);
return playlist;
});
}
private upsertManySqlitePlaylists(playlists: Playlist[]) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return playlists;
}
await electron.dbUpsertAppPlaylists(playlists);
return playlists;
});
}
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
});
}
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(DbStores.Playlists))
).pipe(
map((data) => data.map((playlist) => this.toPlaylistMeta(playlist)))
);
}
addPlaylist(playlist: Playlist) {
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(playlist);
}
return this.dbService
.add(DbStores.Playlists, playlist)
.pipe(map(() => playlist));
}
getPlaylist(id: string) {
if (id === 'global-favorites') {
return this.getPlaylistWithGlobalFavorites();
}
return this.getPlaylistById(id);
}
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
const delete$: Observable<unknown> = this.isElectronStorageAvailable
? this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return undefined;
}
await electron.dbDeletePlaylist(playlistId);
return undefined;
})
: this.dbService.delete(DbStores.Playlists, playlistId);
return delete$.pipe(
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
map(() => ({ success: true }))
);
}
private async runPlaylistDeleteCleanups(playlistId: string): Promise<void> {
if (this.playlistDeleteCleanups.length === 0) {
return;
}
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
);
}
}
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist.detectedEpgUrls,
enabledEpgUrls:
updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
currentPlaylist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist.disabledEpgUrls,
});
const mergedPlaylist: Playlist = {
...currentPlaylist,
...updatedPlaylist,
_id: playlistId,
count:
updatedPlaylist.playlist?.items?.length ??
currentPlaylist.count,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
favorites: currentPlaylist.favorites,
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist.autoRefresh ??
updatedPlaylist.autoRefresh,
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(mergedPlaylist);
}
return this.dbService.update(
DbStores.Playlists,
mergedPlaylist
);
})
);
}
getPlaylistById(id: string) {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlist = electron
? await electron.dbGetAppPlaylist(id)
: null;
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: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
playlist.disabledEpgUrls,
});
const nextPlaylist: Playlist = {
...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 }
: {}),
...(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,
}
: {}),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
updateFavorites(id: string, favorites: string[]) {
return this.getPlaylistById(id).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
favorites,
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
updateManyPlaylists(playlists: Playlist[]) {
if (playlists.length === 0) {
return of([]);
}
if (this.isElectronStorageAvailable) {
const updatedPlaylists = playlists.map((playlist) => ({
...playlist,
updateDate: Date.now(),
autoRefresh: true,
}));
return this.upsertManySqlitePlaylists(updatedPlaylists);
}
return combineLatest(
playlists.map((playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
updateDate: Date.now(),
autoRefresh: true,
});
})
);
}
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 electron = this.electronApi;
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
if (
!electron ||
!this.isElectronStorageAvailable ||
typeof getFavoriteChannels !== 'function'
) {
return of(null);
}
return from(
(async () => {
const alreadyMigrated = await electron.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.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...portal,
favorites: [...(portal.favorites ?? []), item],
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
setPortalFavorites(portalId: string, favorites: StalkerPortalItem[]) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...portal,
favorites,
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
removeFromPortalFavorites(portalId: string, favoriteId: number | string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...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
);
}),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
updatePlaylistPositions(
positionUpdates: {
id: string;
changes: { position: number };
}[]
) {
if (positionUpdates.length === 0) {
return of([]);
}
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? ((await electron.dbGetAppPlaylists()) as Playlist[])
: [];
const positionsById = new Map(
positionUpdates.map((item) => [
item.id,
item.changes.position,
])
);
const updatedPlaylists = playlists
.filter((playlist) => positionsById.has(playlist._id))
.map((playlist) => ({
...playlist,
position: positionsById.get(playlist._id),
}));
if (electron) {
await electron.dbUpsertAppPlaylists(updatedPlaylists);
}
return updatedPlaylists;
});
}
return combineLatest(
positionUpdates.map((item) => {
return this.dbService
.getByID<Playlist>(DbStores.Playlists, item.id)
.pipe(
switchMap((playlist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
position: item.changes.position,
})
)
);
})
);
}
async handlePlaylistParsing(
uploadType: 'FILE' | 'URL' | 'TEXT',
playlist: string,
title: string,
path?: string
) {
try {
// Dynamic import keeps the ~130KB validator dep (transitively pulled
// by iptv-playlist-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> {
if (this.isElectronStorageAvailable) {
return this.upsertManySqlitePlaylists(playlists);
}
return 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.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
favorites,
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
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.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
return electron
? ((await electron.dbGetAppPlaylists()) as Playlist[])
: [];
});
}
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getAll<Playlist>(DbStores.Playlists))
);
}
removeAll(): Observable<void> {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (electron) {
await electron.dbDeleteAllPlaylists();
}
return undefined;
}).pipe(map(() => undefined));
}
return this.dbService
.clear(DbStores.Playlists)
.pipe(map(() => 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.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
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
);
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: [nextItem, ...remainingItems],
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
removeFromPlaylistRecentlyViewed(
playlistId: string,
identity: string | number
) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!this.matchesPlaylistRecentIdentity(item, identity)
),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
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.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!identities.some((identity) =>
this.matchesPlaylistRecentIdentity(
item,
identity
)
)
),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
clearPlaylistRecentlyViewed(playlistId: string) {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: [],
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
}
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);
}
}