diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts index 3e5d07be5..a9f6fdf08 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts @@ -76,13 +76,40 @@ describe('epg-db.events', () => { }); it('searches programmes with a LIKE pattern built from the trimmed term', async () => { - const rows = [{ title: 'News', channel_name: 'NHK' }]; + const rows = [ + { + channel_id: 'nhk.jp', + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T16:30:00.000Z', + title: 'News', + description: 'Evening bulletin', + category: 'news', + icon_url: 'https://x/icon.png', + rating: null, + episode_num: 'S1E2', + channel_name: 'NHK', + }, + ]; const all = jest.fn().mockResolvedValue(rows); getDatabase.mockResolvedValue({ all }); + // Raw snake_case columns become the public `EpgProgram` shape. await expect( getIpcMainHandler('EPG_DB_SEARCH_PROGRAMS')({}, ' news ', 25) - ).resolves.toEqual(rows); + ).resolves.toEqual([ + { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T16:30:00.000Z', + channel: 'nhk.jp', + title: 'News', + desc: 'Evening bulletin', + category: 'news', + iconUrl: 'https://x/icon.png', + rating: null, + episodeNum: 'S1E2', + channelName: 'NHK', + }, + ]); expect(all).toHaveBeenCalledTimes(1); const query = all.mock.calls[0][0] as { diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.ts b/apps/electron-backend/src/app/events/database/epg-db.events.ts index 7d96345f1..fa11bec8e 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -9,8 +9,45 @@ import { sql } from 'drizzle-orm'; import { ipcMain } from 'electron'; +import type { ElectronBridgeEpgSearchProgram } from '@iptvnator/shared/interfaces'; import { getDatabase } from '../../database/connection'; +/** Raw `epg_programs` columns as `db.all` returns them, plus the joined channel name. */ +interface EpgSearchRow { + channel_id: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + icon_url: string | null; + rating: string | null; + episode_num: string | null; + channel_name: string | null; +} + +/** + * `db.all` hands back snake_case columns; the bridge promises the public + * `EpgProgram` shape (`channel`, `desc`, `iconUrl`, …), so map here or every + * consumer sees `program.channel === undefined`. + */ +export function toSearchProgram( + row: EpgSearchRow +): ElectronBridgeEpgSearchProgram { + return { + start: row.start, + stop: row.stop, + channel: row.channel_id, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.icon_url, + rating: row.rating, + episodeNum: row.episode_num, + channelName: row.channel_name ?? null, + }; +} + const loggerLabel = '[EPG DB]'; /** @@ -35,7 +72,7 @@ ipcMain.handle( // JOIN with epg_channels to get channel display name // Include all programs (past and future) for catchup/archive feature - const results = await db.all(sql` + const results = (await db.all(sql` SELECT p.*, c.display_name as channel_name @@ -48,9 +85,9 @@ ipcMain.handle( ) ORDER BY p.start LIMIT ${limit} - `); + `)) as EpgSearchRow[]; - return results; + return results.map(toSearchProgram); } catch (error) { console.error(loggerLabel, 'Error searching EPG programs:', error); throw error; diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index f66c1e585..a0e3d907e 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -8,6 +8,7 @@ import { ElectronBridgeEpgProgress, ElectronBridgeEpgProgressStats, ElectronBridgeEpgProgressStatus, + ElectronBridgeEpgSearchProgram, ElectronBridgeEpgSearchResult, ElectronBridgeEpgGuideWindow, ElectronBridgeEpgFreshnessResult, @@ -234,7 +235,7 @@ export class EpgRuntimeBridgeService { searchPrograms( searchTerm: string, limit?: number - ): Promise { + ): Promise { if (!this.supportsProgramSearch) { return Promise.resolve(null); } diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index 419ad6798..23dccdacd 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -45,6 +45,7 @@ describe('M3uEpgGuideSourceService', () => { const channels = signal([]); const favoriteKeys = signal([]); const activeChannel = signal(null); + const activePlaybackUrl = signal(null); const selectedGroup = signal(null); const dispatch = jest.fn(); const getProgramsForChannels = jest.fn(); @@ -97,7 +98,13 @@ describe('M3uEpgGuideSourceService', () => { ], }); service = TestBed.inject(M3uEpgGuideSourceService); - service.bind({ channels, favoriteKeys, activeChannel, selectedGroup }); + service.bind({ + channels, + favoriteKeys, + activeChannel, + activePlaybackUrl, + selectedGroup, + }); }); it('offers all / groups / favorites scopes and lists channels in playlist order', () => { @@ -203,6 +210,12 @@ describe('M3uEpgGuideSourceService', () => { expect(service.activeChannelId()).toBe('0:dup'); }); + it('reports live playback unless the host plays a catch-up url', () => { + expect(service.livePlayback()).toBe(true); + activePlaybackUrl.set('https://example.com/archive.m3u8'); + expect(service.livePlayback()).toBe(false); + }); + it('propagates a coverage failure so the guide keeps coverage unknown', async () => { getProgramCoverage.mockRejectedValue(new Error('bridge down')); await expect( @@ -241,6 +254,7 @@ describe('M3uEpgGuideSourceService', () => { channels: duplicated, favoriteKeys, activeChannel: signal(duplicated()[0]), + activePlaybackUrl, selectedGroup, }); @@ -303,6 +317,7 @@ describe('M3uEpgGuideSourceService', () => { channels: sportsOnlyChannels, favoriteKeys, activeChannel: newsActiveChannel, + activePlaybackUrl, selectedGroup, }); diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index 71b541888..a2cc9f97e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -29,6 +29,8 @@ export interface M3uEpgGuideInputs { */ favoriteKeys: Signal; activeChannel: Signal; + /** The catch-up/archive URL the host plays instead of the live stream, if any. */ + activePlaybackUrl: Signal; /** * Group the sidebar's groups view currently shows, by title. The guide * opens on what the user was looking at, which is not necessarily the @@ -180,6 +182,9 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { return match?.rowId ?? null; }); + /** Live unless the host plays a catch-up URL for the active channel. */ + readonly livePlayback = computed(() => !this.inputs()?.activePlaybackUrl()); + bind(inputs: M3uEpgGuideInputs): void { this.inputs.set(inputs); } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 73ed12cbb..0d2a26bb0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -730,6 +730,7 @@ export class VideoPlayerComponent // nullable, so normalize rather than widen the shared type. activeChannel: computed(() => this.activeChannel() ?? null), selectedGroup: this.selectedSidebarGroup.asReadonly(), + activePlaybackUrl: this.activePlaybackUrl, }); // Radio, a recognised movie, or a lost channel takes the guide's host // (and its player) away — close instead of leaving it stranded. diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 159f47b20..5edea1aef 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -357,6 +357,11 @@ export interface ElectronBridgeEpgMapping { playlistId: string | null; } +/** A programme-search hit: the programme plus the XMLTV channel's display name. */ +export interface ElectronBridgeEpgSearchProgram extends EpgProgram { + channelName: string | null; +} + export interface ElectronBridgeEpgSearchResult { id: string; displayName: string; @@ -830,7 +835,7 @@ export interface ElectronBridgeApi { searchEpgPrograms: ( searchTerm: string, limit?: number - ) => Promise; + ) => Promise; // EPG channel mapping (manual user overrides) getEpgMapping: ( diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts index a76b19f52..a6387f1eb 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts @@ -68,6 +68,12 @@ export interface EpgGuideSource { /** Ids of channels with at least one programme in the window. */ loadCoverage(range: EpgGuideWindow): Promise>; readonly activeChannelId: Signal; + /** + * False while the host plays something other than the active channel's + * live stream (catch-up/archive). The guide then lets the active row be + * activated again, which is how the host returns to live. + */ + readonly livePlayback?: Signal; /** Switch playback; the guide stays open. */ activate(channelId: string): void; /** Optional programme search; the toolbar hides its field when absent. */ diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts index 5e36715ba..0eaa4833d 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts @@ -60,6 +60,7 @@ describe('EpgGuideComponent', () => { let component: EpgGuideComponent; const channels = signal([]); const activeChannelId = signal(null); + const livePlayback = signal(true); const offsetMinutes = signal(0); const searchHits = signal([]); const activate = jest.fn(); @@ -79,6 +80,7 @@ describe('EpgGuideComponent', () => { channel('c', 'c', 3), ]); activeChannelId.set('a'); + livePlayback.set(true); const source: EpgGuideSource = { channels, scopes: signal([{ id: 'all', label: 'All channels', kind: 'all' }]), @@ -93,6 +95,7 @@ describe('EpgGuideComponent', () => { ), loadCoverage: async () => new Set(['a']), activeChannelId, + livePlayback, activate, searchPrograms: async () => searchHits(), }; @@ -217,6 +220,17 @@ describe('EpgGuideComponent', () => { expect(activate).toHaveBeenCalledWith('c'); }); + it('re-activates the active row while catch-up plays, so the host can return to live', async () => { + livePlayback.set(false); + await settle(fixture); + component.activateRow(component.rows()[0]); + expect(activate).toHaveBeenCalledWith('a'); + livePlayback.set(true); + activate.mockClear(); + component.activateRow(component.rows()[0]); + expect(activate).not.toHaveBeenCalled(); + }); + it('drops the programme search when the scope changes', async () => { await settle(fixture); component.onSearchQueryChange('news'); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index 5c7809590..512f01303 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -348,7 +348,15 @@ export class EpgGuideComponent implements OnDestroy { * left alone; `commitRow` then only closes. */ activateRow(channel: EpgGuideChannel | undefined): void { - if (!channel || channel.id === this.activeChannelId()) { + if (!channel) { + return; + } + // The active row is left alone only while its live stream plays; in + // catch-up the same click is the way back to live. + const alreadyLive = + channel.id === this.activeChannelId() && + (this.source.livePlayback?.() ?? true); + if (alreadyLive) { return; } this.source.activate(channel.id);