Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# services
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test services` to execute the unit tests.
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'services',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/services',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "services",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/services/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/services/jest.config.ts",
"tsConfig": "libs/services/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-backup.service';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/settings-store.service';
export * from './lib/workspace-product-branding.token';
export * from './lib/sort.service';
export * from './lib/xtream-pending-restore.service';
+29
View File
@@ -0,0 +1,29 @@
type ElectronProcessWindow = Window & {
process?: {
type?: string;
};
};
export abstract class DataService {
get isElectron(): boolean {
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get remote() {
return null;
}
get ipcRenderer() {
return null;
}
abstract getAppVersion(): string;
abstract sendIpcEvent<T = unknown>(
type: string,
payload?: unknown
): T | Promise<T>;
abstract removeAllListeners(type: string): void;
abstract listenOn(
command: string,
callback: (...args: unknown[]) => void
): void;
abstract getAppEnvironment(): string;
}
@@ -0,0 +1,860 @@
/**
* Database service for Electron renderer process
* Communicates with the main process database via IPC
*/
import { Injectable } from '@angular/core';
import {
PlaylistMeta,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
XtreamCategory,
} from 'shared-interfaces';
export interface XCategoryFromDb {
id: number;
name: string;
playlist_id: string;
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
}
export interface XtreamContent {
id: number;
category_id: number;
title: string;
rating: string;
added: string;
poster_url: string;
backdrop_url?: string | null;
epg_channel_id?: string | null;
tv_archive?: number | null;
tv_archive_duration?: number | null;
direct_source?: string | null;
xtream_id: number;
type: string;
added_at?: string;
viewed_at?: string;
position?: number | null;
}
export interface XtreamPlaylist {
id: string;
name: string;
serverUrl: string;
username: string;
password: string;
type: string;
}
type XtreamDatabasePlaylistUpdate = {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
};
type XtreamContentStream =
| {
category_id: string | number;
rating?: string | number;
rating_imdb?: string;
last_modified?: string;
added?: string;
stream_icon?: string;
poster?: string;
cover?: string;
name?: string;
title?: string;
epg_channel_id?: string;
tv_archive?: string | number;
tv_archive_duration?: string | number;
direct_source?: string;
series_id?: string | number;
stream_id?: string | number;
}
| Record<string, unknown>;
export interface GlobalSearchResult extends XtreamContent {
playlist_id: string;
playlist_name: string;
}
export interface GlobalRecentItem extends XtreamContent {
playlist_id: string;
playlist_name: string;
viewed_at: string;
}
export interface GlobalFavoriteItem extends XtreamContent {
playlist_id: string;
playlist_name: string;
added_at: string;
}
export interface DbOperationEvent {
operationId?: string;
operation: string;
playlistId?: string;
status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error';
phase?: string;
current?: number;
total?: number;
increment?: number;
error?: string;
}
export interface DbOperationOptions {
operationId?: string;
onEvent?: (event: DbOperationEvent) => void;
}
export type XtreamImportStatus =
| 'idle'
| 'importing'
| 'completed'
| 'cancelled'
| 'failed';
export function isDbAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series';
export type GlobalRecentlyAddedPlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
export interface GlobalRecentlyAddedItem extends XtreamContent {
playlist_id: string;
playlist_name: string;
added_at: string;
}
@Injectable({
providedIn: 'root',
})
export class DatabaseService {
private buildXtreamImportStateKey(
playlistId: string,
type: 'live' | 'movie' | 'series'
): string {
return `xtream-import-status:${playlistId}:${type}`;
}
createOperationId(prefix = 'db-op'): string {
return (
globalThis.crypto?.randomUUID?.() ??
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
}
supportsDbOperationEvents(): boolean {
return typeof window.electron.onDbOperationEvent === 'function';
}
supportsDbOperationCancellation(): boolean {
return typeof window.electron.dbCancelOperation === 'function';
}
async cancelOperation(operationId: string): Promise<boolean> {
if (!operationId || !this.supportsDbOperationCancellation()) {
return false;
}
try {
const result = await window.electron.dbCancelOperation(operationId);
return result.success;
} catch (error) {
console.error('Error cancelling DB operation:', error);
return false;
}
}
private subscribeToOperation(
operationId: string,
operation: string,
onEvent?: (event: DbOperationEvent) => void,
onProgress?: (count: number) => void
): (() => void) | undefined {
if (!onEvent && !onProgress) {
return undefined;
}
if (window.electron.onDbOperationEvent) {
return window.electron.onDbOperationEvent(
(event: DbOperationEvent) => {
if (
event.operationId !== operationId ||
event.operation !== operation
) {
return;
}
onEvent?.(event);
if (event.status === 'progress') {
onProgress?.(event.increment ?? event.current ?? 0);
}
}
);
}
if (onProgress && operation === 'save-content') {
window.electron.onDbSaveContentProgress(onProgress);
return () => window.electron.removeDbSaveContentProgress();
}
return undefined;
}
private async runWithOperationEvents<TResult>(
prefix: string,
operation: string,
execute: (operationId: string) => Promise<TResult>,
options?: DbOperationOptions,
onProgress?: (count: number) => void
): Promise<TResult> {
const operationId =
options?.operationId ?? this.createOperationId(prefix);
const unsubscribe = this.subscribeToOperation(
operationId,
operation,
options?.onEvent,
onProgress
);
try {
return await execute(operationId);
} finally {
unsubscribe?.();
}
}
/**
* Delete a playlist and all its related data
*/
async deletePlaylist(
playlistId: string,
options?: DbOperationOptions
): Promise<boolean> {
try {
await this.runWithOperationEvents(
'db-delete-playlist',
'delete-playlist',
(operationId) =>
window.electron.dbDeletePlaylist(playlistId, operationId),
options
);
return true;
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error deleting playlist:', error);
}
return false;
}
}
/**
* Delete all content and categories for an Xtream playlist (for refresh).
* Keeps the playlist entry but removes all imported data.
*/
async deleteXtreamPlaylistContent(
playlistId: string,
options?: DbOperationOptions
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
return this.runWithOperationEvents(
'db-delete-xtream-content',
'delete-xtream-content',
(operationId) =>
window.electron.dbDeleteXtreamContent(playlistId, operationId),
options
);
}
/**
* Restore favorites and recently viewed items after Xtream refresh
*/
async restoreXtreamUserData(
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[],
options?: DbOperationOptions
): Promise<void> {
await this.runWithOperationEvents(
'db-restore-xtream-user-data',
'restore-xtream-user-data',
(operationId) =>
window.electron.dbRestoreXtreamUserData(
playlistId,
favorites,
recentlyViewed,
operationId
),
options
);
}
async deleteAllPlaylists(options?: DbOperationOptions): Promise<boolean> {
try {
await this.runWithOperationEvents(
'db-delete-all-playlists',
'delete-all-playlists',
(operationId) =>
window.electron.dbDeleteAllPlaylists(operationId),
options
);
return true;
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error deleting all playlists:', error);
}
return false;
}
}
/**
* Update playlist basic info
*/
async updateXtreamPlaylist(
playlist: Pick<XtreamPlaylist, 'id' | 'name'>
): Promise<boolean> {
try {
await window.electron.dbUpdatePlaylist(playlist.id, {
name: playlist.name,
});
return true;
} catch (error) {
console.error('Error updating playlist:', error);
return false;
}
}
/**
* Update playlist details including credentials
*/
async updateXtreamPlaylistDetails(playlist: {
id: string;
title?: string;
username?: string;
password?: string;
serverUrl?: string;
updateDate?: number;
}): Promise<boolean> {
try {
const updates: XtreamDatabasePlaylistUpdate = {};
if (playlist.title) updates.name = playlist.title;
if (playlist.username) updates.username = playlist.username;
if (playlist.password) updates.password = playlist.password;
if (playlist.serverUrl) updates.serverUrl = playlist.serverUrl;
if (playlist.updateDate !== undefined)
updates.lastUpdated = new Date(
playlist.updateDate
).toISOString();
await window.electron.dbUpdatePlaylist(playlist.id, updates);
return true;
} catch (error) {
console.error('Error updating playlist details:', error);
return false;
}
}
/**
* Check if categories exist
*/
async hasXtreamCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<boolean> {
return await window.electron.dbHasCategories(playlistId, type);
}
/**
* Get categories for a playlist
*/
async getXtreamCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<XCategoryFromDb[]> {
return await window.electron.dbGetCategories(playlistId, type);
}
/**
* Save categories in bulk
* Optionally accepts hidden category xtreamIds to restore visibility preferences
*/
async saveXtreamCategories(
playlistId: string,
categories: XtreamCategory[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<void> {
await window.electron.dbSaveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
);
}
/**
* Get all categories for a playlist (including hidden, for management dialog)
*/
async getAllXtreamCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
): Promise<XCategoryFromDb[]> {
return await window.electron.dbGetAllCategories(playlistId, type);
}
/**
* Update category visibility (show/hide categories)
*/
async updateCategoryVisibility(
categoryIds: number[],
hidden: boolean
): Promise<boolean> {
try {
await window.electron.dbUpdateCategoryVisibility(
categoryIds,
hidden
);
return true;
} catch (error) {
console.error('Error updating category visibility:', error);
return false;
}
}
/**
* Check if content exists
*/
async hasXtreamContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
return await window.electron.dbHasContent(playlistId, type);
}
/**
* Get content for a playlist
*/
async getXtreamContent(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamContent[]> {
return await window.electron.dbGetContent(playlistId, type);
}
/**
* Save content in bulk
*/
async saveXtreamContent(
playlistId: string,
streams: XtreamContentStream[],
type: 'live' | 'movie' | 'series',
onProgress?: (count: number) => void,
options?: DbOperationOptions
): Promise<number> {
try {
const result = await this.runWithOperationEvents(
'db-save-content',
'save-content',
(operationId) =>
window.electron.dbSaveContent(
playlistId,
streams,
type,
operationId
),
options,
onProgress
);
return (result as { count: number }).count;
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error saving Xtream content:', error);
}
throw error;
}
}
async clearXtreamImportCache(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<boolean> {
try {
await window.electron.dbClearXtreamImportCache(playlistId, type);
return true;
} catch (error) {
console.error('Error clearing Xtream import cache:', error);
return false;
}
}
async getAppState(key: string): Promise<string | null> {
try {
return await window.electron.dbGetAppState(key);
} catch (error) {
console.error('Error getting app state:', error);
return null;
}
}
async setAppState(key: string, value: string): Promise<boolean> {
try {
await window.electron.dbSetAppState(key, value);
return true;
} catch (error) {
console.error('Error setting app state:', error);
return false;
}
}
async getXtreamImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamImportStatus> {
const value = await this.getAppState(
this.buildXtreamImportStateKey(playlistId, type)
);
switch (value) {
case 'importing':
case 'completed':
case 'cancelled':
case 'failed':
case 'idle':
return value;
default:
return 'idle';
}
}
async setXtreamImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series',
status: XtreamImportStatus
): Promise<boolean> {
return this.setAppState(
this.buildXtreamImportStateKey(playlistId, type),
status
);
}
/**
* Search content within a playlist
*/
async searchXtreamContent(
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden?: boolean
): Promise<XtreamContent[]> {
return await window.electron.dbSearchContent(
playlistId,
searchTerm,
types,
excludeHidden
);
}
/**
* Global search across all playlists
*/
async globalSearchContent(
searchTerm: string,
types: string[],
excludeHidden?: boolean
): Promise<GlobalSearchResult[]> {
return await window.electron.dbGlobalSearch(
searchTerm,
types,
excludeHidden
);
}
/**
* Get recently added VOD and series items across all Xtream playlists.
* When `playlistType` is supplied, the LIMIT window is applied only to
* rows from playlists of that type, so Xtream items cannot be squeezed
* out by newer M3U/Stalker content.
*/
async getGlobalRecentlyAdded(
kind: GlobalRecentlyAddedKind,
limit = 200,
playlistType?: GlobalRecentlyAddedPlaylistType
): Promise<GlobalRecentlyAddedItem[]> {
try {
const items = await window.electron.dbGetGlobalRecentlyAdded(
kind,
limit,
playlistType
);
return items || [];
} catch (error) {
console.error('Error getting global recently added items:', error);
return [];
}
}
/**
* Get recently viewed items
*/
async getGlobalRecentlyViewed(): Promise<GlobalRecentItem[]> {
try {
const items = await window.electron.dbGetRecentlyViewed();
return items || [];
} catch (error) {
console.error('Error getting recently viewed:', error);
return [];
}
}
/**
* Get global favorites across all playlists
*/
async getGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
try {
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
} catch (error) {
console.error('Error getting global favorites:', error);
return [];
}
}
/**
* Get global favorites across all playlists (all content types)
*/
async getAllGlobalFavorites(): Promise<GlobalFavoriteItem[]> {
try {
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
console.error('Error getting all global favorites:', error);
return [];
}
}
/**
* Clear recently viewed items
*/
async clearGlobalRecentlyViewed(): Promise<void> {
try {
await window.electron.dbClearRecentlyViewed();
} catch (error) {
console.error('Error clearing recently viewed:', error);
throw error;
}
}
/**
* Get playlist by ID
*/
async getPlaylistById(playlistId: string): Promise<XtreamPlaylist | null> {
return await window.electron.dbGetPlaylist(playlistId);
}
/**
* Create a new playlist
*/
async createPlaylist(playlist: PlaylistMeta): Promise<void> {
await window.electron.dbCreatePlaylist({
id: playlist._id,
name: playlist.title,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
type: 'xtream',
});
}
/**
* Add content to favorites.
* @param backdropUrl optionally persisted to `content.backdrop_url` when
* the row doesn't already have one. Lets the dashboard hero surface a
* cinematic backdrop without a separate round-trip.
*/
async addToFavorites(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
try {
await window.electron.dbAddFavorite(
contentId,
playlistId,
backdropUrl
);
return true;
} catch (error) {
console.error('Error adding to favorites:', error);
return false;
}
}
/**
* Remove content from favorites
*/
async removeFromFavorites(
contentId: number,
playlistId: string
): Promise<boolean> {
try {
await window.electron.dbRemoveFavorite(contentId, playlistId);
return true;
} catch (error) {
console.error('Error removing from favorites:', error);
return false;
}
}
/**
* Check if content is favorited
*/
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
try {
return await window.electron.dbIsFavorite(contentId, playlistId);
} catch (error) {
console.error('Error checking favorite:', error);
return false;
}
}
/**
* Get all favorites for a playlist
*/
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
try {
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
console.error('Error getting favorites:', error);
return [];
}
}
/**
* Get recently viewed items for a specific playlist
*/
async getRecentItems(playlistId: string): Promise<XtreamContent[]> {
try {
return await window.electron.dbGetRecentItems(playlistId);
} catch (error) {
console.error('Error getting recent items:', error);
return [];
}
}
/**
* Add item to recently viewed. See `addToFavorites` for `backdropUrl`.
*/
async addRecentItem(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<boolean> {
try {
await window.electron.dbAddRecentItem(
contentId,
playlistId,
backdropUrl
);
return true;
} catch (error) {
console.error('Error adding recent item:', error);
return false;
}
}
/**
* Persist a backdrop URL onto an Xtream content row without touching
* recently viewed ordering or timestamps.
*/
async setContentBackdropIfMissing(
contentId: number,
backdropUrl?: string
): Promise<boolean> {
const normalizedBackdropUrl = backdropUrl?.trim();
if (!normalizedBackdropUrl) {
return true;
}
if (!window.electron?.dbSetContentBackdropIfMissing) {
return true;
}
try {
await window.electron.dbSetContentBackdropIfMissing(
contentId,
normalizedBackdropUrl
);
return true;
} catch (error) {
console.error('Error backfilling content backdrop:', error);
return false;
}
}
/**
* Clear recently viewed for a specific playlist
*/
async clearPlaylistRecentItems(playlistId: string): Promise<boolean> {
try {
await window.electron.dbClearPlaylistRecentItems(playlistId);
return true;
} catch (error) {
console.error('Error clearing playlist recent items:', error);
return false;
}
}
/**
* Remove specific item from recently viewed
*/
async removeRecentItem(
contentId: number,
playlistId: string
): Promise<boolean> {
try {
await window.electron.dbRemoveRecentItem(contentId, playlistId);
return true;
} catch (error) {
console.error('Error removing recent item:', error);
return false;
}
}
/**
* Get content by xtream ID
*/
async getContentByXtreamId(
xtreamId: number,
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContent | null> {
try {
return await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId,
contentType
);
} catch (error) {
console.error('Error getting content by xtream ID:', error);
return null;
}
}
}
+424
View File
@@ -0,0 +1,424 @@
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import { SettingsStore } from './settings-store.service';
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'completed'
| 'failed'
| 'canceled';
export interface DownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
title: string;
url: string;
fileName?: string;
filePath?: string;
posterUrl?: string;
status: DownloadStatus;
bytesDownloaded?: number;
totalBytes?: number;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
private readonly settingsStore = inject(SettingsStore);
private unsubscribe?: () => void;
/** Signal for the list of downloads */
readonly downloads = signal<DownloadItem[]>([]);
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => !!window.electron?.downloadsGetList);
/** Whether there are any downloads */
readonly hasDownloads = computed(() => this.downloads().length > 0);
/** Active downloads count */
readonly activeCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'queued' || d.status === 'downloading'
).length
);
/** Completed downloads count */
readonly completedCount = computed(
() => this.downloads().filter((d) => d.status === 'completed').length
);
/** Failed downloads count */
readonly failedCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'failed' || d.status === 'canceled'
).length
);
/** Current download folder */
readonly downloadFolder = signal<string>('');
constructor() {
this.init();
}
private async init() {
if (!this.isAvailable()) {
return;
}
// Load initial download list
await this.loadDownloads();
// Load download folder
await this.loadDownloadFolder();
// Subscribe to download updates
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
if (!this.isAvailable()) return;
try {
const list = await window.electron.downloadsGetList(playlistId);
this.downloads.set(list);
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
}
}
/**
* Load or resolve download folder
*/
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// First check settings
const storedFolder = this.settingsStore.getDownloadFolder?.();
if (storedFolder) {
this.downloadFolder.set(storedFolder);
return storedFolder;
}
// Fall back to default
try {
const defaultFolder =
await window.electron.downloadsGetDefaultFolder();
this.downloadFolder.set(defaultFolder);
return defaultFolder;
} catch (error) {
console.error(
'[DownloadsService] Error getting default folder:',
error
);
return '';
}
}
/**
* Start a new download
*/
async startDownload(data: {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
// Playlist info for auto-creation if needed (Stalker playlists)
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}): Promise<{ success: boolean; id?: number; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
const result = await window.electron.downloadsStart({
...data,
downloadFolder: folder,
});
return result;
} catch (error) {
console.error('[DownloadsService] Error starting download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Cancel a download
*/
async cancelDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsCancel(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error canceling download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Retry a failed download
*/
async retryDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
return await window.electron.downloadsRetry(downloadId, folder);
} catch (error) {
console.error(
'[DownloadsService] Error retrying download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Remove a download from the list
*/
async removeDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRemove(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error removing download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Play a downloaded file
*/
async playDownload(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsPlayFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error playing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Reveal file in system file manager
*/
async revealFile(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRevealFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error revealing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Select a new download folder
*/
async selectFolder(): Promise<string | null> {
if (!this.isAvailable()) {
return null;
}
try {
const folder = await window.electron.downloadsSelectFolder();
if (folder) {
this.downloadFolder.set(folder);
// Save to settings
await this.settingsStore.updateSettings({ downloadFolder: folder });
}
return folder;
} catch (error) {
console.error(
'[DownloadsService] Error selecting folder:',
error
);
return null;
}
}
/**
* Clear completed/failed downloads
*/
async clearCompleted(
playlistId?: string
): Promise<{ success: boolean }> {
if (!this.isAvailable()) {
return { success: false };
}
try {
return await window.electron.downloadsClearCompleted(playlistId);
} catch (error) {
console.error(
'[DownloadsService] Error clearing completed:',
error
);
return { success: false };
}
}
/**
* Get download progress as percentage
*/
getProgressPercent(item: DownloadItem): number {
if (!item.totalBytes || item.totalBytes === 0) {
return 0;
}
return Math.round(
((item.bytesDownloaded || 0) / item.totalBytes) * 100
);
}
/**
* Get download item by xtreamId and playlistId
*/
getDownloadByContent(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): DownloadItem | undefined {
return this.downloads().find(
(d) =>
d.xtreamId === xtreamId &&
d.playlistId === playlistId &&
d.contentType === contentType
);
}
/**
* Check if content is downloaded (completed status)
*/
isDownloaded(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'completed' && !!download.filePath;
}
/**
* Check if content is currently downloading or queued
*/
isDownloading(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'downloading' || download?.status === 'queued';
}
/**
* Get the file path for a downloaded content item
*/
getDownloadedFilePath(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): string | undefined {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (download?.status === 'completed') {
return download.filePath;
}
return undefined;
}
/**
* Format bytes to human readable string
*/
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
}
@@ -0,0 +1,100 @@
import { Injectable } from '@angular/core';
import { PlaybackPositionData } from 'shared-interfaces';
@Injectable({
providedIn: 'root',
})
export class PlaybackPositionService {
async savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
try {
await window.electron.dbSavePlaybackPosition(playlistId, data);
} catch (error) {
console.error('Error saving playback position:', error);
}
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null> {
try {
return await window.electron.dbGetPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
} catch (error) {
console.error('Error getting playback position:', error);
return null;
}
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]> {
try {
return await window.electron.dbGetSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
} catch (error) {
console.error('Error getting series playback positions:', error);
return [];
}
}
async getRecentPlaybackPositions(
playlistId: string,
limit?: number
): Promise<PlaybackPositionData[]> {
try {
return await window.electron.dbGetRecentPlaybackPositions(
playlistId,
limit
);
} catch (error) {
console.error('Error getting recent playback positions:', error);
return [];
}
}
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
try {
return await window.electron.dbGetAllPlaybackPositions(playlistId);
} catch (error) {
console.error('Error getting all playback positions:', error);
return [];
}
}
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
try {
await window.electron.dbClearAllPlaybackPositions(playlistId);
} catch (error) {
console.error('Error clearing all playback positions:', error);
}
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
try {
await window.electron.dbClearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
} catch (error) {
console.error('Error clearing playback position:', error);
}
}
}
@@ -0,0 +1,279 @@
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
} from 'shared-interfaces';
import {
PlaylistBackupError,
PlaylistBackupService,
} from './playlist-backup.service';
describe('PlaylistBackupService', () => {
function createService(overrides: Record<string, unknown> = {}) {
const service = Object.create(
PlaylistBackupService.prototype
) as PlaylistBackupService;
Object.assign(service as object, {
playlistsService: {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(
(_uploadType: string, rawM3u: string, title: string) => ({
_id: 'generated-id',
title,
filename: title,
count: rawM3u.split('\n').filter(Boolean).length,
playlist: {
header: { raw: '#EXTM3U' },
items: [],
},
importDate: '2026-04-21T00:00:00.000Z',
lastUsage: '2026-04-21T00:00:00.000Z',
favorites: [],
autoRefresh: false,
})
),
},
settingsStore: {
getSettings: jest.fn(() => ({ epgUrl: [] })),
updateSettings: jest.fn().mockResolvedValue(undefined),
},
databaseService: {
getAllXtreamCategories: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
hasXtreamCategories: jest.fn().mockResolvedValue(false),
hasXtreamContent: jest.fn().mockResolvedValue(false),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
playbackPositionService: {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
clearAllPlaybackPositions: jest
.fn()
.mockResolvedValue(undefined),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
},
pendingRestoreService: {
set: jest.fn(),
clear: jest.fn(),
},
...overrides,
});
return service;
}
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('exports self-contained M3U data and strips Stalker session fields', async () => {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() =>
of([
{
_id: 'm3u-file-1',
title: 'Local Playlist',
count: 2,
importDate: '2026-04-21T00:00:00.000Z',
lastUsage: '2026-04-21T00:00:00.000Z',
autoRefresh: true,
filePath: '/tmp/playlist.m3u',
favorites: ['https://example.com/stream-1'],
recentlyViewed: [],
},
{
_id: 'stalker-1',
title: 'Stalker Portal',
count: 0,
importDate: '2026-04-21T00:00:00.000Z',
lastUsage: '2026-04-21T00:00:00.000Z',
autoRefresh: false,
portalUrl:
'http://example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
stalkerToken: 'session-token',
stalkerAccountInfo: {
login: 'demo',
},
},
] as Playlist[])
),
getRawPlaylistById: jest.fn((playlistId: string) =>
of(
playlistId === 'm3u-file-1'
? '#EXTM3U\n#EXTINF:-1,One\nhttps://example.com/stream-1'
: '#EXTM3U'
)
),
handlePlaylistParsing: jest.fn(),
};
const settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: ['https://example.com/epg.xml'],
})),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
const service = createService({
playlistsService,
settingsStore,
});
const backup = await service.exportBackup();
expect(backup.defaultFileName).toEqual(
expect.stringMatching(
/^iptvnator-playlist-backup-\d{4}-\d{2}-\d{2}\.json$/
)
);
expect(backup.manifest.settings).toEqual({
epgUrls: ['https://example.com/epg.xml'],
});
expect(backup.manifest.playlists).toEqual(
expect.arrayContaining([
expect.objectContaining({
portalType: 'm3u',
source: expect.objectContaining({
kind: 'file',
rawM3u: '#EXTM3U\n#EXTINF:-1,One\nhttps://example.com/stream-1',
filePathHint: '/tmp/playlist.m3u',
}),
}),
expect.objectContaining({
portalType: 'stalker',
connection: expect.objectContaining({
portalUrl:
'http://example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
}),
}),
])
);
expect(JSON.stringify(backup.manifest)).not.toContain('session-token');
expect(JSON.stringify(backup.manifest)).not.toContain('demo');
});
it('rejects legacy raw playlist arrays on import', async () => {
const service = createService();
await expect(service.importBackup('[]')).rejects.toBeInstanceOf(
PlaylistBackupError
);
});
it('merges onto an existing M3U playlist by normalized URL and restores backup-first EPG urls', async () => {
const existingPlaylist: Playlist = {
_id: 'existing-playlist-id',
title: 'Original Title',
count: 1,
importDate: '2026-04-20T00:00:00.000Z',
lastUsage: '2026-04-20T00:00:00.000Z',
autoRefresh: false,
url: 'https://example.com/playlist.m3u',
favorites: ['https://example.com/original'],
recentlyViewed: [],
hiddenGroupTitles: ['News'],
} as Playlist;
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([existingPlaylist])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(
(_uploadType: string, rawM3u: string, title: string) => ({
_id: 'generated-id',
title,
filename: title,
count: rawM3u.split('\n').filter(Boolean).length,
playlist: {
header: { raw: '#EXTM3U' },
items: [],
},
importDate: '2026-04-21T00:00:00.000Z',
lastUsage: '2026-04-21T00:00:00.000Z',
favorites: [],
autoRefresh: false,
})
),
};
const settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: ['https://existing.example.com/epg.xml'],
})),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
const service = createService({
playlistsService,
settingsStore,
});
const manifest: PlaylistBackupManifestV1 = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-04-21T00:00:00.000Z',
includeSecrets: true,
settings: {
epgUrls: [
'https://backup.example.com/epg.xml',
'https://existing.example.com/epg.xml',
],
},
playlists: [
{
portalType: 'm3u',
exportedId: 'backup-playlist-id',
title: 'Imported Title',
autoRefresh: true,
position: 3,
source: {
kind: 'url',
rawM3u: '#EXTM3U\n#EXTINF:-1,Backup\nhttps://example.com/stream-1',
url: 'https://example.com/playlist.m3u/',
userAgent: 'BackupAgent/1.0',
},
userState: {
favorites: ['https://example.com/stream-1'],
recentlyViewed: [],
hiddenGroupTitles: ['Sports'],
},
},
],
};
const summary = await service.importBackup(JSON.stringify(manifest));
expect(summary).toEqual({
imported: 0,
merged: 1,
skipped: 0,
failed: 0,
errors: [],
});
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
epgUrl: [
'https://backup.example.com/epg.xml',
'https://existing.example.com/epg.xml',
],
});
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'existing-playlist-id',
title: 'Imported Title',
autoRefresh: true,
position: 3,
url: 'https://example.com/playlist.m3u/',
userAgent: 'BackupAgent/1.0',
filePath: undefined,
favorites: ['https://example.com/stream-1'],
hiddenGroupTitles: ['Sports'],
})
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
import { Injectable } from '@angular/core';
import {
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
export interface PlaylistRefreshOptions {
onEvent?: (event: PlaylistRefreshEvent) => void;
}
@Injectable({
providedIn: 'root',
})
export class PlaylistRefreshService {
async refreshPlaylist(
payload: PlaylistRefreshPayload,
options?: PlaylistRefreshOptions
): Promise<Playlist> {
if (!window.electron?.refreshPlaylist) {
throw new Error('Playlist refresh is only available in Electron');
}
const unsubscribe = window.electron.onPlaylistRefreshEvent?.((event) => {
if (event.operationId !== payload.operationId) {
return;
}
options?.onEvent?.(event);
});
try {
return await window.electron.refreshPlaylist(payload);
} finally {
unsubscribe?.();
}
}
async cancelRefresh(operationId: string): Promise<boolean> {
if (!window.electron?.cancelPlaylistRefresh || !operationId) {
return false;
}
try {
const result = await window.electron.cancelPlaylistRefresh(operationId);
return result.success;
} catch (error) {
console.error('Failed to cancel playlist refresh:', error);
return false;
}
}
}
@@ -0,0 +1,252 @@
import { firstValueFrom, of } from 'rxjs';
import { DbStores, Playlist, PlaylistMeta } from 'shared-interfaces';
import { PlaylistsService } from './playlists.service';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
'm3u-playlists-stalker-metadata-v1';
describe('PlaylistsService', () => {
const testWindow = window as unknown as { electron?: unknown };
const originalElectron = testWindow.electron;
afterEach(() => {
testWindow.electron = originalElectron;
localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG);
jest.restoreAllMocks();
});
function createService(overrides: Record<string, unknown> = {}) {
const service = Object.create(PlaylistsService.prototype) as PlaylistsService;
Object.assign(service as object, {
dbService: {
clear: jest.fn(() => of(undefined)),
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(undefined)),
update: jest.fn(() => of(undefined)),
...overrides,
},
snackBar: {
open: jest.fn(),
},
translateService: {
instant: jest.fn((key: string) => key),
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
});
return service;
}
it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => {
let storedPlaylists: Playlist[] = [
{
_id: 'stalker-1',
title: 'Legacy Stalker',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'http://example.com/stalker_portal/c/',
} as Playlist,
];
const appState = new Map<string, string>([
[SQLITE_PLAYLIST_MIGRATION_FLAG, '1'],
]);
const electron = {
dbGetAppPlaylists: jest.fn(async () => storedPlaylists),
dbGetAppState: jest.fn(
async (key: string) => appState.get(key) ?? null
),
dbSetAppState: jest.fn(async (key: string, value: string) => {
appState.set(key, value);
}),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => {
const updates = new Map(
playlists.map((playlist) => [playlist._id, playlist])
);
storedPlaylists = storedPlaylists.map(
(playlist) => updates.get(playlist._id) ?? playlist
);
}),
};
testWindow.electron = electron;
const service = createService();
await expect(firstValueFrom(service.getAllPlaylists())).resolves.toEqual([
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
}),
]);
expect(electron.dbUpsertAppPlaylists).toHaveBeenCalledWith([
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
}),
]);
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe('1');
});
it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => {
const electron = {
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) => {
if (
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
) {
return '1';
}
return null;
}),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
await firstValueFrom(service.getAllPlaylists());
await firstValueFrom(service.getAllPlaylists());
expect(electron.dbUpsertAppPlaylists).not.toHaveBeenCalled();
expect(electron.dbSetAppState).not.toHaveBeenCalledWith(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
'1'
);
});
it('migrates legacy Stalker portal flags in IndexedDB before returning full playlists', async () => {
let storedPlaylists: Playlist[] = [
{
_id: 'stalker-2',
title: 'IndexedDB Stalker',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
macAddress: '00:1A:79:11:22:33',
portalUrl: 'http://example.com/portal/c/',
} as Playlist,
];
const dbService = {
getAll: jest.fn(() => of(storedPlaylists)),
update: jest.fn((_storeName: string, playlist: Playlist) => {
storedPlaylists = storedPlaylists.map((current) =>
current._id === playlist._id ? playlist : current
);
return of(playlist);
}),
};
testWindow.electron = undefined;
const service = createService(dbService);
await expect(firstValueFrom(service.getAllData())).resolves.toEqual([
expect.objectContaining({
_id: 'stalker-2',
isFullStalkerPortal: false,
}),
]);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
_id: 'stalker-2',
isFullStalkerPortal: false,
})
);
expect(
localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
).toBe('1');
});
it('persists hiddenGroupTitles in playlist meta updates', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-1',
title: 'Playlist One',
count: 2,
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
autoRefresh: false,
hiddenGroupTitles: ['News'],
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updatePlaylistMeta({
_id: 'playlist-1',
hiddenGroupTitles: ['Movies', 'Sports'],
} as PlaylistMeta)
);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
_id: 'playlist-1',
hiddenGroupTitles: ['Movies', 'Sports'],
})
);
});
it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-2',
title: 'Playlist Two',
count: 1,
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
autoRefresh: false,
hiddenGroupTitles: ['Radio-de'],
playlist: {
items: [{ id: 'channel-1' }],
},
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updatePlaylist('playlist-2', {
_id: 'playlist-2',
playlist: {
items: [],
},
} as Playlist)
);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
_id: 'playlist-2',
hiddenGroupTitles: ['Radio-de'],
count: 0,
})
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import {
EnvironmentInjector,
Injector,
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { DataService } from './data.service';
import { PortalStatusService } from './portal-status.service';
describe('PortalStatusService', () => {
let service: PortalStatusService;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
const injector = createEnvironmentInjector([
{
provide: DataService,
useValue: dataService,
},
], Injector.NULL as unknown as EnvironmentInjector);
service = runInInjectionContext(
injector,
() => new PortalStatusService()
);
});
it('marks portal status checks as silent background probes', async () => {
const futureExpDate = String(
Math.floor(Date.now() / 1000) + 60 * 60 * 24
);
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
status: 'Active',
exp_date: futureExpDate,
},
},
});
const status = await service.checkPortalStatus(
'http://example.com',
'user',
'pass'
);
expect(status).toBe('active');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'XTREAM_REQUEST',
expect.objectContaining({
suppressErrorLog: true,
url: 'http://example.com',
params: {
action: 'get_account_info',
password: 'pass',
username: 'user',
},
})
);
});
it('returns unavailable without console noise when the portal is offline', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
dataService.sendIpcEvent.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(
service.checkPortalStatus('http://example.com', 'user', 'pass')
).resolves.toBe('unavailable');
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,126 @@
import { Injectable, inject } from '@angular/core';
import { DataService } from './data.service';
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
interface XtreamPortalStatusResponse {
payload?: {
user_info?: {
status?: string;
exp_date?: string;
};
};
}
@Injectable({
providedIn: 'root',
})
export class PortalStatusService {
private readonly dataService = inject(DataService);
/**
* Checks the status of an Xtream Code portal
*
* @param serverUrl The base URL of the server
* @param username The username for authentication
* @param password The password for authentication
* @returns A promise that resolves to the portal status
*/
async checkPortalStatus(
serverUrl: string,
username: string,
password: string
): Promise<PortalStatus> {
try {
let normalizedUrl = serverUrl;
if (serverUrl && !serverUrl.endsWith('/')) {
normalizedUrl = serverUrl;
}
const response =
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
suppressErrorLog: true,
}
);
const payload = response?.payload;
if (!payload?.user_info?.status) {
return 'unavailable';
}
if (payload.user_info.status === 'Active') {
if (!payload.user_info.exp_date) {
return 'active';
}
const expDate = new Date(
parseInt(payload.user_info.exp_date, 10) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
} else {
return 'inactive';
}
} catch (error) {
return 'unavailable';
}
}
/**
* Gets a user-friendly message based on the portal status
*
* @param status The portal status
* @returns A message describing the status
*/
getStatusMessage(status: PortalStatus | null): string {
switch (status) {
case 'active':
return 'Connection successful! Portal is active.';
case 'inactive':
return 'Portal is inactive.';
case 'expired':
return 'Portal subscription has expired.';
case 'unavailable':
return 'Could not connect to the portal.';
default:
return '';
}
}
/**
* Gets a CSS class name based on the portal status
*
* @param status The portal status
* @returns A CSS class name
*/
getStatusClass(status: PortalStatus | null): string {
return status ? `status-${status}` : '';
}
/**
* Gets an icon name based on the portal status
*
* @param status The portal status
* @returns A material icon name
*/
getStatusIcon(status: PortalStatus | null): string {
switch (status) {
case 'active':
return 'check_circle';
case 'inactive':
return 'cancel';
case 'expired':
return 'warning';
case 'unavailable':
default:
return 'error';
}
}
}
@@ -0,0 +1,124 @@
import { inject, Injector } from '@angular/core';
import {
patchState,
signalStore,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { StorageMap } from '@ngx-pwa/local-storage';
import { firstValueFrom } from 'rxjs';
import {
Language,
Settings,
StartupBehavior,
STORE_KEY,
StreamFormat,
Theme,
VideoPlayer,
} from 'shared-interfaces';
import { WORKSPACE_PRODUCT_BRANDING } from './workspace-product-branding.token';
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
streamFormat: StreamFormat.M3u8StreamFormat,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
};
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(DEFAULT_SETTINGS),
withMethods((store, storage = inject(StorageMap), injector = inject(Injector)) => ({
async loadSettings() {
try {
const branding = injector.get(WORKSPACE_PRODUCT_BRANDING);
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
if (stored) {
patchState(store, {
...DEFAULT_SETTINGS,
...(stored as Settings),
});
} else if (branding) {
patchState(store, {
...DEFAULT_SETTINGS,
player: branding.defaultVideoPlayer,
streamFormat: branding.defaultStreamFormat,
language: branding.defaultLanguage,
});
}
} catch (error) {
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
}
},
async updateSettings(settings: Partial<Settings>) {
patchState(store, settings);
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
try {
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
getSettings() {
return {
player: store.player(),
streamFormat: store.streamFormat(),
language: store.language(),
showCaptions: store.showCaptions(),
showDashboard: store.showDashboard(),
startupBehavior: store.startupBehavior(),
showExternalPlaybackBar: store.showExternalPlaybackBar!(),
theme: store.theme(),
mpvPlayerPath: store.mpvPlayerPath(),
mpvReuseInstance: store.mpvReuseInstance(),
vlcPlayerPath: store.vlcPlayerPath(),
remoteControl: store.remoteControl(),
remoteControlPort: store.remoteControlPort(),
epgUrl: store.epgUrl(),
downloadFolder: store.downloadFolder!(),
};
},
getDownloadFolder() {
return store.downloadFolder!();
},
getPlayer() {
return store.player();
},
isEmbeddedPlayer() {
return (
store.player() === VideoPlayer.VideoJs ||
store.player() === VideoPlayer.Html5Player ||
store.player() === VideoPlayer.ArtPlayer
);
},
})),
withHooks({
onInit(store) {
store.loadSettings();
},
})
);
+119
View File
@@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { PlaylistMeta } from 'shared-interfaces';
export enum SortBy {
DATE_ADDED = 'date',
NAME = 'name',
CUSTOM = 'custom',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export interface SortOptions {
by: SortBy;
order: SortOrder;
}
const SORT_OPTIONS_STORAGE_KEY = 'iptvnator-sort-options';
@Injectable({
providedIn: 'root',
})
export class SortService {
private sortOptions = new BehaviorSubject<SortOptions>(
this.getSavedSortOptions()
);
getSortOptions(): Observable<SortOptions> {
return this.sortOptions.asObservable();
}
setSortOptions(options: SortOptions): void {
this.sortOptions.next(options);
this.saveSortOptions(options);
}
private getSavedSortOptions(): SortOptions {
const defaultOptions: SortOptions = {
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
};
try {
const savedOptions = localStorage.getItem(SORT_OPTIONS_STORAGE_KEY);
if (!savedOptions) {
return defaultOptions;
}
const parsedOptions = JSON.parse(savedOptions) as SortOptions;
// Validate that the saved options match our expected types
if (
(parsedOptions.by === SortBy.DATE_ADDED ||
parsedOptions.by === SortBy.NAME ||
parsedOptions.by === SortBy.CUSTOM) &&
(parsedOptions.order === SortOrder.ASC ||
parsedOptions.order === SortOrder.DESC)
) {
return parsedOptions;
}
return defaultOptions;
} catch (error) {
console.error(
'Error retrieving sort options from localStorage:',
error
);
return defaultOptions;
}
}
private saveSortOptions(options: SortOptions): void {
try {
localStorage.setItem(
SORT_OPTIONS_STORAGE_KEY,
JSON.stringify(options)
);
} catch (error) {
console.error('Error saving sort options to localStorage:', error);
}
}
sortPlaylists(
playlists: PlaylistMeta[],
options: SortOptions
): PlaylistMeta[] {
const { by, order } = options;
return [...playlists].sort((a, b) => {
let comparison = 0;
if (by === SortBy.NAME) {
comparison = a.title
.toLowerCase()
.localeCompare(b.title.toLowerCase());
} else if (by === SortBy.DATE_ADDED) {
const dateA = new Date(a.importDate).getTime();
const dateB = new Date(b.importDate).getTime();
comparison = dateA - dateB;
} else if (by === SortBy.CUSTOM) {
// Sort by position field (lower position comes first)
const posA = a.position ?? Number.MAX_SAFE_INTEGER;
const posB = b.position ?? Number.MAX_SAFE_INTEGER;
comparison = posA - posB;
// Custom sort always uses ASC order
return comparison;
}
return order === SortOrder.ASC ? comparison : -comparison;
});
}
isCustomSort(): boolean {
return this.sortOptions.getValue().by === SortBy.CUSTOM;
}
}
@@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core';
import type { WorkspaceProductBranding } from 'shared-interfaces';
export type { WorkspaceProductBranding } from 'shared-interfaces';
export const WORKSPACE_PRODUCT_BRANDING =
new InjectionToken<WorkspaceProductBranding | null>(
'WORKSPACE_PRODUCT_BRANDING'
);
@@ -0,0 +1,89 @@
import { Injectable } from '@angular/core';
import {
getXtreamPendingRestoreStorageKey,
XtreamPendingRestoreState,
} from 'shared-interfaces';
const EMPTY_RESTORE_STATE: XtreamPendingRestoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
};
@Injectable({
providedIn: 'root',
})
export class XtreamPendingRestoreService {
get(playlistId: string): XtreamPendingRestoreState | null {
if (!playlistId) {
return null;
}
try {
const rawState = localStorage.getItem(
getXtreamPendingRestoreStorageKey(playlistId)
);
if (!rawState) {
return null;
}
return this.normalize(JSON.parse(rawState));
} catch {
return null;
}
}
set(playlistId: string, state: XtreamPendingRestoreState): void {
if (!playlistId) {
return;
}
try {
localStorage.setItem(
getXtreamPendingRestoreStorageKey(playlistId),
JSON.stringify(this.normalize(state))
);
} catch {
// Ignore local storage write failures.
}
}
clear(playlistId: string): void {
if (!playlistId) {
return;
}
try {
localStorage.removeItem(
getXtreamPendingRestoreStorageKey(playlistId)
);
} catch {
// Ignore local storage remove failures.
}
}
private normalize(value: unknown): XtreamPendingRestoreState {
if (!value || typeof value !== 'object') {
return { ...EMPTY_RESTORE_STATE };
}
const candidate = value as Partial<XtreamPendingRestoreState>;
return {
hiddenCategories: Array.isArray(candidate.hiddenCategories)
? candidate.hiddenCategories
: [],
favorites: Array.isArray(candidate.favorites)
? candidate.favorites
: [],
recentlyViewed: Array.isArray(candidate.recentlyViewed)
? candidate.recentlyViewed
: [],
playbackPositions: Array.isArray(candidate.playbackPositions)
? candidate.playbackPositions
: [],
};
}
}
+9
View File
@@ -0,0 +1,9 @@
declare module '@angular/material/snack-bar' {
export class MatSnackBar {
open(
message: string,
action?: string,
config?: { duration?: number }
): void;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"files": ["../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts", "../../../global.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts", "../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}