refactor(playback): split diagnostics utilities

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-14 22:47:07 +02:00
1 parent eaa9ad3e6b
commit c3ef581b6f
4 files changed
+481 -441

No files matched your search

@@ -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;
}
@@ -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<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): 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<PlaybackSourceMetadataInput, 'audioCodecs' | 'videoCodecs'>
): 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
);
}
@@ -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);
}
}
@@ -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<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): 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<PlaybackSourceMetadataInput, 'audioCodecs' | 'videoCodecs'>
): 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 : '';
}