From c952b55da1f352ae89f167b5274e0809ff45ce12 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:26:26 +0200 Subject: [PATCH] feat(playback): enrich failure diagnostics and add safe support reports (#1574) --- .changes/playback-diagnostic-details.md | 6 + AGENTS.md | 8 + CLAUDE.md | 8 + .../src/dash-clearkey.e2e.ts | 44 ++++- apps/web-e2e/src/dash-clearkey.e2e.ts | 118 ++++++++++++- apps/web/src/assets/i18n/ar.json | 22 ++- apps/web/src/assets/i18n/ary.json | 22 ++- apps/web/src/assets/i18n/by.json | 22 ++- apps/web/src/assets/i18n/de.json | 22 ++- apps/web/src/assets/i18n/el.json | 22 ++- apps/web/src/assets/i18n/en.json | 22 ++- apps/web/src/assets/i18n/es.json | 22 ++- apps/web/src/assets/i18n/fr.json | 22 ++- apps/web/src/assets/i18n/hu.json | 22 ++- apps/web/src/assets/i18n/it.json | 22 ++- apps/web/src/assets/i18n/ja.json | 22 ++- apps/web/src/assets/i18n/ko.json | 22 ++- apps/web/src/assets/i18n/nl.json | 22 ++- apps/web/src/assets/i18n/pl.json | 22 ++- apps/web/src/assets/i18n/pt.json | 22 ++- apps/web/src/assets/i18n/ru.json | 22 ++- apps/web/src/assets/i18n/tr.json | 22 ++- apps/web/src/assets/i18n/zh.json | 22 ++- apps/web/src/assets/i18n/zhtw.json | 22 ++- docs/architecture/embedded-inline-playback.md | 68 +++++++- docs/architecture/m3u-playlist-module.md | 7 +- libs/playback/util/src/index.ts | 1 + .../diagnostics/playback-diagnostics.model.ts | 6 + .../diagnostics/playback-diagnostics.util.ts | 3 + .../diagnostics/playback-media-source.util.ts | 1 + .../playback-stream-metadata.spec.ts | 56 ++++++ .../diagnostics/playback-stream-metadata.ts | 77 +++++++++ .../lib/diagnostics/shaka-error-classifier.ts | 7 +- .../lib/diagnostics/shaka-error-contract.ts | 6 + .../shaka-playback-evidence.util.spec.ts | 30 +++- .../shaka-playback-evidence.util.ts | 31 +++- .../art-player-source-session.spec.ts | 5 + .../art-player/art-player-source-session.ts | 24 +-- .../html-video-player-diagnostics.spec.ts | 48 ++++++ .../html-video-player-diagnostics.ts | 30 +++- .../html-video-player.component.ts | 14 +- .../playback-diagnostic-evidence-view.util.ts | 58 +++++++ .../playback-diagnostic-panel.component.html | 25 ++- ...layback-diagnostic-panel.component.spec.ts | 34 ++++ .../playback-diagnostic-panel.component.ts | 29 ++++ .../playback-diagnostic-report.util.spec.ts | 93 ++++++++++ .../playback-diagnostic-report.util.ts | 119 +++++++++++++ .../playback-diagnostic-summary.util.spec.ts | 68 ++++++++ .../playback-diagnostic-summary.util.ts | 60 +++++++ .../playback-diagnostic-view.util.spec.ts | 76 +++++--- .../playback-diagnostic-view.util.ts | 6 + .../shaka-engine/shaka-manifest-metadata.ts | 103 +++++++++++ .../lib/shaka-engine/shaka-module.types.ts | 11 ++ .../shaka-video-session.metadata.spec.ts | 162 ++++++++++++++++++ .../lib/shaka-engine/shaka-video-session.ts | 9 + .../lib/vjs-player/vjs-mpegts-session.spec.ts | 27 +-- .../src/lib/vjs-player/vjs-mpegts-session.ts | 2 + .../vjs-player/vjs-playback-diagnostics.ts | 29 ++++ .../lib/vjs-player/vjs-player.component.ts | 26 +-- .../lib/vjs-player/vjs-player.types.spec.ts | 38 ++++ .../src/lib/vjs-player/vjs-player.types.ts | 28 ++- 61 files changed, 1908 insertions(+), 111 deletions(-) create mode 100644 .changes/playback-diagnostic-details.md create mode 100644 libs/playback/util/src/lib/diagnostics/playback-stream-metadata.spec.ts create mode 100644 libs/playback/util/src/lib/diagnostics/playback-stream-metadata.ts create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-evidence-view.util.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts diff --git a/.changes/playback-diagnostic-details.md b/.changes/playback-diagnostic-details.md new file mode 100644 index 000000000..9be70d132 --- /dev/null +++ b/.changes/playback-diagnostic-details.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index f04dd31be..fd2fb995c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e7008f398..006c9ad52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 26066acd5..7bbf9c4b6 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -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"]' diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index 673e99aa5..e2ad28d2f 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -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( + '' + + '' + + '' + + ' { + 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'); +}); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f4d370b13..3dc980321 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "إخفاء قائمة القنوات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 17d169646..aa7c4cacc 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "إخفاء قائمة القنوات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5bf494cac..b3a3dbc5a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Схаваць спіс каналаў", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d71555cf6..676b15a2f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 6488ac900..497d05359 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Απόκρυψη λίστας καναλιών", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 5a05b958c..9fbe27656 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3e6c332c0..a43ac3214 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index aa709e6c9..beca8d75d 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 7e68c74bc..ff5add2d1 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index cfa0294ff..73af484ab 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 1059a8633..8994aa4ed 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "チャンネル一覧を非表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5281dc48d..93c2d0056 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "채널 목록 숨기기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7b4ad4985..21224d971 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 56bc70077..240bfb8b0 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b79a001d9..b9129fff4 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 902dff313..87b25d8c9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Скрыть список каналов", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 3fc3cf1e9..0ed77670d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 9072d98a4..6c107bb23 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "隐藏频道列表", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9af8fab53..e161bad41 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "隱藏頻道列表", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index a2a4c8279..8eb6f610f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 6fb2d449a..8f6da45fa 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/playback/util/src/index.ts b/libs/playback/util/src/index.ts index 43c5078cb..0f2cde009 100644 --- a/libs/playback/util/src/index.ts +++ b/libs/playback/util/src/index.ts @@ -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'; diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts index 14341f405..3015be589 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts @@ -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; diff --git a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts index 99d272b11..540db9693 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts @@ -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, diff --git a/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts b/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts index 8ac8ff5d9..fc62ca9bc 100644 --- a/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts @@ -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), diff --git a/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.spec.ts b/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.spec.ts new file mode 100644 index 000000000..2f12d666c --- /dev/null +++ b/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.spec.ts @@ -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); + }); +}); diff --git a/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.ts b/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.ts new file mode 100644 index 000000000..0bbc9403a --- /dev/null +++ b/libs/playback/util/src/lib/diagnostics/playback-stream-metadata.ts @@ -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([ + ['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(); + const video = new Set(); + 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): 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); + } + } +} diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts index 7f914497b..a4446be6d 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts @@ -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, }); } diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts index 031b46154..5624dc09c 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts @@ -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; diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts index 89d9e450e..2e0b61ca3 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts @@ -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>; readonly version: string; readonly severity: Readonly>; readonly category: Readonly>; @@ -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); diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts index 00ae4c116..33a3e345d 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts @@ -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 | 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; diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index 4f3515791..4d81991e3 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -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: { diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index 1f27fff21..b6c4d948f 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -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; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.spec.ts new file mode 100644 index 000000000..037221d41 --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.spec.ts @@ -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'], + }); + }); +}); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts index ac3da7102..165225194 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -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 + ) ) ); } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 652651186..3b6fe56bb 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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 ?? [] ); } diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-evidence-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-evidence-view.util.ts new file mode 100644 index 000000000..addbea5ff --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-evidence-view.util.ts @@ -0,0 +1,58 @@ +import type { + PlaybackDiagnostic, + PlaybackDrmSystem, +} from '@iptvnator/playback/util'; +import type { PlaybackDiagnosticDetail } from './playback-diagnostic-view.util'; + +const DRM_LABELS: Record = { + widevine: 'Widevine', + playready: 'PlayReady', + clearkey: 'ClearKey', + fairplay: 'FairPlay', +}; + +const STAGE_KEYS: Readonly> = { + 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(', '), + }, + ] + : []), + ]; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html index 012bae4e5..0845cb289 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html @@ -172,6 +172,23 @@
+ + + {{ copyStatusKey() ? (copyStatusKey() | translate) : '' }} +
} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts index aa866cb82..2025edf23 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts index a67075a03..ff1bc5f6e 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts @@ -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(); readonly sourceCheckRequested = output(); + 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) ); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts new file mode 100644 index 000000000..c56d65407 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.spec.ts @@ -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'); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts new file mode 100644 index 000000000..964b9b873 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-report.util.ts @@ -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( + 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 + ); +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts new file mode 100644 index 000000000..985487db8 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.spec.ts @@ -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' + ); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts new file mode 100644 index 000000000..127c6b9a1 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-summary.util.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts index c2aff28e2..7256dbe7e 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts @@ -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); + } + ); }); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts index 9afebe81e..85abcf985 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts @@ -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, diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts new file mode 100644 index 000000000..d92e19e4a --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-manifest-metadata.ts @@ -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 (/ ({ + 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. + } + }; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts index 0156d6013..3c1428d79 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -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; configure(config: Record): boolean; load(assetUri: string): Promise; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts new file mode 100644 index 000000000..da163e832 --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.metadata.spec.ts @@ -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 = ` + + + + + + + + https://secret.test/?token=secret +`; + +describe('Shaka diagnostic source evidence', () => { + function setup() { + const filters = new Set(); + 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', '', 0], + [ + 'DTD-bearing', + ']>' + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 5443d3f77..ca5fecb8b 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -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, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts index f9194cd40..85989ff99 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts @@ -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 ); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts index 08b0347a6..033fc83c9 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts @@ -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, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts b/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts new file mode 100644 index 000000000..868669dae --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-playback-diagnostics.ts @@ -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); +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 5756d95cd..e2a6bc4a0 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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) ); }; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts index c546ea1f9..24baa0313 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.spec.ts @@ -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 })); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts index e6d5ca89d..ba1ea4218 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts @@ -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 & { - selectedIndex?: number; - }; +export type VideoJsQualityLevelList = VideoJsTrackList & { + selectedIndex?: number; +}; export type VideoJsTech = { el?: () => Element | null; vhs?: { + representations?: () => { + codecs?: { audio?: unknown; video?: unknown }; + }[]; playlists?: { main?: { mediaGroups?: { AUDIO?: Record } }; master?: { mediaGroups?: { AUDIO?: Record } }; @@ -125,3 +128,20 @@ export function hasActiveVhsSourceHandler( return false; } } + +export function getVideoJsPlaybackCodecs(player: Pick) { + 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([]); + } +}