Merge pull request #711 from 4gray/feat/epg-improvements

feat/epg-improvements
This commit is contained in:
4gray authored and GitHub committed 2026-01-06 00:40:07 +01:00
commit 0a4d00c081
13 files changed
+379 -223

No files matched your search

@@ -1,11 +1,11 @@
import { and, eq, gte, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { Worker } from 'worker_threads';
import { EpgProgram } from 'shared-interfaces';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { EpgProgram } from 'shared-interfaces';
/**
* EPG Events Handler
@@ -96,7 +96,10 @@ export default class EpgEvents {
_event,
args: { urls: string[]; maxAgeHours?: number }
): Promise<{ staleUrls: string[]; freshUrls: string[] }> => {
return this.checkEpgFreshness(args.urls, args.maxAgeHours ?? 12);
return this.checkEpgFreshness(
args.urls,
args.maxAgeHours ?? 12
);
}
);
@@ -143,16 +146,26 @@ export default class EpgEvents {
}
}
} catch (error) {
console.error(this.loggerLabel, 'Error checking EPG freshness:', error);
console.error(
this.loggerLabel,
'Error checking EPG freshness:',
error
);
return { staleUrls: urls, freshUrls: [] };
}
// Log summary
if (freshUrls.length > 0) {
console.log(this.loggerLabel, `EPG fresh (skipping): ${freshUrls.length} source(s)`);
console.log(
this.loggerLabel,
`EPG fresh (skipping): ${freshUrls.length} source(s)`
);
}
if (staleUrls.length > 0) {
console.log(this.loggerLabel, `EPG stale (will fetch): ${staleUrls.length} source(s)`);
console.log(
this.loggerLabel,
`EPG stale (will fetch): ${staleUrls.length} source(s)`
);
}
return { staleUrls, freshUrls };
@@ -173,15 +186,28 @@ export default class EpgEvents {
}
// Check which URLs have fresh data and can be skipped
const { staleUrls, freshUrls } = await this.checkEpgFreshness(validUrls, 12);
const { staleUrls, freshUrls } = await this.checkEpgFreshness(
validUrls,
12
);
if (staleUrls.length === 0) {
return { success: true, message: 'All EPG data is fresh', skipped: freshUrls };
return {
success: true,
message: 'All EPG data is fresh',
skipped: freshUrls,
};
}
// Send queued status for all stale URLs first
staleUrls.forEach((url, index) => {
this.sendProgressToRenderer(url, 'queued', undefined, undefined, index + 1);
this.sendProgressToRenderer(
url,
'queued',
undefined,
undefined,
index + 1
);
});
// Process only stale URLs sequentially to avoid database locking
@@ -191,8 +217,14 @@ export default class EpgEvents {
try {
await this.fetchEpgFromUrl(url);
} catch (error) {
console.error(this.loggerLabel, `Error fetching EPG from ${url}:`, error);
errors.push(error instanceof Error ? error.message : String(error));
console.error(
this.loggerLabel,
`Error fetching EPG from ${url}:`,
error
);
errors.push(
error instanceof Error ? error.message : String(error)
);
}
}
@@ -214,7 +246,10 @@ export default class EpgEvents {
private static async fetchEpgFromUrl(url: string): Promise<void> {
// Skip if already fetched this session
if (this.fetchedUrls.has(url)) {
console.log(this.loggerLabel, `Skipping already fetched URL: ${url}`);
console.log(
this.loggerLabel,
`Skipping already fetched URL: ${url}`
);
return;
}
@@ -232,7 +267,11 @@ export default class EpgEvents {
'epg-parser.worker.js'
);
} else {
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
workerPath = path.join(
__dirname,
'workers',
'epg-parser.worker.js'
);
}
let worker: Worker;
@@ -245,7 +284,11 @@ export default class EpgEvents {
},
});
} catch (error) {
console.error(this.loggerLabel, 'Failed to create worker:', error);
console.error(
this.loggerLabel,
'Failed to create worker:',
error
);
reject(error);
return;
}
@@ -264,7 +307,10 @@ export default class EpgEvents {
switch (message.type) {
case 'READY':
// Notify renderer that loading started
this.sendProgressToRenderer(url, 'loading', { totalChannels: 0, totalPrograms: 0 });
this.sendProgressToRenderer(url, 'loading', {
totalChannels: 0,
totalPrograms: 0,
});
worker.postMessage({ type: 'FETCH_EPG', url });
break;
@@ -275,7 +321,11 @@ export default class EpgEvents {
`Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs`
);
// Forward progress to renderer
this.sendProgressToRenderer(url, 'loading', message.stats);
this.sendProgressToRenderer(
url,
'loading',
message.stats
);
}
break;
@@ -286,10 +336,17 @@ export default class EpgEvents {
message.stats
);
// Notify renderer of completion
this.sendProgressToRenderer(url, 'complete', message.stats);
this.sendProgressToRenderer(
url,
'complete',
message.stats
);
this.fetchedUrls.add(url);
// Trigger cleanup in worker thread (non-blocking)
worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep: 24 });
worker.postMessage({
type: 'CLEANUP_EXPIRED',
hoursToKeep: 24,
});
break;
case 'CLEANUP_COMPLETE':
@@ -309,14 +366,25 @@ export default class EpgEvents {
message.error
);
// Notify renderer of error
this.sendProgressToRenderer(url, 'error', undefined, message.error);
this.sendProgressToRenderer(
url,
'error',
undefined,
message.error
);
worker.terminate();
this.workers.delete(url);
reject(new Error(message.error || 'Unknown error'));
reject(
new Error(message.error || 'Unknown error')
);
break;
}
} catch (err) {
console.error(this.loggerLabel, 'Error handling message:', err);
console.error(
this.loggerLabel,
'Error handling message:',
err
);
reject(err);
}
}
@@ -371,7 +439,9 @@ export default class EpgEvents {
/**
* Get programs for a specific channel from database
*/
private static async handleGetChannelPrograms(channelId: string): Promise<EpgProgram[]> {
private static async handleGetChannelPrograms(
channelId: string
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const now = new Date().toISOString();
@@ -420,7 +490,11 @@ export default class EpgEvents {
return [];
} catch (error) {
console.error(this.loggerLabel, 'Error getting channel programs:', error);
console.error(
this.loggerLabel,
'Error getting channel programs:',
error
);
return [];
}
}
@@ -444,7 +518,11 @@ export default class EpgEvents {
return { channels, programs: [] };
} catch (error) {
console.error(this.loggerLabel, 'Error getting all channels:', error);
console.error(
this.loggerLabel,
'Error getting all channels:',
error
);
return { channels: [], programs: [] };
}
}
@@ -455,7 +533,14 @@ export default class EpgEvents {
private static async handleGetChannelsByRange(
skip: number,
limit: number
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null; programs: EpgProgram[] }>> {
): Promise<
Array<{
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}>
> {
try {
const db = await getDatabase();
const channels = await db
@@ -487,7 +572,11 @@ export default class EpgEvents {
return channelsWithPrograms;
} catch (error) {
console.error(this.loggerLabel, 'Error getting channels by range:', error);
console.error(
this.loggerLabel,
'Error getting channels by range:',
error
);
return [];
}
}
@@ -495,7 +584,9 @@ export default class EpgEvents {
/**
* Run cleanup in worker thread to avoid blocking main thread
*/
private static async runCleanupInWorker(hoursToKeep: number): Promise<{ success: boolean }> {
private static async runCleanupInWorker(
hoursToKeep: number
): Promise<{ success: boolean }> {
return new Promise((resolve, reject) => {
let workerPath: string;
@@ -510,7 +601,11 @@ export default class EpgEvents {
'epg-parser.worker.js'
);
} else {
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
workerPath = path.join(
__dirname,
'workers',
'epg-parser.worker.js'
);
}
let worker: Worker;
@@ -518,26 +613,44 @@ export default class EpgEvents {
const workerURL = pathToFileURL(workerPath);
worker = new Worker(workerURL);
} catch (error) {
console.error(this.loggerLabel, 'Failed to create worker for cleanup:', error);
console.error(
this.loggerLabel,
'Failed to create worker for cleanup:',
error
);
reject(error);
return;
}
worker.on('message', (message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEANUP_EXPIRED', hoursToKeep });
} else if (message.type === 'CLEANUP_COMPLETE') {
worker.terminate();
resolve({ success: true });
} else if (message.type === 'EPG_ERROR') {
console.error(this.loggerLabel, 'Worker cleanup error:', message.error);
worker.terminate();
reject(new Error(message.error || 'Cleanup failed'));
worker.on(
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({
type: 'CLEANUP_EXPIRED',
hoursToKeep,
});
} else if (message.type === 'CLEANUP_COMPLETE') {
worker.terminate();
resolve({ success: true });
} else if (message.type === 'EPG_ERROR') {
console.error(
this.loggerLabel,
'Worker cleanup error:',
message.error
);
worker.terminate();
reject(new Error(message.error || 'Cleanup failed'));
}
}
});
);
worker.on('error', (error) => {
console.error(this.loggerLabel, 'Worker error during cleanup:', error);
console.error(
this.loggerLabel,
'Worker error during cleanup:',
error
);
worker.terminate();
reject(error);
});
@@ -562,7 +675,11 @@ export default class EpgEvents {
'epg-parser.worker.js'
);
} else {
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
workerPath = path.join(
__dirname,
'workers',
'epg-parser.worker.js'
);
}
let worker: Worker;
@@ -570,31 +687,49 @@ export default class EpgEvents {
const workerURL = pathToFileURL(workerPath);
worker = new Worker(workerURL);
} catch (error) {
console.error(this.loggerLabel, 'Failed to create worker for clear:', error);
console.error(
this.loggerLabel,
'Failed to create worker for clear:',
error
);
reject(error);
return;
}
worker.on('message', (message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEAR_EPG' });
} else if (message.type === 'CLEAR_COMPLETE') {
console.log(this.loggerLabel, 'EPG data cleared via worker');
this.fetchedUrls.clear();
// Terminate any running fetch workers
this.workers.forEach((w) => w.terminate());
this.workers.clear();
worker.terminate();
resolve();
} else if (message.type === 'EPG_ERROR') {
console.error(this.loggerLabel, 'Worker clear error:', message.error);
worker.terminate();
reject(new Error(message.error || 'Clear failed'));
worker.on(
'message',
(message: { type: string; error?: string }) => {
if (message.type === 'READY') {
worker.postMessage({ type: 'CLEAR_EPG' });
} else if (message.type === 'CLEAR_COMPLETE') {
console.log(
this.loggerLabel,
'EPG data cleared via worker'
);
this.fetchedUrls.clear();
// Terminate any running fetch workers
this.workers.forEach((w) => w.terminate());
this.workers.clear();
worker.terminate();
resolve();
} else if (message.type === 'EPG_ERROR') {
console.error(
this.loggerLabel,
'Worker clear error:',
message.error
);
worker.terminate();
reject(new Error(message.error || 'Clear failed'));
}
}
});
);
worker.on('error', (error) => {
console.error(this.loggerLabel, 'Worker error during clear:', error);
console.error(
this.loggerLabel,
'Worker error during clear:',
error
);
worker.terminate();
reject(error);
});
+2
View File
@@ -1 +1,3 @@
<router-outlet />
<app-epg-progress-panel />
+14 -3
View File
@@ -1,5 +1,6 @@
import { Component, HostBinding, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
@@ -16,13 +17,14 @@ import {
Theme,
} from 'shared-interfaces';
import { SettingsService } from './services/settings.service';
import { EpgProgressPanelComponent } from './shared/epg-progress-panel/epg-progress-panel.component';
import { RecentlyViewedComponent } from './xtream-tauri/recently-viewed/recently-viewed.component';
import { SearchResultsComponent } from './xtream-tauri/search-results/search-results.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
imports: [RouterOutlet],
imports: [EpgProgressPanelComponent, RouterOutlet],
})
export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() {
@@ -34,6 +36,7 @@ export class AppComponent implements OnInit {
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
private router = inject(Router);
private store = inject(Store);
private translate = inject(TranslateService);
@@ -62,7 +65,7 @@ export class AppComponent implements OnInit {
});
}
}
/* if (isTauri()) {
if (window.electron) {
document.addEventListener('keydown', (event) => {
if (event.ctrlKey || event.metaKey) {
if (event.key === 'f') {
@@ -74,7 +77,7 @@ export class AppComponent implements OnInit {
}
}
});
} */
}
}
ngOnInit() {
@@ -177,6 +180,14 @@ export class AppComponent implements OnInit {
console.log(
`EPG: ${result.freshUrls.length} source(s) already fresh, skipping fetch`
);
// Show snackbar if all EPG sources are fresh (no stale URLs)
if (result.staleUrls.length === 0) {
this.snackBar.open(
this.translate.instant('EPG.UP_TO_DATE'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
}
if (result.staleUrls.length > 0) {
@@ -1,95 +1,117 @@
@if (isVisible()) {
<div class="epg-progress-panel">
<div class="panel-header">
<span class="header-title">
<mat-icon>live_tv</mat-icon>
{{ 'EPG.IMPORT_PROGRESS' | translate }}
</span>
@if (imports().length > 1) {
<button
mat-icon-button
class="dismiss-all-btn"
(click)="dismissAll()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="imports-list">
<!-- Active/Completed imports -->
@for (item of activeImports; track item.url) {
<div class="import-item" [class]="getStatusClass(item.status)">
<div class="item-header">
<div class="status-icon-wrapper">
<mat-icon [class.spinning]="item.status === 'loading'">
{{ getStatusIcon(item.status) }}
</mat-icon>
</div>
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<div class="epg-progress-panel">
<div class="panel-header">
<span class="header-title">
<mat-icon>live_tv</mat-icon>
{{ 'EPG.IMPORT_PROGRESS' | translate }}
</span>
@if (imports().length > 1) {
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
class="dismiss-all-btn"
(click)="dismissAll()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
@if (item.status === 'loading') {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
<div class="imports-list">
<!-- Active/Completed imports -->
@for (item of activeImports; track item.url) {
<div class="import-item" [class]="getStatusClass(item.status)">
<div class="item-header">
<div class="status-icon-wrapper">
<mat-icon
[class.spinning]="item.status === 'loading'"
>
{{ getStatusIcon(item.status) }}
</mat-icon>
</div>
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (item.status === 'loading') {
<mat-progress-bar mode="indeterminate" />
}
<div class="item-stats">
@if (item.stats) {
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_CHANNELS' | translate
}}"
>
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
</span>
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_PROGRAMS' | translate
}}"
>
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
}
<div class="item-stats">
@if (item.stats) {
<span class="stat-badge">
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
</span>
<span class="stat-badge">
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
}
<!-- Queued imports section -->
@if (queuedImports.length > 0) {
<div class="queue-section">
<div class="queue-header">
<mat-icon>queue</mat-icon>
<span>{{ 'EPG.QUEUED' | translate }} ({{ queuedImports.length }})</span>
</div>
<div class="queue-list">
@for (item of queuedImports; track item.url; let i = $index) {
<div class="queue-item">
<span class="queue-position">{{ i + 1 }}</span>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
<!-- Queued imports section -->
@if (queuedImports.length > 0) {
<div class="queue-section">
<div class="queue-header">
<mat-icon>queue</mat-icon>
<span
>{{ 'EPG.QUEUED' | translate }} ({{
queuedImports.length
}})</span
>
</div>
<div class="queue-list">
@for (
item of queuedImports;
track item.url;
let i = $index
) {
<div class="queue-item">
<span class="queue-position">{{ i + 1 }}</span>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="
'BUTTONS.CLOSE' | translate
"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
}
@@ -144,16 +144,12 @@
.dismiss-all-btn {
width: 28px;
height: 28px;
padding: 2px;
line-height: 28px;
border-radius: 8px;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
&:hover {
background: var(--epg-item-hover);
color: var(--epg-text-primary);
}
mat-icon {
font-size: 18px;
width: 18px;
@@ -199,11 +195,11 @@
}
.import-item {
padding: 14px 16px;
margin: 0 8px;
padding: 6px 12px;
border-radius: 10px;
transition: all 0.2s ease;
position: relative;
margin: 0 6px;
&:not(:last-child) {
margin-bottom: 2px;
@@ -252,17 +248,13 @@
.dismiss-btn {
width: 26px;
height: 26px;
padding: 2px;
line-height: 26px;
border-radius: 6px;
opacity: 0;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
&:hover {
background: var(--epg-error-glow);
color: var(--epg-error);
}
mat-icon {
font-size: 16px;
width: 16px;
@@ -378,7 +370,7 @@
// Queue section styles
.queue-section {
margin: 8px;
//margin: 8px;
padding: 12px;
border-radius: 10px;
animation: fadeIn 0.3s ease;
@@ -455,6 +447,7 @@
.dismiss-btn {
width: 22px;
height: 22px;
padding: 0;
line-height: 22px;
border-radius: 4px;
opacity: 0;
@@ -462,11 +455,6 @@
color: var(--epg-text-tertiary);
flex-shrink: 0;
&:hover {
background: var(--epg-error-glow);
color: var(--epg-error);
}
mat-icon {
font-size: 14px;
width: 14px;
@@ -1,12 +1,13 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatIconModule } from '@angular/material/icon';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
EpgProgressService,
EpgImportProgress,
EpgProgressService,
} from '../../services/epg-progress.service';
@Component({
@@ -14,9 +15,10 @@ import {
standalone: true,
imports: [
CommonModule,
MatProgressBarModule,
MatIconModule,
MatButtonModule,
MatIconModule,
MatTooltip,
MatProgressBar,
TranslateModule,
],
templateUrl: './epg-progress-panel.component.html',
+4 -1
View File
@@ -265,7 +265,10 @@
"CHANNELS": "channels",
"PROGRAMS": "programs",
"LOADING": "Loading",
"QUEUED": "Queued"
"QUEUED": "Queued",
"TOTAL_CHANNELS": "Total channels",
"TOTAL_PROGRAMS": "Total programs",
"UP_TO_DATE": "EPG is up-to-date, no sync needed"
},
"LANGUAGES": {
"ARABIC": "العربية",
+31 -44
View File
@@ -34,7 +34,6 @@ export class EpgService {
*/
fetchEpg(urls: string[]): void {
if (!this.isDesktop) return;
this.showFetchSnackbar();
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
@@ -45,7 +44,6 @@ export class EpgService {
tap((result) => {
if (result.success) {
this.epgAvailable.next(true);
this.showSuccessSnackbar();
} else {
this.epgAvailable.next(false);
this.showErrorSnackbar(result.message);
@@ -91,30 +89,6 @@ export class EpgService {
});
}
/**
* Shows fetch in progress snackbar
*/
showFetchSnackbar(): void {
this.snackBar.open(this.translate.instant('EPG.FETCH_EPG'), undefined, {
duration: 2000,
horizontalPosition: 'start',
});
}
/**
* Shows success snackbar
*/
private showSuccessSnackbar(): void {
this.snackBar.open(
this.translate.instant('EPG.FETCH_SUCCESS'),
undefined,
{
duration: 2000,
horizontalPosition: 'start',
}
);
}
/**
* Shows error snackbar
*/
@@ -131,7 +105,9 @@ export class EpgService {
* @param channelId Channel ID (tvg-id or channel name)
* @returns Observable of current program or null
*/
getCurrentProgramForChannel(channelId: string): Observable<EpgProgram | null> {
getCurrentProgramForChannel(
channelId: string
): Observable<EpgProgram | null> {
if (!this.isDesktop || !channelId) {
return of(null);
}
@@ -140,7 +116,7 @@ export class EpgService {
const cached = this.programCache.get(channelId);
const now = Date.now();
if (cached && (now - cached.timestamp) < this.CACHE_TTL) {
if (cached && now - cached.timestamp < this.CACHE_TTL) {
return of(cached.program);
}
@@ -148,7 +124,10 @@ export class EpgService {
return from(window.electron.getChannelPrograms(channelId)).pipe(
map((programs: EpgProgram[]) => {
if (!programs || programs.length === 0) {
this.programCache.set(channelId, { program: null, timestamp: now });
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return null;
}
@@ -160,19 +139,23 @@ export class EpgService {
}));
// Find current program from transformed programs
const currentProgram = this.findCurrentProgram(transformedPrograms);
const currentProgram =
this.findCurrentProgram(transformedPrograms);
// Cache the result
this.programCache.set(channelId, {
program: currentProgram,
timestamp: now
timestamp: now,
});
return currentProgram;
}),
catchError((err) => {
console.error('EPG get current program error:', err);
this.programCache.set(channelId, { program: null, timestamp: now });
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return of(null);
})
);
@@ -184,11 +167,13 @@ export class EpgService {
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;
return (
programs.find((program) => {
const start = new Date(program.start);
const stop = new Date(program.stop);
return start <= now && now <= stop;
}) || null
);
}
/**
@@ -196,7 +181,9 @@ export class EpgService {
* @param channelIds Array of channel IDs
* @returns Observable of Map with channelId -> current program
*/
getCurrentProgramsForChannels(channelIds: string[]): Observable<Map<string, EpgProgram | null>> {
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
if (!this.isDesktop) {
return of(new Map());
}
@@ -210,9 +197,9 @@ export class EpgService {
const now = Date.now();
// Check cache for each channel
channelIds.forEach(channelId => {
channelIds.forEach((channelId) => {
const cached = this.programCache.get(channelId);
if (cached && (now - cached.timestamp) < this.CACHE_TTL) {
if (cached && now - cached.timestamp < this.CACHE_TTL) {
resultMap.set(channelId, cached.program);
} else {
channelsToFetch.push(channelId);
@@ -225,18 +212,18 @@ export class EpgService {
}
// Fetch uncached channels with timeout and error handling per request
const fetchObservables = channelsToFetch.map(channelId =>
const fetchObservables = channelsToFetch.map((channelId) =>
this.getCurrentProgramForChannel(channelId).pipe(
timeout(5000), // 5 second timeout per request
map(program => ({ channelId, program })),
map((program) => ({ channelId, program })),
catchError(() => of({ channelId, program: null }))
)
);
// Combine all fetches using forkJoin
return forkJoin(fetchObservables).pipe(
map(results => {
results.forEach(result => {
map((results) => {
results.forEach((result) => {
resultMap.set(result.channelId, result.program);
});
return resultMap;
@@ -1,7 +1,7 @@
<span class="time"
>{{ item.start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }}
>{{ item.start | momentDate: 'HH:mm' }}
-
{{ item.stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }}</span
{{ item.stop | momentDate: 'HH:mm' }}</span
>
@if (timeNow >= item.start && timeNow <= item.stop) {
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
@@ -33,7 +33,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{ dateToday | momentDate: 'YYYY-MM-DD' : 'MMMM Do, dddd' }}</span
{{ dateToday | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD' }}</span
>
<button
class="next-day"
@@ -30,9 +30,9 @@
}
<div class="program-timeline">
<div class="timeline-info">
<span class="time-start">{{ start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }}</span>
<span class="time-start">{{ start | momentDate: 'HH:mm' }}</span>
<span class="time-separator">—</span>
<span class="time-end">{{ stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }}</span>
<span class="time-end">{{ stop | momentDate: 'HH:mm' }}</span>
</div>
<div class="progress-track">
<div
@@ -14,7 +14,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ today() | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
{{ today() | momentDate: 'MMMM Do, dddd' : 'YYYYMMDD' }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
+10 -4
View File
@@ -2,7 +2,9 @@ import { Pipe, PipeTransform } from '@angular/core';
import moment from 'moment';
/**
* Moment.js based pipe to parse and return the provided date based on given params
* Moment.js based pipe to parse and return the provided date based on given params.
* Auto-detects ISO 8601 format and converts to user's local timezone.
* Optionally accepts a format string for non-ISO date strings.
*/
@Pipe({
name: 'momentDate',
@@ -11,9 +13,13 @@ import moment from 'moment';
export class MomentDatePipe implements PipeTransform {
transform(
value: string,
formatToParse: string,
formatToReturn = 'MMMM Do, dddd'
formatToReturn = 'MMMM Do, dddd',
formatToParse?: string
): string {
return moment(value, formatToParse).format(formatToReturn);
// If formatToParse is provided, use it; otherwise auto-detect (works for ISO 8601)
const parsed = formatToParse
? moment(value, formatToParse)
: moment(value);
return parsed.format(formatToReturn);
}
}