feat(playback): enrich failure diagnostics and add safe support reports (#1574)

This commit is contained in:
4gray authored and GitHub committed 2026-09-08 08:26:26 +02:00
1 parent 7f06690e72
commit c952b55da1
61 files changed
+1908 -111

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: playback
---
Playback errors now explain confirmed access and DRM license failures, with stream codecs, DRM systems and failure stages in the details. Copy diagnostics creates a support report without stream URLs or credentials. No extra requests are made to your provider.
+8
View File
@@ -589,6 +589,14 @@ Key files:
most three actions, and `WebPlayerViewComponent` executes only the action
the user selects. The policy is a sibling of `PlayerController`; shared
controls only gate interaction while the diagnostic panel is visible.
Technical details also expose localized stages, safe engine codec metadata
and allowlisted source DRM names. DASH observes existing Shaka manifest
responses (bounded to 2 MiB); it does not fetch again or retain license URLs,
keys or XML. Evidence is scoped to one engine and never proves playability.
The panel refines descriptions only from explicit runtime/engine evidence;
HTTP 401/403 segment failures never imply token expiry. Copy diagnostics
creates an allowlisted local report without URLs, credentials or raw messages;
its content and copy status follow the current diagnostic.
`WebPlayerViewComponent` owns a host-derived content-session key that is
stable for the mounted logical selection, attempted target IDs, the temporary
player override, and VOD handoff position. Its `PlaybackBinding` is exactly
+8
View File
@@ -939,6 +939,14 @@ app as a real argument, so it is not an option.
most three actions, and `WebPlayerViewComponent` executes only the action
the user selects. The policy is a sibling of `PlayerController`; shared
controls only gate interaction while the diagnostic panel is visible.
Technical details also expose localized stages, safe engine codec metadata
and allowlisted source DRM names. DASH observes existing Shaka manifest
responses (bounded to 2 MiB); it does not fetch again or retain license URLs,
keys or XML. Evidence is scoped to one engine and never proves playability.
The panel refines descriptions only from explicit runtime/engine evidence;
HTTP 401/403 segment failures never imply token expiry. Copy diagnostics
creates an allowlisted local report without URLs, credentials or raw messages;
its content and copy status follow the current diagnostic.
`WebPlayerViewComponent` owns a host-derived content-session key that is
stable for the mounted logical selection, attempted target IDs, the temporary
player override, and VOD handoff position. Its `PlaybackBinding` is exactly
@@ -560,7 +560,9 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
.click();
const banner = app.mainWindow.getByTestId('playback-diagnostic-banner');
await expect(banner).toBeVisible({ timeout: 15_000 });
await expect(banner).toContainText(/encrypted or DRM-protected/i);
await expect(banner).toContainText(
'This player does not support the stream’s DRM configuration.'
);
await expect(
banner.locator('[data-test-id="playback-fallback-mpv"]')
).toHaveCount(0);
@@ -575,6 +577,32 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
);
await widevineDetails.locator('summary').click();
await expect(widevineDetails).toContainText('drm-or-encryption');
await expect(widevineDetails).toContainText('Widevine');
const copyDiagnostic = banner.getByRole('button', {
name: 'Copy diagnostics',
exact: true,
});
await copyDiagnostic.click();
await expect(
banner.getByTestId('playback-copy-diagnostic-status')
).toHaveText('Diagnostics copied');
const drmReport = await app.electronApp.evaluate(({ clipboard }) =>
clipboard.readText()
);
expect(JSON.parse(drmReport)).toMatchObject({
app: 'IPTVnator',
runtime: 'electron',
code: 'drm-or-encryption',
drmSystems: ['widevine'],
});
for (const secret of [
fixtureServer.origin,
'license.example.com',
CLEARKEY_KEY,
CLEARKEY_KID,
]) {
expect(drmReport).not.toContain(secret);
}
await installPlaybackRecommendationLaunchCapture(app);
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
@@ -589,6 +617,20 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
);
await mkvDetails.locator('summary').click();
await expect(mkvDetails).toContainText('unsupported-container');
await expect(
banner.getByTestId('playback-copy-diagnostic-status')
).toBeEmpty();
await copyDiagnostic.click();
const mkvReport = JSON.parse(
await app.electronApp.evaluate(({ clipboard }) =>
clipboard.readText()
)
);
expect(mkvReport).toMatchObject({
code: 'unsupported-container',
drmSystems: [],
});
expect(mkvReport).not.toHaveProperty('runtimeSupport');
const mpvFallback = banner.locator(
'[data-test-id="playback-fallback-mpv"]'
+117 -1
View File
@@ -149,7 +149,15 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 15_000 });
await expect(banner).toContainText(/encrypted or DRM-protected/i);
await expect(banner).toContainText(
'This player does not support the stream’s DRM configuration.'
);
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('DRM declared by source');
await expect(details).toContainText('Widevine');
await expect(
banner.locator('[data-test-id="playback-fallback-mpv"]')
).toHaveCount(0);
@@ -160,3 +168,111 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
banner.locator('[data-test-id^="playback-recommendation-"]')
).toHaveCount(0);
});
test('@web @m3u @dash manifest DRM and codecs survive a startup failure', async ({
page,
}) => {
await serveDashFixtures(page);
let manifestRequests = 0;
const body = readFileSync(
join(FIXTURE_DIR, 'clear.mpd'),
'utf8'
).replaceAll(
'<Representation ',
'<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"/>' +
'<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"/>' +
'<!-- private-license-marker -->' +
'<Representation '
);
await page.route(`${FIXTURE_HOST}/clear.mpd`, async (route) => {
manifestRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/dash+xml',
body,
});
});
await importDashPlaylist(page);
await page.getByText('2. Clear DASH').click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 20_000 });
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('Widevine, PlayReady');
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
await expect(details).toContainText('opus');
await expect(details).not.toContainText('private-license-marker');
expect(manifestRequests).toBe(1);
});
test('@web @m3u @dash segment failures show their stage and preserve codecs', async ({
page,
context,
}) => {
await serveDashFixtures(page);
await page.route(`${FIXTURE_HOST}/clear-video.mp4`, (route) =>
route.fulfill({ status: 403, body: 'private-response-marker' })
);
await importDashPlaylist(page);
await page.getByText('2. Clear DASH').click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 20_000 });
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('Media segment loading');
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
await expect(details).toContainText('HTTP 403');
await expect(details).not.toContainText('private-response-marker');
await expect(details).not.toContainText('DRM declared by source');
await expect(banner).toContainText(
'The server denied access to the media segments.'
);
await expect(banner).not.toContainText(/token.*expired/i);
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const copy = banner.getByRole('button', {
name: 'Copy diagnostics',
exact: true,
});
await copy.click();
await expect(
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
).toHaveText('Diagnostics copied');
const reportText = await page.evaluate(() =>
navigator.clipboard.readText()
);
expect(JSON.parse(reportText)).toMatchObject({
app: 'IPTVnator',
engine: 'shaka',
stage: 'segment',
httpStatus: 403,
videoCodecs: ['vp09.00.11.08.01.02.02.02.00'],
});
for (const secret of [
'private-response-marker',
FIXTURE_HOST,
'sourceUrl',
]) {
expect(reportText).not.toContain(secret);
}
await page.getByText('3. Widevine DASH').click();
await expect(banner).toContainText(
'This player does not support the stream’s DRM configuration.'
);
await expect(
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
).toBeEmpty();
await copy.click();
const next = JSON.parse(
await page.evaluate(() => navigator.clipboard.readText())
);
expect(next).toMatchObject({
code: 'drm-or-encryption',
drmSystems: ['widevine'],
});
expect(next).not.toHaveProperty('httpStatus');
expect(next).not.toHaveProperty('stage');
});
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "تحميل البث",
"STAGE_DEMUX": "تحليل الحاوية",
"DETAIL_STAGE": "مرحلة الفشل",
"DETAIL_DRM_SYSTEMS": "DRM المعلن من المصدر",
"STAGE_MANIFEST": "تحميل ملف الوصف",
"STAGE_PLAYLIST": "تحميل قائمة الوسائط",
"STAGE_SEGMENT": "تحميل مقطع الوسائط",
"STAGE_KEY": "تحميل مفتاح التشفير",
"STAGE_LICENSE": "تبادل ترخيص DRM",
"STAGE_MEDIA": "معالجة الوسائط",
"UNSUPPORTED_CONTAINER": {
"TITLE": "قد لا تكون حاوية هذا البث مدعومة في مشغل المتصفح",
"DESCRIPTION": "قد لا يستطيع مشغل المتصفح فك ترميز هذه الحاوية داخل التطبيق. انسخ رابط البث وافتحه في مشغل خارجي مثل MPV أو VLC أو IINA للحصول على دعم أوسع للترميزات."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "ترميزات الصوت",
"DETAIL_NATIVE_ERROR_CODE": "رمز الخطأ الأصلي",
"DETAIL_NATIVE_ERROR_MESSAGE": "رسالة الخطأ الأصلية",
"DETAIL_ERROR_DETAILS": "تفاصيل الخطأ"
"DETAIL_ERROR_DETAILS": "تفاصيل الخطأ",
"DRM_CONFIGURATION_UNSUPPORTED": "لا يدعم هذا المشغل إعداد DRM للبث. اطلب بثًا متوافقًا من مزود الخدمة.",
"DRM_CONFIGURATION_UNAVAILABLE": "إعداد DRM المطلوب غير متاح في هذه البيئة. تحقق من إعدادات المحتوى المحمي في المتصفح أو اطلب بثًا متوافقًا.",
"LICENSE_REQUEST_FAILED": "تعذر على المشغل الحصول على ترخيص DRM. تحقق من الاتصال وصلاحية المشاهدة لدى مزود الخدمة.",
"LICENSE_RESPONSE_REJECTED": "رفضت وحدة DRM استجابة الترخيص. اتصل بمزود الخدمة للتحقق من الترخيص.",
"LICENSE_SERVER_MISSING": "لم يتم إعداد خادم تراخيص لهذا البث المحمي. اطلب إعدادات التشغيل من مزود الخدمة.",
"SEGMENT_ACCESS_DENIED": "رفض الخادم الوصول إلى مقاطع الوسائط. تحقق من صلاحية المشاهدة لدى مزود الخدمة.",
"ACTION_COPY_DIAGNOSTIC": "نسخ التشخيص",
"REPORT_COPIED": "تم نسخ التشخيص",
"COPY_FAILED": "فشل النسخ. حاول مجددًا.",
"REPORT_PRIVACY": "ينسخ البيانات التقنية دون روابط البث أو بيانات الاعتماد أو رسائل الخطأ الأصلية."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "تحميل البث",
"STAGE_DEMUX": "تحليل الحاوية",
"DETAIL_STAGE": "مرحلة الخطأ",
"DETAIL_DRM_SYSTEMS": "DRM اللي صرّح به المصدر",
"STAGE_MANIFEST": "تحميل ملف الوصف",
"STAGE_PLAYLIST": "تحميل لائحة الوسائط",
"STAGE_SEGMENT": "تحميل مقطع الوسائط",
"STAGE_KEY": "تحميل مفتاح التشفير",
"STAGE_LICENSE": "تبادل رخصة DRM",
"STAGE_MEDIA": "معالجة الوسائط",
"UNSUPPORTED_CONTAINER": {
"TITLE": "حاوية هاد البث غالباً ما مدعومةش فمشغل المتصفح",
"DESCRIPTION": "مشغل المتصفح يمكن ما يقدرش يفك هاد الحاوية داخل التطبيق. نسخ رابط البث وحلو فمشغل خارجي بحال MPV ولا VLC ولا IINA لدعم كوديكات أوسع."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "كوديكات الصوت",
"DETAIL_NATIVE_ERROR_CODE": "كود الخطأ المحلي",
"DETAIL_NATIVE_ERROR_MESSAGE": "رسالة الخطأ المحلية",
"DETAIL_ERROR_DETAILS": "تفاصيل الخطأ"
"DETAIL_ERROR_DETAILS": "تفاصيل الخطأ",
"DRM_CONFIGURATION_UNSUPPORTED": "هاد المشغل ما كيدعمش إعداد DRM ديال البث. طلب بث متوافق من المزوّد.",
"DRM_CONFIGURATION_UNAVAILABLE": "إعداد DRM المطلوب ما متوفرش فهاد البيئة. راجع إعدادات المحتوى المحمي فالمتصفح ولا طلب بث متوافق.",
"LICENSE_REQUEST_FAILED": "المشغل ما قدرش يجيب رخصة DRM. تأكد من الاتصال ومن حق المشاهدة عند المزوّد.",
"LICENSE_RESPONSE_REJECTED": "وحدة DRM رفضات جواب الرخصة. تاصل بالمزوّد باش يتأكد من الرخصة.",
"LICENSE_SERVER_MISSING": "ما تهيّأ حتى خادم رخص لهاد البث المحمي. طلب إعدادات التشغيل من المزوّد.",
"SEGMENT_ACCESS_DENIED": "الخادم رفض الوصول لمقاطع البث. تأكد من حق المشاهدة عند المزوّد.",
"ACTION_COPY_DIAGNOSTIC": "نسخ التشخيص",
"REPORT_COPIED": "تنسخ التشخيص",
"COPY_FAILED": "فشل النسخ. عاود حاول.",
"REPORT_PRIVACY": "كينسخ المعطيات التقنية بلا روابط البث ولا معلومات الدخول ولا رسائل الخطأ الأصلية."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Загрузка патоку",
"STAGE_DEMUX": "Разбор кантэйнера",
"DETAIL_STAGE": "Этап збою",
"DETAIL_DRM_SYSTEMS": "DRM, заяўленая крыніцай",
"STAGE_MANIFEST": "Загрузка маніфеста",
"STAGE_PLAYLIST": "Загрузка медыяплэйліста",
"STAGE_SEGMENT": "Загрузка медыясегмента",
"STAGE_KEY": "Загрузка ключа шыфравання",
"STAGE_LICENSE": "Абмен DRM-ліцэнзіяй",
"STAGE_MEDIA": "Апрацоўка медыяданых",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Кантэйнер гэтага патоку, верагодна, не падтрымліваецца браўзерным плэерам",
"DESCRIPTION": "Браўзерны плэер можа не здолець дэкадаваць гэты кантэйнер унутры праграмы. Скапіюйце URL патоку і адкрыйце яго ў знешнім плэеры, напрыклад MPV, VLC або IINA, для шырэйшай падтрымкі кодэкаў."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Аўдыякодэкі",
"DETAIL_NATIVE_ERROR_CODE": "Код сістэмнай памылкі",
"DETAIL_NATIVE_ERROR_MESSAGE": "Паведамленне сістэмнай памылкі",
"DETAIL_ERROR_DETAILS": "Падрабязнасці памылкі"
"DETAIL_ERROR_DETAILS": "Падрабязнасці памылкі",
"DRM_CONFIGURATION_UNSUPPORTED": "Плэер не падтрымлівае DRM-канфігурацыю гэтага патоку. Запытайце ў правайдара сумяшчальны паток.",
"DRM_CONFIGURATION_UNAVAILABLE": "Запытаная DRM-канфігурацыя недаступная ў гэтым асяроддзі. Праверце налады абароненага кантэнту браўзера або запытайце сумяшчальны паток.",
"LICENSE_REQUEST_FAILED": "Плэер не змог атрымаць DRM-ліцэнзію. Праверце злучэнне і доступ да прагляду ў правайдара.",
"LICENSE_RESPONSE_REJECTED": "DRM-модуль адхіліў адказ з ліцэнзіяй. Звярніцеся да правайдара для праверкі ліцэнзіі.",
"LICENSE_SERVER_MISSING": "Для абароненага патоку не наладжаны сервер ліцэнзій. Запытайце ў правайдара параметры прайгравання.",
"SEGMENT_ACCESS_DENIED": "Сервер адмовіў у доступе да сегментаў патоку. Удакладніце доступ да прагляду ў правайдара.",
"ACTION_COPY_DIAGNOSTIC": "Скапіяваць дыягностыку",
"REPORT_COPIED": "Дыягностыка скапіявана",
"COPY_FAILED": "Не ўдалося скапіяваць. Паспрабуйце яшчэ раз.",
"REPORT_PRIVACY": "Капіруе тэхнічныя даныя без спасылак на паток, уліковых даных і зыходных паведамленняў пра памылкі."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Схаваць спіс каналаў",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Stream laden",
"STAGE_DEMUX": "Container verarbeiten",
"DETAIL_STAGE": "Fehlerphase",
"DETAIL_DRM_SYSTEMS": "Von der Quelle angegebenes DRM",
"STAGE_MANIFEST": "Manifest laden",
"STAGE_PLAYLIST": "Medienplaylist laden",
"STAGE_SEGMENT": "Mediensegment laden",
"STAGE_KEY": "Verschlüsselungsschlüssel laden",
"STAGE_LICENSE": "DRM-Lizenzaustausch",
"STAGE_MEDIA": "Medienverarbeitung",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Dieser Stream-Container wird wahrscheinlich nicht vom Browser-Player unterstützt",
"DESCRIPTION": "Der Browser-Player kann diesen Container möglicherweise nicht direkt dekodieren. Kopiere die Stream-URL und öffne sie für breitere Codec-Unterstützung in einem externen Player wie MPV, VLC oder IINA."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Audio-Codecs",
"DETAIL_NATIVE_ERROR_CODE": "Nativer Fehlercode",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native Fehlermeldung",
"DETAIL_ERROR_DETAILS": "Fehlerdetails"
"DETAIL_ERROR_DETAILS": "Fehlerdetails",
"DRM_CONFIGURATION_UNSUPPORTED": "Dieser Player unterstützt die DRM-Konfiguration des Streams nicht. Fragen Sie Ihren Anbieter nach einem kompatiblen Stream.",
"DRM_CONFIGURATION_UNAVAILABLE": "Die angeforderte DRM-Konfiguration ist hier nicht verfügbar. Prüfen Sie die Browser-Einstellungen für geschützte Inhalte oder fragen Sie Ihren Anbieter nach einem kompatiblen Stream.",
"LICENSE_REQUEST_FAILED": "Der Player konnte keine DRM-Lizenz abrufen. Prüfen Sie Ihre Verbindung und die Wiedergabeberechtigung beim Anbieter.",
"LICENSE_RESPONSE_REJECTED": "Das DRM-Modul hat die Lizenzantwort abgelehnt. Wenden Sie sich zur Lizenzprüfung an Ihren Anbieter.",
"LICENSE_SERVER_MISSING": "Für diesen geschützten Stream ist kein Lizenzserver konfiguriert. Fragen Sie Ihren Anbieter nach den Wiedergabeeinstellungen.",
"SEGMENT_ACCESS_DENIED": "Der Server hat den Zugriff auf die Mediensegmente verweigert. Prüfen Sie die Wiedergabeberechtigung beim Anbieter.",
"ACTION_COPY_DIAGNOSTIC": "Diagnose kopieren",
"REPORT_COPIED": "Diagnose kopiert",
"COPY_FAILED": "Kopieren fehlgeschlagen. Erneut versuchen.",
"REPORT_PRIVACY": "Kopiert technische Daten ohne Stream-URLs, Zugangsdaten oder rohe Fehlermeldungen."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Senderliste ausblenden",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Φόρτωση ροής",
"STAGE_DEMUX": "Ανάλυση περιέκτη",
"DETAIL_STAGE": "Στάδιο αποτυχίας",
"DETAIL_DRM_SYSTEMS": "DRM που δηλώνει η πηγή",
"STAGE_MANIFEST": "Φόρτωση manifest",
"STAGE_PLAYLIST": "Φόρτωση λίστας πολυμέσων",
"STAGE_SEGMENT": "Φόρτωση τμήματος πολυμέσων",
"STAGE_KEY": "Φόρτωση κλειδιού κρυπτογράφησης",
"STAGE_LICENSE": "Ανταλλαγή άδειας DRM",
"STAGE_MEDIA": "Επεξεργασία πολυμέσων",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Αυτό το container ροής πιθανότατα δεν υποστηρίζεται από τον player του browser",
"DESCRIPTION": "Ο player του browser μπορεί να μην αποκωδικοποιεί αυτό το container εντός της εφαρμογής. Αντιγράψτε το URL της ροής και ανοίξτε το σε εξωτερικό player όπως MPV, VLC ή IINA για ευρύτερη υποστήριξη codecs."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Codec ήχου",
"DETAIL_NATIVE_ERROR_CODE": "Εγγενής κωδικός σφάλματος",
"DETAIL_NATIVE_ERROR_MESSAGE": "Εγγενές μήνυμα σφάλματος",
"DETAIL_ERROR_DETAILS": "Λεπτομέρειες σφάλματος"
"DETAIL_ERROR_DETAILS": "Λεπτομέρειες σφάλματος",
"DRM_CONFIGURATION_UNSUPPORTED": "Το πρόγραμμα αναπαραγωγής δεν υποστηρίζει τη διαμόρφωση DRM της ροής. Ζητήστε συμβατή ροή από τον πάροχο.",
"DRM_CONFIGURATION_UNAVAILABLE": "Η ζητούμενη διαμόρφωση DRM δεν είναι διαθέσιμη εδώ. Ελέγξτε τις ρυθμίσεις προστατευμένου περιεχομένου του προγράμματος περιήγησης ή ζητήστε συμβατή ροή.",
"LICENSE_REQUEST_FAILED": "Δεν ήταν δυνατή η λήψη άδειας DRM. Ελέγξτε τη σύνδεση και τα δικαιώματα αναπαραγωγής με τον πάροχο.",
"LICENSE_RESPONSE_REJECTED": "Η μονάδα DRM απέρριψε την απάντηση άδειας. Επικοινωνήστε με τον πάροχο για έλεγχο της άδειας.",
"LICENSE_SERVER_MISSING": "Δεν έχει οριστεί διακομιστής αδειών για αυτή την προστατευμένη ροή. Ζητήστε τις ρυθμίσεις αναπαραγωγής από τον πάροχο.",
"SEGMENT_ACCESS_DENIED": "Ο διακομιστής αρνήθηκε την πρόσβαση στα τμήματα πολυμέσων. Ελέγξτε τα δικαιώματα αναπαραγωγής με τον πάροχο.",
"ACTION_COPY_DIAGNOSTIC": "Αντιγραφή διάγνωσης",
"REPORT_COPIED": "Η διάγνωση αντιγράφηκε",
"COPY_FAILED": "Η αντιγραφή απέτυχε. Δοκιμάστε ξανά.",
"REPORT_PRIVACY": "Αντιγράφει τεχνικά δεδομένα χωρίς διευθύνσεις ροής, διαπιστευτήρια ή αρχικά μηνύματα σφάλματος."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Stream loading",
"STAGE_DEMUX": "Container parsing",
"DETAIL_STAGE": "Failure stage",
"DETAIL_DRM_SYSTEMS": "DRM declared by source",
"STAGE_MANIFEST": "Manifest loading",
"STAGE_PLAYLIST": "Media playlist loading",
"STAGE_SEGMENT": "Media segment loading",
"STAGE_KEY": "Encryption key loading",
"STAGE_LICENSE": "DRM license exchange",
"STAGE_MEDIA": "Media processing",
"UNSUPPORTED_CONTAINER": {
"TITLE": "This stream container is likely unsupported by the browser player",
"DESCRIPTION": "The browser player may not decode this container inline. Copy the stream URL and open it in an external player such as MPV, VLC, or IINA for broader codec support."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
"DETAIL_ERROR_DETAILS": "Error details",
"DRM_CONFIGURATION_UNSUPPORTED": "This player does not support the stream’s DRM configuration. Ask your provider for a compatible stream.",
"DRM_CONFIGURATION_UNAVAILABLE": "The requested DRM configuration is unavailable in this environment. Check the browser’s protected-content settings or ask your provider for a compatible stream.",
"LICENSE_REQUEST_FAILED": "The player could not obtain a DRM license. Check your connection and playback access with your provider.",
"LICENSE_RESPONSE_REJECTED": "The DRM module rejected the license response. Contact your provider to check the license.",
"LICENSE_SERVER_MISSING": "No license server is configured for this protected stream. Ask your provider for the required playback configuration.",
"SEGMENT_ACCESS_DENIED": "The server denied access to the media segments. Check playback access with your provider.",
"ACTION_COPY_DIAGNOSTIC": "Copy diagnostics",
"REPORT_COPIED": "Diagnostics copied",
"COPY_FAILED": "Copy failed. Try again.",
"REPORT_PRIVACY": "Copies technical facts without stream URLs, credentials or raw error messages."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Hide channels list",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Carga del flujo",
"STAGE_DEMUX": "Análisis del contenedor",
"DETAIL_STAGE": "Etapa del fallo",
"DETAIL_DRM_SYSTEMS": "DRM declarado por la fuente",
"STAGE_MANIFEST": "Carga del manifiesto",
"STAGE_PLAYLIST": "Carga de la lista multimedia",
"STAGE_SEGMENT": "Carga del segmento multimedia",
"STAGE_KEY": "Carga de la clave de cifrado",
"STAGE_LICENSE": "Intercambio de licencia DRM",
"STAGE_MEDIA": "Procesamiento multimedia",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Es probable que este contenedor de stream no sea compatible con el reproductor del navegador",
"DESCRIPTION": "Puede que el reproductor del navegador no decodifique este contenedor en línea. Copia la URL del stream y ábrela en un reproductor externo como MPV, VLC o IINA para tener mayor compatibilidad de códecs."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Códecs de audio",
"DETAIL_NATIVE_ERROR_CODE": "Código de error nativo",
"DETAIL_NATIVE_ERROR_MESSAGE": "Mensaje de error nativo",
"DETAIL_ERROR_DETAILS": "Detalles del error"
"DETAIL_ERROR_DETAILS": "Detalles del error",
"DRM_CONFIGURATION_UNSUPPORTED": "Este reproductor no admite la configuración DRM del flujo. Solicita un flujo compatible a tu proveedor.",
"DRM_CONFIGURATION_UNAVAILABLE": "La configuración DRM solicitada no está disponible aquí. Revisa los ajustes de contenido protegido del navegador o solicita un flujo compatible.",
"LICENSE_REQUEST_FAILED": "El reproductor no pudo obtener una licencia DRM. Comprueba la conexión y el acceso con tu proveedor.",
"LICENSE_RESPONSE_REJECTED": "El módulo DRM rechazó la respuesta de licencia. Contacta con tu proveedor para comprobarla.",
"LICENSE_SERVER_MISSING": "No hay un servidor de licencias configurado para este flujo protegido. Solicita la configuración de reproducción a tu proveedor.",
"SEGMENT_ACCESS_DENIED": "El servidor denegó el acceso a los segmentos multimedia. Comprueba el acceso con tu proveedor.",
"ACTION_COPY_DIAGNOSTIC": "Copiar diagnóstico",
"REPORT_COPIED": "Diagnóstico copiado",
"COPY_FAILED": "No se pudo copiar. Inténtalo de nuevo.",
"REPORT_PRIVACY": "Copia datos técnicos sin URL del flujo, credenciales ni mensajes de error originales."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Chargement du flux",
"STAGE_DEMUX": "Analyse du conteneur",
"DETAIL_STAGE": "Étape de l’échec",
"DETAIL_DRM_SYSTEMS": "DRM déclaré par la source",
"STAGE_MANIFEST": "Chargement du manifeste",
"STAGE_PLAYLIST": "Chargement de la playlist média",
"STAGE_SEGMENT": "Chargement du segment média",
"STAGE_KEY": "Chargement de la clé de chiffrement",
"STAGE_LICENSE": "Échange de licence DRM",
"STAGE_MEDIA": "Traitement du média",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Ce conteneur de flux n’est probablement pas pris en charge par le lecteur du navigateur",
"DESCRIPTION": "Le lecteur du navigateur pourrait ne pas décoder ce conteneur en ligne. Copiez l’URL du flux et ouvrez-la dans un lecteur externe comme MPV, VLC ou IINA pour une meilleure prise en charge des codecs."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Codecs audio",
"DETAIL_NATIVE_ERROR_CODE": "Code d’erreur natif",
"DETAIL_NATIVE_ERROR_MESSAGE": "Message d’erreur natif",
"DETAIL_ERROR_DETAILS": "Détails de l’erreur"
"DETAIL_ERROR_DETAILS": "Détails de l’erreur",
"DRM_CONFIGURATION_UNSUPPORTED": "Ce lecteur ne prend pas en charge la configuration DRM du flux. Demandez un flux compatible à votre fournisseur.",
"DRM_CONFIGURATION_UNAVAILABLE": "La configuration DRM demandée est indisponible ici. Vérifiez les paramètres de contenu protégé du navigateur ou demandez un flux compatible.",
"LICENSE_REQUEST_FAILED": "Le lecteur n’a pas pu obtenir de licence DRM. Vérifiez votre connexion et vos droits de lecture auprès du fournisseur.",
"LICENSE_RESPONSE_REJECTED": "Le module DRM a rejeté la réponse de licence. Contactez votre fournisseur pour la vérifier.",
"LICENSE_SERVER_MISSING": "Aucun serveur de licences n’est configuré pour ce flux protégé. Demandez les paramètres de lecture à votre fournisseur.",
"SEGMENT_ACCESS_DENIED": "Le serveur a refusé l’accès aux segments multimédias. Vérifiez vos droits de lecture auprès du fournisseur.",
"ACTION_COPY_DIAGNOSTIC": "Copier le diagnostic",
"REPORT_COPIED": "Diagnostic copié",
"COPY_FAILED": "Échec de la copie. Réessayez.",
"REPORT_PRIVACY": "Copie les données techniques sans URL du flux, identifiants ni messages d’erreur bruts."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Adatfolyam betöltése",
"STAGE_DEMUX": "Konténer elemzése",
"DETAIL_STAGE": "Hiba szakasza",
"DETAIL_DRM_SYSTEMS": "A forrás által jelzett DRM",
"STAGE_MANIFEST": "Jegyzék betöltése",
"STAGE_PLAYLIST": "Médialejátszási lista betöltése",
"STAGE_SEGMENT": "Médiaszegmens betöltése",
"STAGE_KEY": "Titkosítási kulcs betöltése",
"STAGE_LICENSE": "DRM-licenccsere",
"STAGE_MEDIA": "Médiafeldolgozás",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Ezt az adatfolyam-konténert a böngészős lejátszó valószínűleg nem támogatja",
"DESCRIPTION": "Előfordulhat, hogy a böngészős lejátszó nem tudja helyben dekódolni ezt a konténerformátumot. Másolja ki az adatfolyam URL-jét, és nyissa meg szélesebb kodektámogatást nyújtó külső lejátszóban, például MPV-ben, VLC-ben vagy IINA-ban."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Hangkodekek",
"DETAIL_NATIVE_ERROR_CODE": "Natív hibakód",
"DETAIL_NATIVE_ERROR_MESSAGE": "Natív hibaüzenet",
"DETAIL_ERROR_DETAILS": "A hiba részletei"
"DETAIL_ERROR_DETAILS": "A hiba részletei",
"DRM_CONFIGURATION_UNSUPPORTED": "A lejátszó nem támogatja a stream DRM-konfigurációját. Kérjen kompatibilis streamet a szolgáltatótól.",
"DRM_CONFIGURATION_UNAVAILABLE": "A kért DRM-konfiguráció ebben a környezetben nem érhető el. Ellenőrizze a böngésző védett tartalmakra vonatkozó beállításait, vagy kérjen kompatibilis streamet.",
"LICENSE_REQUEST_FAILED": "A lejátszó nem tudott DRM-licencet szerezni. Ellenőrizze a kapcsolatot és a lejátszási jogosultságot a szolgáltatónál.",
"LICENSE_RESPONSE_REJECTED": "A DRM-modul elutasította a licencválaszt. Forduljon a szolgáltatóhoz a licenc ellenőrzéséhez.",
"LICENSE_SERVER_MISSING": "Ehhez a védett streamhez nincs licenckiszolgáló beállítva. Kérje a lejátszási beállításokat a szolgáltatótól.",
"SEGMENT_ACCESS_DENIED": "A kiszolgáló megtagadta a médiaszegmensek elérését. Ellenőrizze a lejátszási jogosultságot a szolgáltatónál.",
"ACTION_COPY_DIAGNOSTIC": "Diagnosztika másolása",
"REPORT_COPIED": "Diagnosztika másolva",
"COPY_FAILED": "Sikertelen másolás. Próbálja újra.",
"REPORT_PRIVACY": "Technikai adatokat másol streamcímek, hitelesítő adatok és nyers hibaüzenetek nélkül."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Csatornalista elrejtése",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Caricamento del flusso",
"STAGE_DEMUX": "Analisi del contenitore",
"DETAIL_STAGE": "Fase dell’errore",
"DETAIL_DRM_SYSTEMS": "DRM dichiarato dalla sorgente",
"STAGE_MANIFEST": "Caricamento del manifest",
"STAGE_PLAYLIST": "Caricamento della playlist multimediale",
"STAGE_SEGMENT": "Caricamento del segmento multimediale",
"STAGE_KEY": "Caricamento della chiave di cifratura",
"STAGE_LICENSE": "Scambio della licenza DRM",
"STAGE_MEDIA": "Elaborazione multimediale",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Questo contenitore dello stream probabilmente non è supportato dal player del browser",
"DESCRIPTION": "Il player del browser potrebbe non decodificare questo contenitore inline. Copia l’URL dello stream e aprilo in un player esterno come MPV, VLC o IINA per un supporto codec più ampio."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Codec audio",
"DETAIL_NATIVE_ERROR_CODE": "Codice errore nativo",
"DETAIL_NATIVE_ERROR_MESSAGE": "Messaggio errore nativo",
"DETAIL_ERROR_DETAILS": "Dettagli errore"
"DETAIL_ERROR_DETAILS": "Dettagli errore",
"DRM_CONFIGURATION_UNSUPPORTED": "Il lettore non supporta la configurazione DRM del flusso. Richiedi un flusso compatibile al fornitore.",
"DRM_CONFIGURATION_UNAVAILABLE": "La configurazione DRM richiesta non è disponibile in questo ambiente. Controlla le impostazioni dei contenuti protetti del browser o richiedi un flusso compatibile.",
"LICENSE_REQUEST_FAILED": "Il lettore non ha ottenuto una licenza DRM. Controlla la connessione e i diritti di riproduzione con il fornitore.",
"LICENSE_RESPONSE_REJECTED": "Il modulo DRM ha rifiutato la risposta della licenza. Contatta il fornitore per verificarla.",
"LICENSE_SERVER_MISSING": "Nessun server di licenze è configurato per questo flusso protetto. Richiedi al fornitore le impostazioni di riproduzione.",
"SEGMENT_ACCESS_DENIED": "Il server ha negato l’accesso ai segmenti multimediali. Verifica i diritti di riproduzione con il fornitore.",
"ACTION_COPY_DIAGNOSTIC": "Copia diagnostica",
"REPORT_COPIED": "Diagnostica copiata",
"COPY_FAILED": "Copia non riuscita. Riprova.",
"REPORT_PRIVACY": "Copia dati tecnici senza URL del flusso, credenziali o messaggi di errore originali."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "ストリームの読み込み",
"STAGE_DEMUX": "コンテナの解析",
"DETAIL_STAGE": "失敗した段階",
"DETAIL_DRM_SYSTEMS": "ソースが宣言したDRM",
"STAGE_MANIFEST": "マニフェストの読み込み",
"STAGE_PLAYLIST": "メディアプレイリストの読み込み",
"STAGE_SEGMENT": "メディアセグメントの読み込み",
"STAGE_KEY": "暗号化キーの読み込み",
"STAGE_LICENSE": "DRMライセンスの交換",
"STAGE_MEDIA": "メディア処理",
"UNSUPPORTED_CONTAINER": {
"TITLE": "このストリームのコンテナはブラウザプレーヤーでサポートされていない可能性があります",
"DESCRIPTION": "ブラウザプレーヤーでは、このコンテナをアプリ内でデコードできない場合があります。より広いコーデック対応が必要な場合は、ストリーム URL をコピーして MPV、VLC、IINA などの外部プレーヤーで開いてください。"
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "音声コーデック",
"DETAIL_NATIVE_ERROR_CODE": "ネイティブエラーコード",
"DETAIL_NATIVE_ERROR_MESSAGE": "ネイティブエラーメッセージ",
"DETAIL_ERROR_DETAILS": "エラー詳細"
"DETAIL_ERROR_DETAILS": "エラー詳細",
"DRM_CONFIGURATION_UNSUPPORTED": "このプレーヤーはストリームのDRM構成に対応していません。対応するストリームを配信元にお問い合わせください。",
"DRM_CONFIGURATION_UNAVAILABLE": "要求されたDRM構成はこの環境では利用できません。ブラウザーの保護されたコンテンツの設定を確認するか、対応するストリームを配信元にお問い合わせください。",
"LICENSE_REQUEST_FAILED": "DRMライセンスを取得できませんでした。接続と視聴権限を配信元に確認してください。",
"LICENSE_RESPONSE_REJECTED": "DRMモジュールがライセンス応答を拒否しました。ライセンスについて配信元にお問い合わせください。",
"LICENSE_SERVER_MISSING": "この保護されたストリームにはライセンスサーバーが設定されていません。必要な再生設定を配信元にお問い合わせください。",
"SEGMENT_ACCESS_DENIED": "サーバーがメディアセグメントへのアクセスを拒否しました。視聴権限を配信元に確認してください。",
"ACTION_COPY_DIAGNOSTIC": "診断情報をコピー",
"REPORT_COPIED": "診断情報をコピーしました",
"COPY_FAILED": "コピーできませんでした。再試行してください。",
"REPORT_PRIVACY": "ストリームURL、認証情報、生のエラーメッセージを含めずに技術情報をコピーします。"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "스트림 로드",
"STAGE_DEMUX": "컨테이너 분석",
"DETAIL_STAGE": "오류 발생 단계",
"DETAIL_DRM_SYSTEMS": "소스에서 선언한 DRM",
"STAGE_MANIFEST": "매니페스트 로드",
"STAGE_PLAYLIST": "미디어 재생목록 로드",
"STAGE_SEGMENT": "미디어 세그먼트 로드",
"STAGE_KEY": "암호화 키 로드",
"STAGE_LICENSE": "DRM 라이선스 교환",
"STAGE_MEDIA": "미디어 처리",
"UNSUPPORTED_CONTAINER": {
"TITLE": "이 스트림 컨테이너는 브라우저 플레이어에서 지원되지 않을 수 있습니다",
"DESCRIPTION": "브라우저 플레이어가 이 컨테이너를 앱 안에서 디코딩하지 못할 수 있습니다. 더 넓은 코덱 지원이 필요하면 스트림 URL을 복사해 MPV, VLC, IINA 같은 외부 플레이어에서 여세요."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "오디오 코덱",
"DETAIL_NATIVE_ERROR_CODE": "네이티브 오류 코드",
"DETAIL_NATIVE_ERROR_MESSAGE": "네이티브 오류 메시지",
"DETAIL_ERROR_DETAILS": "오류 세부 정보"
"DETAIL_ERROR_DETAILS": "오류 세부 정보",
"DRM_CONFIGURATION_UNSUPPORTED": "이 플레이어는 스트림의 DRM 구성을 지원하지 않습니다. 제공업체에 호환 스트림을 요청하세요.",
"DRM_CONFIGURATION_UNAVAILABLE": "요청한 DRM 구성을 이 환경에서 사용할 수 없습니다. 브라우저의 보호된 콘텐츠 설정을 확인하거나 호환 스트림을 요청하세요.",
"LICENSE_REQUEST_FAILED": "DRM 라이선스를 가져오지 못했습니다. 연결과 재생 권한을 제공업체에 확인하세요.",
"LICENSE_RESPONSE_REJECTED": "DRM 모듈이 라이선스 응답을 거부했습니다. 라이선스 확인을 위해 제공업체에 문의하세요.",
"LICENSE_SERVER_MISSING": "이 보호된 스트림에 라이선스 서버가 설정되지 않았습니다. 제공업체에 재생 설정을 요청하세요.",
"SEGMENT_ACCESS_DENIED": "서버가 미디어 세그먼트 접근을 거부했습니다. 제공업체에 재생 권한을 확인하세요.",
"ACTION_COPY_DIAGNOSTIC": "진단 정보 복사",
"REPORT_COPIED": "진단 정보가 복사되었습니다",
"COPY_FAILED": "복사하지 못했습니다. 다시 시도하세요.",
"REPORT_PRIVACY": "스트림 URL, 인증 정보 및 원본 오류 메시지를 제외한 기술 정보를 복사합니다."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "채널 목록 숨기기",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Stream laden",
"STAGE_DEMUX": "Container verwerken",
"DETAIL_STAGE": "Foutfase",
"DETAIL_DRM_SYSTEMS": "DRM opgegeven door de bron",
"STAGE_MANIFEST": "Manifest laden",
"STAGE_PLAYLIST": "Media-afspeellijst laden",
"STAGE_SEGMENT": "Mediasegment laden",
"STAGE_KEY": "Versleutelingssleutel laden",
"STAGE_LICENSE": "DRM-licentie-uitwisseling",
"STAGE_MEDIA": "Mediaverwerking",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Deze streamcontainer wordt waarschijnlijk niet ondersteund door de browserspeler",
"DESCRIPTION": "De browserspeler kan deze container mogelijk niet inline decoderen. Kopieer de stream-URL en open deze in een externe speler zoals MPV, VLC of IINA voor bredere codec-ondersteuning."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Audiocodecs",
"DETAIL_NATIVE_ERROR_CODE": "Native foutcode",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native foutmelding",
"DETAIL_ERROR_DETAILS": "Foutdetails"
"DETAIL_ERROR_DETAILS": "Foutdetails",
"DRM_CONFIGURATION_UNSUPPORTED": "Deze speler ondersteunt de DRM-configuratie van de stream niet. Vraag uw aanbieder om een compatibele stream.",
"DRM_CONFIGURATION_UNAVAILABLE": "De gevraagde DRM-configuratie is hier niet beschikbaar. Controleer de browserinstellingen voor beveiligde inhoud of vraag om een compatibele stream.",
"LICENSE_REQUEST_FAILED": "De speler kon geen DRM-licentie ophalen. Controleer uw verbinding en afspeelrechten bij uw aanbieder.",
"LICENSE_RESPONSE_REJECTED": "De DRM-module heeft het licentieantwoord geweigerd. Neem contact op met uw aanbieder om de licentie te controleren.",
"LICENSE_SERVER_MISSING": "Er is geen licentieserver ingesteld voor deze beveiligde stream. Vraag uw aanbieder om de afspeelinstellingen.",
"SEGMENT_ACCESS_DENIED": "De server heeft toegang tot de mediasegmenten geweigerd. Controleer uw afspeelrechten bij uw aanbieder.",
"ACTION_COPY_DIAGNOSTIC": "Diagnose kopiëren",
"REPORT_COPIED": "Diagnose gekopieerd",
"COPY_FAILED": "Kopiëren mislukt. Probeer opnieuw.",
"REPORT_PRIVACY": "Kopieert technische gegevens zonder stream-URL’s, inloggegevens of onbewerkte foutmeldingen."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Zenderlijst verbergen",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Ładowanie strumienia",
"STAGE_DEMUX": "Analiza kontenera",
"DETAIL_STAGE": "Etap błędu",
"DETAIL_DRM_SYSTEMS": "DRM zadeklarowany przez źródło",
"STAGE_MANIFEST": "Ładowanie manifestu",
"STAGE_PLAYLIST": "Ładowanie listy multimediów",
"STAGE_SEGMENT": "Ładowanie segmentu multimediów",
"STAGE_KEY": "Ładowanie klucza szyfrowania",
"STAGE_LICENSE": "Wymiana licencji DRM",
"STAGE_MEDIA": "Przetwarzanie multimediów",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Ten kontener strumienia prawdopodobnie nie jest obsługiwany przez odtwarzacz przeglądarkowy",
"DESCRIPTION": "Odtwarzacz przeglądarkowy może nie zdekodować tego kontenera bezpośrednio w aplikacji. Skopiuj URL strumienia i otwórz go w zewnętrznym odtwarzaczu, takim jak MPV, VLC lub IINA, aby skorzystać z szerszej obsługi kodeków."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Kodeki audio",
"DETAIL_NATIVE_ERROR_CODE": "Natywny kod błędu",
"DETAIL_NATIVE_ERROR_MESSAGE": "Natywny komunikat błędu",
"DETAIL_ERROR_DETAILS": "Szczegóły błędu"
"DETAIL_ERROR_DETAILS": "Szczegóły błędu",
"DRM_CONFIGURATION_UNSUPPORTED": "Odtwarzacz nie obsługuje konfiguracji DRM tego strumienia. Poproś dostawcę o zgodny strumień.",
"DRM_CONFIGURATION_UNAVAILABLE": "Żądana konfiguracja DRM jest niedostępna w tym środowisku. Sprawdź ustawienia chronionych treści przeglądarki lub poproś o zgodny strumień.",
"LICENSE_REQUEST_FAILED": "Odtwarzacz nie uzyskał licencji DRM. Sprawdź połączenie i uprawnienia do odtwarzania u dostawcy.",
"LICENSE_RESPONSE_REJECTED": "Moduł DRM odrzucił odpowiedź licencyjną. Skontaktuj się z dostawcą w celu sprawdzenia licencji.",
"LICENSE_SERVER_MISSING": "Nie skonfigurowano serwera licencji dla tego chronionego strumienia. Poproś dostawcę o ustawienia odtwarzania.",
"SEGMENT_ACCESS_DENIED": "Serwer odmówił dostępu do segmentów multimediów. Sprawdź uprawnienia do odtwarzania u dostawcy.",
"ACTION_COPY_DIAGNOSTIC": "Kopiuj diagnostykę",
"REPORT_COPIED": "Diagnostyka skopiowana",
"COPY_FAILED": "Kopiowanie nie powiodło się. Spróbuj ponownie.",
"REPORT_PRIVACY": "Kopiuje dane techniczne bez adresów strumieni, danych logowania i surowych komunikatów błędów."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ukryj listę kanałów",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Carregamento do fluxo",
"STAGE_DEMUX": "Análise do contêiner",
"DETAIL_STAGE": "Etapa da falha",
"DETAIL_DRM_SYSTEMS": "DRM declarado pela fonte",
"STAGE_MANIFEST": "Carregamento do manifesto",
"STAGE_PLAYLIST": "Carregamento da lista de mídia",
"STAGE_SEGMENT": "Carregamento do segmento de mídia",
"STAGE_KEY": "Carregamento da chave de criptografia",
"STAGE_LICENSE": "Troca de licença DRM",
"STAGE_MEDIA": "Processamento de mídia",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Este contêiner de stream provavelmente não é compatível com o reprodutor do navegador",
"DESCRIPTION": "O reprodutor do navegador talvez não consiga decodificar este contêiner no app. Copie a URL do stream e abra em um reprodutor externo como MPV, VLC ou IINA para ter suporte mais amplo a codecs."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Codecs de áudio",
"DETAIL_NATIVE_ERROR_CODE": "Código de erro nativo",
"DETAIL_NATIVE_ERROR_MESSAGE": "Mensagem de erro nativo",
"DETAIL_ERROR_DETAILS": "Detalhes do erro"
"DETAIL_ERROR_DETAILS": "Detalhes do erro",
"DRM_CONFIGURATION_UNSUPPORTED": "Este leitor não suporta a configuração DRM do fluxo. Peça um fluxo compatível ao fornecedor.",
"DRM_CONFIGURATION_UNAVAILABLE": "A configuração DRM solicitada não está disponível neste ambiente. Verifique as definições de conteúdo protegido do navegador ou peça um fluxo compatível.",
"LICENSE_REQUEST_FAILED": "O leitor não conseguiu obter uma licença DRM. Verifique a ligação e o acesso à reprodução com o fornecedor.",
"LICENSE_RESPONSE_REJECTED": "O módulo DRM rejeitou a resposta da licença. Contacte o fornecedor para a verificar.",
"LICENSE_SERVER_MISSING": "Não há servidor de licenças configurado para este fluxo protegido. Peça as definições de reprodução ao fornecedor.",
"SEGMENT_ACCESS_DENIED": "O servidor negou acesso aos segmentos multimédia. Verifique o acesso à reprodução com o fornecedor.",
"ACTION_COPY_DIAGNOSTIC": "Copiar diagnóstico",
"REPORT_COPIED": "Diagnóstico copiado",
"COPY_FAILED": "Falha ao copiar. Tente novamente.",
"REPORT_PRIVACY": "Copia dados técnicos sem URLs do fluxo, credenciais ou mensagens de erro originais."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canais",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Загрузка потока",
"STAGE_DEMUX": "Разбор контейнера",
"DETAIL_STAGE": "Этап сбоя",
"DETAIL_DRM_SYSTEMS": "DRM, заявленная источником",
"STAGE_MANIFEST": "Загрузка манифеста",
"STAGE_PLAYLIST": "Загрузка медиаплейлиста",
"STAGE_SEGMENT": "Загрузка медиасегмента",
"STAGE_KEY": "Загрузка ключа шифрования",
"STAGE_LICENSE": "Обмен DRM-лицензией",
"STAGE_MEDIA": "Обработка медиаданных",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Этот контейнер потока, вероятно, не поддерживается браузерным плеером",
"DESCRIPTION": "Браузерный плеер может не декодировать этот контейнер внутри приложения. Скопируйте URL потока и откройте его во внешнем плеере, например MPV, VLC или IINA, для более широкой поддержки кодеков."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Аудиокодеки",
"DETAIL_NATIVE_ERROR_CODE": "Нативный код ошибки",
"DETAIL_NATIVE_ERROR_MESSAGE": "Нативное сообщение об ошибке",
"DETAIL_ERROR_DETAILS": "Сведения об ошибке"
"DETAIL_ERROR_DETAILS": "Сведения об ошибке",
"DRM_CONFIGURATION_UNSUPPORTED": "Плеер не поддерживает DRM-конфигурацию этого потока. Запросите у провайдера совместимый поток.",
"DRM_CONFIGURATION_UNAVAILABLE": "Запрошенная DRM-конфигурация недоступна в этой среде. Проверьте настройки защищённого контента в браузере или запросите у провайдера совместимый поток.",
"LICENSE_REQUEST_FAILED": "Плеер не смог получить DRM-лицензию. Проверьте соединение и доступ к просмотру у провайдера.",
"LICENSE_RESPONSE_REJECTED": "DRM-модуль отклонил ответ с лицензией. Обратитесь к провайдеру для проверки лицензии.",
"LICENSE_SERVER_MISSING": "Для защищённого потока не настроен сервер лицензий. Запросите у провайдера необходимые параметры воспроизведения.",
"SEGMENT_ACCESS_DENIED": "Сервер отказал в доступе к сегментам потока. Уточните доступ к просмотру у провайдера.",
"ACTION_COPY_DIAGNOSTIC": "Скопировать диагностику",
"REPORT_COPIED": "Диагностика скопирована",
"COPY_FAILED": "Не удалось скопировать. Попробуйте ещё раз.",
"REPORT_PRIVACY": "Копирует технические данные без ссылок на поток, учётных данных и исходных сообщений об ошибках."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Скрыть список каналов",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "Akış yükleme",
"STAGE_DEMUX": "Kapsayıcı ayrıştırma",
"DETAIL_STAGE": "Hata aşaması",
"DETAIL_DRM_SYSTEMS": "Kaynağın bildirdiği DRM",
"STAGE_MANIFEST": "Manifest yükleme",
"STAGE_PLAYLIST": "Medya oynatma listesi yükleme",
"STAGE_SEGMENT": "Medya bölümü yükleme",
"STAGE_KEY": "Şifreleme anahtarı yükleme",
"STAGE_LICENSE": "DRM lisans alışverişi",
"STAGE_MEDIA": "Medya işleme",
"UNSUPPORTED_CONTAINER": {
"TITLE": "Bu akış kapsayıcısı muhtemelen tarayıcı oynatıcısı tarafından desteklenmiyor",
"DESCRIPTION": "Tarayıcı oynatıcısı bu kapsayıcıyı uygulama içinde çözemeyebilir. Daha geniş kodek desteği için akış URL’sini kopyalayıp MPV, VLC veya IINA gibi harici bir oynatıcıda açın."
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "Ses kodekleri",
"DETAIL_NATIVE_ERROR_CODE": "Yerel hata kodu",
"DETAIL_NATIVE_ERROR_MESSAGE": "Yerel hata iletisi",
"DETAIL_ERROR_DETAILS": "Hata ayrıntıları"
"DETAIL_ERROR_DETAILS": "Hata ayrıntıları",
"DRM_CONFIGURATION_UNSUPPORTED": "Bu oynatıcı akışın DRM yapılandırmasını desteklemiyor. Sağlayıcınızdan uyumlu bir akış isteyin.",
"DRM_CONFIGURATION_UNAVAILABLE": "İstenen DRM yapılandırması bu ortamda kullanılamıyor. Tarayıcının korumalı içerik ayarlarını kontrol edin veya uyumlu bir akış isteyin.",
"LICENSE_REQUEST_FAILED": "Oynatıcı DRM lisansı alamadı. Bağlantınızı ve oynatma erişiminizi sağlayıcınızla kontrol edin.",
"LICENSE_RESPONSE_REJECTED": "DRM modülü lisans yanıtını reddetti. Lisansı kontrol etmek için sağlayıcınızla iletişime geçin.",
"LICENSE_SERVER_MISSING": "Bu korumalı akış için lisans sunucusu yapılandırılmamış. Sağlayıcınızdan oynatma ayarlarını isteyin.",
"SEGMENT_ACCESS_DENIED": "Sunucu medya segmentlerine erişimi reddetti. Oynatma erişiminizi sağlayıcınızla kontrol edin.",
"ACTION_COPY_DIAGNOSTIC": "Tanılamayı kopyala",
"REPORT_COPIED": "Tanılama kopyalandı",
"COPY_FAILED": "Kopyalanamadı. Tekrar deneyin.",
"REPORT_PRIVACY": "Akış adresleri, kimlik bilgileri ve ham hata mesajları olmadan teknik bilgileri kopyalar."
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Kanal listesini gizle",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "加载流",
"STAGE_DEMUX": "解析容器",
"DETAIL_STAGE": "失败阶段",
"DETAIL_DRM_SYSTEMS": "来源声明的 DRM",
"STAGE_MANIFEST": "加载清单",
"STAGE_PLAYLIST": "加载媒体播放列表",
"STAGE_SEGMENT": "加载媒体分段",
"STAGE_KEY": "加载加密密钥",
"STAGE_LICENSE": "DRM 许可证交换",
"STAGE_MEDIA": "媒体处理",
"UNSUPPORTED_CONTAINER": {
"TITLE": "浏览器播放器可能不支持此流容器",
"DESCRIPTION": "浏览器播放器可能无法在应用内解码此容器。复制流 URL,并用 MPV、VLC 或 IINA 等外部播放器打开,可获得更广泛的编解码支持。"
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "音频编码",
"DETAIL_NATIVE_ERROR_CODE": "原生错误代码",
"DETAIL_NATIVE_ERROR_MESSAGE": "原生错误信息",
"DETAIL_ERROR_DETAILS": "错误详情"
"DETAIL_ERROR_DETAILS": "错误详情",
"DRM_CONFIGURATION_UNSUPPORTED": "此播放器不支持该流的 DRM 配置。请向提供商索取兼容的流。",
"DRM_CONFIGURATION_UNAVAILABLE": "此环境无法使用所请求的 DRM 配置。请检查浏览器的受保护内容设置,或向提供商索取兼容的流。",
"LICENSE_REQUEST_FAILED": "播放器无法获取 DRM 许可证。请检查网络连接并向提供商确认播放权限。",
"LICENSE_RESPONSE_REJECTED": "DRM 模块拒绝了许可证响应。请联系提供商检查许可证。",
"LICENSE_SERVER_MISSING": "此受保护的流未配置许可证服务器。请向提供商索取所需的播放配置。",
"SEGMENT_ACCESS_DENIED": "服务器拒绝访问媒体分段。请向提供商确认播放权限。",
"ACTION_COPY_DIAGNOSTIC": "复制诊断信息",
"REPORT_COPIED": "诊断信息已复制",
"COPY_FAILED": "复制失败。请重试。",
"REPORT_PRIVACY": "复制技术信息,不包含流地址、凭据或原始错误消息。"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隐藏频道列表",
+21 -1
View File
@@ -599,6 +599,16 @@
}
},
"PLAYBACK_DIAGNOSTICS": {
"STAGE_LOADER": "載入串流",
"STAGE_DEMUX": "解析容器",
"DETAIL_STAGE": "失敗階段",
"DETAIL_DRM_SYSTEMS": "來源宣告的 DRM",
"STAGE_MANIFEST": "載入資訊清單",
"STAGE_PLAYLIST": "載入媒體播放清單",
"STAGE_SEGMENT": "載入媒體片段",
"STAGE_KEY": "載入加密金鑰",
"STAGE_LICENSE": "DRM 授權交換",
"STAGE_MEDIA": "媒體處理",
"UNSUPPORTED_CONTAINER": {
"TITLE": "瀏覽器播放器可能不支援此串流容器",
"DESCRIPTION": "瀏覽器播放器可能無法在應用程式內解碼此容器。複製串流 URL,並用 MPV、VLC 或 IINA 等外部播放器開啟,可取得更廣泛的編解碼支援。"
@@ -669,7 +679,17 @@
"DETAIL_AUDIO_CODECS": "音訊編碼",
"DETAIL_NATIVE_ERROR_CODE": "原生錯誤代碼",
"DETAIL_NATIVE_ERROR_MESSAGE": "原生錯誤訊息",
"DETAIL_ERROR_DETAILS": "錯誤詳情"
"DETAIL_ERROR_DETAILS": "錯誤詳情",
"DRM_CONFIGURATION_UNSUPPORTED": "此播放器不支援串流的 DRM 設定。請向供應商索取相容的串流。",
"DRM_CONFIGURATION_UNAVAILABLE": "此環境無法使用所要求的 DRM 設定。請檢查瀏覽器的受保護內容設定,或向供應商索取相容的串流。",
"LICENSE_REQUEST_FAILED": "播放器無法取得 DRM 授權。請檢查網路連線並向供應商確認播放權限。",
"LICENSE_RESPONSE_REJECTED": "DRM 模組拒絕了授權回應。請聯絡供應商檢查授權。",
"LICENSE_SERVER_MISSING": "此受保護的串流未設定授權伺服器。請向供應商索取所需的播放設定。",
"SEGMENT_ACCESS_DENIED": "伺服器拒絕存取媒體片段。請向供應商確認播放權限。",
"ACTION_COPY_DIAGNOSTIC": "複製診斷資訊",
"REPORT_COPIED": "已複製診斷資訊",
"COPY_FAILED": "複製失敗。請重試。",
"REPORT_PRIVACY": "複製技術資訊,不包含串流網址、認證資料或原始錯誤訊息。"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隱藏頻道列表",
+65 -3
View File
@@ -556,7 +556,12 @@ code-derived stage and failure kind, and a validated HTTP status. A direct
`Network.BAD_HTTP_STATUS` may expose `data[1]` as the status; the same status is
accepted from the documented nested networking error for
`Drm.LICENSE_REQUEST_FAILED` and
`Drm.SERVER_CERTIFICATE_REQUEST_FAILED`. No other `error.data` value is read.
`Drm.SERVER_CERTIFICATE_REQUEST_FAILED`. For direct network errors, the
public request type also identifies the stage: `BAD_HTTP_STATUS.data[4]`,
`HTTP_ERROR.data[2]`, or `TIMEOUT.data[1]`. Only MANIFEST, SEGMENT and LICENSE
are mapped, with their numeric values checked against the installed Shaka
contract. Other types and malformed slots remain unknown; request URLs, bodies
and headers are never inspected to guess the stage.
A recoverable Shaka `error` event does not become a terminal playback
diagnostic because the engine continues its retry/recovery lifecycle. A
@@ -588,10 +593,11 @@ the launch contract cannot transfer the key configuration.
Shaka messages, URLs, headers, request/response bodies, credentials,
license/key payloads, and arbitrary `error.data` objects are neither retained
nor rendered. Technical details show only sanitized stage, failure, severity,
nor rendered. Structured error details show only sanitized stage, failure, severity,
category, code, disposition, and optional HTTP status. Unsupported playlist
DRM uses a fixed safe description rather than echoing provider license
configuration.
configuration. A recognized configured license type can add only its
allowlisted display name.
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
@@ -657,6 +663,62 @@ diagnostic whose ranked actions may include MPV/VLC.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
### Source metadata in diagnostics
The existing technical-details list has localized failure-stage and declared-DRM
rows. Unknown stages are omitted from the dedicated row and remain visible as
`unknown` in raw structured details. These rows do not change the original
failure classification or recovery ranking.
`ShakaManifestMetadata` observes the active Shaka networking engine's existing
MANIFEST responses through a public response filter. It reads only DASH
`AdaptationSet`/`Representation` codec attributes and `ContentProtection`
scheme IDs, before licensing can fail. Inspection is bounded to 2 MiB and
bounded element/codec counts. Non-DASH, malformed, DTD-bearing and oversized
XML is ignored; the response is never changed, and an inspection failure never
fails playback. No internal Shaka manifest API or extra fetch is used.
Only known Widevine, PlayReady, ClearKey and FairPlay identifiers are retained.
Generic CENC/common PSSH does not prove a particular DRM system. Configuration
may also supply a known system name, including ClearKey; these are source facts,
not proof that EME initialized or that a license is usable. No manifest body,
key ID, key, license URL, header or arbitrary provider string enters the added
metadata. Each observer belongs to one engine, unregisters on teardown and
ignores late callbacks; switching channels cannot reuse the previous evidence.
Fatal HLS errors retain the current hls.js level codec metadata, Video.js reads
its current VHS representations, and MPEG-TS errors read the current engine's
`mediaInfo`. DASH lists the advertised manifest codecs, which may include
alternative renditions, rather than claiming they were successfully decoded.
All added codec values pass a bounded identifier allowlist. An unrecognized or
unavailable codec is omitted. Metadata does not itself trigger a failure or
prove that a browser supports a codec.
### Evidence-backed summaries and support reports
The shared diagnostic panel refines its description only from owned evidence:
`drm-configuration-unsupported` marks an app-rejected DRM configuration; Shaka
6001 means the requested DRM configuration is unavailable (including denied
permission or unsupported features, not proof that a particular CDM is absent).
Shaka 6007, 6008 and 6012 distinguish license acquisition failure, a rejected
license response and missing license-server configuration. An owned license
network request also identifies acquisition failure. Confirmed segment requests
with HTTP 401/403 report access refusal, without asserting token expiry. Source
DRM names and arbitrary messages never select these explanations. Existing
classification, recovery ranking and generic descriptions remain the fallback.
**Copy diagnostics** writes a versioned JSON report to the local clipboard on
explicit activation. The report allowlists app version, coarse OS/runtime,
player/engine, diagnostic and numeric error codes, stage, HTTP status, container,
codec identifiers and declared DRM names. Unknown values are omitted or marked
unknown. It never serializes the diagnostic object, raw user agent, URL, title,
provider message, headers, credentials, key IDs or licenses. It issues no network
request and sends nothing to support automatically. The independent **Copy URL**
action still copies the original stream URL and may include access credentials.
Copy success/failure is announced accessibly; changing diagnostics refreshes the
report and clears the prior result. The report contains source facts, not proof
that a license, codec or stream is playable.
## Recovery Recommendation Policy
Recommendations change playback paths only when structured evidence supports
+5 -2
View File
@@ -1338,8 +1338,11 @@ player in settings.
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
and treats a rejected load as terminal even if its final retry error retains
recoverable severity. Raw messages and `error.data` never cross the boundary;
only the documented direct/nested `BAD_HTTP_STATUS` slot may contribute a
validated HTTP status. Exact category/code pairs classify failures, while an
only documented direct/nested `BAD_HTTP_STATUS` slots contribute HTTP
status, and direct network request-type slots identify manifest, segment or
license stages. A session-owned response observer adds safe advertised codec
and DRM names from the already fetched DASH manifest; it never fetches extra
data or retains license URLs, keys or the manifest body. Exact category/code pairs classify failures, while an
ambiguous Manifest category or unknown pair remains an unknown diagnostic.
The public streaming-startup code `5006` is retained exactly but keeps
unknown stage/failure. Public DASH text-parser codes likewise retain their
+1
View File
@@ -1,6 +1,7 @@
export * from './lib/diagnostics/playback-diagnostics.util';
export * from './lib/diagnostics/shaka-error-classifier';
export * from './lib/diagnostics/shaka-error-contract';
export * from './lib/diagnostics/playback-stream-metadata';
export * from './lib/diagnostics/shaka-error-lifecycle';
export type * from './lib/diagnostics/shaka-error.types';
export * from './lib/playback-recommendation.model';
@@ -4,6 +4,7 @@ import type {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
import type { PlaybackDrmSystem } from './playback-stream-metadata';
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
export * from './mpegts-playback-evidence.model';
@@ -34,6 +35,7 @@ export type PlaybackDiagnosticSource =
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
export const PlaybackRuntimeSupport = {
DrmConfigurationUnsupported: 'drm-configuration-unsupported',
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
} as const;
@@ -50,6 +52,7 @@ export type InlinePlaybackPlayer =
(typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer];
export interface PlaybackSourceMetadataInput {
readonly drmSystems?: readonly PlaybackDrmSystem[];
readonly url: string;
readonly mimeType?: string;
readonly player?: InlinePlaybackPlayer;
@@ -58,6 +61,7 @@ export interface PlaybackSourceMetadataInput {
}
export interface PlaybackSourceMetadata {
readonly drmSystems?: readonly PlaybackDrmSystem[];
readonly url: string;
readonly extension: string;
readonly container: string;
@@ -255,6 +259,8 @@ export interface ShakaPlaybackEvidence {
}
export interface PlaybackDiagnostic {
/** Declared by the source/configuration, not proof of usable licenses. */
readonly drmSystems?: readonly PlaybackDrmSystem[];
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly sourceUrl: string;
@@ -231,6 +231,9 @@ export function createPlaybackDiagnostic(options: {
return {
code,
source,
...(metadata.drmSystems?.length
? { drmSystems: metadata.drmSystems }
: {}),
sourceUrl: metadata.url,
container: metadata.container,
mimeType: metadata.mimeType,
@@ -39,6 +39,7 @@ export function createPlaybackSourceMetadata(
const mimeType = input.mimeType?.trim() || undefined;
return {
...(input.drmSystems?.length ? { drmSystems: input.drmSystems } : {}),
url: input.url,
extension,
container: extension || inferContainerFromMimeType(mimeType),
@@ -0,0 +1,56 @@
import {
collectPlaybackCodecs,
getPlaybackDrmSystem,
} from './playback-stream-metadata';
describe('safe playback source metadata', () => {
it('retains codec identifiers and discards provider text, URLs and unsupported shapes', () => {
expect(
collectPlaybackCodecs([
{
videoCodec: 'avc1.64001e, hvc1.1.6.L93.B0',
audioCodec: 'mp4a.40.2, ac-3',
},
{
videoCodec: 'https://secret.test/?token=secret',
audioCodec: 'Authorization: secret',
},
{ videoCodec: { token: 'secret' }, audioCodec: 17 },
{ videoCodec: 'avc1.64001e', audioCodec: 'mp4a.40.2' },
])
).toEqual({
videoCodecs: ['avc1.64001e', 'hvc1.1.6.L93.B0'],
audioCodecs: ['mp4a.40.2', 'ac-3'],
});
});
it('retains full AV1 codec strings with color metadata', () => {
const fullAv1 = 'av01.0.04M.08.0.110.01.01.01.0';
expect(
collectPlaybackCodecs([{ videoCodec: fullAv1 }]).videoCodecs
).toEqual([fullAv1]);
expect(
collectPlaybackCodecs([{ videoCodec: fullAv1 + '.0' }]).videoCodecs
).toEqual([]);
});
it('does not confuse generic CENC with a named DRM system', () => {
expect(
getPlaybackDrmSystem('urn:mpeg:dash:mp4protection:2011')
).toBeUndefined();
expect(
getPlaybackDrmSystem(
'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b'
)
).toBeUndefined();
expect(getPlaybackDrmSystem('widevine-secret-token')).toBeUndefined();
expect(getPlaybackDrmSystem(' COM.WIDEVINE.ALPHA ')).toBe('widevine');
});
it('bounds retained codec lists', () => {
const tracks = Array.from({ length: 300 }, (_, i) => ({
videoCodec: `avc1.${i.toString(16).padStart(6, '0')}`,
}));
expect(collectPlaybackCodecs(tracks).videoCodecs).toHaveLength(16);
});
});
@@ -0,0 +1,77 @@
/** Safe, bounded facts about a source; never evidence that it can be played. */
export const PlaybackDrmSystem = {
Widevine: 'widevine',
PlayReady: 'playready',
ClearKey: 'clearkey',
FairPlay: 'fairplay',
} as const;
export type PlaybackDrmSystem =
(typeof PlaybackDrmSystem)[keyof typeof PlaybackDrmSystem];
const DRM_SYSTEMS = new Map<string, PlaybackDrmSystem>([
['com.widevine.alpha', PlaybackDrmSystem.Widevine],
[
'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
PlaybackDrmSystem.Widevine,
],
['com.microsoft.playready', PlaybackDrmSystem.PlayReady],
['com.microsoft.playready.recommendation', PlaybackDrmSystem.PlayReady],
[
'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95',
PlaybackDrmSystem.PlayReady,
],
['org.w3.clearkey', PlaybackDrmSystem.ClearKey],
['clearkey', PlaybackDrmSystem.ClearKey],
[
'urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e',
PlaybackDrmSystem.ClearKey,
],
['com.apple.fps', PlaybackDrmSystem.FairPlay],
['com.apple.fps.1_0', PlaybackDrmSystem.FairPlay],
[
'urn:uuid:94ce86fb-07ff-4f43-adb8-93d2fa968ca2',
PlaybackDrmSystem.FairPlay,
],
]);
export function getPlaybackDrmSystem(
value: unknown
): PlaybackDrmSystem | undefined {
return typeof value === 'string'
? DRM_SYSTEMS.get(value.trim().toLowerCase())
: undefined;
}
export interface PlaybackCodecTrack {
readonly audioCodec?: unknown;
readonly videoCodec?: unknown;
}
// Only codec identifiers, never arbitrary provider labels or engine messages.
// Full AV1 has nine suffix fields: https://aomediacodec.github.io/av1-isobmff/#codecsparam
const VIDEO_CODEC =
/^(?:(?:avc1|avc3)\.[0-9a-f]{6}|(?:hev1|hvc1|hvc2|vp09|vp08)(?:\.[0-9a-fhlm]{1,12}){1,8}|av01(?:\.[0-9mh]{1,12}){1,9}|h264|h265|hevc|vp8|vp9|mpeg2video|mp2v|theora)$/i;
const AUDIO_CODEC =
/^(?:mp4a\.[0-9a-f]{2}(?:\.\d{1,2})?|aac|ac-?3|ec-?3|eac-?3|dac3|dec3|dts|dtsc|dtse|dtsh|dtsl|opus|vorbis|flac|mp3|mp2)$/i;
export function collectPlaybackCodecs(tracks: readonly unknown[]) {
const audio = new Set<string>();
const video = new Set<string>();
for (const value of tracks.slice(0, 256)) {
if (!value || typeof value !== 'object') continue;
const track = value as PlaybackCodecTrack;
collect(track.audioCodec, AUDIO_CODEC, audio);
collect(track.videoCodec, VIDEO_CODEC, video);
}
return { audioCodecs: [...audio], videoCodecs: [...video] };
}
function collect(value: unknown, pattern: RegExp, target: Set<string>): void {
if (typeof value !== 'string' || value.length > 256) return;
for (const token of value.split(',').map((item) => item.trim())) {
if (target.size < 16 && token.length <= 64 && pattern.test(token)) {
target.add(token);
}
}
}
@@ -17,6 +17,7 @@ import { createPlaybackDiagnostic } from './playback-diagnostics.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-error.types';
import { getPlaybackDrmSystem } from './playback-stream-metadata';
export {
SHAKA_DIAGNOSTIC_VERSION,
@@ -81,14 +82,16 @@ function getDiagnosticCode(
* handle. Provider-supplied license strings are intentionally not retained.
*/
export function createUnsupportedDrmDiagnostic(
_licenseType: string,
licenseType: string,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const system = getPlaybackDrmSystem(licenseType);
return createPlaybackDiagnostic({
code: DiagnosticCode.DrmOrEncryption,
source: DiagnosticSource.Shaka,
metadata,
metadata: system ? { ...metadata, drmSystems: [system] } : metadata,
details: 'Unsupported DRM license configuration',
runtimeSupport: PlaybackRuntimeSupport.DrmConfigurationUnsupported,
});
}
@@ -123,3 +123,9 @@ export const SHAKA_ERROR_CODE = {
MISSING_EME_SUPPORT: 6020,
LOAD_INTERRUPTED: 7000,
} as const;
/** Shaka 5.2.4 public NetworkingEngine request types used by diagnostics. */
export const SHAKA_REQUEST_TYPE = {
MANIFEST: 0,
SEGMENT: 1,
LICENSE: 2,
} as const;
@@ -1,5 +1,6 @@
import { execFileSync } from 'node:child_process';
import * as shakaDiagnostics from './shaka-error-classifier';
import { SHAKA_REQUEST_TYPE } from './shaka-error-contract';
interface ShakaErrorInput {
readonly severity?: unknown;
@@ -21,6 +22,7 @@ interface ExpectedShakaPlaybackEvidence {
}
interface InstalledShakaContract {
readonly requestTypes: Readonly<Record<string, number>>;
readonly version: string;
readonly severity: Readonly<Record<string, number>>;
readonly category: Readonly<Record<string, number>>;
@@ -41,8 +43,33 @@ interface ShakaEvidenceExports {
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
describe('Shaka playback evidence', () => {
it.each([
[1001, ['secret-url', 403, 'secret-body', {}, 0], 'manifest'],
[1001, ['secret-url', 404, null, {}, 1], 'segment'],
[1001, ['secret-url', 403, null, {}, 2], 'license'],
[1002, ['secret-url', new Error('secret'), 1], 'segment'],
[1003, ['secret-url', 0], 'manifest'],
[1003, ['secret-url', 2], 'license'],
[1003, ['secret-url', 5], 'unknown'],
[1001, ['secret-url', 404, null, {}, '1'], 'unknown'],
[1001, ['secret-url', 404, null, {}, 999], 'unknown'],
])(
'reads documented network request slots for code %s',
(code, data, stage) => {
const evidence = exportsUnderTest.createShakaPlaybackEvidence?.(
{ severity: 2, category: 1, code, data },
'terminal'
);
expect(evidence?.stage).toBe(stage);
expect(JSON.stringify(evidence)).not.toContain('secret');
}
);
it('matches the installed public Shaka 5.2.4 error contract', () => {
const installed = getInstalledShakaContract();
expect(installed.requestTypes).toEqual(
expect.objectContaining(SHAKA_REQUEST_TYPE)
);
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
installed.version
@@ -89,7 +116,7 @@ describe('Shaka playback evidence', () => {
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'unknown',
stage: 'manifest',
failure: 'network',
httpStatus: 503,
});
@@ -361,6 +388,7 @@ import('shaka-player').then((module) => {
severity: shaka.util.Error.Severity,
category: shaka.util.Error.Category,
code: shaka.util.Error.Code,
requestTypes: shaka.net.NetworkingEngine.RequestType,
}));
}).catch((error) => {
console.error(error);
@@ -14,6 +14,7 @@ import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
SHAKA_REQUEST_TYPE,
} from './shaka-error-contract';
import {
SHAKA_AMBIGUOUS_MANIFEST_CODES,
@@ -43,13 +44,41 @@ export function createShakaPlaybackEvidence(
category,
engineCode,
disposition,
stage: getStage(category, engineCode),
stage: getNetworkStage(error) ?? getStage(category, engineCode),
failure: getFailure(category, engineCode),
};
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
/** Public error.data request-type slots in Shaka 5.2.4; no URL inspection. */
function getNetworkStage(error: Partial<ShakaErrorLike> | null | undefined) {
if (
error?.category !== SHAKA_ERROR_CATEGORY.NETWORK ||
!Array.isArray(error.data)
)
return undefined;
const slot =
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
? 4
: error.code === SHAKA_ERROR_CODE.HTTP_ERROR
? 2
: error.code === SHAKA_ERROR_CODE.TIMEOUT
? 1
: undefined;
const type = slot === undefined ? undefined : error.data?.[slot];
switch (type) {
case SHAKA_REQUEST_TYPE.MANIFEST:
return ShakaPlaybackStage.Manifest;
case SHAKA_REQUEST_TYPE.SEGMENT:
return ShakaPlaybackStage.Segment;
case SHAKA_REQUEST_TYPE.LICENSE:
return ShakaPlaybackStage.License;
default:
return undefined;
}
}
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
return ShakaPlaybackSeverity.Recoverable;
@@ -306,6 +306,9 @@ describe('ArtPlayerSourceSession', () => {
player
);
Object.assign(hlsInstances[0], {
levels: [{ audioCodec: 'mp4a.40.2', videoCodec: 'avc1.64001e' }],
});
hlsInstances[0].emit(MockHls.Events.ERROR, null, {
type: 'networkError',
details: 'manifestLoadError',
@@ -327,6 +330,8 @@ describe('ArtPlayerSourceSession', () => {
expect(emitted).toEqual([
expect.objectContaining({
code: 'network-error',
audioCodecs: ['mp4a.40.2'],
videoCodecs: ['avc1.64001e'],
source: 'hls',
httpStatus: 503,
hls: {
@@ -12,6 +12,7 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
collectPlaybackCodecs,
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
import type { WebVideoControlsAdapter } from '../player-controls';
@@ -63,15 +64,13 @@ export class ArtPlayerSourceSession {
private pendingControlsSource: WebVideoControlsSource = { kind: 'native' };
private hls: Hls | null = null;
private hlsManifestListener:
| ((event: unknown, data: ManifestParsedData) => void)
| null = null;
((event: unknown, data: ManifestParsedData) => void) | null = null;
private hlsErrorListener:
| ((event: unknown, data: ErrorData) => void)
| null = null;
((event: unknown, data: ErrorData) => void) | null = null;
private mpegTsPlayer: mpegts.Player | null = null;
private mpegTsErrorListener:
| ((type: unknown, details: unknown, info: unknown) => void)
| null = null;
((type: unknown, details: unknown, info: unknown) => void) | null =
null;
private shakaSession: ShakaVideoSession | null = null;
private destroyed = false;
@@ -197,7 +196,10 @@ export class ArtPlayerSourceSession {
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, info),
this.createSourceMetadata(url, 'video/mp2t')
{
...this.createSourceMetadata(url, 'video/mp2t'),
...collectPlaybackCodecs([engine.mediaInfo ?? {}]),
}
)
);
};
@@ -244,8 +246,7 @@ export class ArtPlayerSourceSession {
private getShakaSession(): ShakaVideoSession {
this.shakaSession ??= new ShakaVideoSession({
player: InlinePlaybackPlayer.ArtPlayer,
emitPlaybackIssue: (issue) =>
this.config.emitPlaybackIssue(issue),
emitPlaybackIssue: (issue) => this.config.emitPlaybackIssue(issue),
showCaptions: this.config.showCaptions,
loadShaka: this.config.loadShaka,
});
@@ -322,7 +323,10 @@ export class ArtPlayerSourceSession {
private handleHlsError(url: string, data: ErrorData): void {
const issue = classifyHlsPlaybackIssue(
createHlsPlaybackEvidence(data),
this.createSourceMetadata(url, 'application/x-mpegURL')
{
...this.createSourceMetadata(url, 'application/x-mpegURL'),
...collectPlaybackCodecs(this.hls?.levels ?? []),
}
);
if (!issue) {
return;
@@ -0,0 +1,48 @@
import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
emitFatalHlsPlaybackError,
emitMpegTsPlaybackError,
} from './html-video-player-diagnostics';
describe('HTML5 engine codec evidence', () => {
it('keeps advertised HLS codecs on an ordinary segment network error', () => {
const issues: PlaybackDiagnostic[] = [];
emitFatalHlsPlaybackError(
'https://example.test/live.m3u8',
{
fatal: true,
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_ERROR,
} as ErrorData,
(issue) => issues.push(issue),
[{ audioCodec: 'mp4a.40.2', videoCodec: 'avc1.64001e' }]
);
expect(issues[0]).toMatchObject({
code: 'network-error',
audioCodecs: ['mp4a.40.2'],
videoCodecs: ['avc1.64001e'],
});
});
it('keeps MPEG-TS media-info codecs without copying its private fields', () => {
const issues: PlaybackDiagnostic[] = [];
emitMpegTsPlaybackError(
'https://example.test/live.ts',
{
type: 'MediaError',
details: 'MediaMSEError',
info: {},
},
(issue) => issues.push(issue),
{
audioCodec: 'aac',
videoCodec: 'h264',
}
);
expect(issues[0]).toMatchObject({
audioCodecs: ['aac'],
videoCodecs: ['h264'],
});
});
});
@@ -9,6 +9,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
collectPlaybackCodecs,
type PlaybackCodecTrack,
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
@@ -59,11 +61,18 @@ export function emitUnsupportedHlsManifestCodecs(
export function emitFatalHlsPlaybackError(
url: string,
data: ErrorData,
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void,
levels: readonly PlaybackCodecTrack[] = []
): void {
const codecs = collectPlaybackCodecs(levels);
const issue = classifyHlsPlaybackIssue(
createHlsPlaybackEvidence(data),
createHtml5SourceMetadata(url, 'application/x-mpegURL')
createHtml5SourceMetadata(
url,
'application/x-mpegURL',
codecs.audioCodecs,
codecs.videoCodecs
)
);
if (!issue) {
return;
@@ -75,16 +84,19 @@ export function emitFatalHlsPlaybackError(
export function emitMpegTsPlaybackError(
url: string,
error: { type: unknown; details: unknown; info: unknown },
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void,
mediaInfo: unknown = {}
): void {
const codecs = collectPlaybackCodecs([mediaInfo]);
emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(
error.type,
error.details,
error.info
),
createHtml5SourceMetadata(url, 'video/mp2t')
createMpegTsPlaybackEvidence(error.type, error.details, error.info),
createHtml5SourceMetadata(
url,
'video/mp2t',
codecs.audioCodecs,
codecs.videoCodecs
)
)
);
}
@@ -244,13 +244,16 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.videoPlayer.nativeElement
);
this.bindControlsSource({ kind: 'mpegts' });
const engine = this.mpegtsPlayer;
this.mpegtsPlayer.on(
mpegts.Events.ERROR,
(type: string, details: string, info: unknown): void => {
if (this.mpegtsPlayer !== engine) return;
emitMpegTsPlaybackError(
url,
{ type, details, info },
(issue) => this.playbackIssue.emit(issue)
(issue) => this.playbackIssue.emit(issue),
engine.mediaInfo
);
}
);
@@ -269,9 +272,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
const hls = new Hls();
this.hls = hls;
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
if (this.hls !== hls) return;
this.handleHlsManifestParsed(url, data);
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (this.hls !== hls) return;
this.handleHlsError(url, data);
});
hls.attachMedia(this.videoPlayer.nativeElement);
@@ -348,8 +353,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
private handleHlsError(url: string, data: ErrorData): void {
emitFatalHlsPlaybackError(url, data, (issue) =>
this.playbackIssue.emit(issue)
emitFatalHlsPlaybackError(
url,
data,
(issue) => this.playbackIssue.emit(issue),
this.hls?.levels ?? []
);
}
@@ -0,0 +1,58 @@
import type {
PlaybackDiagnostic,
PlaybackDrmSystem,
} from '@iptvnator/playback/util';
import type { PlaybackDiagnosticDetail } from './playback-diagnostic-view.util';
const DRM_LABELS: Record<PlaybackDrmSystem, string> = {
widevine: 'Widevine',
playready: 'PlayReady',
clearkey: 'ClearKey',
fairplay: 'FairPlay',
};
const STAGE_KEYS: Readonly<Record<string, string>> = {
manifest: 'MANIFEST',
playlist: 'PLAYLIST',
level: 'PLAYLIST',
segment: 'SEGMENT',
key: 'KEY',
license: 'LICENSE',
media: 'MEDIA',
loader: 'LOADER',
demux: 'DEMUX',
'media-source': 'MEDIA',
};
export function getDiagnosticEvidenceDetails(
issue: PlaybackDiagnostic
): PlaybackDiagnosticDetail[] {
const stage =
issue.shaka?.stage ??
issue.hls?.stage ??
issue.mpegTs?.stage ??
issue.vhs?.stage;
const stageKey = stage ? STAGE_KEYS[stage] : undefined;
const drm = (issue.drmSystems ?? [])
.map((system) => DRM_LABELS[system])
.filter(Boolean);
return [
...(stage && stageKey
? [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
value: stage,
valueKey: `PLAYBACK_DIAGNOSTICS.STAGE_${stageKey}`,
},
]
: []),
...(drm.length
? [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
value: drm.join(', '),
},
]
: []),
];
}
@@ -172,6 +172,23 @@
</div>
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="diagnosticReport()"
(cdkCopyToClipboardCopied)="onReportCopied($event)"
[matTooltip]="'PLAYBACK_DIAGNOSTICS.REPORT_PRIVACY' | translate"
data-test-id="playback-copy-diagnostic"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_DIAGNOSTIC' | translate }}
</button>
<span
aria-live="polite"
data-test-id="playback-copy-diagnostic-status"
>
{{ copyStatusKey() ? (copyStatusKey() | translate) : '' }}
</span>
<button
class="web-player-diagnostic__utility-button"
type="button"
@@ -203,7 +220,13 @@
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
<dd>
{{
detail.valueKey
? (detail.valueKey | translate)
: detail.value
}}
</dd>
</div>
}
</dl>
@@ -1,3 +1,4 @@
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -498,6 +499,39 @@ describe('PlaybackDiagnosticPanelComponent', () => {
expect(checkedSources).toEqual(['playlist-8:xtream:8']);
});
it('copies a safe report and reports success or failure for the current diagnostic', () => {
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css('[data-test-id="playback-copy-diagnostic"]')
);
expect(button).not.toBeNull();
const copy = button.injector.get(CdkCopyToClipboard);
expect(JSON.parse(copy.text)).toMatchObject({
app: 'IPTVnator',
code: DIAGNOSTIC.code,
});
expect(copy.text).not.toContain('example.com');
copy.copied.emit(false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.COPY_FAILED'
);
copy.copied.emit(true);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
);
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.NetworkError,
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
);
expect(JSON.parse(copy.text).code).toBe('network-error');
});
it('always renders Copy URL and Technical details utilities', () => {
fixture.detectChanges();
@@ -1,8 +1,13 @@
import { DOCUMENT } from '@angular/common';
import packageJson from '@package';
import { createDiagnosticReport } from './playback-diagnostic-report.util';
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
input,
output,
} from '@angular/core';
@@ -78,6 +83,30 @@ export class PlaybackDiagnosticPanelComponent {
readonly alternativeSourceRequested = output<string>();
readonly sourceCheckRequested = output<string>();
private readonly document = inject(DOCUMENT);
private readonly copyResult = signal<{
issue: PlaybackDiagnostic;
success: boolean;
} | null>(null);
readonly diagnosticReport = computed(() =>
createDiagnosticReport(
this.diagnostic(),
packageJson.version,
this.document.defaultView?.navigator.userAgent ?? ''
)
);
readonly copyStatusKey = computed(() => {
const result = this.copyResult();
if (!result || result.issue !== this.diagnostic()) return '';
return result.success
? 'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
: 'PLAYBACK_DIAGNOSTICS.COPY_FAILED';
});
onReportCopied(success: boolean): void {
this.copyResult.set({ issue: this.diagnostic(), success });
}
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
@@ -0,0 +1,93 @@
import { type PlaybackDiagnostic } from '@iptvnator/playback/util';
import { createDiagnosticReport } from './playback-diagnostic-report.util';
const issue: PlaybackDiagnostic = {
code: 'network-error',
source: 'shaka',
player: 'html5',
sourceUrl: 'https://user:secret@private.test/live.mpd?token=secret',
container: 'mpd',
audioCodecs: ['opus'],
videoCodecs: ['avc1.640028'],
drmSystems: ['widevine'],
httpStatus: 403,
nativeErrorMessage: 'secret',
nativeErrorType: 'secret',
details: 'secret',
shaka: {
category: 'network',
severity: 'critical',
engineCode: 1001,
stage: 'segment',
failure: 'network',
disposition: 'terminal',
},
};
describe('safe diagnostic report', () => {
it('copies bounded facts without raw URLs, messages or user agent', () => {
const report = createDiagnosticReport(
issue,
'1.0.0',
'Mozilla/5.0 (Macintosh) Electron/42 secret'
);
expect(JSON.parse(report)).toMatchObject({
app: 'IPTVnator',
appVersion: '1.0.0',
os: 'macOS',
runtime: 'electron',
code: 'network-error',
engine: 'shaka',
player: 'html5',
stage: 'segment',
engineCode: 1001,
httpStatus: 403,
drmSystems: ['widevine'],
videoCodecs: ['avc1.640028'],
});
for (const secret of [
'secret',
'private.test',
'Mozilla',
'sourceUrl',
'nativeErrorMessage',
])
expect(report).not.toContain(secret);
});
it('rejects unexpected strings even when a caller breaks the types', () => {
const report = createDiagnosticReport(
{
...issue,
code: 'secret',
source: 'secret',
player: 'secret',
container: 'secret',
videoCodecs: ['secret'],
audioCodecs: ['https://secret'],
drmSystems: ['secret'],
httpStatus: Infinity,
nativeErrorCode: NaN,
shaka: {
...issue.shaka,
stage: 'secret',
engineCode: 'secret',
},
} as unknown as PlaybackDiagnostic,
'secret',
'secret'
);
expect(report).not.toContain('secret');
expect(report).not.toContain('null');
});
it('does not carry another engine’s evidence', () => {
expect(
JSON.parse(
createDiagnosticReport(
{ ...issue, source: 'native' },
'1.0.0',
'Linux'
)
)
).not.toHaveProperty('stage');
});
});
@@ -0,0 +1,119 @@
import {
collectPlaybackCodecs,
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackDrmSystem,
PlaybackRuntimeSupport,
SHAKA_ERROR_CODE,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import { getDiagnosticEvidence } from './playback-diagnostic-summary.util';
function allowed<T extends string>(
value: unknown,
choices: readonly T[]
): T | undefined {
return choices.find((choice) => choice === value);
}
function integer(value: unknown, min: number, max: number): number | undefined {
return typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
value <= max
? value
: undefined;
}
function operatingSystem(agent: string): string {
if (/Android/i.test(agent)) return 'Android';
if (/iPhone|iPad|iPod/i.test(agent)) return 'iOS';
if (/Windows/i.test(agent)) return 'Windows';
if (/Macintosh|Mac OS X/i.test(agent)) return 'macOS';
if (/CrOS/i.test(agent)) return 'ChromeOS';
if (/Linux/i.test(agent)) return 'Linux';
return 'unknown';
}
/** Explicit allowlist: never serialize the diagnostic, source, or raw error. */
export function createDiagnosticReport(
issue: PlaybackDiagnostic,
appVersion: string,
userAgent: string
): string {
const evidence = getDiagnosticEvidence(issue);
const codecs = collectPlaybackCodecs([
...issue.videoCodecs.slice(0, 16).map((videoCodec) => ({ videoCodec })),
...issue.audioCodecs.slice(0, 16).map((audioCodec) => ({ audioCodec })),
]);
const shakaCode =
issue.source === 'shaka' ? issue.shaka?.engineCode : undefined;
return JSON.stringify(
{
schemaVersion: 1,
app: 'IPTVnator',
appVersion:
/^\d{1,4}\.\d{1,4}\.\d{1,4}(?:-(?:alpha|beta|rc)\.?\d{1,5})?$/.test(
appVersion
)
? appVersion
: 'unknown',
os: operatingSystem(userAgent),
runtime: /Electron\//i.test(userAgent) ? 'electron' : 'browser',
code: allowed(issue.code, Object.values(PlaybackDiagnosticCode)),
engine: allowed(
issue.source,
Object.values(PlaybackDiagnosticSource)
),
player: allowed(issue.player, Object.values(InlinePlaybackPlayer)),
runtimeSupport: allowed(
issue.runtimeSupport,
Object.values(PlaybackRuntimeSupport)
),
stage: allowed(evidence?.stage, [
'manifest',
'playlist',
'level',
'segment',
'key',
'license',
'media',
'loader',
'demux',
'media-source',
]),
engineCode: Object.values(SHAKA_ERROR_CODE).find(
(code) => code === shakaCode
),
nativeErrorCode: integer(issue.nativeErrorCode, 0, 99999),
httpStatus: integer(
evidence?.httpStatus ?? issue.httpStatus,
100,
599
),
container: allowed(issue.container, [
'mpd',
'dash',
'hls',
'm3u8',
'mp4',
'webm',
'matroska',
'mpegts',
'mpeg-ts',
'ts',
'avi',
'mov',
'flv',
'ogg',
'mp3',
'aac',
]),
...codecs,
drmSystems: Object.values(PlaybackDrmSystem).filter((system) =>
issue.drmSystems?.includes(system)
),
},
null,
2
);
}
@@ -0,0 +1,68 @@
import {
createUnsupportedDrmDiagnostic,
createPlaybackSourceMetadata,
classifyShakaPlaybackIssue,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import { getDiagnosticDescriptionKey } from './playback-diagnostic-view.util';
const metadata = createPlaybackSourceMetadata({
url: 'https://private.test/live.mpd',
});
function shaka(code: number, category = 6): PlaybackDiagnostic {
const result = classifyShakaPlaybackIssue(
{ code, category, severity: 2 },
metadata,
'terminal'
);
if (!result) throw new Error('Expected terminal diagnostic');
return result;
}
const description = (issue: PlaybackDiagnostic) =>
getDiagnosticDescriptionKey(issue, false, false);
describe('evidence-backed diagnostic summaries', () => {
it('explains an explicitly unsupported app DRM configuration', () => {
expect(
description(
createUnsupportedDrmDiagnostic('com.widevine.alpha', metadata)
)
).toBe('PLAYBACK_DIAGNOSTICS.DRM_CONFIGURATION_UNSUPPORTED');
});
it.each([
[6001, 'DRM_CONFIGURATION_UNAVAILABLE'],
[6007, 'LICENSE_REQUEST_FAILED'],
[6008, 'LICENSE_RESPONSE_REJECTED'],
[6012, 'LICENSE_SERVER_MISSING'],
])('explains Shaka DRM code %s', (code, key) => {
expect(description(shaka(code))).toBe(`PLAYBACK_DIAGNOSTICS.${key}`);
});
it('does not infer unsupported DRM from the manifest or arbitrary message', () => {
expect(
description({
...shaka(6002),
drmSystems: ['widevine'],
details: 'Unsupported DRM license configuration',
})
).toBe('PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION');
});
it('explains access refusal only for a confirmed segment request', () => {
const result = classifyShakaPlaybackIssue(
{
code: 1001,
category: 1,
severity: 2,
data: ['secret', 403, 'body', {}, 1],
},
metadata,
'terminal'
);
if (!result) throw new Error('Expected terminal diagnostic');
expect(description(result)).toBe(
'PLAYBACK_DIAGNOSTICS.SEGMENT_ACCESS_DENIED'
);
expect(description({ ...result, shaka: undefined })).toBe(
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.DESCRIPTION'
);
});
});
@@ -0,0 +1,60 @@
import {
PlaybackRuntimeSupport,
SHAKA_ERROR_CODE,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
/** Read evidence only from the engine that owns this diagnostic. */
export function getDiagnosticEvidence(issue: PlaybackDiagnostic) {
switch (issue.source) {
case 'shaka':
return issue.shaka;
case 'hls':
return issue.hls;
case 'vhs':
return issue.vhs;
case 'mpegts':
return issue.mpegTs;
default:
return undefined;
}
}
/** Source DRM declarations alone never select a failure explanation. */
export function getDiagnosticSummaryKey(
issue: PlaybackDiagnostic
): string | undefined {
const prefix = 'PLAYBACK_DIAGNOSTICS.';
if (
issue.code === 'drm-or-encryption' &&
issue.runtimeSupport ===
PlaybackRuntimeSupport.DrmConfigurationUnsupported
) {
return prefix + 'DRM_CONFIGURATION_UNSUPPORTED';
}
if (issue.source === 'shaka' && issue.shaka?.category === 'drm') {
switch (issue.shaka.engineCode) {
case SHAKA_ERROR_CODE.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE:
return prefix + 'DRM_CONFIGURATION_UNAVAILABLE';
case SHAKA_ERROR_CODE.LICENSE_REQUEST_FAILED:
return prefix + 'LICENSE_REQUEST_FAILED';
case SHAKA_ERROR_CODE.LICENSE_RESPONSE_REJECTED:
return prefix + 'LICENSE_RESPONSE_REJECTED';
case SHAKA_ERROR_CODE.NO_LICENSE_SERVER_GIVEN:
return prefix + 'LICENSE_SERVER_MISSING';
}
}
const evidence = getDiagnosticEvidence(issue);
const status = evidence?.httpStatus ?? issue.httpStatus;
if (
issue.code === 'network-error' &&
evidence?.stage === 'segment' &&
(status === 401 || status === 403)
) {
return prefix + 'SEGMENT_ACCESS_DENIED';
}
if (issue.code === 'network-error' && evidence?.stage === 'license') {
return prefix + 'LICENSE_REQUEST_FAILED';
}
return undefined;
}
@@ -12,6 +12,39 @@ import {
} from './playback-diagnostic-view.util';
describe('playback diagnostic view formatters', () => {
it('shows separate source DRM and failure-stage facts', () => {
const details = getDiagnosticDetails({
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Shaka,
sourceUrl: 'https://example.test/live.mpd',
container: 'mpd',
audioCodecs: [],
videoCodecs: [],
drmSystems: ['widevine', 'playready'],
shaka: {
severity: 'critical',
category: 'network',
engineCode: 1001,
disposition: 'terminal',
stage: 'segment',
failure: 'network',
httpStatus: 403,
},
});
expect(details).toEqual(
expect.arrayContaining([
expect.objectContaining({
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
value: 'segment',
}),
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
value: 'Widevine, PlayReady',
},
])
);
});
const issue: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Native,
@@ -47,8 +80,7 @@ describe('playback diagnostic view formatters', () => {
{
diagnostic: 'browser access',
code: PlaybackDiagnosticCode.BrowserAccessError,
desktopKey:
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
desktopKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION',
},
{
@@ -62,8 +94,7 @@ describe('playback diagnostic view formatters', () => {
code: PlaybackDiagnosticCode.UnsupportedContainer,
desktopKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
pwaKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
},
{
diagnostic: 'media decode',
@@ -85,18 +116,18 @@ describe('playback diagnostic view formatters', () => {
supportsManagedExternalPlayers: false,
expected: pwaKey,
},
])('preserves transferable $runtime copy', ({
supportsManagedExternalPlayers,
expected,
}) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
true
)
).toBe(expected);
});
])(
'preserves transferable $runtime copy',
({ supportsManagedExternalPlayers, expected }) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
true
)
).toBe(expected);
}
);
it.each([
['desktop', true],
@@ -131,9 +162,12 @@ describe('playback diagnostic view formatters', () => {
PlaybackDiagnosticCode.DrmOrEncryption,
'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION',
],
])('preserves neutral %s copy for protected playback', (_label, code, expected) => {
expect(
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
).toBe(expected);
});
])(
'preserves neutral %s copy for protected playback',
(_label, code, expected) => {
expect(
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
).toBe(expected);
}
);
});
@@ -3,6 +3,8 @@ import {
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '@iptvnator/playback/util';
import { getDiagnosticSummaryKey } from './playback-diagnostic-summary.util';
import { getDiagnosticEvidenceDetails } from './playback-diagnostic-evidence-view.util';
const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
PlaybackDiagnostic['code']
@@ -16,6 +18,7 @@ const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
readonly valueKey?: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
@@ -27,6 +30,8 @@ export function getDiagnosticDescriptionKey(
supportsManagedExternalPlayers: boolean,
playbackExternallyTransferable: boolean
): string {
const summary = getDiagnosticSummaryKey(issue);
if (summary) return summary;
if (
!playbackExternallyTransferable &&
UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code)
@@ -77,6 +82,7 @@ export function getDiagnosticDetails(
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
...getDiagnosticEvidenceDetails(issue),
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
@@ -0,0 +1,103 @@
import {
collectPlaybackCodecs,
getPlaybackDrmSystem,
SHAKA_REQUEST_TYPE,
type PlaybackDrmSystem,
} from '@iptvnator/playback/util';
import type {
ShakaNetworkingEngineLike,
ShakaResponseFilter,
} from './shaka-module.types';
const DASH_NAMESPACE = 'urn:mpeg:dash:schema:mpd:2011';
const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
/** Observes existing responses only; owns no requests, URLs, keys or XML. */
export class ShakaManifestMetadata {
private active = true;
private codecs = collectPlaybackCodecs([]);
private drmSystems: PlaybackDrmSystem[] = [];
private readonly configuredSystem: PlaybackDrmSystem | undefined;
constructor(
private readonly network: ShakaNetworkingEngineLike | null,
licenseType?: string
) {
this.configuredSystem = getPlaybackDrmSystem(licenseType);
network?.registerResponseFilter(this.onResponse);
}
read() {
return {
...this.codecs,
drmSystems: [
...new Set([
...this.drmSystems,
...(this.configuredSystem ? [this.configuredSystem] : []),
]),
],
};
}
destroy(): void {
this.active = false;
this.network?.unregisterResponseFilter(this.onResponse);
}
private readonly onResponse: ShakaResponseFilter = (type, response) => {
if (!this.active || type !== SHAKA_REQUEST_TYPE.MANIFEST) return;
try {
if (response.data.byteLength > MAX_MANIFEST_BYTES) return;
const xml = new TextDecoder().decode(response.data);
if (/<!DOCTYPE/i.test(xml)) return;
const document = new DOMParser().parseFromString(
xml,
'application/xml'
);
if (
document.getElementsByTagName('parsererror').length ||
document.documentElement.localName !== 'MPD' ||
document.documentElement.namespaceURI !== DASH_NAMESPACE
)
return;
const elements: Element[] = [];
for (const name of ['AdaptationSet', 'Representation']) {
elements.push(
...Array.from(
document.getElementsByTagNameNS(DASH_NAMESPACE, name)
).slice(0, 256)
);
}
this.codecs = collectPlaybackCodecs(
elements.map((element) => ({
audioCodec: element.getAttribute('codecs'),
videoCodec: element.getAttribute('codecs'),
}))
);
this.drmSystems = [
...new Set(
Array.from(
document.getElementsByTagNameNS(
DASH_NAMESPACE,
'ContentProtection'
)
)
.slice(0, 256)
.map((element) =>
getPlaybackDrmSystem(
element.getAttribute('schemeIdUri')
)
)
.filter(
(system): system is PlaybackDrmSystem =>
system !== undefined
)
),
];
} catch {
// Metadata is optional. Never turn inspection failure into a
// network-filter failure or interfere with Shaka's own parser.
}
};
}
@@ -40,7 +40,18 @@ export interface ShakaVariantTrackLike {
export type { ShakaErrorLike } from '@iptvnator/playback/util';
export type ShakaResponseFilter = (
type: number,
response: { data: BufferSource }
) => void;
export interface ShakaNetworkingEngineLike {
registerResponseFilter(filter: ShakaResponseFilter): void;
unregisterResponseFilter(filter: ShakaResponseFilter): void;
}
export interface ShakaPlayerLike {
getNetworkingEngine?(): ShakaNetworkingEngineLike | null;
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
configure(config: Record<string, unknown>): boolean;
load(assetUri: string): Promise<unknown>;
@@ -0,0 +1,162 @@
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks as flush,
} from './shaka-player-test-double';
import { ShakaVideoSession } from './shaka-video-session';
type Filter = (type: number, response: { data: BufferSource }) => void;
const manifest = `<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"><Period>
<AdaptationSet contentType="video">
<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"/>
<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"/>
<Representation codecs="avc1.64001e"/>
<Representation codecs="https://secret.test/?token=secret"/>
</AdaptationSet>
<AdaptationSet mimeType="audio/mp4" codecs="mp4a.40.29"/>
<BaseURL>https://secret.test/?token=secret</BaseURL>
</Period></MPD>`;
describe('Shaka diagnostic source evidence', () => {
function setup() {
const filters = new Set<Filter>();
const issues: PlaybackDiagnostic[] = [];
const environment = createFakeShakaEnvironment({
onCreate: (player) =>
Object.assign(player, {
getNetworkingEngine: () => ({
registerResponseFilter: (filter: Filter) =>
filters.add(filter),
unregisterResponseFilter: (filter: Filter) =>
filters.delete(filter),
}),
}),
});
const session = new ShakaVideoSession({
player: InlinePlaybackPlayer.Html5,
emitPlaybackIssue: (issue) => issues.push(issue),
loadShaka: environment.loader,
});
const respond = (xml: string, type = 0) => {
const response = { data: new TextEncoder().encode(xml) };
for (const filter of filters) filter(type, response);
expect(new TextDecoder().decode(response.data)).toBe(xml);
};
return { filters, issues, environment, session, respond };
}
it('keeps advertised codecs and DRM on a terminal startup failure', async () => {
const { filters, issues, environment, session, respond } = setup();
session.start(
document.createElement('video'),
'https://example.test/live.mpd'
);
await flush();
respond(manifest);
environment.instances[0].dispatch('error', {
severity: 2,
category: 6,
code: 6001,
});
expect(issues[0]).toMatchObject({
code: 'drm-or-encryption',
videoCodecs: ['avc1.64001e'],
audioCodecs: ['mp4a.40.29'],
drmSystems: ['widevine', 'playready'],
});
expect(JSON.stringify(issues)).not.toContain('secret');
expect(filters.size).toBe(0);
});
it('does not let old response callbacks enrich the next channel', async () => {
const { filters, issues, environment, session, respond } = setup();
const video = document.createElement('video');
session.start(video, 'https://example.test/first.mpd');
await flush();
const oldFilters = [...filters];
respond(manifest);
session.start(video, 'https://example.test/second.mpd');
await flush();
for (const filter of oldFilters)
filter(0, { data: new TextEncoder().encode(manifest) });
environment.instances[1].dispatch('error', {
severity: 2,
category: 1,
code: 1003,
});
expect(issues[0].audioCodecs).toEqual([]);
expect(issues[0].videoCodecs).toEqual([]);
expect(issues[0]).not.toHaveProperty('drmSystems', [
'widevine',
'playready',
]);
});
it('retains a configured ClearKey name without exposing keys', async () => {
const { session, issues, environment } = setup();
session.start(
document.createElement('video'),
'https://example.test/live.mpd',
{
licenseType: 'clearkey',
supported: true,
clearKeys: { 'private-key-id': 'private-content-key' },
}
);
await flush();
environment.instances[0].dispatch('error', {
severity: 2,
category: 6,
code: 6007,
});
expect(issues[0]).toHaveProperty('drmSystems', ['clearkey']);
expect(JSON.stringify(issues)).not.toContain('private-');
});
it.each([
['malformed', '<MPD><broken>', 0],
[
'DTD-bearing',
'<!DOCTYPE MPD [<!ENTITY label "secret">]>' + manifest,
0,
],
['non-manifest response', manifest, 1],
['oversized', manifest + ' '.repeat(2 * 1024 * 1024), 0],
['non-DASH XML', manifest.replaceAll('MPD', 'other'), 0],
])(
'ignores %s without interfering with playback',
async (_name, xml, type) => {
const { issues, environment, session, respond } = setup();
session.start(
document.createElement('video'),
'https://example.test/live.mpd'
);
await flush();
expect(() => respond(xml, type)).not.toThrow();
environment.instances[0].dispatch('error', {
severity: 2,
category: 4,
code: 4001,
});
expect(issues[0].videoCodecs).toEqual([]);
expect(issues[0].code).toBe('unknown-playback-error');
}
);
it('reports only a known configured DRM name, never its raw license value', () => {
const { session, issues } = setup();
session.start(
document.createElement('video'),
'https://example.test/live.mpd',
{
licenseType: 'com.widevine.alpha',
supported: false,
}
);
expect(issues[0]).toHaveProperty('drmSystems', ['widevine']);
expect(JSON.stringify(issues)).not.toContain('com.widevine.alpha');
});
});
@@ -13,6 +13,7 @@ import {
type PlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
import { ShakaManifestMetadata } from './shaka-manifest-metadata';
import {
loadShakaModule,
type ShakaErrorLike,
@@ -54,6 +55,7 @@ const PLAYER_REFRESH_EVENTS = [
export class ShakaVideoSession {
private module: ShakaModuleLike | null = null;
private player: ShakaPlayerLike | null = null;
private manifestMetadata: ShakaManifestMetadata | null = null;
private playerErrorListener: ((event: Event) => void) | null = null;
private playerRefreshListener: (() => void) | null = null;
private readonly refreshListeners = new Set<() => void>();
@@ -151,6 +153,10 @@ export class ShakaVideoSession {
const player = new module.Player();
this.player = player;
this.manifestMetadata = new ShakaManifestMetadata(
player.getNetworkingEngine?.() ?? null,
drm?.licenseType
);
this.bindPlayerListeners(player, generation, url);
if (drm?.clearKeys) {
@@ -274,6 +280,8 @@ export class ShakaVideoSession {
* {@link pendingTeardown} before attaching to the media element.
*/
private beginPlayerTeardown(): void {
this.manifestMetadata?.destroy();
this.manifestMetadata = null;
const player = this.player;
this.player = null;
this.textSuppression.reset();
@@ -316,6 +324,7 @@ export class ShakaVideoSession {
private createMetadata(url: string): PlaybackSourceMetadata {
return createPlaybackSourceMetadata({
...this.manifestMetadata?.read(),
url,
mimeType: DASH_MIME_TYPE,
player: this.config.player,
@@ -113,26 +113,30 @@ describe('VjsMpegTsSession', () => {
it('emits structured HTTP evidence with Video.js source metadata', () => {
const mpegTsPlayer = createMpegTsPlayer();
Object.assign(mpegTsPlayer, {
mediaInfo: {
audioCodec: 'aac',
videoCodec: 'h264',
url: 'private-media-info-url',
},
});
createPlayerMock.mockReturnValue(mpegTsPlayer);
const video = document.createElement('video');
const { session, emitPlaybackIssue } = createSession();
const secret = 'vjs-mpegts-secret';
session.start('https://example.test/live/stream.ts', video);
mpegTsPlayer.emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 503,
msg: `Service Unavailable ${secret}`,
headers: { Authorization: secret },
}
);
mpegTsPlayer.emit('error', 'NetworkError', 'HttpStatusCodeInvalid', {
code: 503,
msg: `Service Unavailable ${secret}`,
headers: { Authorization: secret },
});
expect(emitPlaybackIssue).toHaveBeenCalledWith(
expect.objectContaining({
code: 'network-error',
audioCodecs: ['aac'],
videoCodecs: ['h264'],
source: 'mpegts',
sourceUrl: 'https://example.test/live/stream.ts',
player: 'videojs',
@@ -145,6 +149,9 @@ describe('VjsMpegTsSession', () => {
}),
})
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
'private-media-info-url'
);
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
secret
);
@@ -3,6 +3,7 @@ import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
collectPlaybackCodecs,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
@@ -62,6 +63,7 @@ export class VjsMpegTsSession {
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
...collectPlaybackCodecs([engine.mediaInfo ?? {}]),
url,
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
@@ -0,0 +1,29 @@
import {
InlinePlaybackPlayer,
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import {
getVideoJsPlaybackCodecs,
hasActiveVhsSourceHandler,
type VideoJsPlayer,
type VideoPlayerSource,
} from './vjs-player.types';
export function createVideoJsPlaybackDiagnostic(
player: VideoJsPlayer,
source: VideoPlayerSource | null | undefined,
video: HTMLVideoElement
) {
const error = typeof player.error === 'function' ? player.error() : null;
const metadata = createPlaybackSourceMetadata({
...getVideoJsPlaybackCodecs(player),
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
});
return error && hasActiveVhsSourceHandler(player)
? classifyVhsPlaybackIssue(error, metadata)
: classifyNativePlaybackIssue(error ?? video.error, metadata);
}
@@ -17,13 +17,8 @@ import { createDevLogger } from '@iptvnator/shared/interfaces';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import { type PlaybackDiagnostic } from '@iptvnator/playback/util';
import { createVideoJsPlaybackDiagnostic } from './vjs-playback-diagnostics';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
@@ -52,7 +47,6 @@ import {
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
hasActiveVhsSourceHandler,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
@@ -266,23 +260,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.videoSession.video() ??
getVideoJsTechVideo(this.player) ??
this.target().nativeElement;
const playerError =
typeof this.player.error === 'function'
? this.player.error()
: null;
const metadata = createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
});
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
playerError && hasActiveVhsSourceHandler(this.player)
? classifyVhsPlaybackIssue(playerError, metadata)
: classifyNativePlaybackIssue(
playerError ?? video.error,
metadata
)
createVideoJsPlaybackDiagnostic(this.player, source, video)
);
};
@@ -2,6 +2,44 @@ import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
import * as playerTypes from './vjs-player.types';
describe('getVideoJsTechVideo', () => {
it('reads safe codecs from the public VHS representations API', () => {
const read = (
playerTypes as unknown as {
getVideoJsPlaybackCodecs: (player: unknown) => unknown;
}
).getVideoJsPlaybackCodecs;
expect(read).toBeDefined();
expect(
read({
tech: () => ({
vhs: {
representations: () => [
{
codecs: {
video: 'avc1.64001e',
audio: 'mp4a.40.2',
},
uri: 'secret-url',
},
{
codecs: {
video: 'https://secret.test',
audio: 'secret',
},
},
],
},
}),
})
).toEqual({ videoCodecs: ['avc1.64001e'], audioCodecs: ['mp4a.40.2'] });
expect(
read({
tech: () => {
throw new Error('disposed');
},
})
).toEqual({ videoCodecs: [], audioCodecs: [] });
});
it('returns the current Video.js Tech video element', () => {
const video = document.createElement('video');
const tech = jest.fn(() => ({ el: () => video }));
@@ -1,5 +1,6 @@
import type videoJs from 'video.js';
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
import { collectPlaybackCodecs } from '@iptvnator/playback/util';
export type VideoPlayerSource = {
src: string;
@@ -62,14 +63,16 @@ export type VideoJsQualityLevel = {
enabled: boolean;
};
export type VideoJsQualityLevelList =
VideoJsTrackList<VideoJsQualityLevel> & {
selectedIndex?: number;
};
export type VideoJsQualityLevelList = VideoJsTrackList<VideoJsQualityLevel> & {
selectedIndex?: number;
};
export type VideoJsTech = {
el?: () => Element | null;
vhs?: {
representations?: () => {
codecs?: { audio?: unknown; video?: unknown };
}[];
playlists?: {
main?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
master?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
@@ -125,3 +128,20 @@ export function hasActiveVhsSourceHandler(
return false;
}
}
export function getVideoJsPlaybackCodecs(player: Pick<VideoJsPlayer, 'tech'>) {
try {
const variants =
player
.tech({ IWillNotUseThisInPlugins: true })
?.vhs?.representations?.() ?? [];
return collectPlaybackCodecs(
variants.slice(0, 256).map((variant) => ({
audioCodec: variant.codecs?.audio,
videoCodec: variant.codecs?.video,
}))
);
} catch {
return collectPlaybackCodecs([]);
}
}