mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* docs(epg): add programme guide redesign spec for the M3U host Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): add programme guide implementation plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add window-scoped guide programme queries Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): harden guide query scoping, caps and row mapping - Scoped guide programme/coverage queries now include legacy (unsourced) rows via source_url IN (...) OR IS NULL OR '', mirroring EpgQueryService's legacy fallback. - getProgramsForChannels/getProgramCoverage build their result from the normalized, capped window.channelIds instead of the raw request, so a key cut by the cap is absent rather than [] — an invalid window now returns {}. Truncation logs counts only. - Split the 100-channel guide cap from a new 2000-key coverage cap, and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a parameter and moved (with guideWindowOverlapSqlText) into epg-guide-window.util.ts. - Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram) into epg-program-row.util.ts, used by both EpgQueryService and EpgGuideQueryService so invalid start/stop rows are dropped identically in both. - Added a real-SQLite-backed test for the overlap predicate's exact text, plus per-key array copies in the response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): render the guide predicate in tests and document its scope Correct the guide query's JSDoc: it runs one query accepting the union of requested-source and unsourced legacy rows, unlike EpgQueryService's two-query scoped-then-legacy fallback. Replace the hand-maintained plain-SQL twin of the Drizzle overlap predicate with a rendered copy of the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a source-scoping case, and drop the now-redundant operator-sequence test. warnIfTruncated reuses uniqueTrimmedStrings and names which read (programme/coverage) was truncated. Rename epg-query.service.ts's local EpgProgramRow to EpgProgramSelectRow so it isn't confused with the shared EpgProgramRow type, and document getProgramCoverage like its sibling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): expose guide programme and coverage reads over the bridge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): separate coverage chunk size in the guide plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add guide source contract, day layout maths and preferences Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): key guide IPC answers by trimmed, present keys only Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): guide search hits carry a row id Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): make guide geometry DST-safe and tighten the contract Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): cache guide programmes per day with batched loading Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add guide keyboard navigation controller Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): make guide programme cache robust to first-run effects and coverage failures Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add the programme guide grid components Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add a Guide button to the timeline toolbar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(m3u): adapt the playlist channel list to the guide contract Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): guard the guide's initial group scope and track language changes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(m3u): open the programme guide in place with a docked player Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(epg): remove the multi-EPG overlay and the channel-range IPC Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): document the programme guide and its release note Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * i18n(epg): translate the programme guide Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): let the guide own the keyboard and gate its entry points While the programme guide is open the docked player carries `data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours alongside `[inert]` — the arrows moved the player's volume instead of the guide's row focus. The external-player strip loses its Collapse toggle (nothing to reveal, no preference to write), the header action and its palette command report `disabled` when the guide cannot open, the docked strip derives its programme from the active channel's own schedule instead of the retained NgRx value, switching playlists closes the guide, and the collapsed strip can reach 48 px on phones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): keep the sidebar mounted while the guide is open Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the guide destroyed `app-channel-list-container`, whose `ngOnDestroy` dispatches `resetActiveChannel()`. That cleared the active channel, which unmounted the block hosting `app-epg-guide` and tripped the `!canOpenGuide()` effect into closing the guide again: the guide never appeared and the page dropped to "Please select a channel". The sidebar now stays mounted and is hidden with `.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read by assistive technology while the guide owns the layout. Hiding also preserves the channel list's scroll position across guide toggles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(e2e): cover the programme guide flow Imports a two-channel playlist with XMLTV, opens the guide from the timeline toolbar and asserts the row list, the "Only with EPG" filter, a channel switch that keeps the guide open, the hidden-but-mounted sidebar, and that the player element survives both the mode and channel switches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(epg): tidy guide docs, palette gating and the unbound output Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): match guide favorites by channel URL and skip re-activating the playing row Favorites are persisted by channel URL (FavoritesActions.updateFavorites), so the Favorites scope compared the wrong key; the id stays as a legacy fallback. A double-click arrives as click, click, dblclick and each activate restarts playback, so the guide now leaves the already-playing row alone and the commit path only closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(epg): let the guide window predicate use the programme time index Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(epg): split the guide shell, add a roving focus model and offset-aware search times The shell component now owns rows, focus and the viewport only: the day, zoom, density, filters, clock and day geometry move to EpgGuideViewState, and every programme-dialog entry point to EpgGuideDialogController. Keyboard navigation is reachable by assistive technology: exactly one grid cell carries tabindex="0" (the focused cell, else the playing row's channel cell, else the first row's), the guide moves DOM focus with it after each handled key, a click hands the roving index to the clicked cell, and the viewport, rows and cells expose grid/row/gridcell roles. Search results were formatting raw provider instants, so they ignored the EPG display offset; they go through getProgramTimeMs like every other time the guide renders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): make guide row ids collision-proof and gate the G shortcut Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): describe guide row ids as scope-local Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown Search hits carry scope-local row ids, so a scope change drops them. Two playlist entries can share an id but not a stream, so the active row is matched by id + url before falling back to the id. A failed coverage query now rejects instead of answering an empty set, which the guide already treats as "coverage unknown" (every row stays visible). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers The store spreads the selected channel, so the active row is matched by id, url, group and name before widening; G no longer closes the guide from a dialog or menu; guide answers use null-prototype records so a key named __proto__ stays an own property. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row EpgQueryService.getChannelMetadata swallowed database errors into {}, so the guide's coverage read could publish an empty set after a transient failure; the guide now uses the strict resolveChannelMetadata (getChannelMetadata is the fail-soft wrapper around it). The active guide row is matched on the whole channel entry (all fields except the reducer-rewritten epgParams) before widening to url and id, so copies that differ only in playback headers or logo are told apart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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> * fix(epg): name search hits, keep guide coverage strict on mapping failures - Search results and the unresolved programme dialog show the channel's display name (playlist row name, else the XMLTV display name the search joined in) instead of the raw XMLTV id. - The guide coverage read resolves manual mappings through a strict variant that rejects on database failure, so a mapped channel can never be reported as uncovered and hidden by "Only with EPG". - Architecture doc describes the tiered active-row resolution. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): offer the Guide action in the list view too The EPG list view mirrors the timeline's input/output contract, but the Guide action was bound only in the timeline branch, so Settings → EPG → Guide view = List lost the in-panel entry point. The list toolbar now carries the same icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U host binds it in both branches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
220 lines
8.1 KiB
TypeScript
220 lines
8.1 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
|
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
|
|
|
|
describe('EpgRuntimeBridgeService', () => {
|
|
let service: EpgRuntimeBridgeService;
|
|
let runtimeCapabilities: Partial<RuntimeCapabilitiesService>;
|
|
const originalElectron = window.electron;
|
|
|
|
beforeEach(() => {
|
|
runtimeCapabilities = {
|
|
supportsEpgImport: false,
|
|
supportsEpgProgress: false,
|
|
supportsEpgProgramLookup: false,
|
|
supportsEpgCurrentProgramBatch: false,
|
|
supportsEpgChannelMetadata: false,
|
|
supportsEpgSourceFreshness: false,
|
|
supportsEpgDataManagement: false,
|
|
supportsEpgGuide: false,
|
|
supportsEpgProgramSearch: false,
|
|
};
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
EpgRuntimeBridgeService,
|
|
{
|
|
provide: RuntimeCapabilitiesService,
|
|
useValue: runtimeCapabilities,
|
|
},
|
|
],
|
|
});
|
|
|
|
service = TestBed.inject(EpgRuntimeBridgeService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.electron = originalElectron;
|
|
TestBed.resetTestingModule();
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it('does not call Electron EPG methods when the matching capability is unavailable', async () => {
|
|
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
|
|
const checkEpgFreshness = jest.fn().mockResolvedValue({
|
|
freshUrls: [],
|
|
staleUrls: [],
|
|
});
|
|
window.electron = {
|
|
...window.electron,
|
|
fetchEpg,
|
|
checkEpgFreshness,
|
|
} as unknown as typeof window.electron;
|
|
|
|
await expect(
|
|
service.fetchEpg(['https://example.com/epg.xml'])
|
|
).resolves.toBeNull();
|
|
await expect(
|
|
service.checkFreshness(['https://example.com/epg.xml'], 12)
|
|
).resolves.toBeNull();
|
|
|
|
expect(fetchEpg).not.toHaveBeenCalled();
|
|
expect(checkEpgFreshness).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delegates import and data-management calls through the typed Electron bridge', async () => {
|
|
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
|
|
const forceFetchEpg = jest.fn().mockResolvedValue({ success: true });
|
|
const clearEpgData = jest.fn().mockResolvedValue({ success: true });
|
|
const clearEpgDataForSource = jest
|
|
.fn()
|
|
.mockResolvedValue({ success: true });
|
|
window.electron = {
|
|
...window.electron,
|
|
fetchEpg,
|
|
forceFetchEpg,
|
|
clearEpgData,
|
|
clearEpgDataForSource,
|
|
} as unknown as typeof window.electron;
|
|
runtimeCapabilities.supportsEpgImport = true;
|
|
runtimeCapabilities.supportsEpgDataManagement = true;
|
|
|
|
await expect(
|
|
service.fetchEpg(['https://example.com/epg.xml'])
|
|
).resolves.toEqual({ success: true });
|
|
await expect(
|
|
service.forceFetchEpg('https://example.com/epg.xml')
|
|
).resolves.toEqual({ success: true });
|
|
await expect(service.clearEpgData()).resolves.toEqual({
|
|
success: true,
|
|
});
|
|
await expect(
|
|
service.clearEpgDataForSource(
|
|
' https://playlist.example.com/guide.xml '
|
|
)
|
|
).resolves.toEqual({
|
|
success: true,
|
|
});
|
|
|
|
expect(fetchEpg).toHaveBeenCalledWith(
|
|
['https://example.com/epg.xml'],
|
|
undefined
|
|
);
|
|
expect(forceFetchEpg).toHaveBeenCalledWith(
|
|
'https://example.com/epg.xml',
|
|
undefined
|
|
);
|
|
expect(clearEpgData).toHaveBeenCalledTimes(1);
|
|
expect(clearEpgDataForSource).toHaveBeenCalledWith(
|
|
'https://playlist.example.com/guide.xml'
|
|
);
|
|
});
|
|
|
|
it('delegates read-side EPG calls through the typed Electron bridge', async () => {
|
|
const getChannelPrograms = jest.fn().mockResolvedValue([]);
|
|
const getCurrentProgramsBatch = jest.fn().mockResolvedValue({
|
|
'channel-1': null,
|
|
});
|
|
const getEpgChannelMetadata = jest.fn().mockResolvedValue({
|
|
'channel-1': null,
|
|
});
|
|
const checkEpgFreshness = jest.fn().mockResolvedValue({
|
|
freshUrls: ['https://example.com/epg.xml'],
|
|
staleUrls: [],
|
|
});
|
|
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
|
|
window.electron = {
|
|
...window.electron,
|
|
getChannelPrograms,
|
|
getCurrentProgramsBatch,
|
|
getEpgChannelMetadata,
|
|
checkEpgFreshness,
|
|
searchEpgPrograms,
|
|
} as unknown as typeof window.electron;
|
|
runtimeCapabilities.supportsEpgProgramLookup = true;
|
|
runtimeCapabilities.supportsEpgCurrentProgramBatch = true;
|
|
runtimeCapabilities.supportsEpgChannelMetadata = true;
|
|
runtimeCapabilities.supportsEpgSourceFreshness = true;
|
|
runtimeCapabilities.supportsEpgProgramSearch = true;
|
|
|
|
await service.getChannelPrograms('channel-1');
|
|
await service.getCurrentProgramsBatch(['channel-1'], {
|
|
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
|
});
|
|
await service.getChannelMetadata(['channel-1'], {
|
|
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
|
});
|
|
await service.checkFreshness(['https://example.com/epg.xml'], 12);
|
|
await service.searchPrograms('news', 20);
|
|
|
|
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
|
|
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1'], {
|
|
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
|
});
|
|
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1'], {
|
|
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
|
});
|
|
expect(checkEpgFreshness).toHaveBeenCalledWith(
|
|
['https://example.com/epg.xml'],
|
|
12
|
|
);
|
|
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
|
|
});
|
|
|
|
it('forwards guide reads when the runtime supports them', async () => {
|
|
const getEpgProgramsForChannels = jest
|
|
.fn()
|
|
.mockResolvedValue({ a: [] });
|
|
const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']);
|
|
window.electron = {
|
|
...window.electron,
|
|
getEpgProgramsForChannels,
|
|
getEpgProgramCoverage,
|
|
} as unknown as typeof window.electron;
|
|
runtimeCapabilities.supportsEpgGuide = true;
|
|
|
|
const guideWindow = { channelIds: ['a'], fromMs: 1, toMs: 2 };
|
|
await expect(
|
|
service.getProgramsForChannels(guideWindow)
|
|
).resolves.toEqual({ a: [] });
|
|
await expect(service.getProgramCoverage(guideWindow)).resolves.toEqual([
|
|
'a',
|
|
]);
|
|
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(guideWindow);
|
|
expect(getEpgProgramCoverage).toHaveBeenCalledWith(guideWindow);
|
|
});
|
|
|
|
it('answers null for guide reads without runtime support or channels', async () => {
|
|
runtimeCapabilities.supportsEpgGuide = false;
|
|
await expect(
|
|
service.getProgramsForChannels({
|
|
channelIds: ['a'],
|
|
fromMs: 1,
|
|
toMs: 2,
|
|
})
|
|
).resolves.toBeNull();
|
|
runtimeCapabilities.supportsEpgGuide = true;
|
|
await expect(
|
|
service.getProgramCoverage({ channelIds: [], fromMs: 1, toMs: 2 })
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('subscribes to EPG progress only when progress events are supported', () => {
|
|
const onEpgProgress = jest.fn();
|
|
window.electron = {
|
|
...window.electron,
|
|
onEpgProgress,
|
|
} as unknown as typeof window.electron;
|
|
|
|
service.onProgress(jest.fn());
|
|
|
|
expect(onEpgProgress).not.toHaveBeenCalled();
|
|
|
|
runtimeCapabilities.supportsEpgProgress = true;
|
|
const callback = jest.fn();
|
|
service.onProgress(callback);
|
|
|
|
expect(onEpgProgress).toHaveBeenCalledWith(callback);
|
|
});
|
|
});
|