feat: implement URL probing functionality and enhance time formatting in EPG component

This commit is contained in:
4gray committed 2026-04-05 19:04:20 +02:00
1 parent 826d8d24b1
commit 04e8709d7b
8 files changed
+198 -62

No files matched your search

+3 -5
View File
@@ -44,17 +44,15 @@
"serve": {
"executor": "nx:run-commands",
"options": {
"commands": [
"nx run web:serve",
"nx run electron-backend:serve-electron"
]
"command": "nx run electron-backend:serve-electron"
}
},
"serve-electron": {
"executor": "nx-electron:execute",
"options": {
"buildTarget": "electron-backend:build",
"args": ["--remote-debugging-port=9222"]
"waitUntilTargets": ["web:serve"],
"remoteDebuggingPort": 9222
}
},
"package": {
@@ -254,6 +254,8 @@ contextBridge.exposeInMainWorld('electron', {
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
xtreamCancelSession: (sessionId: string) =>
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
+7 -1
View File
@@ -10,6 +10,10 @@ export default class App {
static application: Electron.App;
static BrowserWindow;
private static shouldOpenDevTools() {
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
}
public static isDevelopmentMode() {
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
// This allows E2E tests to run in production mode without packaging
@@ -161,7 +165,9 @@ export default class App {
// load the index.html of the app.
if (App.isDevelopmentMode()) {
App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);
App.mainWindow.webContents.openDevTools();
if (App.shouldOpenDevTools()) {
App.mainWindow.webContents.openDevTools();
}
} else {
App.mainWindow.loadFile(
join(__dirname, '..', rendererAppName, 'index.html')
@@ -44,6 +44,23 @@ function getRecentlyAddedContentTypes(
return ['movie', 'series'];
}
function selectContentFields() {
return {
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
epg_channel_id: schema.content.epgChannelId,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
direct_source: schema.content.directSource,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
};
}
export async function hasContent(
db: AppDatabase,
playlistId: string,
@@ -72,16 +89,7 @@ export async function getContent(
type: 'live' | 'movie' | 'series'
) {
const baseQuery = db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
})
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
@@ -112,15 +120,8 @@ export async function getGlobalRecentlyAdded(
return db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
...selectContentFields(),
added_at: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
})
@@ -150,6 +151,10 @@ type XtreamContentValue = {
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
@@ -161,12 +166,16 @@ type XtreamContentSource = Record<string, unknown> & {
last_modified?: string;
added?: string;
stream_icon?: string;
poster?: string;
cover?: string;
name?: string;
title?: string;
series_id?: string | number;
stream_id?: string | number;
poster?: string;
cover?: string;
name?: string;
title?: string;
epg_channel_id?: string;
tv_archive?: string | number;
tv_archive_duration?: string | number;
direct_source?: string;
series_id?: string | number;
stream_id?: string | number;
};
function toXtreamContentValue(
@@ -205,6 +214,25 @@ function toXtreamContentValue(
posterUrl: String(
source.stream_icon || source.poster || source.cover || ''
),
epgChannelId:
type === 'live'
? String(source.epg_channel_id ?? '').trim() || null
: null,
tvArchive:
type === 'live'
? Number.parseInt(String(source.tv_archive ?? '0'), 10) || 0
: null,
tvArchiveDuration:
type === 'live'
? Number.parseInt(
String(source.tv_archive_duration ?? '0'),
10
) || 0
: null,
directSource:
type === 'live'
? String(source.direct_source ?? '').trim() || null
: null,
xtreamId:
type === 'series'
? parseInt(String(source.series_id || '0'))
@@ -343,16 +371,7 @@ export async function getContentByXtreamId(
playlistId: string
) {
const result = await db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
})
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
@@ -397,16 +416,7 @@ export async function searchContent(
}
const candidates = await db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
})
.select(selectContentFields())
.from(schema.content)
.innerJoin(
schema.categories,
@@ -449,14 +459,7 @@ export async function globalSearch(
const candidates = await db
.select({
id: schema.content.id,
category_id: schema.content.categoryId,
title: schema.content.title,
rating: schema.content.rating,
added: schema.content.added,
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
...selectContentFields(),
playlist_id: schema.categories.playlistId,
playlist_name: schema.playlists.name,
})
@@ -245,3 +245,46 @@ type ActiveXtreamRequest = {
};
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
ipcMain.handle(
'XTREAM_PROBE_URL',
async (
_event,
payload: {
url: string;
method?: 'GET' | 'HEAD';
}
) => {
const config: AxiosRequestConfig = {
method: payload.method ?? 'HEAD',
url: payload.url,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
timeout: 10000,
maxRedirects: 5,
validateStatus: () => true,
};
try {
const response = await axios(config);
return {
status: response.status,
url: payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
};
}
return {
status: 0,
url: payload.url,
};
}
}
);
@@ -34,7 +34,7 @@
</div>
<div class="epg-timeline">
<span class="epg-time">{{
formatTime(program.start)
formatTime(program.start, program.startTimestamp)
}}</span>
<div class="epg-progress-track">
<div
@@ -45,7 +45,7 @@
></div>
</div>
<span class="epg-time">{{
formatTime(program.stop)
formatTime(program.stop, program.stopTimestamp)
}}</span>
</div>
} @else {
@@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from './channel-list-item.component';
describe('ChannelListItemComponent', () => {
let fixture: ComponentFixture<ChannelListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ChannelListItemComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelListItemComponent);
});
it('formats preview times from timestamp fields when raw strings are offset', () => {
const startTimestamp = Math.floor(
Date.parse('2026-04-05T05:30:00.000Z') / 1000
);
const stopTimestamp = Math.floor(
Date.parse('2026-04-05T06:00:00.000Z') / 1000
);
const program: EpgProgram = {
start: '2026-04-05 03:00:00',
stop: '2026-04-05 03:30:00',
channel: 'channel-1',
title: 'Current Show',
desc: 'Current description',
category: null,
startTimestamp,
stopTimestamp,
};
fixture.componentRef.setInput('name', 'Cartoon Network');
fixture.componentRef.setInput('epgProgram', program);
fixture.componentRef.setInput('showProgramInfoButton', false);
fixture.detectChanges();
const times = Array.from(
fixture.nativeElement.querySelectorAll('.epg-time'),
(element: Element) => element.textContent?.trim() ?? ''
);
expect(times).toEqual([
new Date(startTimestamp * 1000).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
new Date(stopTimestamp * 1000).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
]);
});
});
@@ -64,8 +64,20 @@ export class ChannelListItemComponent {
/**
* Formats time for display (HH:mm)
*/
formatTime(dateString: string | number): string {
const date = new Date(dateString);
formatTime(
dateString: string | number,
timestampSeconds?: number | null
): string {
const unixTimestamp = Number(timestampSeconds);
const date =
Number.isFinite(unixTimestamp) && unixTimestamp > 0
? new Date(unixTimestamp * 1000)
: new Date(dateString);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',