fix(m3u): unify DASH URL detection with playback extension normalization

isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs
the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd)
were not routed to the Shaka-capable inline player and lost their
ClearKey metadata with Video.js or external players configured
(Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives
in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback
lib) and both routing and engine selection share it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-23 23:40:40 +02:00
1 parent 48d191b0d7
commit 95bd7f7fe0
5 files changed
+145 -122

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/dash.utils';
export * from './lib/kodiprop.utils';
export * from './lib/playback-media-extension.util';
export * from './lib/playlist.utils';
export * from './lib/catchup.utils';
export * from './lib/strip-country-prefix.util';
@@ -3,8 +3,11 @@ import { isDashChannel, isDashStreamUrl } from './dash.utils';
describe('dash.utils', () => {
it.each([
'https://example.com/live/stream.mpd',
'https://example.com/live/stream.MPD',
'https://example.com/live/stream.mpd?token=abc#frag',
'https://example.com/play?extension=mpd',
'https://example.com/play?ext=mpd',
'https://example.com/play?format=mpd',
])('detects %s as DASH', (url) => {
expect(isDashStreamUrl(url)).toBe(true);
});
+6 -2
View File
@@ -1,13 +1,17 @@
import { Channel } from '@iptvnator/shared/interfaces';
import { getStreamExtensionFromUrl } from './playlist.utils';
import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util';
/**
* DASH (`.mpd`) detection for M3U channels. DASH streams must always play in
* a Shaka-capable built-in web engine: external MPV/VLC cannot receive the
* KODIPROP ClearKey configuration, and Video.js has no DASH bridge yet.
*
* Uses the same normalized extension detection as the player engines, so
* routing and engine selection always agree (`stream.MPD`, `?format=mpd`,
* `?ext=mpd` and friends included).
*/
export const isDashStreamUrl = (url: string | undefined): boolean =>
!!url && getStreamExtensionFromUrl(url) === 'mpd';
!!url && getPlaybackMediaExtensionFromUrl(url) === 'mpd';
export const isDashChannel = (
channel: Pick<Channel, 'url'> | null | undefined
@@ -0,0 +1,127 @@
import { getExtensionFromUrl } from './playlist.utils';
/**
* Normalized media-extension detection for playback URLs. Shared between the
* player engines (engine selection in `@iptvnator/ui/playback`) and M3U
* routing (`isDashChannel`), so both always agree on what a URL is.
*/
export const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
'flv',
'm2ts',
'm4v',
'mkv',
'mov',
'mpeg',
'mpg',
'rm',
'rmvb',
'ts',
'vob',
'wmv',
]);
const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext'];
const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container'];
const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([
['hls', 'm3u8'],
['mpegts', 'ts'],
['mpeg-ts', 'ts'],
]);
const DECLARED_MEDIA_EXTENSIONS = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'aac',
'flac',
'm3u',
'm3u8',
'm4s',
'mp3',
'mp4',
'mpd',
'oga',
'ogg',
'ogv',
'webm',
]);
const NON_MEDIA_URL_EXTENSIONS = new Set([
'asp',
'aspx',
'cgi',
'jsp',
'mpv',
'php',
'pl',
]);
export function getPlaybackMediaExtensionFromUrl(url: string): string {
const explicitQueryExtension = getMediaExtensionFromQuery(
url,
EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS
);
if (explicitQueryExtension) {
return explicitQueryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) {
return pathExtension;
}
const formatQueryExtension = getMediaExtensionFromQuery(
url,
DECLARED_MEDIA_FORMAT_QUERY_KEYS
);
if (formatQueryExtension) {
return formatQueryExtension;
}
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
function normalizeExtensionToken(value: string | undefined): string {
return (value?.trim().toLowerCase() ?? '').replace(/^\.+/, '');
}
function getMediaExtensionFromQuery(
url: string,
queryKeys: readonly string[]
): string {
try {
const parsedUrl = new URL(url, 'http://iptvnator.local');
for (const key of queryKeys) {
const declaredExtension = normalizeDeclaredMediaExtension(
parsedUrl.searchParams.get(key) ?? undefined
);
if (declaredExtension) {
return declaredExtension;
}
}
return '';
} catch {
return '';
}
}
function normalizeDeclaredMediaExtension(value: string | undefined): string {
const extension = normalizeExtensionToken(value);
const normalized =
DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension;
if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) {
return '';
}
return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : '';
}
@@ -1,27 +1,17 @@
import { getExtensionFromUrl } from '@iptvnator/shared/m3u-utils';
import {
UNSUPPORTED_CONTAINER_EXTENSIONS,
getPlaybackMediaExtensionFromUrl,
} from '@iptvnator/shared/m3u-utils';
import type {
PlaybackSourceMetadata,
PlaybackSourceMetadataInput,
} from './playback-diagnostics.model';
const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
'avi',
'asf',
'divx',
'flv',
'm2ts',
'm4v',
'mkv',
'mov',
'mpeg',
'mpg',
'rm',
'rmvb',
'ts',
'vob',
'wmv',
]);
// Media-extension normalization lives in @iptvnator/shared/m3u-utils so M3U
// routing (isDashChannel) and engine selection always agree; re-exported here
// to keep the playback-lib import surface stable.
export { getPlaybackMediaExtensionFromUrl };
const UNSUPPORTED_CONTAINER_NAMES = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
@@ -31,42 +21,6 @@ const UNSUPPORTED_CONTAINER_NAMES = new Set([
'x-msvideo',
]);
const EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS = ['extension', 'ext'];
const DECLARED_MEDIA_FORMAT_QUERY_KEYS = ['format', 'container'];
const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([
['hls', 'm3u8'],
['mpegts', 'ts'],
['mpeg-ts', 'ts'],
]);
const DECLARED_MEDIA_EXTENSIONS = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'aac',
'flac',
'm3u',
'm3u8',
'm4s',
'mp3',
'mp4',
'mpd',
'oga',
'ogg',
'ogv',
'webm',
]);
const NON_MEDIA_URL_EXTENSIONS = new Set([
'asp',
'aspx',
'cgi',
'jsp',
'mpv',
'php',
'pl',
]);
const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{
readonly label: string;
readonly pattern: RegExp;
@@ -95,35 +49,6 @@ export function createPlaybackSourceMetadata(
};
}
export function getPlaybackMediaExtensionFromUrl(url: string): string {
const explicitQueryExtension = getMediaExtensionFromQuery(
url,
EXPLICIT_MEDIA_EXTENSION_QUERY_KEYS
);
if (explicitQueryExtension) {
return explicitQueryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (DECLARED_MEDIA_EXTENSIONS.has(pathExtension)) {
return pathExtension;
}
const formatQueryExtension = getMediaExtensionFromQuery(
url,
DECLARED_MEDIA_FORMAT_QUERY_KEYS
);
if (formatQueryExtension) {
return formatQueryExtension;
}
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
export function getLikelyBrowserUnsupportedCodecLabels(
metadata: Pick<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): string[] {
@@ -188,40 +113,3 @@ function normalizeCodecs(codecs: readonly string[] | undefined): string[] {
function normalizeToken(value: string | undefined): string {
return value?.trim().toLowerCase() ?? '';
}
function normalizeExtensionToken(value: string | undefined): string {
return normalizeToken(value).replace(/^\.+/, '');
}
function getMediaExtensionFromQuery(
url: string,
queryKeys: readonly string[]
): string {
try {
const parsedUrl = new URL(url, 'http://iptvnator.local');
for (const key of queryKeys) {
const declaredExtension = normalizeDeclaredMediaExtension(
parsedUrl.searchParams.get(key) ?? undefined
);
if (declaredExtension) {
return declaredExtension;
}
}
return '';
} catch {
return '';
}
}
function normalizeDeclaredMediaExtension(value: string | undefined): string {
const extension = normalizeExtensionToken(value);
const normalized =
DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension;
if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) {
return '';
}
return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : '';
}