mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(m3u): adapt the playlist channel list to the guide contract
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
688fd4584d
commit
07af71e49d
2 files changed
+390
No files matched your search
+175
@@ -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<Channel> & { 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<Channel[]>([]);
|
||||
const favoriteIds = signal<string[]>([]);
|
||||
const activeChannel = signal<Channel | null>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<Channel[]>;
|
||||
favoriteIds: Signal<string[]>;
|
||||
activeChannel: Signal<Channel | null>;
|
||||
}
|
||||
|
||||
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<M3uEpgGuideInputs | null>(null);
|
||||
private readonly scope = signal(SCOPE_ALL);
|
||||
|
||||
readonly scopeId = this.scope.asReadonly();
|
||||
|
||||
private readonly allChannels = computed(
|
||||
() => this.inputs()?.channels() ?? []
|
||||
);
|
||||
|
||||
readonly scopes = computed<EpgGuideScope[]>(() => {
|
||||
const groups: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<EpgGuideChannel[]>(() => {
|
||||
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<Map<string, EpgProgram[]>> {
|
||||
const keyed = this.keyedChannels(window.channels);
|
||||
const result = new Map<string, EpgProgram[]>();
|
||||
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<Set<string>> {
|
||||
const keyed = this.keyedChannels(window.channels);
|
||||
const covered = new Set<string>();
|
||||
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<EpgGuideSearchHit[]> {
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user