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 new file mode 100644 index 000000000..bd6bd5ae9 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -0,0 +1,175 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { ChannelActions } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service'; + +function makeChannel( + id: string, + overrides: Partial & { tvgId?: string; group?: string } = {} +): Channel { + return { + id, + url: `https://example.com/${id}.m3u8`, + name: overrides.name ?? `Channel ${id}`, + group: { title: overrides.group ?? 'News' }, + tvg: { + id: overrides.tvgId ?? '', + name: '', + url: '', + logo: overrides.tvg?.logo ?? '', + rec: '', + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: overrides.radio ?? 'false', + } as Channel; +} + +function program(channel: string): EpgProgram { + return { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T17:00:00.000Z', + channel, + title: `${channel} show`, + desc: null, + category: null, + }; +} + +describe('M3uEpgGuideSourceService', () => { + const channels = signal([]); + const favoriteIds = signal([]); + const activeChannel = signal(null); + const dispatch = jest.fn(); + const getProgramsForChannels = jest.fn(); + const getProgramCoverage = jest.fn(); + const searchPrograms = jest.fn(); + let service: M3uEpgGuideSourceService; + + beforeEach(() => { + dispatch.mockReset(); + getProgramsForChannels.mockReset(); + getProgramCoverage.mockReset(); + searchPrograms.mockReset(); + channels.set([ + makeChannel('a', { tvgId: 'a.tv', group: 'News' }), + makeChannel('b', { name: 'Beta', group: 'Sports' }), + makeChannel('c', { name: ' ', group: 'Sports' }), + ]); + favoriteIds.set(['b']); + activeChannel.set(channels()[0]); + TestBed.configureTestingModule({ + providers: [ + M3uEpgGuideSourceService, + { provide: Store, useValue: { dispatch } }, + { + provide: EpgRuntimeBridgeService, + useValue: { + getProgramsForChannels, + getProgramCoverage, + searchPrograms, + }, + }, + { + provide: SettingsStore, + useValue: { stripCountryPrefix: signal(false) }, + }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + service = TestBed.inject(M3uEpgGuideSourceService); + service.bind({ channels, favoriteIds, activeChannel }); + }); + + it('offers all / groups / favorites scopes and lists channels in playlist order', () => { + expect(service.scopes().map((scope) => scope.id)).toEqual([ + 'all', + 'favorites', + 'group:News', + 'group:Sports', + ]); + expect(service.channels().map((channel) => channel.number)).toEqual([ + 1, 2, 3, + ]); + service.setScope('group:Sports'); + expect(service.channels().map((channel) => channel.id)).toEqual([ + 'b', + 'c', + ]); + expect(service.channels()[0].number).toBe(1); + service.setScope('favorites'); + expect(service.channels().map((channel) => channel.id)).toEqual(['b']); + }); + + it('derives the EPG key from tvg-id, then name, and null for blank names', () => { + const keys = service.channels().map((channel) => channel.epgKey); + expect(keys).toEqual(['a.tv', 'Beta', null]); + }); + + it('loads programmes and coverage through the bridge keyed back by channel id', async () => { + getProgramsForChannels.mockResolvedValue({ + 'a.tv': [program('a.tv')], + Beta: [], + }); + getProgramCoverage.mockResolvedValue(['a.tv']); + const window = { channels: service.channels(), fromMs: 1, toMs: 2 }; + + const programs = await service.loadPrograms(window); + expect(getProgramsForChannels).toHaveBeenCalledWith({ + channelIds: ['a.tv', 'Beta'], + fromMs: 1, + toMs: 2, + }); + expect(programs.get('a')?.[0].title).toBe('a.tv show'); + expect(programs.get('b')).toEqual([]); + expect(programs.has('c')).toBe(false); + + const covered = await service.loadCoverage(window); + expect(covered).toEqual(new Set(['a'])); + }); + + it('answers empty results when the bridge is unavailable', async () => { + getProgramsForChannels.mockResolvedValue(null); + getProgramCoverage.mockResolvedValue(null); + const window = { channels: service.channels(), fromMs: 1, toMs: 2 }; + expect((await service.loadPrograms(window)).size).toBe(0); + expect((await service.loadCoverage(window)).size).toBe(0); + }); + + it('mirrors the active channel and dispatches playback on activate', () => { + expect(service.activeChannelId()).toBe('a'); + service.activate('b'); + expect(dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ + channel: channels()[1], + startPlayback: true, + }) + ); + service.activate('missing'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('seeds the initial scope from the sidebar view', () => { + service.applyInitialScope('favorites'); + expect(service.scopeId()).toBe('favorites'); + service.applyInitialScope('groups'); + expect(service.scopeId()).toBe('group:News'); + service.applyInitialScope('all'); + expect(service.scopeId()).toBe('all'); + }); + + it('forwards programme search to the bridge', async () => { + searchPrograms.mockResolvedValue([program('a.tv'), program('x')]); + const hits = await service.searchPrograms('news'); + expect(hits.map((hit) => hit.channelId)).toEqual(['a', null]); + expect(hits[1].program.title).toBe('x show'); + expect(searchPrograms).toHaveBeenCalledWith('news', 20); + }); +}); 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 new file mode 100644 index 000000000..e66729683 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -0,0 +1,215 @@ +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import { Channel } from '@iptvnator/shared/interfaces'; +import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EpgGuideChannel, + EpgGuideScope, + EpgGuideSearchHit, + EpgGuideSource, + EpgGuideWindow, +} from '@iptvnator/ui/epg'; +import { createM3uChannelPlaybackRequest } from '../video-player/m3u-channel-playback-actions'; + +export interface M3uEpgGuideInputs { + /** Guide-eligible channels (host excludes radio and recognised movies). */ + channels: Signal; + favoriteIds: Signal; + activeChannel: Signal; +} + +const SCOPE_ALL = 'all'; +const SCOPE_FAVORITES = 'favorites'; +const GROUP_PREFIX = 'group:'; +const SEARCH_LIMIT = 20; + +/** + * `EPG_GUIDE_SOURCE` for the M3U player. Channels, favorites and the active + * channel are bound by the host; programmes come from the XMLTV bridge + * keyed by the same lookup chain the sidebar uses (tvg-id → tvg-name → name). + * Queries are unscoped (all imported EPG sources), like the M3U timeline. + */ +@Injectable() +export class M3uEpgGuideSourceService implements EpgGuideSource { + private readonly store = inject(Store); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + private readonly inputs = signal(null); + private readonly scope = signal(SCOPE_ALL); + + readonly scopeId = this.scope.asReadonly(); + + private readonly allChannels = computed( + () => this.inputs()?.channels() ?? [] + ); + + readonly scopes = computed(() => { + const groups: string[] = []; + const seen = new Set(); + for (const channel of this.allChannels()) { + const title = channel.group?.title?.trim(); + if (title && !seen.has(title)) { + seen.add(title); + groups.push(title); + } + } + return [ + { + id: SCOPE_ALL, + label: this.translate.instant('CHANNELS.ALL_CHANNELS'), + kind: 'all', + }, + { + id: SCOPE_FAVORITES, + label: this.translate.instant('CHANNELS.FAVORITES'), + kind: 'favorites', + }, + ...groups.map((title) => ({ + id: `${GROUP_PREFIX}${title}`, + label: title, + kind: 'group' as const, + })), + ]; + }); + + private readonly scopedChannels = computed(() => { + const scope = this.scope(); + const channels = this.allChannels(); + if (scope === SCOPE_FAVORITES) { + const favorites = new Set(this.inputs()?.favoriteIds() ?? []); + return channels.filter((channel) => favorites.has(channel.id)); + } + if (scope.startsWith(GROUP_PREFIX)) { + const title = scope.slice(GROUP_PREFIX.length); + return channels.filter( + (channel) => channel.group?.title?.trim() === title + ); + } + return channels; + }); + + readonly channels = computed(() => { + const strip = this.settingsStore.stripCountryPrefix?.(); + return this.scopedChannels().map((channel, index) => ({ + id: channel.id, + number: index + 1, + name: applyChannelNameStrip(channel.name, strip) || channel.name, + logoUrl: channel.tvg?.logo?.trim() || null, + epgKey: resolveChannelEpgLookupKey(channel) || null, + })); + }); + + readonly activeChannelId = computed( + () => this.inputs()?.activeChannel()?.id ?? null + ); + + bind(inputs: M3uEpgGuideInputs): void { + this.inputs.set(inputs); + } + + /** Called by the host when the guide opens: mirror the sidebar view. */ + applyInitialScope(view: string): void { + if (view === SCOPE_FAVORITES) { + this.scope.set(SCOPE_FAVORITES); + return; + } + const activeGroup = this.inputs() + ?.activeChannel() + ?.group?.title?.trim(); + if (view === 'groups' && activeGroup) { + this.scope.set(`${GROUP_PREFIX}${activeGroup}`); + return; + } + this.scope.set(SCOPE_ALL); + } + + setScope(id: string): void { + if (this.scopes().some((scope) => scope.id === id)) { + this.scope.set(id); + } + } + + async loadPrograms( + window: EpgGuideWindow + ): Promise> { + const keyed = this.keyedChannels(window.channels); + const result = new Map(); + if (keyed.length === 0) { + return result; + } + const response = await this.epgBridge.getProgramsForChannels({ + channelIds: Array.from(new Set(keyed.map(([, key]) => key))), + fromMs: window.fromMs, + toMs: window.toMs, + }); + if (!response) { + return result; + } + for (const [channel, key] of keyed) { + result.set(channel.id, response[key] ?? []); + } + return result; + } + + async loadCoverage(window: EpgGuideWindow): Promise> { + const keyed = this.keyedChannels(window.channels); + const covered = new Set(); + if (keyed.length === 0) { + return covered; + } + const response = await this.epgBridge.getProgramCoverage({ + channelIds: Array.from(new Set(keyed.map(([, key]) => key))), + fromMs: window.fromMs, + toMs: window.toMs, + }); + const keys = new Set(response ?? []); + for (const [channel, key] of keyed) { + if (keys.has(key)) { + covered.add(channel.id); + } + } + return covered; + } + + activate(channelId: string): void { + const channel = this.scopedChannels().find( + (candidate) => candidate.id === channelId + ); + if (channel) { + this.store.dispatch(createM3uChannelPlaybackRequest(channel)); + } + } + + /** Hits carry the row id only when an exact `epgKey` match exists in scope. */ + async searchPrograms(query: string): Promise { + const programs = + (await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? []; + const byKey = new Map( + this.channels() + .filter((channel) => channel.epgKey !== null) + .map((channel) => [channel.epgKey as string, channel.id]) + ); + return programs.map((program) => ({ + channelId: byKey.get(program.channel) ?? null, + program, + })); + } + + private keyedChannels( + channels: readonly EpgGuideChannel[] + ): Array<[EpgGuideChannel, string]> { + return channels + .filter((channel) => channel.epgKey !== null) + .map((channel): [EpgGuideChannel, string] => [ + channel, + channel.epgKey as string, + ]); + } +}