mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
1 parent
36a7ce9f3d
commit
e801028005
71 files changed
+4876
-373
No files matched your search
+118
-8
@@ -65,6 +65,11 @@ describe('ChannelListContainerComponent', () => {
|
||||
let fixture: ComponentFixture<ChannelListContainerComponent>;
|
||||
let dispatch: jest.Mock;
|
||||
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
|
||||
let epgService: {
|
||||
epgAvailable$: BehaviorSubject<boolean>;
|
||||
getChannelMetadataForChannels: jest.Mock;
|
||||
getCurrentProgramsForChannels: jest.Mock;
|
||||
};
|
||||
let favoriteChannelIds$: BehaviorSubject<string[]>;
|
||||
let runtimeCapabilities: { supportsEpg: boolean };
|
||||
let storageGet: jest.Mock;
|
||||
@@ -75,6 +80,15 @@ describe('ChannelListContainerComponent', () => {
|
||||
favoriteChannelIds$ = new BehaviorSubject<string[]>([]);
|
||||
runtimeCapabilities = { supportsEpg: true };
|
||||
storageGet = jest.fn().mockReturnValue(of({}));
|
||||
epgService = {
|
||||
epgAvailable$: new BehaviorSubject<boolean>(false),
|
||||
getChannelMetadataForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
getCurrentProgramsForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
};
|
||||
activePlaylistSignal = signal<PlaylistMeta | null>({
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
@@ -110,14 +124,7 @@ describe('ChannelListContainerComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: {
|
||||
getChannelMetadataForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
getCurrentProgramsForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
},
|
||||
useValue: epgService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
@@ -217,6 +224,109 @@ describe('ChannelListContainerComponent', () => {
|
||||
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
|
||||
});
|
||||
|
||||
it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => {
|
||||
runtimeCapabilities.supportsEpg = true;
|
||||
storageGet.mockReturnValue(of({ epgUrl: [] }));
|
||||
activePlaylistSignal.set({
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
count: 1,
|
||||
importDate: '2026-04-11T00:00:00.000Z',
|
||||
epgUrls: ['https://playlist.example.com/guide.xml'],
|
||||
} as PlaylistMeta);
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.channelList = [
|
||||
createChannel('guide-news', 'https://example.com/news.m3u8'),
|
||||
];
|
||||
|
||||
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
|
||||
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
|
||||
['guide-news'],
|
||||
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
|
||||
);
|
||||
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
|
||||
['guide-news'],
|
||||
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes visible channel EPG when playlist EPG URLs arrive after channels', () => {
|
||||
runtimeCapabilities.supportsEpg = true;
|
||||
storageGet.mockReturnValue(of({ epgUrl: [] }));
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.channelList = [
|
||||
createChannel('guide-news', 'https://example.com/news.m3u8'),
|
||||
];
|
||||
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
|
||||
['guide-news'],
|
||||
undefined
|
||||
);
|
||||
epgService.getCurrentProgramsForChannels.mockClear();
|
||||
epgService.getChannelMetadataForChannels.mockClear();
|
||||
|
||||
activePlaylistSignal.set({
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
count: 1,
|
||||
importDate: '2026-04-11T00:00:00.000Z',
|
||||
epgUrls: ['https://playlist.example.com/guide.xml'],
|
||||
} as PlaylistMeta);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
|
||||
['guide-news'],
|
||||
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
|
||||
);
|
||||
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
|
||||
['guide-news'],
|
||||
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('debounces visible channel EPG row refreshes after EPG imports complete', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
runtimeCapabilities.supportsEpg = true;
|
||||
activePlaylistSignal.set({
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
count: 1,
|
||||
importDate: '2026-04-11T00:00:00.000Z',
|
||||
epgUrls: ['https://playlist.example.com/guide.xml'],
|
||||
} as PlaylistMeta);
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.channelList = [
|
||||
createChannel('guide-news', 'https://example.com/news.m3u8'),
|
||||
];
|
||||
epgService.getCurrentProgramsForChannels.mockClear();
|
||||
|
||||
epgService.epgAvailable$.next(true);
|
||||
epgService.epgAvailable$.next(true);
|
||||
jest.advanceTimersByTime(1999);
|
||||
|
||||
expect(
|
||||
epgService.getCurrentProgramsForChannels
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
|
||||
expect(
|
||||
epgService.getCurrentProgramsForChannels
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
epgService.getCurrentProgramsForChannels
|
||||
).toHaveBeenCalledWith(['guide-news'], {
|
||||
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
||||
});
|
||||
} finally {
|
||||
fixture.destroy();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches playlist meta updates when hidden group titles change', () => {
|
||||
fixture.componentInstance.onHiddenGroupTitlesChanged([
|
||||
'Movies',
|
||||
|
||||
+88
-11
@@ -3,6 +3,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Input,
|
||||
input,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -35,10 +37,12 @@ import {
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
filter,
|
||||
forkJoin,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
PlaylistsService,
|
||||
@@ -55,6 +59,7 @@ import {
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
|
||||
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
|
||||
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
|
||||
import { GroupsViewComponent } from './groups-view/groups-view.component';
|
||||
@@ -88,6 +93,8 @@ function mapChannelsByFirstUrl(channels: Channel[]): Map<string, Channel> {
|
||||
return channelsByUrl;
|
||||
}
|
||||
|
||||
const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-container',
|
||||
templateUrl: './channel-list-container.component.html',
|
||||
@@ -115,6 +122,10 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
/** Route-aware playlist ID for recent-item mutations */
|
||||
private readonly resolvedPlaylistId =
|
||||
this.playlistContext.resolvedPlaylistId;
|
||||
private readonly activePlaylist = this.playlistContext.activePlaylist;
|
||||
|
||||
/** Map of channel ID to current EPG program */
|
||||
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
|
||||
@@ -128,9 +139,40 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Interval for global progress updates */
|
||||
private progressInterval?: number;
|
||||
private epgAvailabilitySubscription?: Subscription;
|
||||
|
||||
/** Whether to show EPG data in channel items */
|
||||
readonly shouldShowEpg = signal(false);
|
||||
private readonly globalEpgUrls = signal<string[]>([]);
|
||||
readonly playlistEpgUrls = computed(() => {
|
||||
const playlist = this.activePlaylist();
|
||||
if (!playlist || playlist.serverUrl || playlist.macAddress) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeEpgUrls(playlist.epgUrls ?? []);
|
||||
});
|
||||
readonly shouldShowEpg = computed(
|
||||
() =>
|
||||
this.runtime.supportsEpg &&
|
||||
(this.globalEpgUrls().length > 0 ||
|
||||
this.playlistEpgUrls().length > 0)
|
||||
);
|
||||
private readonly epgSourceRefreshKey = computed(() => {
|
||||
if (!this.runtime.supportsEpg) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const globalUrls = this.globalEpgUrls();
|
||||
const playlistUrls = this.playlistEpgUrls();
|
||||
if (globalUrls.length === 0 && playlistUrls.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
globalUrls: Array.from(new Set(globalUrls)).sort(),
|
||||
playlistUrls: Array.from(new Set(playlistUrls)).sort(),
|
||||
});
|
||||
});
|
||||
readonly openStreamOnDoubleClick = computed(() =>
|
||||
this.settingsStore.openStreamOnDoubleClick()
|
||||
);
|
||||
@@ -182,6 +224,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
_channelList: Channel[] = [];
|
||||
private readonly channelListSignal = signal<Channel[]>([]);
|
||||
private channelList$ = new BehaviorSubject<Channel[]>([]);
|
||||
private lastEpgSourceRefreshKey = '';
|
||||
private readonly epgSourceRefreshEffect = effect(() => {
|
||||
const refreshKey = this.epgSourceRefreshKey();
|
||||
if (refreshKey === this.lastEpgSourceRefreshKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastEpgSourceRefreshKey = refreshKey;
|
||||
if (!refreshKey || this._channelList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
untracked(() => {
|
||||
this.fetchEpgForChannels(this._channelList);
|
||||
});
|
||||
});
|
||||
|
||||
get channelList(): Channel[] {
|
||||
return this._channelList;
|
||||
@@ -196,11 +254,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
this.fetchEpgForChannels(safeValue);
|
||||
}
|
||||
|
||||
/** Route-aware playlist ID for recent-item mutations */
|
||||
private readonly resolvedPlaylistId =
|
||||
this.playlistContext.resolvedPlaylistId;
|
||||
private readonly activePlaylist = this.playlistContext.activePlaylist;
|
||||
|
||||
readonly hiddenGroupTitles = computed(() => {
|
||||
const playlist = this.activePlaylist();
|
||||
|
||||
@@ -301,13 +354,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
Object.keys(settings as Settings).length > 0
|
||||
) {
|
||||
const epgUrl = (settings as Settings).epgUrl;
|
||||
this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0));
|
||||
this.globalEpgUrls.set(normalizeEpgUrls(epgUrl));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.shouldShowEpg.set(false);
|
||||
this.globalEpgUrls.set([]);
|
||||
}
|
||||
|
||||
this.epgAvailabilitySubscription = this.epgService.epgAvailable$
|
||||
.pipe(
|
||||
filter((available) => available),
|
||||
debounceTime(EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.fetchEpgForChannels(this._channelList);
|
||||
});
|
||||
|
||||
// Set up EPG refresh interval (every 60 seconds)
|
||||
this.epgRefreshInterval = window.setInterval(() => {
|
||||
this.fetchEpgForChannels(this._channelList);
|
||||
@@ -330,6 +392,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
clearInterval(this.progressInterval);
|
||||
}
|
||||
|
||||
this.epgAvailabilitySubscription?.unsubscribe();
|
||||
this.channelList$.complete();
|
||||
}
|
||||
|
||||
@@ -351,10 +414,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
);
|
||||
|
||||
const epgLookupOptions = this.getPlaylistEpgLookupOptions();
|
||||
|
||||
forkJoin({
|
||||
epgMap: this.epgService.getCurrentProgramsForChannels(channelIds),
|
||||
metadataMap:
|
||||
this.epgService.getChannelMetadataForChannels(channelIds),
|
||||
epgMap: this.epgService.getCurrentProgramsForChannels(
|
||||
channelIds,
|
||||
epgLookupOptions
|
||||
),
|
||||
metadataMap: this.epgService.getChannelMetadataForChannels(
|
||||
channelIds,
|
||||
epgLookupOptions
|
||||
),
|
||||
}).subscribe(({ epgMap, metadataMap }) => {
|
||||
this.channelEpgMap.set(epgMap);
|
||||
this.channelIconMap.set(
|
||||
@@ -371,6 +441,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private getPlaylistEpgLookupOptions():
|
||||
| { sourceUrls: string[] }
|
||||
| undefined {
|
||||
const sourceUrls = this.playlistEpgUrls();
|
||||
return sourceUrls.length > 0 ? { sourceUrls } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles channel selection from any tab
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user