feat(xtream): auto-select stream output format

This commit is contained in:
4gray committed 2026-06-27 12:42:53 +02:00
1 parent d9999d1e7f
commit 1679373bb2
10 files changed
+154 -26

No files matched your search

+5 -2
View File
@@ -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',
}