Files
KiraTV---Aplicacion-para-Wi…/libs/services/src/lib/playlist-backup.service.ts
T
2026-05-03 23:36:48 +02:00

1029 lines
34 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
import { PlaybackPositionService } from './playback-position.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import {
isM3uRecentlyViewedItem,
M3uPlaylistBackupEntry,
M3uRecentlyViewedItem,
Playlist,
PlaylistBackupEntry,
PlaylistBackupManifestV1,
PlaylistBackupSettings,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
StalkerPlaylistBackupEntry,
StalkerPortalItem,
XtreamBackupCategoryType,
XtreamBackupContentType,
XtreamPlaylistBackupEntry,
XtreamPendingRestoreState,
} from 'shared-interfaces';
export interface PlaylistBackupExportPayload {
defaultFileName: string;
json: string;
manifest: PlaylistBackupManifestV1;
}
export interface PlaylistBackupImportSummary {
imported: number;
merged: number;
skipped: number;
failed: number;
errors: string[];
}
export class PlaylistBackupError extends Error {
constructor(message: string) {
super(message);
this.name = 'PlaylistBackupError';
}
}
type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker';
type XtreamContentType = XtreamBackupContentType;
type XtreamCategoryType = XtreamBackupCategoryType;
@Injectable({
providedIn: 'root',
})
export class PlaylistBackupService {
private readonly playlistsService = inject(PlaylistsService);
private readonly settingsStore = inject(SettingsStore);
private readonly databaseService = inject(DatabaseService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
async exportBackup(): Promise<PlaylistBackupExportPayload> {
const playlists = await firstValueFrom(
this.playlistsService.getAllData()
);
const settings = this.buildSettingsPayload();
const playlistEntries = await Promise.all(
playlists.map((playlist) => this.buildPlaylistEntry(playlist))
);
const exportedAt = new Date().toISOString();
const manifest: PlaylistBackupManifestV1 = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt,
includeSecrets: true,
...(settings ? { settings } : {}),
playlists: playlistEntries,
};
return {
defaultFileName: `iptvnator-playlist-backup-${exportedAt.slice(
0,
10
)}.json`,
json: JSON.stringify(manifest, null, 2),
manifest,
};
}
async importBackup(json: string): Promise<PlaylistBackupImportSummary> {
const manifest = this.parseManifest(json);
const existingPlaylists = await firstValueFrom(
this.playlistsService.getAllData()
);
const existingIds = new Set(
existingPlaylists.map((playlist) => playlist._id)
);
const fingerprintMap =
await this.buildExistingFingerprintMap(existingPlaylists);
const seenBackupFingerprints = new Set<string>();
const summary: PlaylistBackupImportSummary = {
imported: 0,
merged: 0,
skipped: 0,
failed: 0,
errors: [],
};
await this.restoreSettings(manifest.settings);
for (const entry of manifest.playlists) {
const entryLabel =
entry.title || entry.exportedId || entry.portalType;
try {
const fingerprint = this.getEntryFingerprint(entry);
if (seenBackupFingerprints.has(fingerprint)) {
summary.skipped += 1;
summary.errors.push(
`${entryLabel}: duplicate backup entry skipped`
);
continue;
}
seenBackupFingerprints.add(fingerprint);
const existingMatch = fingerprintMap.get(fingerprint);
const targetId = this.resolveTargetPlaylistId(
entry,
existingMatch?._id,
existingIds
);
const isMerge = existingMatch != null;
const nextPlaylist = await this.buildImportedPlaylist(
entry,
targetId,
existingMatch ?? null
);
await firstValueFrom(
this.playlistsService.addPlaylist(nextPlaylist)
);
if (entry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, entry);
}
if (isMerge) {
summary.merged += 1;
} else {
summary.imported += 1;
existingIds.add(targetId);
}
} catch (error) {
summary.failed += 1;
summary.errors.push(
`${entryLabel}: ${this.getErrorMessage(error)}`
);
}
}
return summary;
}
private async buildPlaylistEntry(
playlist: Playlist
): Promise<PlaylistBackupEntry> {
const portalType = this.getPlaylistPortalType(playlist);
switch (portalType) {
case 'xtream':
return this.buildXtreamEntry(playlist);
case 'stalker':
return this.buildStalkerEntry(playlist);
case 'm3u':
default:
return this.buildM3uEntry(playlist);
}
}
private async buildM3uEntry(
playlist: Playlist
): Promise<M3uPlaylistBackupEntry> {
const rawM3u = await firstValueFrom(
this.playlistsService.getRawPlaylistById(playlist._id)
);
const favorites = Array.isArray(playlist.favorites)
? playlist.favorites.filter(
(item): item is string =>
typeof item === 'string' && item.trim().length > 0
)
: [];
const recentlyViewed = Array.isArray(playlist.recentlyViewed)
? playlist.recentlyViewed.filter(isM3uRecentlyViewedItem)
: [];
return {
portalType: 'm3u',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
source: {
kind: this.resolveM3uSourceKind(playlist),
rawM3u,
...(playlist.url ? { url: playlist.url } : {}),
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
...(playlist.referrer ? { referrer: playlist.referrer } : {}),
...(playlist.origin ? { origin: playlist.origin } : {}),
...(playlist.filePath
? { filePathHint: playlist.filePath }
: {}),
},
userState: {
favorites: this.uniqueStrings(favorites),
recentlyViewed: recentlyViewed.map((item) => ({ ...item })),
hiddenGroupTitles: this.uniqueStrings(
Array.isArray(playlist.hiddenGroupTitles)
? playlist.hiddenGroupTitles
: []
),
},
};
}
private async buildXtreamEntry(
playlist: Playlist
): Promise<XtreamPlaylistBackupEntry> {
if (!this.hasElectronApi()) {
return {
portalType: 'xtream',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
serverUrl: playlist.serverUrl ?? '',
username: playlist.username ?? '',
...(playlist.password
? { password: playlist.password }
: {}),
},
userState: {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
},
};
}
const [
liveCategories,
movieCategories,
seriesCategories,
favorites,
recent,
playbackPositions,
] = await Promise.all([
this.databaseService.getAllXtreamCategories(playlist._id, 'live'),
this.databaseService.getAllXtreamCategories(playlist._id, 'movies'),
this.databaseService.getAllXtreamCategories(playlist._id, 'series'),
this.databaseService.getFavorites(playlist._id),
this.databaseService.getRecentItems(playlist._id),
this.playbackPositionService.getAllPlaybackPositions(playlist._id),
]);
return {
portalType: 'xtream',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
serverUrl: playlist.serverUrl ?? '',
username: playlist.username ?? '',
...(playlist.password ? { password: playlist.password } : {}),
},
userState: {
hiddenCategories: [
...this.mapHiddenCategories(liveCategories, 'live'),
...this.mapHiddenCategories(movieCategories, 'movies'),
...this.mapHiddenCategories(seriesCategories, 'series'),
],
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
: {}),
})),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
viewedAt: item.viewed_at ?? new Date().toISOString(),
})),
playbackPositions: playbackPositions.map((item) => ({
...item,
})),
},
};
}
private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry {
return {
portalType: 'stalker',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
portalUrl: playlist.portalUrl ?? playlist.url ?? '',
macAddress: playlist.macAddress ?? '',
...(playlist.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal: playlist.isFullStalkerPortal,
}
: {}),
...(playlist.username ? { username: playlist.username } : {}),
...(playlist.password ? { password: playlist.password } : {}),
...(playlist.userAgent
? { userAgent: playlist.userAgent }
: {}),
...(playlist.referrer ? { referrer: playlist.referrer } : {}),
...(playlist.origin ? { origin: playlist.origin } : {}),
...(playlist.stalkerSerialNumber
? { stalkerSerialNumber: playlist.stalkerSerialNumber }
: {}),
...(playlist.stalkerDeviceId1
? { stalkerDeviceId1: playlist.stalkerDeviceId1 }
: {}),
...(playlist.stalkerDeviceId2
? { stalkerDeviceId2: playlist.stalkerDeviceId2 }
: {}),
...(playlist.stalkerSignature1
? { stalkerSignature1: playlist.stalkerSignature1 }
: {}),
...(playlist.stalkerSignature2
? { stalkerSignature2: playlist.stalkerSignature2 }
: {}),
},
userState: {
favorites: this.extractStalkerItems(playlist.favorites),
recentlyViewed: this.extractStalkerItems(
playlist.recentlyViewed
),
},
};
}
private buildSettingsPayload(): PlaylistBackupSettings | undefined {
const epgUrls = this.normalizeSettingsUrls(
this.settingsStore.getSettings().epgUrl ?? []
);
if (epgUrls.length === 0) {
return undefined;
}
return { epgUrls };
}
private parseManifest(json: string): PlaylistBackupManifestV1 {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (error) {
throw new PlaylistBackupError(
`Invalid backup JSON: ${this.getErrorMessage(error)}`
);
}
if (Array.isArray(parsed)) {
throw new PlaylistBackupError(
'Legacy playlist JSON exports are not supported. Create a versioned backup first.'
);
}
if (!parsed || typeof parsed !== 'object') {
throw new PlaylistBackupError('Backup file must be a JSON object.');
}
const manifest = parsed as Partial<PlaylistBackupManifestV1>;
if (manifest.kind !== PLAYLIST_BACKUP_KIND) {
throw new PlaylistBackupError('Unsupported backup file format.');
}
if (manifest.version !== PLAYLIST_BACKUP_VERSION) {
throw new PlaylistBackupError(
`Unsupported backup version: ${manifest.version ?? 'unknown'}`
);
}
if (!Array.isArray(manifest.playlists)) {
throw new PlaylistBackupError(
'Backup file must contain playlists.'
);
}
for (const entry of manifest.playlists) {
this.validateEntry(entry);
}
return manifest as PlaylistBackupManifestV1;
}
private validateEntry(entry: PlaylistBackupEntry): void {
if (!entry || typeof entry !== 'object') {
throw new PlaylistBackupError(
'Backup contains an invalid playlist entry.'
);
}
if (!entry.exportedId || !entry.title) {
throw new PlaylistBackupError(
'Backup contains a playlist entry without required metadata.'
);
}
switch (entry.portalType) {
case 'm3u':
if (!entry.source?.rawM3u) {
throw new PlaylistBackupError(
`M3U backup "${entry.title}" is missing raw playlist data.`
);
}
break;
case 'xtream':
if (
!entry.connection?.serverUrl ||
!entry.connection?.username
) {
throw new PlaylistBackupError(
`Xtream backup "${entry.title}" is missing connection metadata.`
);
}
break;
case 'stalker':
if (
!entry.connection?.portalUrl ||
!entry.connection?.macAddress
) {
throw new PlaylistBackupError(
`Stalker backup "${entry.title}" is missing connection metadata.`
);
}
break;
default:
throw new PlaylistBackupError(
`Unsupported backup playlist type: ${(entry as { portalType?: string }).portalType ?? 'unknown'}`
);
}
}
private async buildExistingFingerprintMap(
playlists: Playlist[]
): Promise<Map<string, Playlist>> {
const map = new Map<string, Playlist>();
for (const playlist of playlists) {
const portalType = this.getPlaylistPortalType(playlist);
if (portalType === 'm3u' && !playlist.url) {
const rawM3u = await firstValueFrom(
this.playlistsService.getRawPlaylistById(playlist._id)
);
map.set(this.buildM3uRawFingerprint(rawM3u), playlist);
continue;
}
map.set(this.getPlaylistFingerprint(playlist), playlist);
}
return map;
}
private getPlaylistFingerprint(playlist: Playlist): string {
const portalType = this.getPlaylistPortalType(playlist);
switch (portalType) {
case 'xtream':
return [
'xtream',
this.normalizeUrlIdentity(playlist.serverUrl ?? ''),
this.normalizeIdentityValue(playlist.username ?? ''),
].join('|');
case 'stalker':
return [
'stalker',
this.normalizeUrlIdentity(
playlist.portalUrl ?? playlist.url ?? ''
),
this.normalizeIdentityValue(
playlist.macAddress ?? '',
true
),
].join('|');
case 'm3u':
default:
if (playlist.url) {
return this.buildM3uUrlFingerprint(playlist.url);
}
throw new PlaylistBackupError(
`Unable to fingerprint M3U playlist "${playlist.title}".`
);
}
}
private getEntryFingerprint(entry: PlaylistBackupEntry): string {
switch (entry.portalType) {
case 'xtream':
return [
'xtream',
this.normalizeUrlIdentity(entry.connection.serverUrl),
this.normalizeIdentityValue(entry.connection.username),
].join('|');
case 'stalker':
return [
'stalker',
this.normalizeUrlIdentity(entry.connection.portalUrl),
this.normalizeIdentityValue(
entry.connection.macAddress,
true
),
].join('|');
case 'm3u':
default:
if (entry.source.url) {
return this.buildM3uUrlFingerprint(entry.source.url);
}
return this.buildM3uRawFingerprint(entry.source.rawM3u);
}
}
private buildM3uUrlFingerprint(url: string): string {
return ['m3u', 'url', this.normalizeUrlIdentity(url)].join('|');
}
private buildM3uRawFingerprint(rawM3u: string): string {
return [
'm3u',
'raw',
this.hashString(this.canonicalizeM3u(rawM3u)),
].join('|');
}
private resolveTargetPlaylistId(
entry: PlaylistBackupEntry,
matchedId: string | undefined,
existingIds: Set<string>
): string {
if (matchedId) {
return matchedId;
}
if (entry.exportedId && !existingIds.has(entry.exportedId)) {
return entry.exportedId;
}
let nextId = uuidv4();
while (existingIds.has(nextId)) {
nextId = uuidv4();
}
return nextId;
}
private async buildImportedPlaylist(
entry: PlaylistBackupEntry,
playlistId: string,
existing: Playlist | null
): Promise<Playlist> {
switch (entry.portalType) {
case 'xtream':
return this.buildImportedXtreamPlaylist(
entry,
playlistId,
existing
);
case 'stalker':
return this.buildImportedStalkerPlaylist(
entry,
playlistId,
existing
);
case 'm3u':
default:
return this.buildImportedM3uPlaylist(
entry,
playlistId,
existing
);
}
}
private async buildImportedM3uPlaylist(
entry: M3uPlaylistBackupEntry,
playlistId: string,
existing: Playlist | null
): Promise<Playlist> {
const parsedPlaylist = this.playlistsService.handlePlaylistParsing(
'TEXT',
entry.source.rawM3u,
entry.title
);
const now = new Date().toISOString();
return {
...existing,
...parsedPlaylist,
_id: playlistId,
title: entry.title,
filename: entry.title,
importDate: existing?.importDate ?? now,
lastUsage: existing?.lastUsage ?? now,
autoRefresh: entry.autoRefresh,
position: entry.position,
favorites: this.uniqueStrings(entry.userState.favorites),
recentlyViewed: entry.userState.recentlyViewed.map((item) =>
this.normalizeM3uRecentlyViewedItem(item)
),
hiddenGroupTitles: this.uniqueStrings(
entry.userState.hiddenGroupTitles
),
...(entry.source.url
? { url: entry.source.url }
: { url: undefined }),
...(entry.source.userAgent
? { userAgent: entry.source.userAgent }
: { userAgent: undefined }),
...(entry.source.referrer
? { referrer: entry.source.referrer }
: { referrer: undefined }),
...(entry.source.origin
? { origin: entry.source.origin }
: { origin: undefined }),
filePath: undefined,
};
}
private buildImportedXtreamPlaylist(
entry: XtreamPlaylistBackupEntry,
playlistId: string,
existing: Playlist | null
): Playlist {
const now = new Date().toISOString();
return {
...existing,
_id: playlistId,
title: entry.title,
filename: entry.title,
count: existing?.count ?? 0,
importDate: existing?.importDate ?? now,
lastUsage: existing?.lastUsage ?? now,
autoRefresh: entry.autoRefresh,
position: entry.position,
serverUrl: entry.connection.serverUrl,
username: entry.connection.username,
password: entry.connection.password,
favorites: [],
recentlyViewed: [],
};
}
private buildImportedStalkerPlaylist(
entry: StalkerPlaylistBackupEntry,
playlistId: string,
existing: Playlist | null
): Playlist {
const now = new Date().toISOString();
return {
...existing,
_id: playlistId,
title: entry.title,
filename: entry.title,
count: existing?.count ?? 0,
importDate: existing?.importDate ?? now,
lastUsage: existing?.lastUsage ?? now,
autoRefresh: entry.autoRefresh,
position: entry.position,
portalUrl: entry.connection.portalUrl,
url: entry.connection.portalUrl,
macAddress: entry.connection.macAddress,
username: entry.connection.username,
password: entry.connection.password,
userAgent: entry.connection.userAgent,
referrer: entry.connection.referrer,
origin: entry.connection.origin,
isFullStalkerPortal: entry.connection.isFullStalkerPortal,
favorites: entry.userState.favorites.map((item) => ({ ...item })),
recentlyViewed: entry.userState.recentlyViewed.map((item) => ({
...item,
})),
stalkerSerialNumber: entry.connection.stalkerSerialNumber,
stalkerDeviceId1: entry.connection.stalkerDeviceId1,
stalkerDeviceId2: entry.connection.stalkerDeviceId2,
stalkerSignature1: entry.connection.stalkerSignature1,
stalkerSignature2: entry.connection.stalkerSignature2,
stalkerToken: undefined,
stalkerAccountInfo: undefined,
};
}
private async restoreSettings(
settings: PlaylistBackupSettings | undefined
): Promise<void> {
if (!settings?.epgUrls) {
return;
}
const existingSettings = this.settingsStore.getSettings();
const mergedEpgUrls = this.mergeUrls(
settings.epgUrls,
existingSettings.epgUrl ?? []
);
await this.settingsStore.updateSettings({
epgUrl: mergedEpgUrls,
});
}
private async restoreXtreamEntry(
playlistId: string,
entry: XtreamPlaylistBackupEntry
): Promise<void> {
const restoreState: XtreamPendingRestoreState = {
hiddenCategories: entry.userState.hiddenCategories.map((item) => ({
...item,
})),
favorites: entry.userState.favorites.map((item) => ({ ...item })),
recentlyViewed: entry.userState.recentlyViewed.map((item) => ({
...item,
})),
playbackPositions: entry.userState.playbackPositions.map(
(item) => ({
...item,
})
),
};
this.pendingRestoreService.set(playlistId, restoreState);
if (!this.hasElectronApi()) {
return;
}
if (!(await this.hasCompletedOfflineCache(playlistId))) {
return;
}
await this.applyXtreamRestoreState(playlistId, restoreState);
this.pendingRestoreService.clear(playlistId);
}
private async hasCompletedOfflineCache(
playlistId: string
): Promise<boolean> {
const checks = await Promise.all(
[
{
categoryType: 'live' as const,
contentType: 'live' as const,
},
{
categoryType: 'movies' as const,
contentType: 'movie' as const,
},
{
categoryType: 'series' as const,
contentType: 'series' as const,
},
].map(async ({ categoryType, contentType }) => {
const [importStatus, hasCategories, hasContent] =
await Promise.all([
this.databaseService.getXtreamImportStatus(
playlistId,
contentType
),
this.databaseService.hasXtreamCategories(
playlistId,
categoryType
),
this.databaseService.hasXtreamContent(
playlistId,
contentType
),
]);
return (
importStatus === 'completed' && hasCategories && hasContent
);
})
);
return checks.every(Boolean);
}
private async applyXtreamRestoreState(
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
await this.restoreXtreamCategoryVisibility(playlistId, state);
await this.databaseService.restoreXtreamUserData(
playlistId,
state.favorites,
state.recentlyViewed
);
await this.playbackPositionService.clearAllPlaybackPositions(
playlistId
);
for (const playbackPosition of state.playbackPositions) {
await this.playbackPositionService.savePlaybackPosition(
playlistId,
playbackPosition
);
}
}
private async restoreXtreamCategoryVisibility(
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
const categoriesByType = await Promise.all([
this.databaseService.getAllXtreamCategories(playlistId, 'live'),
this.databaseService.getAllXtreamCategories(playlistId, 'movies'),
this.databaseService.getAllXtreamCategories(playlistId, 'series'),
]);
for (const categories of categoriesByType) {
if (categories.length === 0) {
continue;
}
await this.databaseService.updateCategoryVisibility(
categories.map((category) => category.id),
false
);
const hiddenCategoryIds = categories
.filter((category) =>
state.hiddenCategories.some(
(hiddenCategory) =>
hiddenCategory.categoryType === category.type &&
hiddenCategory.xtreamId === category.xtream_id
)
)
.map((category) => category.id);
if (hiddenCategoryIds.length > 0) {
await this.databaseService.updateCategoryVisibility(
hiddenCategoryIds,
true
);
}
}
}
private mapHiddenCategories(
categories: Array<{
hidden: boolean;
xtream_id: number;
type: XtreamCategoryType;
}>,
categoryType: XtreamCategoryType
): XtreamPendingRestoreState['hiddenCategories'] {
return categories
.filter((category) => category.hidden)
.map((category) => ({
categoryType,
xtreamId: category.xtream_id,
}));
}
private getPlaylistPortalType(playlist: Playlist): PlaylistPortalType {
if (playlist.serverUrl && playlist.username) {
return 'xtream';
}
if (playlist.macAddress && (playlist.portalUrl || playlist.url)) {
return 'stalker';
}
return 'm3u';
}
private resolveM3uSourceKind(playlist: Playlist): 'url' | 'file' | 'text' {
if (playlist.url) {
return 'url';
}
if (playlist.filePath) {
return 'file';
}
return 'text';
}
private extractStalkerItems(items: unknown): StalkerPortalItem[] {
if (!Array.isArray(items)) {
return [];
}
return items.filter(
(item): item is StalkerPortalItem =>
!!item && typeof item === 'object' && !Array.isArray(item)
);
}
private normalizeM3uRecentlyViewedItem(
item: M3uRecentlyViewedItem
): M3uRecentlyViewedItem {
return {
...item,
source: 'm3u',
id: String(item.id ?? item.url),
url: String(item.url),
title: String(item.title ?? ''),
category_id: 'live',
added_at: item.added_at ?? new Date().toISOString(),
};
}
private normalizeSettingsUrls(urls: string[]): string[] {
return this.uniqueStrings(
urls
.map((url) => String(url ?? '').trim())
.filter((url) => url.length > 0)
);
}
private mergeUrls(primary: string[], secondary: string[]): string[] {
const merged = new Map<string, string>();
for (const url of [...primary, ...secondary]) {
const normalized = this.normalizeUrlIdentity(url);
if (!normalized || merged.has(normalized)) {
continue;
}
merged.set(normalized, url.trim());
}
return [...merged.values()];
}
private uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
private normalizeUrlIdentity(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const pathname =
url.pathname === '/'
? '/'
: url.pathname.replace(/\/+$/, '') || '/';
return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}${url.hash}`;
} catch {
return trimmed.toLowerCase();
}
}
private normalizeIdentityValue(value: string, toLowerCase = false): string {
const trimmed = value.trim();
return toLowerCase ? trimmed.toLowerCase() : trimmed;
}
private canonicalizeM3u(rawM3u: string): string {
return rawM3u.replace(/\r\n?/g, '\n').trim();
}
private hashString(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
private hasElectronApi(): boolean {
return !!(window as Window & { electron?: unknown }).electron;
}
}