mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
Merge pull request #711 from 4gray/feat/epg-improvements
feat/epg-improvements
This commit is contained in:
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);
|
||||
});
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
<router-outlet />
|
||||
|
||||
<app-epg-progress-panel />
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user