From e20b9cc7a0d54ed76a69ad7696b69263f961b575 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 13:08:28 +0200 Subject: [PATCH] fix(m3u): guard the guide's initial group scope and track language changes Co-Authored-By: Claude Fable 5.1 --- .../m3u-epg-guide-source.service.spec.ts | 39 ++++++++++++++++++- .../epg-guide/m3u-epg-guide-source.service.ts | 22 ++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index bd6bd5ae9..4d9638513 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -2,6 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { ChannelActions } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; @@ -49,12 +50,16 @@ describe('M3uEpgGuideSourceService', () => { const getProgramCoverage = jest.fn(); const searchPrograms = jest.fn(); let service: M3uEpgGuideSourceService; + let translateStub: { instant: (key: string) => string }; + let onLangChange: Subject<{ lang: string }>; beforeEach(() => { dispatch.mockReset(); getProgramsForChannels.mockReset(); getProgramCoverage.mockReset(); searchPrograms.mockReset(); + onLangChange = new Subject<{ lang: string }>(); + translateStub = { instant: (key: string) => key }; channels.set([ makeChannel('a', { tvgId: 'a.tv', group: 'News' }), makeChannel('b', { name: 'Beta', group: 'Sports' }), @@ -80,7 +85,12 @@ describe('M3uEpgGuideSourceService', () => { }, { provide: TranslateService, - useValue: { instant: (key: string) => key }, + useValue: { + get instant() { + return translateStub.instant; + }, + onLangChange, + }, }, ], }); @@ -165,6 +175,33 @@ describe('M3uEpgGuideSourceService', () => { expect(service.scopeId()).toBe('all'); }); + it("falls back to the all scope when the active channel's group has no eligible channels", () => { + const sportsOnlyChannels = signal([ + makeChannel('b', { name: 'Beta', group: 'Sports' }), + ]); + const newsActiveChannel = signal( + makeChannel('a', { tvgId: 'a.tv', group: 'News' }) + ); + service.bind({ + channels: sportsOnlyChannels, + favoriteIds, + activeChannel: newsActiveChannel, + }); + + service.applyInitialScope('groups'); + + expect(service.scopeId()).toBe('all'); + }); + + it('re-labels scopes when the active language changes', () => { + expect(service.scopes()[0].label).toBe('CHANNELS.ALL_CHANNELS'); + + translateStub.instant = (key: string) => `${key}!`; + onLangChange.next({ lang: 'de' }); + + expect(service.scopes()[0].label).toBe('CHANNELS.ALL_CHANNELS!'); + }); + it('forwards programme search to the bridge', async () => { searchPrograms.mockResolvedValue([program('a.tv'), program('x')]); const hits = await service.searchPrograms('news'); diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index e66729683..660cc4353 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -1,6 +1,8 @@ import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { SettingsStore } from '@iptvnator/services'; @@ -43,6 +45,10 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { private readonly inputs = signal(null); private readonly scope = signal(SCOPE_ALL); + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); readonly scopeId = this.scope.asReadonly(); @@ -51,6 +57,7 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { ); readonly scopes = computed(() => { + this.languageTick(); const groups: string[] = []; const seen = new Set(); for (const channel of this.allChannels()) { @@ -123,8 +130,15 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { const activeGroup = this.inputs() ?.activeChannel() ?.group?.title?.trim(); - if (view === 'groups' && activeGroup) { - this.scope.set(`${GROUP_PREFIX}${activeGroup}`); + const groupScopeId = activeGroup + ? `${GROUP_PREFIX}${activeGroup}` + : null; + if ( + view === 'groups' && + groupScopeId && + this.scopes().some((scope) => scope.id === groupScopeId) + ) { + this.scope.set(groupScopeId); return; } this.scope.set(SCOPE_ALL); @@ -178,6 +192,10 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { return covered; } + /** + * Looks up the channel in the current scope because the guide only ever + * passes back ids it rendered from `channels()`. + */ activate(channelId: string): void { const channel = this.scopedChannels().find( (candidate) => candidate.id === channelId