From 04e8709d7b59bf5a2b7f671341f7170b992b889b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 5 Apr 2026 18:43:45 +0200 Subject: [PATCH] feat: implement URL probing functionality and enhance time formatting in EPG component --- apps/electron-backend/project.json | 8 +- .../src/app/api/main.preload.ts | 2 + apps/electron-backend/src/app/app.ts | 8 +- .../database/operations/content.operations.ts | 107 +++++++++--------- .../src/app/events/xtream.events.ts | 43 +++++++ .../channel-list-item.component.html | 4 +- .../channel-list-item.component.spec.ts | 72 ++++++++++++ .../channel-list-item.component.ts | 16 ++- 8 files changed, 198 insertions(+), 62 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 42fef0ee8..06a0bbf51 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -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": { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 1d20e000b..a9069519f 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index dfd9e4b11..7be7e80e2 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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') diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index efc8e59dc..e310b2242 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -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 & { 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, }) diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 73f21485f..c117bc191 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -245,3 +245,46 @@ type ActiveXtreamRequest = { }; const activeXtreamRequests = new Map(); + +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, + }; + } + } +); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index bd2dc37cf..8b4a7a6c3 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -34,7 +34,7 @@
{{ - formatTime(program.start) + formatTime(program.start, program.startTimestamp) }}
{{ - formatTime(program.stop) + formatTime(program.stop, program.stopTimestamp) }}
} @else { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts new file mode 100644 index 000000000..887f87e81 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -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; + + 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, + }), + ]); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index fc795178f..8aee139d0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -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',