diff --git a/libs/shared/m3u-utils/src/index.ts b/libs/shared/m3u-utils/src/index.ts index 493feb47e..e9c180317 100644 --- a/libs/shared/m3u-utils/src/index.ts +++ b/libs/shared/m3u-utils/src/index.ts @@ -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'; diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts index b51ad7988..32cc17523 100644 --- a/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/dash.utils.spec.ts @@ -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); }); diff --git a/libs/shared/m3u-utils/src/lib/dash.utils.ts b/libs/shared/m3u-utils/src/lib/dash.utils.ts index 3fcf29072..b8860c164 100644 --- a/libs/shared/m3u-utils/src/lib/dash.utils.ts +++ b/libs/shared/m3u-utils/src/lib/dash.utils.ts @@ -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 | null | undefined diff --git a/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts b/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts new file mode 100644 index 000000000..fe919896c --- /dev/null +++ b/libs/shared/m3u-utils/src/lib/playback-media-extension.util.ts @@ -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 : ''; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts index ba3ab2000..8ac8ff5d9 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts @@ -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 ): 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 : ''; -}