mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(xtream): auto-select stream output format
This commit is contained in:
1 parent
d9999d1e7f
commit
1679373bb2
10 files changed
+154
-26
No files matched your search
@@ -4,7 +4,10 @@ import { Router } from '@angular/router';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
EpgRuntimeBridgeService,
|
||||
EpgService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import { MockProvider } from 'ng-mocks';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
@@ -35,7 +38,7 @@ class MockSettingsService {
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
epgUrl: [],
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
|
||||
@@ -32,7 +32,7 @@ export function createSettingsForm(
|
||||
...(supportsEpg
|
||||
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
|
||||
: {}),
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
@@ -101,7 +101,7 @@ export function createSettingsFromFormValue(
|
||||
|
||||
return {
|
||||
player: value.player ?? VideoPlayer.VideoJs,
|
||||
streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat,
|
||||
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
|
||||
language: value.language ?? Language.ENGLISH,
|
||||
showCaptions: value.showCaptions ?? false,
|
||||
|
||||
@@ -91,7 +91,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
(player) => {
|
||||
fixture.componentRef.setInput('form', createForm(player));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsManagedExternalPlayers',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
@@ -128,11 +131,29 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers automatic Xtream stream format selection', async () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement
|
||||
.querySelector('[data-test-id="select-stream-format"]')
|
||||
.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const option = document.body.querySelector('[data-test-id="auto"]');
|
||||
|
||||
expect(option).not.toBeNull();
|
||||
expect(option?.textContent).toContain('auto');
|
||||
});
|
||||
|
||||
it('keeps the double-click option visible when path settings are unavailable', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
false
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
@@ -173,7 +194,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
it('shows MPV bundle guidance and the IINA executable tip for desktop MPV playback', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
@@ -193,7 +217,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
false
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
@@ -212,7 +239,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
it('shows VLC bundle guidance without the IINA tip for desktop VLC playback', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
@@ -232,7 +262,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
false
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
@@ -247,7 +280,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
|
||||
it('does not show external-player path guidance for embedded players', () => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
@@ -266,7 +302,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
it('shows MPV command-line arguments only when MPV is selected', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
@@ -292,7 +331,10 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
it('shows VLC command-line arguments only when VLC is selected', () => {
|
||||
fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC));
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true);
|
||||
fixture.componentRef.setInput(
|
||||
'supportsExternalPlayerPathSettings',
|
||||
true
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
@@ -319,7 +361,7 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
return new FormGroup({
|
||||
player: new FormControl(player),
|
||||
streamFormat: new FormControl(StreamFormat.M3u8StreamFormat),
|
||||
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
||||
openStreamOnDoubleClick: new FormControl(false),
|
||||
showExternalPlaybackBar: new FormControl(true),
|
||||
mpvPlayerPath: new FormControl(''),
|
||||
|
||||
@@ -80,7 +80,7 @@ export class MockRouter {
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
|
||||
@@ -65,9 +65,14 @@ and private-network checks.
|
||||
## Playback URL Formats
|
||||
|
||||
When account info includes `user_info.allowed_output_formats`, the current
|
||||
Xtream playlist keeps those formats for the active session. Live stream URL
|
||||
construction falls back to the first provider-allowed format when the selected
|
||||
application format is not allowed by the portal.
|
||||
Xtream playlist keeps those formats for the active session. The default
|
||||
application format is `auto`: live stream URL construction chooses `m3u8` when
|
||||
the provider allows HLS, falls back to `ts` when MPEG-TS is the only known
|
||||
standard format, and otherwise uses the first provider-advertised format. If
|
||||
the provider does not advertise output formats, `auto` falls back to `m3u8`.
|
||||
Manual `ts` and `m3u8` settings remain supported; when a manual setting is not
|
||||
allowed by the portal, URL construction falls back to the first
|
||||
provider-allowed format.
|
||||
|
||||
If stored Xtream playback credentials contain an invalid server URL or blank
|
||||
username/password, stream URL construction returns an empty URL instead of
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DatabaseService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
StreamFormat,
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -14,6 +15,7 @@ describe('XtreamUrlService', () => {
|
||||
getAppState: jest.Mock<Promise<string | null>, [string]>;
|
||||
setAppState: jest.Mock<Promise<void>, [string, string]>;
|
||||
};
|
||||
let streamFormat: WritableSignal<StreamFormat>;
|
||||
|
||||
const credentials: XtreamCredentials = {
|
||||
serverUrl: 'http://demo.example',
|
||||
@@ -23,6 +25,7 @@ describe('XtreamUrlService', () => {
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(() => {
|
||||
streamFormat = signal(StreamFormat.TsStreamFormat);
|
||||
databaseService = {
|
||||
getAppState: jest.fn().mockResolvedValue(null),
|
||||
setAppState: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -35,7 +38,7 @@ describe('XtreamUrlService', () => {
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
streamFormat: signal('ts'),
|
||||
streamFormat,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -73,6 +76,50 @@ describe('XtreamUrlService', () => {
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8');
|
||||
});
|
||||
|
||||
it('auto-selects HLS for live streams when the provider allows it', () => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
|
||||
const url = service.constructLiveUrl(
|
||||
{
|
||||
...credentials,
|
||||
allowedOutputFormats: ['ts', 'm3u8'],
|
||||
},
|
||||
101
|
||||
);
|
||||
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8');
|
||||
});
|
||||
|
||||
it('auto-selects MPEG-TS for live streams when it is the only provider format', () => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
|
||||
const url = service.constructLiveUrl(
|
||||
{
|
||||
...credentials,
|
||||
allowedOutputFormats: ['ts'],
|
||||
},
|
||||
101
|
||||
);
|
||||
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
|
||||
});
|
||||
|
||||
it('uses HLS as the auto live-stream fallback when provider formats are unknown', () => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
|
||||
const url = service.constructLiveUrl(credentials, 101);
|
||||
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8');
|
||||
});
|
||||
|
||||
it('keeps the manual live-stream format override when provider formats are unknown', () => {
|
||||
streamFormat.set(StreamFormat.TsStreamFormat);
|
||||
|
||||
const url = service.constructLiveUrl(credentials, 101);
|
||||
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
|
||||
});
|
||||
|
||||
it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => {
|
||||
const invalidCredentials: XtreamCredentials = {
|
||||
...credentials,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
normalizeXtreamServerUrl,
|
||||
StreamFormat,
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -108,7 +109,9 @@ export class XtreamUrlService {
|
||||
|
||||
const streamFormat = this.resolveLiveStreamFormat(
|
||||
credentials,
|
||||
format ?? this.settingsStore.streamFormat() ?? 'ts'
|
||||
format ??
|
||||
this.settingsStore.streamFormat() ??
|
||||
StreamFormat.AutoStreamFormat
|
||||
);
|
||||
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
|
||||
}
|
||||
@@ -322,7 +325,9 @@ export class XtreamUrlService {
|
||||
): XtreamCatchupVariant[] {
|
||||
const variants: XtreamCatchupVariant[] = [];
|
||||
|
||||
for (const extension of this.getPreferredCatchupExtensions(credentials)) {
|
||||
for (const extension of this.getPreferredCatchupExtensions(
|
||||
credentials
|
||||
)) {
|
||||
variants.push(
|
||||
extension === XTREAM_CATCHUP_EXTENSIONS.M3U8
|
||||
? XTREAM_CATCHUP_VARIANT.REST_M3U8
|
||||
@@ -372,7 +377,9 @@ export class XtreamUrlService {
|
||||
);
|
||||
}
|
||||
|
||||
private parseCatchupVariant(value: string | null): XtreamCatchupVariant | null {
|
||||
private parseCatchupVariant(
|
||||
value: string | null
|
||||
): XtreamCatchupVariant | null {
|
||||
const variants = Object.values(XTREAM_CATCHUP_VARIANT);
|
||||
return variants.includes(value as XtreamCatchupVariant)
|
||||
? (value as XtreamCatchupVariant)
|
||||
@@ -428,11 +435,15 @@ export class XtreamUrlService {
|
||||
credentials: XtreamCredentials,
|
||||
requestedFormat: string
|
||||
): string {
|
||||
const requested = requestedFormat.trim();
|
||||
const requested = requestedFormat.trim().toLowerCase();
|
||||
const allowedFormats = credentials.allowedOutputFormats
|
||||
?.map((format) => format.trim())
|
||||
?.map((format) => format.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!requested || requested === StreamFormat.AutoStreamFormat) {
|
||||
return this.resolveAutoLiveStreamFormat(allowedFormats);
|
||||
}
|
||||
|
||||
if (allowedFormats?.length && !allowedFormats.includes(requested)) {
|
||||
return allowedFormats[0];
|
||||
}
|
||||
@@ -440,6 +451,24 @@ export class XtreamUrlService {
|
||||
return requested;
|
||||
}
|
||||
|
||||
private resolveAutoLiveStreamFormat(
|
||||
allowedFormats: string[] | undefined
|
||||
): string {
|
||||
if (!allowedFormats?.length) {
|
||||
return StreamFormat.M3u8StreamFormat;
|
||||
}
|
||||
|
||||
if (allowedFormats.includes(StreamFormat.M3u8StreamFormat)) {
|
||||
return StreamFormat.M3u8StreamFormat;
|
||||
}
|
||||
|
||||
if (allowedFormats.includes(StreamFormat.TsStreamFormat)) {
|
||||
return StreamFormat.TsStreamFormat;
|
||||
}
|
||||
|
||||
return allowedFormats[0];
|
||||
}
|
||||
|
||||
private formatCatchupStartTime(
|
||||
timestamp: number,
|
||||
timezone?: string
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('SettingsStore dashboard rail settings', () => {
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().streamFormat).toBe('auto');
|
||||
expect(store.getSettings().dashboardRails).toEqual(
|
||||
expectedDashboardRails()
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Enum with all the formats that are allowed in the application
|
||||
*/
|
||||
export enum StreamFormat {
|
||||
AutoStreamFormat = 'auto',
|
||||
TsStreamFormat = 'ts',
|
||||
M3u8StreamFormat = 'm3u8',
|
||||
}
|
||||
Reference in new issue
Block a user