From 1679373bb2329029a18cb51c2a70215b533aae28 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 27 Jun 2026 12:42:53 +0200 Subject: [PATCH] feat(xtream): auto-select stream output format --- apps/web/src/app/app.component.spec.ts | 7 ++- .../src/app/settings/settings-form.utils.ts | 4 +- ...ettings-playback-section.component.spec.ts | 62 ++++++++++++++++--- .../app/settings/settings.component.spec.ts | 2 +- .../xtream-portal-compatibility.md | 11 +++- .../lib/services/xtream-url.service.spec.ts | 51 ++++++++++++++- .../src/lib/services/xtream-url.service.ts | 39 ++++++++++-- .../src/lib/settings-store.service.spec.ts | 1 + .../src/lib/settings-store.service.ts | 2 +- .../interfaces/src/lib/stream-format.enum.ts | 1 + 10 files changed, 154 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index d8c6dd97f..965e90dda 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 8bbabcd1e..cbdaf7674 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -32,7 +32,7 @@ export function createSettingsForm( ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), - 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, diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 822a7280c..c5401509c 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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(''), diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 5da488c9c..75e9cc7b4 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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, diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 517b34c81..c4acb36e7 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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 diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index 6332b9baf..405926b5e 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -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, [string]>; setAppState: jest.Mock, [string, string]>; }; + let streamFormat: WritableSignal; 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, diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 44461836e..53dfcf868 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -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 diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index e118efe44..b7793d487 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -57,6 +57,7 @@ describe('SettingsStore dashboard rail settings', () => { await store.loadSettings(); + expect(store.getSettings().streamFormat).toBe('auto'); expect(store.getSettings().dashboardRails).toEqual( expectedDashboardRails() ); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 22764b660..c5693c0b3 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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, diff --git a/libs/shared/interfaces/src/lib/stream-format.enum.ts b/libs/shared/interfaces/src/lib/stream-format.enum.ts index f85789b8a..177006e19 100644 --- a/libs/shared/interfaces/src/lib/stream-format.enum.ts +++ b/libs/shared/interfaces/src/lib/stream-format.enum.ts @@ -2,6 +2,7 @@ * Enum with all the formats that are allowed in the application */ export enum StreamFormat { + AutoStreamFormat = 'auto', TsStreamFormat = 'ts', M3u8StreamFormat = 'm3u8', }