diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts new file mode 100644 index 000000000..bcbb2c9ed --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts @@ -0,0 +1,97 @@ +import type { + ExternalPlayerName, + ResolvedPortalPlayback, +} from 'shared-interfaces'; + +export const PlaybackDiagnosticCode = { + UnsupportedContainer: 'unsupported-container', + UnsupportedCodec: 'unsupported-codec', + MediaDecodeError: 'media-decode-error', + NetworkError: 'network-error', + BrowserAccessError: 'browser-access-error', + DrmOrEncryption: 'drm-or-encryption', + UnknownPlaybackError: 'unknown-playback-error', +} as const; + +export type PlaybackDiagnosticCode = + (typeof PlaybackDiagnosticCode)[keyof typeof PlaybackDiagnosticCode]; + +export const PlaybackDiagnosticSource = { + Source: 'source', + Native: 'native', + Hls: 'hls', + MpegTs: 'mpegts', +} as const; + +export type PlaybackDiagnosticSource = + (typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource]; + +export const InlinePlaybackPlayer = { + VideoJs: 'videojs', + Html5: 'html5', + ArtPlayer: 'artplayer', +} as const; + +export type InlinePlaybackPlayer = + (typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer]; + +export interface PlaybackSourceMetadataInput { + readonly url: string; + readonly mimeType?: string; + readonly player?: InlinePlaybackPlayer; + readonly audioCodecs?: readonly string[]; + readonly videoCodecs?: readonly string[]; +} + +export interface PlaybackSourceMetadata { + readonly url: string; + readonly extension: string; + readonly container: string; + readonly mimeType?: string; + readonly player?: InlinePlaybackPlayer; + readonly audioCodecs: readonly string[]; + readonly videoCodecs: readonly string[]; +} + +export interface NativePlaybackErrorInput { + readonly code?: number; + readonly message?: string; +} + +export interface HlsPlaybackErrorInput { + readonly type?: string; + readonly details?: string; + readonly fatal?: boolean; + readonly message?: string; + readonly error?: unknown; + readonly audioCodecs?: readonly string[]; + readonly videoCodecs?: readonly string[]; +} + +export interface MpegTsPlaybackErrorInput { + readonly type?: string; + readonly details?: string; + readonly message?: string; + readonly info?: unknown; +} + +export interface PlaybackDiagnostic { + readonly code: PlaybackDiagnosticCode; + readonly source: PlaybackDiagnosticSource; + readonly sourceUrl: string; + readonly container: string; + readonly mimeType?: string; + readonly player?: InlinePlaybackPlayer; + readonly audioCodecs: readonly string[]; + readonly videoCodecs: readonly string[]; + readonly details?: string; + readonly nativeErrorCode?: number; + readonly nativeErrorMessage?: string; + readonly externalFallbackRecommended: boolean; +} + +export interface PlaybackFallbackRequest { + readonly player: ExternalPlayerName; + readonly playback: ResolvedPortalPlayback; + readonly diagnostic: PlaybackDiagnostic; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts index c0b32e744..a36024acb 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -1,237 +1,37 @@ -import { getExtensionFromUrl } from 'm3u-utils'; import type { - ExternalPlayerName, - ResolvedPortalPlayback, -} from 'shared-interfaces'; + HlsPlaybackErrorInput, + MpegTsPlaybackErrorInput, + NativePlaybackErrorInput, + PlaybackDiagnostic, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + PlaybackSourceMetadata, +} from './playback-diagnostics.model'; +import { PlaybackDiagnosticCode as DiagnosticCode } from './playback-diagnostics.model'; +import { PlaybackDiagnosticSource as DiagnosticSource } from './playback-diagnostics.model'; +import { + isBrowserAccessFailure, + isCodecFailure, + isDrmOrEncryptionFailure, + isNetworkFailure, + normalizeErrorDetails, +} from './playback-error-patterns.util'; +import { + isLikelyContainerIssue, + mergeCodecMetadata, +} from './playback-media-source.util'; -export const PlaybackDiagnosticCode = { - UnsupportedContainer: 'unsupported-container', - UnsupportedCodec: 'unsupported-codec', - MediaDecodeError: 'media-decode-error', - NetworkError: 'network-error', - BrowserAccessError: 'browser-access-error', - DrmOrEncryption: 'drm-or-encryption', - UnknownPlaybackError: 'unknown-playback-error', -} as const; - -export type PlaybackDiagnosticCode = - (typeof PlaybackDiagnosticCode)[keyof typeof PlaybackDiagnosticCode]; - -export const PlaybackDiagnosticSource = { - Source: 'source', - Native: 'native', - Hls: 'hls', - MpegTs: 'mpegts', -} as const; - -export type PlaybackDiagnosticSource = - (typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource]; - -export const InlinePlaybackPlayer = { - VideoJs: 'videojs', - Html5: 'html5', - ArtPlayer: 'artplayer', -} as const; - -export type InlinePlaybackPlayer = - (typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer]; - -export interface PlaybackSourceMetadataInput { - readonly url: string; - readonly mimeType?: string; - readonly player?: InlinePlaybackPlayer; - readonly audioCodecs?: readonly string[]; - readonly videoCodecs?: readonly string[]; -} - -export interface PlaybackSourceMetadata { - readonly url: string; - readonly extension: string; - readonly container: string; - readonly mimeType?: string; - readonly player?: InlinePlaybackPlayer; - readonly audioCodecs: readonly string[]; - readonly videoCodecs: readonly string[]; -} - -export interface NativePlaybackErrorInput { - readonly code?: number; - readonly message?: string; -} - -export interface HlsPlaybackErrorInput { - readonly type?: string; - readonly details?: string; - readonly fatal?: boolean; - readonly message?: string; - readonly error?: unknown; - readonly audioCodecs?: readonly string[]; - readonly videoCodecs?: readonly string[]; -} - -export interface MpegTsPlaybackErrorInput { - readonly type?: string; - readonly details?: string; - readonly message?: string; - readonly info?: unknown; -} - -export interface PlaybackDiagnostic { - readonly code: PlaybackDiagnosticCode; - readonly source: PlaybackDiagnosticSource; - readonly sourceUrl: string; - readonly container: string; - readonly mimeType?: string; - readonly player?: InlinePlaybackPlayer; - readonly audioCodecs: readonly string[]; - readonly videoCodecs: readonly string[]; - readonly details?: string; - readonly nativeErrorCode?: number; - readonly nativeErrorMessage?: string; - readonly externalFallbackRecommended: boolean; -} - -export interface PlaybackFallbackRequest { - readonly player: ExternalPlayerName; - readonly playback: ResolvedPortalPlayback; - readonly diagnostic: PlaybackDiagnostic; -} - -const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([ - 'avi', - 'asf', - 'divx', - 'flv', - 'm2ts', - 'm4v', - 'mkv', - 'mov', - 'mpeg', - 'mpg', - 'rm', - 'rmvb', - 'ts', - 'vob', - 'wmv', -]); - -const UNSUPPORTED_CONTAINER_NAMES = new Set([ - ...UNSUPPORTED_CONTAINER_EXTENSIONS, - 'matroska', - 'quicktime', - 'x-matroska', - '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', -]); +export * from './playback-diagnostics.model'; +export { + createPlaybackSourceMetadata, + getLikelyBrowserUnsupportedCodecLabels, + getPlaybackMediaExtensionFromUrl, +} from './playback-media-source.util'; const SOURCE_NOT_SUPPORTED_CODE = 4; const DECODE_ERROR_CODE = 3; const NETWORK_ERROR_CODE = 2; -const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{ - readonly label: string; - readonly pattern: RegExp; -}> = [ - { label: 'HEVC', pattern: /^(hev1|hvc1|hevc|h265)/ }, - { label: 'AC-3', pattern: /^(ac-?3|dac3)$/ }, - { label: 'E-AC-3', pattern: /^(ec-?3|eac-?3|dec3)$/ }, - { label: 'DTS', pattern: /^(dts|dtsc|dtse|dtsh|dtsl)/ }, - { label: 'MPEG-2 Video', pattern: /^(mp2v|mpeg2video)/ }, -]; - -export function createPlaybackSourceMetadata( - input: PlaybackSourceMetadataInput -): PlaybackSourceMetadata { - const extension = getPlaybackMediaExtensionFromUrl(input.url); - const mimeType = input.mimeType?.trim() || undefined; - - return { - url: input.url, - extension, - container: extension || inferContainerFromMimeType(mimeType), - mimeType, - player: input.player, - audioCodecs: normalizeCodecs(input.audioCodecs), - videoCodecs: normalizeCodecs(input.videoCodecs), - }; -} - -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[] { - const codecs = [...metadata.videoCodecs, ...metadata.audioCodecs].map( - normalizeToken - ); - - return BROWSER_LIMITED_CODEC_PATTERNS.filter(({ pattern }) => - codecs.some((codec) => pattern.test(codec)) - ).map(({ label }) => label); -} - export function classifyNativePlaybackIssue( error: NativePlaybackErrorInput | MediaError | null | undefined, metadata: PlaybackSourceMetadata @@ -246,9 +46,9 @@ export function classifyNativePlaybackIssue( // concrete CORS/mixed-content/CSP-style message. return createDiagnostic({ code: isBrowserAccessFailure(lowerNativeErrorMessage) - ? PlaybackDiagnosticCode.BrowserAccessError - : PlaybackDiagnosticCode.NetworkError, - source: PlaybackDiagnosticSource.Native, + ? DiagnosticCode.BrowserAccessError + : DiagnosticCode.NetworkError, + source: DiagnosticSource.Native, metadata, nativeErrorCode, nativeErrorMessage, @@ -257,8 +57,8 @@ export function classifyNativePlaybackIssue( if (nativeErrorCode === DECODE_ERROR_CODE) { return createDiagnostic({ - code: PlaybackDiagnosticCode.MediaDecodeError, - source: PlaybackDiagnosticSource.Native, + code: DiagnosticCode.MediaDecodeError, + source: DiagnosticSource.Native, metadata, nativeErrorCode, nativeErrorMessage, @@ -268,9 +68,9 @@ export function classifyNativePlaybackIssue( if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) { return createDiagnostic({ code: isLikelyContainerIssue(metadata) - ? PlaybackDiagnosticCode.UnsupportedContainer - : PlaybackDiagnosticCode.UnsupportedCodec, - source: PlaybackDiagnosticSource.Native, + ? DiagnosticCode.UnsupportedContainer + : DiagnosticCode.UnsupportedCodec, + source: DiagnosticSource.Native, metadata, nativeErrorCode, nativeErrorMessage, @@ -278,8 +78,8 @@ export function classifyNativePlaybackIssue( } return createDiagnostic({ - code: PlaybackDiagnosticCode.UnknownPlaybackError, - source: PlaybackDiagnosticSource.Native, + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.Native, metadata, nativeErrorCode, nativeErrorMessage, @@ -301,9 +101,9 @@ export function classifyHlsPlaybackIssue( if (isNetworkFailure(lowerType, lowerDetails)) { return createDiagnostic({ code: isBrowserAccessFailure(lowerDetails) - ? PlaybackDiagnosticCode.BrowserAccessError - : PlaybackDiagnosticCode.NetworkError, - source: PlaybackDiagnosticSource.Hls, + ? DiagnosticCode.BrowserAccessError + : DiagnosticCode.NetworkError, + source: DiagnosticSource.Hls, metadata: mergedMetadata, details, }); @@ -311,8 +111,8 @@ export function classifyHlsPlaybackIssue( if (isDrmOrEncryptionFailure(lowerDetails)) { return createDiagnostic({ - code: PlaybackDiagnosticCode.DrmOrEncryption, - source: PlaybackDiagnosticSource.Hls, + code: DiagnosticCode.DrmOrEncryption, + source: DiagnosticSource.Hls, metadata: mergedMetadata, details, }); @@ -320,8 +120,8 @@ export function classifyHlsPlaybackIssue( if (isCodecFailure(lowerDetails)) { return createDiagnostic({ - code: PlaybackDiagnosticCode.UnsupportedCodec, - source: PlaybackDiagnosticSource.Hls, + code: DiagnosticCode.UnsupportedCodec, + source: DiagnosticSource.Hls, metadata: mergedMetadata, details, }); @@ -329,16 +129,16 @@ export function classifyHlsPlaybackIssue( if (lowerType.includes('media') || lowerType.includes('mux')) { return createDiagnostic({ - code: PlaybackDiagnosticCode.MediaDecodeError, - source: PlaybackDiagnosticSource.Hls, + code: DiagnosticCode.MediaDecodeError, + source: DiagnosticSource.Hls, metadata: mergedMetadata, details, }); } return createDiagnostic({ - code: PlaybackDiagnosticCode.UnknownPlaybackError, - source: PlaybackDiagnosticSource.Hls, + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.Hls, metadata: mergedMetadata, details, }); @@ -355,9 +155,9 @@ export function classifyMpegTsPlaybackIssue( if (isNetworkFailure(lowerType, lowerDetails)) { return createDiagnostic({ code: isBrowserAccessFailure(lowerDetails) - ? PlaybackDiagnosticCode.BrowserAccessError - : PlaybackDiagnosticCode.NetworkError, - source: PlaybackDiagnosticSource.MpegTs, + ? DiagnosticCode.BrowserAccessError + : DiagnosticCode.NetworkError, + source: DiagnosticSource.MpegTs, metadata, details, }); @@ -365,8 +165,8 @@ export function classifyMpegTsPlaybackIssue( if (lowerDetails.includes('codec')) { return createDiagnostic({ - code: PlaybackDiagnosticCode.UnsupportedCodec, - source: PlaybackDiagnosticSource.MpegTs, + code: DiagnosticCode.UnsupportedCodec, + source: DiagnosticSource.MpegTs, metadata, details, }); @@ -374,8 +174,8 @@ export function classifyMpegTsPlaybackIssue( if (lowerDetails.includes('format') || lowerDetails.includes('mse')) { return createDiagnostic({ - code: PlaybackDiagnosticCode.UnsupportedContainer, - source: PlaybackDiagnosticSource.MpegTs, + code: DiagnosticCode.UnsupportedContainer, + source: DiagnosticSource.MpegTs, metadata, details, }); @@ -383,16 +183,16 @@ export function classifyMpegTsPlaybackIssue( if (lowerType.includes('media')) { return createDiagnostic({ - code: PlaybackDiagnosticCode.MediaDecodeError, - source: PlaybackDiagnosticSource.MpegTs, + code: DiagnosticCode.MediaDecodeError, + source: DiagnosticSource.MpegTs, metadata, details, }); } return createDiagnostic({ - code: PlaybackDiagnosticCode.UnknownPlaybackError, - source: PlaybackDiagnosticSource.MpegTs, + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.MpegTs, metadata, details, }); @@ -413,8 +213,8 @@ export function classifyUnsupportedHlsManifestCodecs( } return createDiagnostic({ - code: PlaybackDiagnosticCode.UnsupportedCodec, - source: PlaybackDiagnosticSource.Source, + code: DiagnosticCode.UnsupportedCodec, + source: DiagnosticSource.Source, metadata, details: mimeType, }); @@ -455,186 +255,11 @@ function createDiagnostic(options: { function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean { return ( - code === PlaybackDiagnosticCode.UnsupportedContainer || - code === PlaybackDiagnosticCode.UnsupportedCodec || - code === PlaybackDiagnosticCode.MediaDecodeError || - code === PlaybackDiagnosticCode.BrowserAccessError || - code === PlaybackDiagnosticCode.DrmOrEncryption - ); -} - -function mergeCodecMetadata( - metadata: PlaybackSourceMetadata, - codecs: Pick -): PlaybackSourceMetadata { - const audioCodecs = normalizeCodecs([ - ...metadata.audioCodecs, - ...(codecs.audioCodecs ?? []), - ]); - const videoCodecs = normalizeCodecs([ - ...metadata.videoCodecs, - ...(codecs.videoCodecs ?? []), - ]); - - return { - ...metadata, - audioCodecs, - videoCodecs, - }; -} - -function normalizeCodecs(codecs: readonly string[] | undefined): string[] { - return Array.from( - new Set( - (codecs ?? []) - .map((codec) => codec.trim()) - .filter((codec) => codec.length > 0) - ) - ); -} - -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 : ''; -} - -function inferContainerFromMimeType(mimeType: string | undefined): string { - if (!mimeType) { - return ''; - } - - const subtype = mimeType.split(';')[0]?.split('/')[1] ?? ''; - return subtype.toLowerCase(); -} - -function isLikelyContainerIssue(metadata: PlaybackSourceMetadata): boolean { - return ( - UNSUPPORTED_CONTAINER_NAMES.has(metadata.extension) || - UNSUPPORTED_CONTAINER_NAMES.has(metadata.container) || - metadata.mimeType === 'video/matroska' - ); -} - -function normalizeErrorDetails( - error: HlsPlaybackErrorInput | MpegTsPlaybackErrorInput -): string { - const hlsError = 'error' in error ? error.error : undefined; - const mpegTsInfo = 'info' in error ? error.info : undefined; - const errorMessage = normalizeErrorPayload(hlsError); - const info = normalizeErrorPayload(mpegTsInfo); - - return [error.details, error.message, errorMessage, info] - .filter((part): part is string => Boolean(part)) - .join(' '); -} - -function normalizeErrorPayload(payload: unknown): string { - if (!payload) { - return ''; - } - - if (typeof payload === 'string') { - return payload; - } - - if (payload instanceof Error) { - const extraDetails = stringifyUnknown(payload); - return [payload.message, extraDetails === '{}' ? '' : extraDetails] - .filter(Boolean) - .join(' '); - } - - return stringifyUnknown(payload); -} - -function stringifyUnknown(value: unknown): string { - try { - return JSON.stringify(value) || ''; - } catch { - return String(value); - } -} - -function isNetworkFailure(type: string, details: string): boolean { - return ( - type.includes('network') || - details.includes('network') || - details.includes('loaderror') || - details.includes('timeout') || - details.includes('status') - ); -} - -function isBrowserAccessFailure(details: string): boolean { - return ( - details.includes('cors') || - details.includes('cross-origin') || - details.includes('cross origin') || - details.includes('access-control') || - details.includes('access control') || - details.includes('content security policy') || - details.includes('mixed content') || - details.includes('private network access') || - details.includes('blocked by content security') || - details.includes('blocked by cors') || - details.includes('blocked by mixed content') || - details.includes('not allowed to load local resource') || - details.includes('err_blocked') || - details.includes('err_cleartext') - ); -} - -function isCodecFailure(details: string): boolean { - return ( - details.includes('codec') || - details.includes('incompatiblecodecs') || - details.includes('addcodec') - ); -} - -function isDrmOrEncryptionFailure(details: string): boolean { - return ( - details.includes('decrypt') || - details.includes('keysystem') || - details.includes('keyload') || - details.includes('license') || - details.includes('drm') + code === DiagnosticCode.UnsupportedContainer || + code === DiagnosticCode.UnsupportedCodec || + code === DiagnosticCode.MediaDecodeError || + code === DiagnosticCode.BrowserAccessError || + code === DiagnosticCode.DrmOrEncryption ); } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts new file mode 100644 index 000000000..9044469b0 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts @@ -0,0 +1,91 @@ +import type { + HlsPlaybackErrorInput, + MpegTsPlaybackErrorInput, +} from './playback-diagnostics.model'; + +export function normalizeErrorDetails( + error: HlsPlaybackErrorInput | MpegTsPlaybackErrorInput +): string { + const hlsError = 'error' in error ? error.error : undefined; + const mpegTsInfo = 'info' in error ? error.info : undefined; + const errorMessage = normalizeErrorPayload(hlsError); + const info = normalizeErrorPayload(mpegTsInfo); + + return [error.details, error.message, errorMessage, info] + .filter((part): part is string => Boolean(part)) + .join(' '); +} + +export function isNetworkFailure(type: string, details: string): boolean { + return ( + type.includes('network') || + details.includes('network') || + details.includes('loaderror') || + details.includes('timeout') || + details.includes('status') + ); +} + +export function isBrowserAccessFailure(details: string): boolean { + return ( + details.includes('cors') || + details.includes('cross-origin') || + details.includes('cross origin') || + details.includes('access-control') || + details.includes('access control') || + details.includes('content security policy') || + details.includes('mixed content') || + details.includes('private network access') || + details.includes('blocked by content security') || + details.includes('blocked by cors') || + details.includes('blocked by mixed content') || + details.includes('not allowed to load local resource') || + details.includes('err_blocked') || + details.includes('err_cleartext') + ); +} + +export function isCodecFailure(details: string): boolean { + return ( + details.includes('codec') || + details.includes('incompatiblecodecs') || + details.includes('addcodec') + ); +} + +export function isDrmOrEncryptionFailure(details: string): boolean { + return ( + details.includes('decrypt') || + details.includes('keysystem') || + details.includes('keyload') || + details.includes('license') || + details.includes('drm') + ); +} + +function normalizeErrorPayload(payload: unknown): string { + if (!payload) { + return ''; + } + + if (typeof payload === 'string') { + return payload; + } + + if (payload instanceof Error) { + const extraDetails = stringifyUnknown(payload); + return [payload.message, extraDetails === '{}' ? '' : extraDetails] + .filter(Boolean) + .join(' '); + } + + return stringifyUnknown(payload); +} + +function stringifyUnknown(value: unknown): string { + try { + return JSON.stringify(value) || ''; + } catch { + return String(value); + } +} 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 new file mode 100644 index 000000000..3915ac822 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts @@ -0,0 +1,227 @@ +import { getExtensionFromUrl } from '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', +]); + +const UNSUPPORTED_CONTAINER_NAMES = new Set([ + ...UNSUPPORTED_CONTAINER_EXTENSIONS, + 'matroska', + 'quicktime', + 'x-matroska', + '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; +}> = [ + { label: 'HEVC', pattern: /^(hev1|hvc1|hevc|h265)/ }, + { label: 'AC-3', pattern: /^(ac-?3|dac3)$/ }, + { label: 'E-AC-3', pattern: /^(ec-?3|eac-?3|dec3)$/ }, + { label: 'DTS', pattern: /^(dts|dtsc|dtse|dtsh|dtsl)/ }, + { label: 'MPEG-2 Video', pattern: /^(mp2v|mpeg2video)/ }, +]; + +export function createPlaybackSourceMetadata( + input: PlaybackSourceMetadataInput +): PlaybackSourceMetadata { + const extension = getPlaybackMediaExtensionFromUrl(input.url); + const mimeType = input.mimeType?.trim() || undefined; + + return { + url: input.url, + extension, + container: extension || inferContainerFromMimeType(mimeType), + mimeType, + player: input.player, + audioCodecs: normalizeCodecs(input.audioCodecs), + videoCodecs: normalizeCodecs(input.videoCodecs), + }; +} + +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[] { + const codecs = [...metadata.videoCodecs, ...metadata.audioCodecs].map( + normalizeToken + ); + + return BROWSER_LIMITED_CODEC_PATTERNS.filter(({ pattern }) => + codecs.some((codec) => pattern.test(codec)) + ).map(({ label }) => label); +} + +export function isLikelyContainerIssue( + metadata: PlaybackSourceMetadata +): boolean { + return ( + UNSUPPORTED_CONTAINER_NAMES.has(metadata.extension) || + UNSUPPORTED_CONTAINER_NAMES.has(metadata.container) || + metadata.mimeType === 'video/matroska' + ); +} + +export function mergeCodecMetadata( + metadata: PlaybackSourceMetadata, + codecs: Pick +): PlaybackSourceMetadata { + const audioCodecs = normalizeCodecs([ + ...metadata.audioCodecs, + ...(codecs.audioCodecs ?? []), + ]); + const videoCodecs = normalizeCodecs([ + ...metadata.videoCodecs, + ...(codecs.videoCodecs ?? []), + ]); + + return { + ...metadata, + audioCodecs, + videoCodecs, + }; +} + +function inferContainerFromMimeType(mimeType: string | undefined): string { + if (!mimeType) { + return ''; + } + + const subtype = mimeType.split(';')[0]?.split('/')[1] ?? ''; + return subtype.toLowerCase(); +} + +function normalizeCodecs(codecs: readonly string[] | undefined): string[] { + return Array.from( + new Set( + (codecs ?? []) + .map((codec) => codec.trim()) + .filter((codec) => codec.length > 0) + ) + ); +} + +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 : ''; +}