From 14d45b9c4c57c6f4251e5bb4d540fc9884b2c800 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 20:15:25 +0200 Subject: [PATCH] fix(epg): keep the source-scope ladder identical without the batch endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preload without `getCurrentProgramsBatch` collapsed straight into the source-less per-channel lookup, dropping the caller's playlist scope — the very thing the scopes exist to prevent, since two imported guides reusing one XMLTV id would then answer each other's channels. The scoped pass now runs there too, as per-channel `getScopedCurrentProgramForChannel` calls that walk the same scope -> fallback-scope ladder and share the same scoped cache key, and only the any-source retry is source-less. That also removes the `perChannelUnscoped` flag the previous round introduced: everything reaching the unscoped path now genuinely wants the pool. Codex P2 on 104c6e01c. Co-Authored-By: Claude Opus 5 --- docs/architecture/m3u-playlist-module.md | 12 ++- .../data-access/src/lib/epg.service.spec.ts | 65 +++++++++--- libs/epg/data-access/src/lib/epg.service.ts | 100 ++++++++++++------ 3 files changed, 127 insertions(+), 50 deletions(-) diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index ddb39b3a2..5522ece47 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -982,10 +982,14 @@ These URLs are playlist-scoped by default: `EpgLookupOptions.anySourceFallback` (renderer-only, never forwarded to the bridge): after the scope — playlist sources, then the global ones — has answered, the keys still without a programme are retried once against every - imported source through the source-less batch path and its cache. On a - preload without the batch endpoint that retry uses the unscoped - per-channel lookup; every other caller keeps the historical per-channel - behaviour there, which re-applies the Settings scope. The dashboard live + imported source through the source-less batch path and its cache. The + ladder has the same shape with or without the bridge's batch endpoint: on + an older preload the scoped pass runs as per-channel scoped lookups + (`getScopedCurrentProgramForChannel`, same scope -> fallback-scope walk, + same scoped cache key) and only the any-source retry is source-less. + Collapsing that preload straight into the source-less lookup would drop + the caller's scope, which is what the scopes exist to prevent. The + dashboard live rails pass the option: without it a favourite whose guide only exists in another playlist's XMLTV showed no programme on the dashboard while its "See all" row — resolved by `StreamResolverService`, which never scopes by diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 06e0377f2..4a0fbcb12 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -651,10 +651,9 @@ describe('EpgService', () => { expect(retryOptions).not.toHaveProperty('sourceUrls'); }); - it('asks the unscoped pool per channel when the any-source retry runs on a preload without the batch endpoint', async () => { - // The legacy per-channel path used to answer through - // `getCurrentProgramForChannel`, which re-applies the Settings scope, - // so the retry re-asked the question the scoped pass had answered. + it('walks scope then any-source per channel on a preload without the batch endpoint', async () => { + // The ladder must not change shape with the capability: the scoped + // pass runs first and only its misses reach the source-less pool. settingsStore.getSettings.mockReturnValue({ epgUrl: ['https://global.example.com/guide.xml'], trustedPrivateNetworkEpgUrls: [], @@ -662,20 +661,25 @@ describe('EpgService', () => { }); epgBridge.supportsProgramLookup = true; epgBridge.supportsCurrentProgramBatch = false; - epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([ - { - channel: 'guide-sports', - start: '2026-05-23T10:00:00.000Z', - stop: '2026-05-23T11:00:00.000Z', - title: 'Other Playlist Sports', - }, - ]); + epgBridge.getChannelPrograms = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Other Playlist Sports', + }, + ]); jest.useFakeTimers(); jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); try { const result = await firstValueFrom( service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], anySourceFallback: true, }) ); @@ -683,15 +687,44 @@ describe('EpgService', () => { expect(result.get('guide-sports')?.title).toBe( 'Other Playlist Sports' ); - // No source scope reaches the bridge on this path. - expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith( - 'guide-sports' - ); + const calls = (epgBridge.getChannelPrograms as jest.Mock).mock + .calls; + expect(calls.map(([, options]) => options?.sourceUrls)).toEqual([ + ['https://playlist.example.com/guide.xml'], + ['https://global.example.com/guide.xml'], + undefined, + ]); } finally { jest.useRealTimers(); } }); + it('keeps the playlist scope on a preload without the batch endpoint when no any-source retry was asked for', async () => { + // Jumping straight to the source-less lookup here would defeat the + // scopes: two guides reusing one XMLTV id would answer each other. + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([]); + + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + expect(result.get('guide-sports')).toBeNull(); + const calls = (epgBridge.getChannelPrograms as jest.Mock).mock.calls; + expect(calls.map(([, options]) => options?.sourceUrls)).toEqual([ + ['https://playlist.example.com/guide.xml'], + ['https://global.example.com/guide.xml'], + ]); + }); + it('keeps the Settings scope on the legacy per-channel path when no any-source retry was asked for', async () => { settingsStore.getSettings.mockReturnValue({ epgUrl: ['https://global.example.com/guide.xml'], diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index df6ddcae2..95a40113b 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -268,15 +268,9 @@ export class EpgService { options ); if (!scoped$) { - // No scope applies, or the bridge has no batch endpoint. The - // per-channel path below keeps its historical behaviour here - // (`getCurrentProgramForChannel`, which re-applies the global - // scope when Settings hold URLs); only the explicit any-source - // retry asks for a genuinely source-less lookup. - return this.getUnscopedCurrentProgramsForChannels( - channelIds, - Boolean(options?.anySourceFallback) - ); + // Nothing declares a scope — neither the caller nor Settings — so + // the pool of every imported source is the only answer there is. + return this.getUnscopedCurrentProgramsForChannels(channelIds); } if (!options?.anySourceFallback) { return scoped$; @@ -291,26 +285,29 @@ export class EpgService { * The scoped lookup: the caller's playlist sources first (with the global * sources as fallback), else the Settings-managed global sources alone. * `null` when no scope applies and the unscoped pool is the only answer. + * + * The ladder is the same whether or not the bridge has the batch + * endpoint. Collapsing a legacy preload straight into the source-less + * lookup would drop the caller's scope entirely, which is exactly what + * the scopes exist to prevent: two imported guides reusing one XMLTV id + * would answer each other's channels. */ private getSourceScopedCurrentProgramsForChannels( channelIds: string[], options?: EpgLookupOptions ): Observable> | null { - if (!this.epgBridge.supportsCurrentProgramBatch) { - return null; - } - const sourceUrls = this.normalizeSourceUrls(options); if (sourceUrls.length > 0) { - return this.getScopedCurrentProgramsForChannels( + return this.scopedCurrentProgramsForChannels( channelIds, - sourceUrls + sourceUrls, + this.getGlobalEpgSourceUrls(sourceUrls) ); } const globalSourceUrls = this.getGlobalEpgSourceUrls(); if (globalSourceUrls.length > 0) { - return this.getScopedCurrentProgramsForChannels( + return this.scopedCurrentProgramsForChannels( channelIds, globalSourceUrls, [] @@ -320,6 +317,55 @@ export class EpgService { return null; } + /** One scoped batch, or its per-channel equivalent on an older preload. */ + private scopedCurrentProgramsForChannels( + channelIds: string[], + sourceUrls: string[], + fallbackSourceUrls: string[] + ): Observable> { + if (this.epgBridge.supportsCurrentProgramBatch) { + return this.getScopedCurrentProgramsForChannels( + channelIds, + sourceUrls, + fallbackSourceUrls + ); + } + + const normalizedChannelIds = this.normalizeChannelIds(channelIds); + if (normalizedChannelIds.length === 0) { + return of(new Map()); + } + + // `getScopedCurrentProgramForChannel` walks the same scope -> + // fallback-scope ladder the batch query does, and caches under the + // same scoped key. + return forkJoin( + normalizedChannelIds.map((channelId) => + this.getScopedCurrentProgramForChannel( + channelId, + sourceUrls, + fallbackSourceUrls + ).pipe( + this.sourceSettings.guard(), + timeout(5000), + map((program) => ({ channelId, program })), + catchError(() => of({ channelId, program: null })) + ) + ) + ).pipe( + this.sourceSettings.guard(), + map( + (results) => + new Map( + results.map((result) => [ + result.channelId, + result.program, + ]) + ) + ) + ); + } + /** * `anySourceFallback`: keys the scope answered with `null` are retried * against every imported source. A scoped miss is kept as the answer @@ -336,10 +382,7 @@ export class EpgService { return of(scopedMap); } - return this.getUnscopedCurrentProgramsForChannels( - unresolvedIds, - true - ).pipe( + return this.getUnscopedCurrentProgramsForChannels(unresolvedIds).pipe( map((anySourceMap) => { const mergedMap = new Map(scopedMap); anySourceMap.forEach((program, channelId) => { @@ -354,8 +397,7 @@ export class EpgService { /** Lookup across every imported source, cached under the source-less key. */ private getUnscopedCurrentProgramsForChannels( - channelIds: string[], - perChannelUnscoped = false + channelIds: string[] ): Observable> { const resultMap = new Map(); const channelsToFetch: string[] = []; @@ -413,15 +455,13 @@ export class EpgService { } // Fallback for older preload bundles without the batch endpoint. - // An any-source retry must use the unscoped per-channel lookup: the - // public `getCurrentProgramForChannel` puts the Settings-managed - // scope back on, so the retry would re-ask the question the scoped - // pass already answered and the fallback would do nothing here. + // Deliberately the unscoped per-channel lookup: everything reaching + // here wants the source-less pool — either nothing declared a scope, + // or this is the any-source retry after the scoped pass. The public + // `getCurrentProgramForChannel` would put the Settings scope back on + // and make the retry re-ask the question the scoped pass answered. const fetchObservables = channelsToFetch.map((channelId) => - (perChannelUnscoped - ? this.getUnscopedCurrentProgramForChannel(channelId) - : this.getCurrentProgramForChannel(channelId) - ).pipe( + this.getUnscopedCurrentProgramForChannel(channelId).pipe( this.sourceSettings.guard(), timeout(5000), map((program) => ({ channelId, program })),