mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
refactor(playback): split diagnostics utilities
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
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 : '';
|
||||
}
|
||||
Reference in new issue
Block a user