fix(m3u): restore DASH playback from favorites and recently viewed (#1597)

This commit is contained in:
4gray authored and GitHub committed 2026-09-13 11:17:55 +02:00
1 parent bd848aaad6
commit 17b8aa309d
11 files changed
+341 -9

No files matched your search

@@ -0,0 +1,124 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import { Channel, ChannelDrm } from '@iptvnator/shared/interfaces';
import { StreamResolverService } from './stream-resolver.service';
// The main resolver spec is at the test line cap.
describe('StreamResolverService M3U collection DRM', () => {
const clearKey: ChannelDrm = {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
};
const unsupported: ChannelDrm = {
licenseType: 'com.widevine.alpha',
supported: false,
};
const raw = [
'#EXTINF:-1,Encrypted channel',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
'#KODIPROP:inputstream.adaptive.license_key=00112233445566778899aabbccddeeff:ffeeddccbbaa99887766554433221100',
'https://example.test/live.mpd',
].join('\n');
const item: UnifiedCollectionItem = {
uid: 'm3u::playlist::channel',
sourceType: 'm3u',
contentType: 'live',
playlistId: 'playlist',
playlistName: 'Playlist',
name: 'Channel',
channelId: 'channel',
streamUrl: 'https://example.test/live.mpd',
};
let service: StreamResolverService;
let channel: Channel;
beforeEach(() => {
channel = {
id: 'channel',
name: 'Channel',
url: item.streamUrl!,
group: { title: '' },
tvg: { id: '', name: '', logo: '', url: '', rec: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
};
TestBed.configureTestingModule({
providers: [
StreamResolverService,
{
provide: PlaylistsService,
useValue: {
getPlaylistById: () =>
of({
_id: 'playlist',
playlist: { items: [channel] },
}),
},
},
...[
XtreamApiService,
XtreamUrlService,
StalkerSessionService,
DataService,
SettingsStore,
].map((provide) => ({ provide, useValue: {} })),
{
provide: EpgRuntimeBridgeService,
useValue: { supportsProgramLookup: false },
},
],
});
service = TestBed.inject(StreamResolverService);
});
describe.each([
'resolvePlayback',
'resolveLiveDetail',
'resolveM3uPlaybackDetail',
] as const)('%s', (method) => {
it.each([
{
name: 'stored ClearKey',
metadata: { drm: clearKey },
expected: clearKey,
},
{
name: 'legacy raw KODIPROP',
metadata: { raw },
expected: clearKey,
},
{
name: 'explicit unsupported DRM over raw fallback',
metadata: { drm: unsupported, raw },
expected: unsupported,
},
{ name: 'unencrypted channel', metadata: {}, expected: undefined },
])('preserves $name', async ({ metadata, expected }) => {
Object.assign(channel, metadata);
const original = JSON.parse(JSON.stringify(channel));
const result = await service[method](item);
const playback = 'playback' in result ? result.playback : result;
expect(playback.drm).toEqual(expected);
expect(playback.streamUrl).toBe(channel.url);
expect(channel).toEqual(original);
});
});
});
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
import {
DataService,
PlaylistsService,
@@ -357,6 +358,7 @@ export class StreamResolverService {
streamUrl: channel.url ?? '',
title: channel.name,
thumbnail: channel.tvg?.logo ?? null,
drm: channel.drm ?? extractDrmFromRaw(channel.raw),
headers: Object.keys(headers).length > 0 ? headers : undefined,
userAgent,
referer,
@@ -63,7 +63,7 @@
[streamUrl]="currentStreamUrl()"
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
[playerOverride]="player()"
[playerOverride]="inlinePlayer()"
[recordingMetadata]="recordingMetadata()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -21,6 +21,8 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
applyChannelNameStrip,
getM3uArchiveDays,
isDashChannel,
isDashStreamUrl,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from '@iptvnator/shared/m3u-utils';
@@ -86,6 +88,7 @@ import {
RecordingStartMetadata,
RecordingStoppedEvent,
toRecordingProgramSnapshot,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
@@ -313,9 +316,24 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
readonly isRadioSelection = computed(
() => this.activeRadioChannel() !== null
);
readonly shouldUseInlinePlayer = computed(() => {
return this.isRadioSelection() || this.isEmbeddedPlayer();
});
// Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC selected.
readonly isM3uDashSelection = computed(
() =>
this.isM3uSelection() &&
(isDashStreamUrl(this.currentStreamUrl()) ||
isDashChannel(this.currentM3uChannel()))
);
readonly inlinePlayer = computed(() =>
this.isM3uDashSelection() && this.player() !== VideoPlayer.ArtPlayer
? VideoPlayer.Html5Player
: this.player()
);
readonly shouldUseInlinePlayer = computed(
() =>
this.isRadioSelection() ||
this.isM3uDashSelection() ||
this.isEmbeddedPlayer()
);
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -981,6 +999,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
): boolean {
if (
this.isRadioDetail(detail) ||
this.isM3uDashSelection() ||
this.portalPlayer.isEmbeddedPlayer()
) {
return false;
@@ -49,11 +49,13 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
openResolvedPlayback: jest.Mock;
openExternalPlayback: jest.Mock;
};
let player = signal(VideoPlayer.VideoJs);
const originalElectron = window.electron;
beforeEach(async () => {
window.electron = { platform: 'darwin' } as typeof window.electron;
localStorage.removeItem('live-epg-panel-state');
player = signal(VideoPlayer.VideoJs);
streamResolver = {
resolveLiveDetail: jest.fn(),
@@ -86,7 +88,7 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
player: signal(VideoPlayer.VideoJs),
player,
stripCountryPrefix: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
@@ -128,6 +130,49 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
window.electron = originalElectron;
});
it.each(Object.values(VideoPlayer))(
'renders M3U DASH with Shaka and retains the %s preference',
async (configuredPlayer) => {
player.set(configuredPlayer);
portalPlayer.isEmbeddedPlayer.mockReturnValue(
configuredPlayer !== VideoPlayer.MPV &&
configuredPlayer !== VideoPlayer.VLC
);
const item = {
...buildM3uLiveItem(),
streamUrl: 'https://example.com/live.mpd',
};
const drm = {
licenseType: 'clearkey',
supported: true,
clearKeys: { kid: 'key' },
};
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: { streamUrl: item.streamUrl, title: item.name, drm },
epgPrograms: [],
});
recentData.recordLivePlayback.mockResolvedValue(item);
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.playerOverride()).toBe(
configuredPlayer === VideoPlayer.ArtPlayer
? VideoPlayer.ArtPlayer
: VideoPlayer.Html5Player
);
expect(webPlayer.playback()?.drm).toEqual(drm);
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(player()).toBe(configuredPlayer);
}
);
it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => {
const first = buildM3uLiveItem();
const second: UnifiedCollectionItem = {