fix(playback): handle IPTV proxy URLs without file extensions (#934)

* 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 <serega05@gmail.com>
This commit is contained in:
Toni Orioland4gray authored and GitHub committed 2026-05-14 00:13:08 +02:00
1 parent 9ae984ad7f
commit 2f9aef1578
9 files changed
+121 -16

No files matched your search

@@ -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);
}
);
});
});
@@ -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;
};
@@ -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',
@@ -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';
}
}
@@ -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',
@@ -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');
@@ -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(
@@ -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 {
@@ -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';