From 17b8aa309d49fc894128f6830c90b90751d2d67e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:17:55 +0200 Subject: [PATCH] fix(m3u): restore DASH playback from favorites and recently viewed (#1597) --- .changes/m3u-collection-drm.md | 7 + AGENTS.md | 6 + CLAUDE.md | 6 + .../src/dash-clearkey.e2e.ts | 75 ++++++++++- apps/web-e2e/src/dash-clearkey.e2e.ts | 43 +++++- docs/architecture/m3u-playlist-module.md | 13 +- .../stream-resolver.service.drm.spec.ts | 124 ++++++++++++++++++ .../lib/collection/stream-resolver.service.ts | 2 + .../unified-live-tab.component.html | 2 +- .../unified-live-tab.component.ts | 25 +++- .../unified-live-tab.fullscreen-panel.spec.ts | 47 ++++++- 11 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 .changes/m3u-collection-drm.md create mode 100644 libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.drm.spec.ts diff --git a/.changes/m3u-collection-drm.md b/.changes/m3u-collection-drm.md new file mode 100644 index 000000000..76318a744 --- /dev/null +++ b/.changes/m3u-collection-drm.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 751bc2c61..504ea352c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 2d7aea709..d02196c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.*` diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index f26c78f0e..511f6f070 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -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, }) => { diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index 49f60313c..59330584b 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -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, }) => { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index e698f4f1f..d636b3c45 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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:** diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.drm.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.drm.spec.ts new file mode 100644 index 000000000..e5660720e --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.drm.spec.ts @@ -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); + }); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 184b62675..78f03e4c7 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index a205255c3..ec31978f2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -63,7 +63,7 @@ [streamUrl]="currentStreamUrl()" [title]="activeDetail()?.playback?.title ?? ''" [playback]="inlinePlayback()" - [playerOverride]="player()" + [playerOverride]="inlinePlayer()" [recordingMetadata]="recordingMetadata()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 821bc1b3c..4b52efbf0 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index 17ca1cc97..9c32c3d7a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -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 = {