Proyecto App Windows
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "epg-data-access",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/epg/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:data-access"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/epg-progress.service';
|
||||
export * from './lib/epg.service';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { normalizeEpgPrograms } from './epg-program-normalization.util';
|
||||
|
||||
function buildProgram(overrides: Partial<EpgProgram> = {}): EpgProgram {
|
||||
return {
|
||||
start: '2026-04-15T20:00:00Z',
|
||||
stop: '2026-04-15T21:00:00Z',
|
||||
channel: 'channel-1',
|
||||
title: 'Sample',
|
||||
desc: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeEpgPrograms', () => {
|
||||
it('keeps valid rows and normalizes dates', () => {
|
||||
expect(normalizeEpgPrograms([buildProgram()])).toEqual([
|
||||
expect.objectContaining({
|
||||
start: '2026-04-15T20:00:00.000Z',
|
||||
stop: '2026-04-15T21:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops rows with invalid stop dates', () => {
|
||||
expect(
|
||||
normalizeEpgPrograms([
|
||||
buildProgram({ title: 'ok' }),
|
||||
buildProgram({ title: 'bad', stop: '' }),
|
||||
])
|
||||
).toEqual([expect.objectContaining({ title: 'ok' })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
|
||||
function toIsoDate(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop malformed EPG rows instead of failing the whole channel request.
|
||||
*/
|
||||
export function normalizeEpgPrograms(programs: EpgProgram[]): EpgProgram[] {
|
||||
const normalizedPrograms: EpgProgram[] = [];
|
||||
|
||||
for (const program of programs) {
|
||||
const start = toIsoDate(program.start);
|
||||
const stop = toIsoDate(program.stop);
|
||||
|
||||
if (!start || !stop) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedPrograms.push({
|
||||
...program,
|
||||
start,
|
||||
stop,
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedPrograms;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface EpgImportProgress {
|
||||
url: string;
|
||||
status: 'queued' | 'loading' | 'complete' | 'error';
|
||||
stats?: { totalChannels: number; totalPrograms: number };
|
||||
error?: string;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EpgProgressService {
|
||||
private readonly importsMap = signal<Map<string, EpgImportProgress>>(
|
||||
new Map()
|
||||
);
|
||||
private initialized = false;
|
||||
|
||||
readonly imports = computed(() => Array.from(this.importsMap().values()));
|
||||
readonly hasActiveImports = computed(() =>
|
||||
this.imports().some((item) => item.status === 'loading')
|
||||
);
|
||||
readonly activeCount = computed(
|
||||
() => this.imports().filter((item) => item.status === 'loading').length
|
||||
);
|
||||
readonly queuedImports = computed(() =>
|
||||
this.imports()
|
||||
.filter((item) => item.status === 'queued')
|
||||
.sort(
|
||||
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
|
||||
)
|
||||
);
|
||||
readonly queuedCount = computed(() => this.queuedImports().length);
|
||||
readonly isVisible = computed(() => this.imports().length > 0);
|
||||
|
||||
constructor() {
|
||||
this.initializeListener();
|
||||
}
|
||||
|
||||
dismiss(url: string): void {
|
||||
this.removeImport(url);
|
||||
}
|
||||
|
||||
dismissAll(): void {
|
||||
this.importsMap.set(new Map());
|
||||
}
|
||||
|
||||
retry(url: string): void {
|
||||
// Clear the errored row so the backend's subsequent 'queued' event
|
||||
// reappears cleanly rather than updating an existing error row.
|
||||
this.removeImport(url);
|
||||
void window.electron?.forceFetchEpg?.(url);
|
||||
}
|
||||
|
||||
private initializeListener(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
if (window.electron?.onEpgProgress) {
|
||||
window.electron.onEpgProgress((data) => {
|
||||
this.updateProgress(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateProgress(progress: EpgImportProgress): void {
|
||||
this.importsMap.update((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(progress.url, progress);
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (progress.status === 'complete' || progress.status === 'error') {
|
||||
setTimeout(() => this.removeImport(progress.url), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private removeImport(url: string): void {
|
||||
this.importsMap.update((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.delete(url);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/* import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { MockModule, MockProviders } from 'ng-mocks';
|
||||
import { DataService } from './data.service';
|
||||
import { EpgService } from './epg.service';
|
||||
|
||||
jest.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: jest.fn(),
|
||||
isTauri: () => true,
|
||||
}));
|
||||
|
||||
describe('EpgService', () => {
|
||||
let service: EpgService;
|
||||
let snackBar: MatSnackBar;
|
||||
let store: MockStore;
|
||||
let translateService: TranslateService;
|
||||
let dispatchSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgService,
|
||||
MockProviders(DataService, TranslateService, MatSnackBar),
|
||||
provideMockStore(),
|
||||
],
|
||||
imports: [MockModule(MatSnackBarModule)],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EpgService);
|
||||
snackBar = TestBed.inject(MatSnackBar);
|
||||
store = TestBed.inject(MockStore);
|
||||
translateService = TestBed.inject(TranslateService);
|
||||
|
||||
jest.spyOn(translateService, 'instant').mockImplementation(
|
||||
(key) => key
|
||||
);
|
||||
jest.spyOn(snackBar, 'open');
|
||||
dispatchSpy = jest.spyOn(store, 'dispatch');
|
||||
|
||||
// Suppress console.error during tests
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a service instance', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('fetchEpg', () => {
|
||||
it('should show success notification on successful fetch', async () => {
|
||||
(invoke as jest.Mock).mockResolvedValueOnce({});
|
||||
service.fetchEpg(['http://example.com/epg.xml']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'EPG.FETCH_SUCCESS',
|
||||
null,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error notification on fetch failure', async () => {
|
||||
(invoke as jest.Mock).mockRejectedValueOnce(new Error('Failed'));
|
||||
service.fetchEpg(['http://example.com/epg.xml']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'EPG.ERROR',
|
||||
'CLOSE',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannelPrograms', () => {
|
||||
it('should update programs and set EPG flag to true when programs exist', (done) => {
|
||||
const mockPrograms = [
|
||||
{
|
||||
start: new Date().toISOString(),
|
||||
stop: new Date().toISOString(),
|
||||
title: 'Test',
|
||||
},
|
||||
];
|
||||
(invoke as jest.Mock).mockResolvedValueOnce(mockPrograms);
|
||||
|
||||
service.getChannelPrograms('test-channel');
|
||||
|
||||
service.currentEpgPrograms$.subscribe((programs) => {
|
||||
expect(programs).toHaveLength(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: true })
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set EPG flag to false when no programs found', (done) => {
|
||||
(invoke as jest.Mock).mockResolvedValueOnce([]);
|
||||
|
||||
service.getChannelPrograms('test-channel');
|
||||
|
||||
service.currentEpgPrograms$.subscribe((programs) => {
|
||||
expect(programs).toHaveLength(0);
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: false })
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -0,0 +1,277 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap, timeout } from 'rxjs/operators';
|
||||
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
|
||||
import { normalizeEpgPrograms } from './epg-program-normalization.util';
|
||||
|
||||
interface CachedProgram {
|
||||
program: EpgProgram | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type EpgChannelMetadataApi = {
|
||||
getEpgChannelMetadata?: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, EpgChannelMetadata | null>>;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EpgService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private translate = inject(TranslateService);
|
||||
|
||||
private epgAvailable = new BehaviorSubject<boolean>(false);
|
||||
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
|
||||
|
||||
// Cache for channel programs with 60-second TTL
|
||||
private programCache = new Map<string, CachedProgram>();
|
||||
private readonly CACHE_TTL = 60000; // 60 seconds
|
||||
|
||||
private readonly isDesktop = !!window.electron;
|
||||
|
||||
readonly epgAvailable$ = this.epgAvailable.asObservable();
|
||||
readonly currentEpgPrograms$ = this.currentEpgPrograms.asObservable();
|
||||
|
||||
/**
|
||||
* Fetches EPG from the given URLs
|
||||
*/
|
||||
fetchEpg(urls: string[]): void {
|
||||
if (!this.isDesktop) return;
|
||||
|
||||
// Filter out empty URLs and send all URLs at once
|
||||
const validUrls = urls.filter((url) => url?.trim());
|
||||
if (validUrls.length === 0) return;
|
||||
|
||||
from(window.electron.fetchEpg(validUrls))
|
||||
.pipe(
|
||||
tap((result) => {
|
||||
if (result.success) {
|
||||
this.epgAvailable.next(true);
|
||||
} else {
|
||||
this.epgAvailable.next(false);
|
||||
this.showErrorSnackbar(result.message);
|
||||
}
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG fetch error:', err);
|
||||
this.epgAvailable.next(false);
|
||||
this.showErrorSnackbar();
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets EPG programs for a specific channel
|
||||
*/
|
||||
getChannelPrograms(channelId: string): void {
|
||||
if (!this.isDesktop) return;
|
||||
console.log('Fetching EPG for channel ID:', channelId);
|
||||
|
||||
from(window.electron.getChannelPrograms(channelId))
|
||||
.pipe(
|
||||
timeout(3000),
|
||||
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
|
||||
catchError((err) => {
|
||||
console.error('EPG get programs error:', err);
|
||||
this.showErrorSnackbar();
|
||||
this.currentEpgPrograms.next([]);
|
||||
this.epgAvailable.next(false);
|
||||
return of([]);
|
||||
})
|
||||
)
|
||||
.subscribe((programs) => {
|
||||
this.epgAvailable.next(programs.length > 0);
|
||||
this.currentEpgPrograms.next(programs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows error snackbar
|
||||
*/
|
||||
private showErrorSnackbar(message?: string): void {
|
||||
const errorMessage = message || this.translate.instant('EPG.ERROR');
|
||||
this.snackBar.open(errorMessage, this.translate.instant('CLOSE'), {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current EPG program for a specific channel (with caching)
|
||||
* @param channelId Channel ID (tvg-id or channel name)
|
||||
* @returns Observable of current program or null
|
||||
*/
|
||||
getCurrentProgramForChannel(
|
||||
channelId: string
|
||||
): Observable<EpgProgram | null> {
|
||||
if (!this.isDesktop || !channelId) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = this.programCache.get(channelId);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached && now - cached.timestamp < this.CACHE_TTL) {
|
||||
return of(cached.program);
|
||||
}
|
||||
|
||||
// Fetch from backend
|
||||
return from(window.electron.getChannelPrograms(channelId)).pipe(
|
||||
map((programs: EpgProgram[]) => normalizeEpgPrograms(programs)),
|
||||
map((programs: EpgProgram[]) => {
|
||||
if (!programs.length) {
|
||||
this.programCache.set(channelId, {
|
||||
program: null,
|
||||
timestamp: now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentProgram = this.findCurrentProgram(programs);
|
||||
|
||||
// Cache the result
|
||||
this.programCache.set(channelId, {
|
||||
program: currentProgram,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
return currentProgram;
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG get current program error:', err);
|
||||
this.programCache.set(channelId, {
|
||||
program: null,
|
||||
timestamp: now,
|
||||
});
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the current program from a list of programs
|
||||
*/
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const start = new Date(program.start);
|
||||
const stop = new Date(program.stop);
|
||||
return start <= now && now <= stop;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current programs for multiple channels (batch operation)
|
||||
* @param channelIds Array of channel IDs
|
||||
* @returns Observable of Map with channelId -> current program
|
||||
*/
|
||||
getCurrentProgramsForChannels(
|
||||
channelIds: string[]
|
||||
): Observable<Map<string, EpgProgram | null>> {
|
||||
if (!this.isDesktop) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
if (!channelIds || channelIds.length === 0) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
const resultMap = new Map<string, EpgProgram | null>();
|
||||
const channelsToFetch: string[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache for each channel
|
||||
channelIds.forEach((channelId) => {
|
||||
const cached = this.programCache.get(channelId);
|
||||
if (cached && now - cached.timestamp < this.CACHE_TTL) {
|
||||
resultMap.set(channelId, cached.program);
|
||||
} else {
|
||||
channelsToFetch.push(channelId);
|
||||
}
|
||||
});
|
||||
|
||||
// If all channels were cached, return immediately
|
||||
if (channelsToFetch.length === 0) {
|
||||
return of(resultMap);
|
||||
}
|
||||
|
||||
// Fetch uncached channels with timeout and error handling per request
|
||||
const fetchObservables = channelsToFetch.map((channelId) =>
|
||||
this.getCurrentProgramForChannel(channelId).pipe(
|
||||
timeout(5000), // 5 second timeout per request
|
||||
map((program) => ({ channelId, program })),
|
||||
catchError(() => of({ channelId, program: null }))
|
||||
)
|
||||
);
|
||||
|
||||
// Combine all fetches using forkJoin
|
||||
return forkJoin(fetchObservables).pipe(
|
||||
map((results) => {
|
||||
results.forEach((result) => {
|
||||
resultMap.set(result.channelId, result.program);
|
||||
});
|
||||
return resultMap;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getChannelMetadataForChannels(
|
||||
channelIds: string[]
|
||||
): Observable<Map<string, EpgChannelMetadata | null>> {
|
||||
if (!this.isDesktop) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
const normalizedChannelIds = Array.from(
|
||||
new Set(
|
||||
channelIds
|
||||
.map((channelId) => channelId.trim())
|
||||
.filter((channelId) => channelId.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
const getEpgChannelMetadata = (
|
||||
window.electron as EpgChannelMetadataApi | undefined
|
||||
)?.getEpgChannelMetadata;
|
||||
|
||||
if (typeof getEpgChannelMetadata !== 'function') {
|
||||
return of(new Map());
|
||||
}
|
||||
|
||||
return from(getEpgChannelMetadata(normalizedChannelIds)).pipe(
|
||||
map((metadataByChannelId) => {
|
||||
return new Map<string, EpgChannelMetadata | null>(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
metadataByChannelId[channelId] ?? null,
|
||||
])
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG get channel metadata error:', err);
|
||||
return of(new Map<string, EpgChannelMetadata | null>());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the program cache (useful when EPG is refreshed)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.programCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user