mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(m3u): restore DASH playback from favorites and recently viewed (#1597)
This commit is contained in:
1 parent
bd848aaad6
commit
17b8aa309d
11 files changed
+341
-9
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
+124
@@ -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,
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
[streamUrl]="currentStreamUrl()"
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="inlinePlayback()"
|
||||
[playerOverride]="player()"
|
||||
[playerOverride]="inlinePlayer()"
|
||||
[recordingMetadata]="recordingMetadata()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
|
||||
+22
-3
@@ -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;
|
||||
|
||||
+46
-1
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user