fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:08:28 +02:00
1 parent 07af71e49d
commit e20b9cc7a0
2 files changed
+58 -3

No files matched your search

@@ -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<Channel[]>([
makeChannel('b', { name: 'Beta', group: 'Sports' }),
]);
const newsActiveChannel = signal<Channel | null>(
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');
@@ -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<M3uEpgGuideInputs | null>(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<EpgGuideScope[]>(() => {
this.languageTick();
const groups: string[] = [];
const seen = new Set<string>();
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