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 })),