fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 17:36:55 +02:00
1 parent c00ebe69a2
commit c15598b33f
10 files changed
+128 -9

No files matched your search

@@ -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 {
@@ -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;
@@ -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<EpgProgram[] | null> {
): Promise<ElectronBridgeEpgSearchProgram[] | null> {
if (!this.supportsProgramSearch) {
return Promise.resolve(null);
}
@@ -45,6 +45,7 @@ describe('M3uEpgGuideSourceService', () => {
const channels = signal<Channel[]>([]);
const favoriteKeys = signal<string[]>([]);
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const selectedGroup = signal<string | null>(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<Channel | null>(duplicated()[0]),
activePlaybackUrl,
selectedGroup,
});
@@ -303,6 +317,7 @@ describe('M3uEpgGuideSourceService', () => {
channels: sportsOnlyChannels,
favoriteKeys,
activeChannel: newsActiveChannel,
activePlaybackUrl,
selectedGroup,
});
@@ -29,6 +29,8 @@ export interface M3uEpgGuideInputs {
*/
favoriteKeys: Signal<string[]>;
activeChannel: Signal<Channel | null>;
/** The catch-up/archive URL the host plays instead of the live stream, if any. */
activePlaybackUrl: Signal<string | null>;
/**
* 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);
}
@@ -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.
@@ -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<EpgProgram[]>;
) => Promise<ElectronBridgeEpgSearchProgram[]>;
// EPG channel mapping (manual user overrides)
getEpgMapping: (
@@ -68,6 +68,12 @@ export interface EpgGuideSource {
/** Ids of channels with at least one programme in the window. */
loadCoverage(range: EpgGuideWindow): Promise<Set<string>>;
readonly activeChannelId: Signal<string | null>;
/**
* 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<boolean>;
/** Switch playback; the guide stays open. */
activate(channelId: string): void;
/** Optional programme search; the toolbar hides its field when absent. */
@@ -60,6 +60,7 @@ describe('EpgGuideComponent', () => {
let component: EpgGuideComponent;
const channels = signal<EpgGuideChannel[]>([]);
const activeChannelId = signal<string | null>(null);
const livePlayback = signal(true);
const offsetMinutes = signal(0);
const searchHits = signal<EpgGuideSearchHit[]>([]);
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');
@@ -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);