mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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);
|
||||
}
|
||||
|
||||
+16
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user