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

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: m3u
issues: [1590]
---
DASH channels, including ClearKey-protected streams, now play from Recently Viewed and Favorites using the compatible built-in player. Existing imports keep their DRM keys without needing to re-import the playlist.
+6
View File
@@ -627,6 +627,12 @@ unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
it collapse onto one target. Only the timeline scrub commits an absolute
`seek`. Contract: `docs/architecture/embedded-mpv-native.md` ("Resume And
Track Handling").
- M3U Favorites and Recently Viewed resolve `Channel.drm` into
`ResolvedPortalPlayback.drm` through `StreamResolverService`, with the same
legacy raw KODIPROP fallback as the main M3U player. Both playlist and global
collection scopes retain ClearKey playback and unsupported-DRM diagnostics.
Collections also route M3U DASH inline through HTML5/Shaka (or ArtPlayer),
regardless of the configured player, without changing the saved preference.
- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
ArtPlayer components; ClearKey keys come from KODIPROP-derived
+6
View File
@@ -1098,6 +1098,12 @@ app as a real argument, so it is not an option.
those statuses, keeps closable errors visible until Stop confirms teardown and
terminal errors visible until dismissal, and intentionally has no retry because
it does not own the original launch headers or credentials.
- M3U Favorites and Recently Viewed resolve `Channel.drm` into
`ResolvedPortalPlayback.drm` through `StreamResolverService`, with the same
legacy raw KODIPROP fallback as the main M3U player. Both playlist and global
collection scopes retain ClearKey playback and unsupported-DRM diagnostics.
Collections also route M3U DASH inline through HTML5/Shaka (or ArtPlayer),
regardless of the configured player, without changing the saved preference.
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
@@ -16,11 +16,16 @@ import {
openAddPlaylistDialog,
openSourceEditor,
openSettings,
openPlaylistRecent,
openPlaylistFavorites,
openGlobalRecent,
openGlobalFavorites,
openSettingsSection,
openSources,
saveSourceDialog,
saveSettings,
sourceRowByTitle,
switchUnifiedCollectionScope,
test,
updateSourceDialog,
waitForM3uCatalog,
@@ -173,7 +178,7 @@ function buildDashPlaylist(origin: string): string {
'#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH',
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
`${origin}/clearkey.mpd`,
`${origin}/clearkey.mpd?widevine=1`,
'#EXTINF:-1 tvg-id="unsupported-mkv" group-title="DASH",Unsupported MKV',
`${origin}/unsupported.mkv`,
].join('\n');
@@ -514,6 +519,74 @@ for (const player of ['mpv', 'vlc']) {
});
}
for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) {
test(`@electron @dash ClearKey reopens from recent and favorites collections with ${configuredPlayer}`, async ({
dataDir,
}) => {
const fixtureServer = await startDashFixtureServer();
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
// Video.js is the default; selecting it again leaves no dirty form to save.
if (configuredPlayer !== 'videojs') {
await openSettings(page);
await openSettingsSection(page, 'playback');
await page.getByTestId('select-video-player').click();
await page.getByTestId(configuredPlayer).click();
await saveSettings(page);
await goToDashboard(page);
}
await importDashPlaylistFromText(
app,
buildDashPlaylist(fixtureServer.origin)
);
const channel = channelItemByTitle(page, 'ClearKey DASH').first();
await channel.click();
await channel.locator('.favorite-button').click();
await expect(
channel.locator('.favorite-button mat-icon')
).toHaveText('star');
for (const openCollection of [
openPlaylistRecent,
openPlaylistFavorites,
openGlobalRecent,
openGlobalFavorites,
]) {
await openCollection(page);
if (
openCollection === openGlobalRecent ||
openCollection === openGlobalFavorites
) {
await switchUnifiedCollectionScope(page, 'All playlists');
}
const collection = page.locator('app-unified-live-tab');
await channelItemByTitle(page, 'ClearKey DASH').first().click();
const video = collection
.locator('app-web-player-view video')
.first();
await expect(video).toBeVisible();
await expect
.poll(
() =>
video.evaluate(
(element: HTMLVideoElement) =>
element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0.5);
await expect(
page.getByTestId('playback-diagnostic-banner')
).toBeHidden();
}
} finally {
await closeElectronApp(app);
await fixtureServer.close();
}
});
}
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
dataDir,
}) => {
+42 -1
View File
@@ -43,7 +43,7 @@ const DASH_PLAYLIST = [
'#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH',
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
`${FIXTURE_HOST}/clearkey.mpd`,
`${FIXTURE_HOST}/clearkey.mpd?widevine=1`,
].join('\n');
// The inline player starts playback programmatically; without this flag the
@@ -154,6 +154,47 @@ test('@web @m3u @dash ClearKey and clear DASH channels play inline', async ({
await expectVideoPlaying(page);
});
test('@web @m3u @dash ClearKey reopens from recent and favorites collections', async ({
page,
}) => {
await serveDashFixtures(page);
await importDashPlaylist(page);
await page.getByText('1. ClearKey DASH').click();
await expectVideoPlaying(page);
const playlistUrl = page.url().replace(/\/all$/, '');
const channel = page.locator('.channel-list-item').filter({
hasText: '1. ClearKey DASH',
});
await channel.locator('.favorite-button').click();
await expect(channel.locator('.favorite-button mat-icon')).toHaveText(
'star'
);
for (const route of [
`${playlistUrl}/recent`,
`${playlistUrl}/favorites`,
'/workspace/global-recent',
'/workspace/global-favorites',
]) {
// Full navigation also proves persisted channels survive a cold load.
await page.goto(route);
if (route.startsWith('/workspace/global-')) {
await page
.locator('.scope-toggle')
.getByText('All playlists', { exact: true })
.click();
}
const collection = page.locator('app-unified-live-tab');
await collection
.locator('.channel-name')
.filter({
hasText: 'ClearKey DASH',
})
.click();
await expectVideoPlaying(page);
}
});
test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
page,
}) => {
+11 -2
View File
@@ -1352,9 +1352,18 @@ player in settings.
channel, and reaches the engine. Persistence is free for newly imported or
refreshed playlists (playlist JSON blob / IndexedDB object). Playlists
imported **before** the DRM feature carry no `drm` field yet, but the raw
`#KODIPROP` block survived in the stored items — the M3U player page falls
`#KODIPROP` block survived in the stored items — the M3U player page and
collection `StreamResolverService.buildM3uPlayback()` fall
back to `extractDrmFromRaw(channel.raw)` at playback time, so encrypted
channels of pre-upgrade playlists work without a re-import.
channels of pre-upgrade playlists work without a re-import. Favorites and
Recently Viewed (playlist and global scopes) pass the same DRM metadata
through `ResolvedPortalPlayback.drm`; an explicit stored DRM result,
including `supported: false`, takes precedence over the raw fallback.
`UnifiedLiveTabComponent` applies the same M3U DASH routing to favorites and
recent selections: ArtPlayer keeps its Shaka engine; all other preferences
use HTML5/Shaka inline. This also applies to a resolved DASH catch-up URL and
does not change the saved player preference. Clear DASH needs this routing too.
**Engine selection and routing:**
@@ -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 = {