Proyecto App Windows
This commit is contained in:
@@ -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.
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -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
@@ -0,0 +1,9 @@
|
||||
declare module '@angular/material/snack-bar' {
|
||||
export class MatSnackBar {
|
||||
open(
|
||||
message: string,
|
||||
action?: string,
|
||||
config?: { duration?: number }
|
||||
): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user