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
+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