Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+13
View File
@@ -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"
}
}
}
+2
View File
@@ -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();
});
});
});
});
*/
+277
View File
@@ -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();
}
}
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}