mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user