From 2f9aef15785c0433d4dd719c0cbe939446a09873 Mon Sep 17 00:00:00 2001 From: Toni Oriol Date: Thu, 14 May 2026 00:13:08 +0200 Subject: [PATCH] fix(playback): handle IPTV proxy URLs without file extensions (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(playback): handle IPTV proxy URLs without file extensions IPTV proxy servers (Acexy, Xtream Codes, etc.) use query-string URLs like `https://proxy.example.com/ace/getstream?infohash=abc` that return raw MPEG-TS streams without a file extension in the path. `getExtensionFromUrl()` was returning garbage for these URLs (e.g. `com/ace/getstream` from splitting on dots across path segments). All four player backends then misrouted the stream — typically to HLS.js which failed because the response is raw MPEG-TS, not an HLS manifest. Root fix: rewrite `getExtensionFromUrl()` to extract the extension from the last path segment only, returning `undefined` when there is no dot-separated extension. Player adjustments: treat `undefined` extension as MPEG-TS (the most common format for live IPTV proxy streams) across all four backends (HTML5, ArtPlayer, VideoJs, web-player-view). * test(m3u-utils): cover URL extension parsing * fix(playback): preserve query-declared stream extensions --------- Co-authored-by: 4gray --- .../m3u-utils/src/lib/playlist.utils.spec.ts | 32 ++++++++++++++++- .../m3u-utils/src/lib/playlist.utils.ts | 36 +++++++++++++++++-- .../art-player/art-player.component.spec.ts | 10 ++++++ .../lib/art-player/art-player.component.ts | 12 ++++--- .../html-video-player.component.ts | 6 ++-- .../vjs-player/vjs-player.component.spec.ts | 16 +++++++++ .../lib/vjs-player/vjs-player.component.ts | 5 +-- .../web-player-view.component.spec.ts | 14 ++++++++ .../web-player-view.component.ts | 6 ++-- 9 files changed, 121 insertions(+), 16 deletions(-) diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts index a30525e98..c6ef64006 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts @@ -1,5 +1,9 @@ import { Channel, Playlist } from 'shared-interfaces'; -import { aggregateFavoriteChannels } from './playlist.utils'; +import { + aggregateFavoriteChannels, + getExtensionFromUrl, + getStreamExtensionFromUrl, +} from './playlist.utils'; function createChannel(id: string, url: string, name = id): Channel { return { @@ -75,4 +79,30 @@ describe('playlist utils', () => { expect(result).toEqual([first, third]); }); + + describe('getExtensionFromUrl', () => { + it.each([ + ['https://host/path/file.ts?token=x', 'ts'], + ['https://host/ace/getstream?infohash=x', undefined], + ['https://host/path.with.dots/stream?x=y', undefined], + ['https://host/path/file.m3u8', 'm3u8'], + ['https://host/path/.ts', undefined], + ])('extracts the path extension from %s', (url, expected) => { + expect(getExtensionFromUrl(url)).toBe(expected); + }); + }); + + describe('getStreamExtensionFromUrl', () => { + it.each([ + ['https://host/play?extension=m3u8&token=x', 'm3u8'], + ['https://host/live.php?stream=123&extension=ts', 'ts'], + ['https://host/path/file.ts?token=x', 'ts'], + ['https://host/ace/getstream?infohash=x', undefined], + ])( + 'prefers declared stream extension metadata from %s', + (url, expected) => { + expect(getStreamExtensionFromUrl(url)).toBe(expected); + } + ); + }); }); diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index 5da5eff2d..c3d77b93d 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -99,6 +99,38 @@ export const createPlaylistObject = ( }; }; -export const getExtensionFromUrl = (url: string) => { - return url.split(/[#?]/)[0].split('.').pop()?.trim(); +/** + * Extract the file extension from a URL, ignoring query strings and fragments. + * + * Returns `undefined` when no real extension is found — e.g. for IPTV proxy + * URLs like `https://proxy.example.com/ace/getstream?infohash=abc` where the + * path segment has no dot-separated extension. + */ +export const getExtensionFromUrl = (url: string): string | undefined => { + const path = url.split(/[#?]/)[0]; + const lastSegment = path.split('/').pop() || ''; + const dotIndex = lastSegment.lastIndexOf('.'); + if (dotIndex < 1) return undefined; + const ext = lastSegment.slice(dotIndex + 1).trim(); + return ext || undefined; +}; + +export const getStreamExtensionFromUrl = (url: string): string | undefined => { + return getExtensionFromUrlQuery(url) ?? getExtensionFromUrl(url); +}; + +const getExtensionFromUrlQuery = (url: string): string | undefined => { + try { + const parsedUrl = new URL(url, 'http://iptvnator.local'); + return normalizeExtensionToken(parsedUrl.searchParams.get('extension')); + } catch { + return undefined; + } +}; + +const normalizeExtensionToken = ( + value: string | null | undefined +): string | undefined => { + const extension = value?.trim().replace(/^\.+/, '').toLowerCase(); + return extension || undefined; }; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 2184fe402..76f62f4ea 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -193,6 +193,16 @@ describe('ArtPlayerComponent', () => { expect(issues).toEqual([]); }); + it('uses HLS playback for URLs with a query-declared m3u8 extension', () => { + createComponent({ + url: 'https://example.com/play?extension=m3u8&token=signed', + name: 'Signed HLS Live', + }); + + expect(artPlayerInstances[0].options['type']).toBe('m3u8'); + expect(artPlayerInstances[0].options['isLive']).toBe(true); + }); + it('emits a playback issue when mpegts.js reports an unsupported codec', () => { createComponent({ url: 'https://example.com/live/channel.ts', diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 6de258f9e..b875a0609 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import Artplayer from 'artplayer'; import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; -import { getExtensionFromUrl } from 'm3u-utils'; +import { getStreamExtensionFromUrl } from 'm3u-utils'; import mpegts from 'mpegts.js'; import { Channel } from 'shared-interfaces'; import { @@ -131,8 +131,8 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { const el = this.elementRef.nativeElement.querySelector( '.artplayer-container' ); - const extension = getExtensionFromUrl(this.channel?.url ?? ''); - const isLive = extension === 'm3u8' || extension === 'ts'; + const extension = getStreamExtensionFromUrl(this.channel?.url ?? ''); + const isLive = extension === 'm3u8' || extension === 'ts' || !extension; this.player = new Artplayer({ container: el, @@ -325,7 +325,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { } private getVideoType(url: string): string { - const extension = getExtensionFromUrl(url); + const extension = getStreamExtensionFromUrl(url); switch (extension) { case 'mkv': return 'video/matroska'; @@ -336,7 +336,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { case 'ts': return 'ts'; default: - return 'auto'; + // No recognized extension (e.g. IPTV proxy URL) → default to + // MPEG-TS which is the most common format for live IPTV streams. + return extension ? 'auto' : 'ts'; } } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 2a4cb49a1..a4aa4ca8d 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; import mpegts from 'mpegts.js'; -import { getExtensionFromUrl } from 'm3u-utils'; +import { getStreamExtensionFromUrl } from 'm3u-utils'; import { DataService } from 'services'; import { Channel } from 'shared-interfaces'; import { @@ -143,7 +143,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { if (channel.url) { this.playbackIssue.emit(null); const url = channel.url + (channel.epgParams ?? ''); - const extension = getExtensionFromUrl(channel.url); + const extension = getStreamExtensionFromUrl(channel.url); // Set user agent if specified on channel if (channel.http?.['user-agent']) { @@ -153,7 +153,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ); } - if (extension === 'ts' && mpegts.isSupported()) { + if ((extension === 'ts' || !extension) && mpegts.isSupported()) { console.log('Using mpegts.js for TS stream:', channel.name, url); this.mpegtsPlayer = mpegts.createPlayer({ type: 'mpegts', diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index 4df67a477..b25298785 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -149,6 +149,22 @@ describe('VjsPlayerComponent', () => { expect(player.volume).toHaveBeenCalledWith(0.75); }); + it('does not treat query-declared HLS streams as MPEG-TS sources', () => { + mpegTsIsSupportedMock.mockReturnValue(true); + const isMpegTsSource = ( + component as unknown as { + isMpegTsSource: (url?: string) => boolean; + } + ).isMpegTsSource.bind(component); + + expect( + isMpegTsSource('https://example.com/play?extension=m3u8&token=x') + ).toBe(false); + expect( + isMpegTsSource('https://example.com/live.php?extension=ts') + ).toBe(true); + }); + it('emits a playback issue when VideoJS reports an unsupported source', () => { const issues: unknown[] = []; const videoElement = document.createElement('video'); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 57c625ac8..d1b538aa8 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -11,7 +11,7 @@ import { viewChild, } from '@angular/core'; import '@yangkghjh/videojs-aspect-ratio-panel'; -import { getExtensionFromUrl } from 'm3u-utils'; +import { getStreamExtensionFromUrl } from 'm3u-utils'; import mpegts from 'mpegts.js'; import videoJs from 'video.js'; import 'videojs-contrib-quality-levels'; @@ -254,7 +254,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private isMpegTsSource(url?: string): boolean { if (!url) return false; - return getExtensionFromUrl(url) === 'ts' && mpegts.isSupported(); + const extension = getStreamExtensionFromUrl(url); + return (extension === 'ts' || !extension) && mpegts.isSupported(); } private hasSourceChanged( diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index b8017fc17..90fedae49 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -168,6 +168,20 @@ describe('WebPlayerViewComponent', () => { }), ]); }); + + it('keeps query-declared HLS streams on the HLS mime type', () => { + const streamUrl = + 'https://example.com/play?extension=m3u8&token=signed'; + + component.setVjsOptions(streamUrl); + + expect(component.vjsOptions.sources).toEqual([ + { + src: streamUrl, + type: 'application/x-mpegURL', + }, + ]); + }); }); function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index f5999b1f8..7e7559579 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -17,7 +17,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe } from '@ngx-translate/core'; -import { getExtensionFromUrl } from 'm3u-utils'; +import { getStreamExtensionFromUrl } from 'm3u-utils'; import { ResolvedPortalPlayback, Settings, @@ -115,11 +115,11 @@ export class WebPlayerViewComponent { } setVjsOptions(streamUrl: string) { - const extension = getExtensionFromUrl(streamUrl); + const extension = getStreamExtensionFromUrl(streamUrl); const mimeType = extension === 'm3u' || extension === 'm3u8' ? 'application/x-mpegURL' - : extension === 'ts' + : extension === 'ts' || !extension ? 'video/mp2t' : 'video/mp4';