mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(playback): enrich failure diagnostics and add safe support reports (#1574)
This commit is contained in:
1 parent
7f06690e72
commit
c952b55da1
61 files changed
+1908
-111
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -939,6 +939,14 @@ app as a real argument, so it is not an option.
|
||||
most three actions, and `WebPlayerViewComponent` executes only the action
|
||||
the user selects. The policy is a sibling of `PlayerController`; shared
|
||||
controls only gate interaction while the diagnostic panel is visible.
|
||||
Technical details also expose localized stages, safe engine codec metadata
|
||||
and allowlisted source DRM names. DASH observes existing Shaka manifest
|
||||
responses (bounded to 2 MiB); it does not fetch again or retain license URLs,
|
||||
keys or XML. Evidence is scoped to one engine and never proves playability.
|
||||
The panel refines descriptions only from explicit runtime/engine evidence;
|
||||
HTTP 401/403 segment failures never imply token expiry. Copy diagnostics
|
||||
creates an allowlisted local report without URLs, credentials or raw messages;
|
||||
its content and copy status follow the current diagnostic.
|
||||
`WebPlayerViewComponent` owns a host-derived content-session key that is
|
||||
stable for the mounted logical selection, attempted target IDs, the temporary
|
||||
player override, and VOD handoff position. Its `PlaybackBinding` is exactly
|
||||
|
||||
@@ -560,7 +560,9 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
.click();
|
||||
const banner = app.mainWindow.getByTestId('playback-diagnostic-banner');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
||||
await expect(banner).toContainText(
|
||||
'This player does not support the stream’s DRM configuration.'
|
||||
);
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-fallback-mpv"]')
|
||||
).toHaveCount(0);
|
||||
@@ -575,6 +577,32 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
);
|
||||
await widevineDetails.locator('summary').click();
|
||||
await expect(widevineDetails).toContainText('drm-or-encryption');
|
||||
await expect(widevineDetails).toContainText('Widevine');
|
||||
const copyDiagnostic = banner.getByRole('button', {
|
||||
name: 'Copy diagnostics',
|
||||
exact: true,
|
||||
});
|
||||
await copyDiagnostic.click();
|
||||
await expect(
|
||||
banner.getByTestId('playback-copy-diagnostic-status')
|
||||
).toHaveText('Diagnostics copied');
|
||||
const drmReport = await app.electronApp.evaluate(({ clipboard }) =>
|
||||
clipboard.readText()
|
||||
);
|
||||
expect(JSON.parse(drmReport)).toMatchObject({
|
||||
app: 'IPTVnator',
|
||||
runtime: 'electron',
|
||||
code: 'drm-or-encryption',
|
||||
drmSystems: ['widevine'],
|
||||
});
|
||||
for (const secret of [
|
||||
fixtureServer.origin,
|
||||
'license.example.com',
|
||||
CLEARKEY_KEY,
|
||||
CLEARKEY_KID,
|
||||
]) {
|
||||
expect(drmReport).not.toContain(secret);
|
||||
}
|
||||
|
||||
await installPlaybackRecommendationLaunchCapture(app);
|
||||
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
|
||||
@@ -589,6 +617,20 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
);
|
||||
await mkvDetails.locator('summary').click();
|
||||
await expect(mkvDetails).toContainText('unsupported-container');
|
||||
await expect(
|
||||
banner.getByTestId('playback-copy-diagnostic-status')
|
||||
).toBeEmpty();
|
||||
await copyDiagnostic.click();
|
||||
const mkvReport = JSON.parse(
|
||||
await app.electronApp.evaluate(({ clipboard }) =>
|
||||
clipboard.readText()
|
||||
)
|
||||
);
|
||||
expect(mkvReport).toMatchObject({
|
||||
code: 'unsupported-container',
|
||||
drmSystems: [],
|
||||
});
|
||||
expect(mkvReport).not.toHaveProperty('runtimeSupport');
|
||||
|
||||
const mpvFallback = banner.locator(
|
||||
'[data-test-id="playback-fallback-mpv"]'
|
||||
|
||||
@@ -149,7 +149,15 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
|
||||
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
||||
await expect(banner).toContainText(
|
||||
'This player does not support the stream’s DRM configuration.'
|
||||
);
|
||||
await banner.locator('summary').click();
|
||||
const details = banner.locator(
|
||||
'[data-test-id="playback-diagnostic-details"]'
|
||||
);
|
||||
await expect(details).toContainText('DRM declared by source');
|
||||
await expect(details).toContainText('Widevine');
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-fallback-mpv"]')
|
||||
).toHaveCount(0);
|
||||
@@ -160,3 +168,111 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
|
||||
banner.locator('[data-test-id^="playback-recommendation-"]')
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('@web @m3u @dash manifest DRM and codecs survive a startup failure', async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveDashFixtures(page);
|
||||
let manifestRequests = 0;
|
||||
const body = readFileSync(
|
||||
join(FIXTURE_DIR, 'clear.mpd'),
|
||||
'utf8'
|
||||
).replaceAll(
|
||||
'<Representation ',
|
||||
'<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"/>' +
|
||||
'<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"/>' +
|
||||
'<!-- private-license-marker -->' +
|
||||
'<Representation '
|
||||
);
|
||||
await page.route(`${FIXTURE_HOST}/clear.mpd`, async (route) => {
|
||||
manifestRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/dash+xml',
|
||||
body,
|
||||
});
|
||||
});
|
||||
await importDashPlaylist(page);
|
||||
await page.getByText('2. Clear DASH').click();
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
await expect(banner).toBeVisible({ timeout: 20_000 });
|
||||
await banner.locator('summary').click();
|
||||
const details = banner.locator(
|
||||
'[data-test-id="playback-diagnostic-details"]'
|
||||
);
|
||||
await expect(details).toContainText('Widevine, PlayReady');
|
||||
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
|
||||
await expect(details).toContainText('opus');
|
||||
await expect(details).not.toContainText('private-license-marker');
|
||||
expect(manifestRequests).toBe(1);
|
||||
});
|
||||
|
||||
test('@web @m3u @dash segment failures show their stage and preserve codecs', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await serveDashFixtures(page);
|
||||
await page.route(`${FIXTURE_HOST}/clear-video.mp4`, (route) =>
|
||||
route.fulfill({ status: 403, body: 'private-response-marker' })
|
||||
);
|
||||
await importDashPlaylist(page);
|
||||
await page.getByText('2. Clear DASH').click();
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
await expect(banner).toBeVisible({ timeout: 20_000 });
|
||||
await banner.locator('summary').click();
|
||||
const details = banner.locator(
|
||||
'[data-test-id="playback-diagnostic-details"]'
|
||||
);
|
||||
await expect(details).toContainText('Media segment loading');
|
||||
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
|
||||
await expect(details).toContainText('HTTP 403');
|
||||
await expect(details).not.toContainText('private-response-marker');
|
||||
await expect(details).not.toContainText('DRM declared by source');
|
||||
await expect(banner).toContainText(
|
||||
'The server denied access to the media segments.'
|
||||
);
|
||||
await expect(banner).not.toContainText(/token.*expired/i);
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
const copy = banner.getByRole('button', {
|
||||
name: 'Copy diagnostics',
|
||||
exact: true,
|
||||
});
|
||||
await copy.click();
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
|
||||
).toHaveText('Diagnostics copied');
|
||||
const reportText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText()
|
||||
);
|
||||
expect(JSON.parse(reportText)).toMatchObject({
|
||||
app: 'IPTVnator',
|
||||
engine: 'shaka',
|
||||
stage: 'segment',
|
||||
httpStatus: 403,
|
||||
videoCodecs: ['vp09.00.11.08.01.02.02.02.00'],
|
||||
});
|
||||
for (const secret of [
|
||||
'private-response-marker',
|
||||
FIXTURE_HOST,
|
||||
'sourceUrl',
|
||||
]) {
|
||||
expect(reportText).not.toContain(secret);
|
||||
}
|
||||
await page.getByText('3. Widevine DASH').click();
|
||||
await expect(banner).toContainText(
|
||||
'This player does not support the stream’s DRM configuration.'
|
||||
);
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
|
||||
).toBeEmpty();
|
||||
await copy.click();
|
||||
const next = JSON.parse(
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
);
|
||||
expect(next).toMatchObject({
|
||||
code: 'drm-or-encryption',
|
||||
drmSystems: ['widevine'],
|
||||
});
|
||||
expect(next).not.toHaveProperty('httpStatus');
|
||||
expect(next).not.toHaveProperty('stage');
|
||||
});
|
||||
@@ -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": "إخفاء قائمة القنوات",
|
||||
|
||||
@@ -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": "إخفاء قائمة القنوات",
|
||||
|
||||
@@ -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": "Схаваць спіс каналаў",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Απόκρυψη λίστας καναλιών",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "チャンネル一覧を非表示",
|
||||
|
||||
@@ -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": "채널 목록 숨기기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Скрыть список каналов",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "隐藏频道列表",
|
||||
|
||||
@@ -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": "隱藏頻道列表",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1338,8 +1338,11 @@ player in settings.
|
||||
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
|
||||
and treats a rejected load as terminal even if its final retry error retains
|
||||
recoverable severity. Raw messages and `error.data` never cross the boundary;
|
||||
only the documented direct/nested `BAD_HTTP_STATUS` slot may contribute a
|
||||
validated HTTP status. Exact category/code pairs classify failures, while an
|
||||
only documented direct/nested `BAD_HTTP_STATUS` slots contribute HTTP
|
||||
status, and direct network request-type slots identify manifest, segment or
|
||||
license stages. A session-owned response observer adds safe advertised codec
|
||||
and DRM names from the already fetched DASH manifest; it never fetches extra
|
||||
data or retains license URLs, keys or the manifest body. Exact category/code pairs classify failures, while an
|
||||
ambiguous Manifest category or unknown pair remains an unknown diagnostic.
|
||||
The public streaming-startup code `5006` is retained exactly but keeps
|
||||
unknown stage/failure. Public DASH text-parser codes likewise retain their
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './lib/diagnostics/playback-diagnostics.util';
|
||||
export * from './lib/diagnostics/shaka-error-classifier';
|
||||
export * from './lib/diagnostics/shaka-error-contract';
|
||||
export * from './lib/diagnostics/playback-stream-metadata';
|
||||
export * from './lib/diagnostics/shaka-error-lifecycle';
|
||||
export type * from './lib/diagnostics/shaka-error.types';
|
||||
export * from './lib/playback-recommendation.model';
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import type { PlaybackDrmSystem } from './playback-stream-metadata';
|
||||
import type { MpegTsPlaybackEvidence } from './mpegts-playback-evidence.model';
|
||||
|
||||
export * from './mpegts-playback-evidence.model';
|
||||
@@ -34,6 +35,7 @@ export type PlaybackDiagnosticSource =
|
||||
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
|
||||
|
||||
export const PlaybackRuntimeSupport = {
|
||||
DrmConfigurationUnsupported: 'drm-configuration-unsupported',
|
||||
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
|
||||
} as const;
|
||||
|
||||
@@ -50,6 +52,7 @@ export type InlinePlaybackPlayer =
|
||||
(typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer];
|
||||
|
||||
export interface PlaybackSourceMetadataInput {
|
||||
readonly drmSystems?: readonly PlaybackDrmSystem[];
|
||||
readonly url: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
@@ -58,6 +61,7 @@ export interface PlaybackSourceMetadataInput {
|
||||
}
|
||||
|
||||
export interface PlaybackSourceMetadata {
|
||||
readonly drmSystems?: readonly PlaybackDrmSystem[];
|
||||
readonly url: string;
|
||||
readonly extension: string;
|
||||
readonly container: string;
|
||||
@@ -255,6 +259,8 @@ export interface ShakaPlaybackEvidence {
|
||||
}
|
||||
|
||||
export interface PlaybackDiagnostic {
|
||||
/** Declared by the source/configuration, not proof of usable licenses. */
|
||||
readonly drmSystems?: readonly PlaybackDrmSystem[];
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly sourceUrl: string;
|
||||
|
||||
@@ -231,6 +231,9 @@ export function createPlaybackDiagnostic(options: {
|
||||
return {
|
||||
code,
|
||||
source,
|
||||
...(metadata.drmSystems?.length
|
||||
? { drmSystems: metadata.drmSystems }
|
||||
: {}),
|
||||
sourceUrl: metadata.url,
|
||||
container: metadata.container,
|
||||
mimeType: metadata.mimeType,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createPlaybackSourceMetadata(
|
||||
const mimeType = input.mimeType?.trim() || undefined;
|
||||
|
||||
return {
|
||||
...(input.drmSystems?.length ? { drmSystems: input.drmSystems } : {}),
|
||||
url: input.url,
|
||||
extension,
|
||||
container: extension || inferContainerFromMimeType(mimeType),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
collectPlaybackCodecs,
|
||||
getPlaybackDrmSystem,
|
||||
} from './playback-stream-metadata';
|
||||
|
||||
describe('safe playback source metadata', () => {
|
||||
it('retains codec identifiers and discards provider text, URLs and unsupported shapes', () => {
|
||||
expect(
|
||||
collectPlaybackCodecs([
|
||||
{
|
||||
videoCodec: 'avc1.64001e, hvc1.1.6.L93.B0',
|
||||
audioCodec: 'mp4a.40.2, ac-3',
|
||||
},
|
||||
{
|
||||
videoCodec: 'https://secret.test/?token=secret',
|
||||
audioCodec: 'Authorization: secret',
|
||||
},
|
||||
{ videoCodec: { token: 'secret' }, audioCodec: 17 },
|
||||
{ videoCodec: 'avc1.64001e', audioCodec: 'mp4a.40.2' },
|
||||
])
|
||||
).toEqual({
|
||||
videoCodecs: ['avc1.64001e', 'hvc1.1.6.L93.B0'],
|
||||
audioCodecs: ['mp4a.40.2', 'ac-3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('retains full AV1 codec strings with color metadata', () => {
|
||||
const fullAv1 = 'av01.0.04M.08.0.110.01.01.01.0';
|
||||
expect(
|
||||
collectPlaybackCodecs([{ videoCodec: fullAv1 }]).videoCodecs
|
||||
).toEqual([fullAv1]);
|
||||
expect(
|
||||
collectPlaybackCodecs([{ videoCodec: fullAv1 + '.0' }]).videoCodecs
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not confuse generic CENC with a named DRM system', () => {
|
||||
expect(
|
||||
getPlaybackDrmSystem('urn:mpeg:dash:mp4protection:2011')
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getPlaybackDrmSystem(
|
||||
'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b'
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(getPlaybackDrmSystem('widevine-secret-token')).toBeUndefined();
|
||||
expect(getPlaybackDrmSystem(' COM.WIDEVINE.ALPHA ')).toBe('widevine');
|
||||
});
|
||||
|
||||
it('bounds retained codec lists', () => {
|
||||
const tracks = Array.from({ length: 300 }, (_, i) => ({
|
||||
videoCodec: `avc1.${i.toString(16).padStart(6, '0')}`,
|
||||
}));
|
||||
expect(collectPlaybackCodecs(tracks).videoCodecs).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Safe, bounded facts about a source; never evidence that it can be played. */
|
||||
export const PlaybackDrmSystem = {
|
||||
Widevine: 'widevine',
|
||||
PlayReady: 'playready',
|
||||
ClearKey: 'clearkey',
|
||||
FairPlay: 'fairplay',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDrmSystem =
|
||||
(typeof PlaybackDrmSystem)[keyof typeof PlaybackDrmSystem];
|
||||
|
||||
const DRM_SYSTEMS = new Map<string, PlaybackDrmSystem>([
|
||||
['com.widevine.alpha', PlaybackDrmSystem.Widevine],
|
||||
[
|
||||
'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
|
||||
PlaybackDrmSystem.Widevine,
|
||||
],
|
||||
['com.microsoft.playready', PlaybackDrmSystem.PlayReady],
|
||||
['com.microsoft.playready.recommendation', PlaybackDrmSystem.PlayReady],
|
||||
[
|
||||
'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95',
|
||||
PlaybackDrmSystem.PlayReady,
|
||||
],
|
||||
['org.w3.clearkey', PlaybackDrmSystem.ClearKey],
|
||||
['clearkey', PlaybackDrmSystem.ClearKey],
|
||||
[
|
||||
'urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e',
|
||||
PlaybackDrmSystem.ClearKey,
|
||||
],
|
||||
['com.apple.fps', PlaybackDrmSystem.FairPlay],
|
||||
['com.apple.fps.1_0', PlaybackDrmSystem.FairPlay],
|
||||
[
|
||||
'urn:uuid:94ce86fb-07ff-4f43-adb8-93d2fa968ca2',
|
||||
PlaybackDrmSystem.FairPlay,
|
||||
],
|
||||
]);
|
||||
|
||||
export function getPlaybackDrmSystem(
|
||||
value: unknown
|
||||
): PlaybackDrmSystem | undefined {
|
||||
return typeof value === 'string'
|
||||
? DRM_SYSTEMS.get(value.trim().toLowerCase())
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export interface PlaybackCodecTrack {
|
||||
readonly audioCodec?: unknown;
|
||||
readonly videoCodec?: unknown;
|
||||
}
|
||||
|
||||
// Only codec identifiers, never arbitrary provider labels or engine messages.
|
||||
// Full AV1 has nine suffix fields: https://aomediacodec.github.io/av1-isobmff/#codecsparam
|
||||
const VIDEO_CODEC =
|
||||
/^(?:(?:avc1|avc3)\.[0-9a-f]{6}|(?:hev1|hvc1|hvc2|vp09|vp08)(?:\.[0-9a-fhlm]{1,12}){1,8}|av01(?:\.[0-9mh]{1,12}){1,9}|h264|h265|hevc|vp8|vp9|mpeg2video|mp2v|theora)$/i;
|
||||
const AUDIO_CODEC =
|
||||
/^(?:mp4a\.[0-9a-f]{2}(?:\.\d{1,2})?|aac|ac-?3|ec-?3|eac-?3|dac3|dec3|dts|dtsc|dtse|dtsh|dtsl|opus|vorbis|flac|mp3|mp2)$/i;
|
||||
|
||||
export function collectPlaybackCodecs(tracks: readonly unknown[]) {
|
||||
const audio = new Set<string>();
|
||||
const video = new Set<string>();
|
||||
for (const value of tracks.slice(0, 256)) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
const track = value as PlaybackCodecTrack;
|
||||
collect(track.audioCodec, AUDIO_CODEC, audio);
|
||||
collect(track.videoCodec, VIDEO_CODEC, video);
|
||||
}
|
||||
return { audioCodecs: [...audio], videoCodecs: [...video] };
|
||||
}
|
||||
|
||||
function collect(value: unknown, pattern: RegExp, target: Set<string>): void {
|
||||
if (typeof value !== 'string' || value.length > 256) return;
|
||||
for (const token of value.split(',').map((item) => item.trim())) {
|
||||
if (target.size < 16 && token.length <= 64 && pattern.test(token)) {
|
||||
target.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { createPlaybackDiagnostic } from './playback-diagnostics.util';
|
||||
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
|
||||
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
|
||||
import type { ShakaErrorLike } from './shaka-error.types';
|
||||
import { getPlaybackDrmSystem } from './playback-stream-metadata';
|
||||
|
||||
export {
|
||||
SHAKA_DIAGNOSTIC_VERSION,
|
||||
@@ -81,14 +82,16 @@ function getDiagnosticCode(
|
||||
* handle. Provider-supplied license strings are intentionally not retained.
|
||||
*/
|
||||
export function createUnsupportedDrmDiagnostic(
|
||||
_licenseType: string,
|
||||
licenseType: string,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const system = getPlaybackDrmSystem(licenseType);
|
||||
return createPlaybackDiagnostic({
|
||||
code: DiagnosticCode.DrmOrEncryption,
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
metadata: system ? { ...metadata, drmSystems: [system] } : metadata,
|
||||
details: 'Unsupported DRM license configuration',
|
||||
runtimeSupport: PlaybackRuntimeSupport.DrmConfigurationUnsupported,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -123,3 +123,9 @@ export const SHAKA_ERROR_CODE = {
|
||||
MISSING_EME_SUPPORT: 6020,
|
||||
LOAD_INTERRUPTED: 7000,
|
||||
} as const;
|
||||
/** Shaka 5.2.4 public NetworkingEngine request types used by diagnostics. */
|
||||
export const SHAKA_REQUEST_TYPE = {
|
||||
MANIFEST: 0,
|
||||
SEGMENT: 1,
|
||||
LICENSE: 2,
|
||||
} as const;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as shakaDiagnostics from './shaka-error-classifier';
|
||||
import { SHAKA_REQUEST_TYPE } from './shaka-error-contract';
|
||||
|
||||
interface ShakaErrorInput {
|
||||
readonly severity?: unknown;
|
||||
@@ -21,6 +22,7 @@ interface ExpectedShakaPlaybackEvidence {
|
||||
}
|
||||
|
||||
interface InstalledShakaContract {
|
||||
readonly requestTypes: Readonly<Record<string, number>>;
|
||||
readonly version: string;
|
||||
readonly severity: Readonly<Record<string, number>>;
|
||||
readonly category: Readonly<Record<string, number>>;
|
||||
@@ -41,8 +43,33 @@ interface ShakaEvidenceExports {
|
||||
const exportsUnderTest = shakaDiagnostics as ShakaEvidenceExports;
|
||||
|
||||
describe('Shaka playback evidence', () => {
|
||||
it.each([
|
||||
[1001, ['secret-url', 403, 'secret-body', {}, 0], 'manifest'],
|
||||
[1001, ['secret-url', 404, null, {}, 1], 'segment'],
|
||||
[1001, ['secret-url', 403, null, {}, 2], 'license'],
|
||||
[1002, ['secret-url', new Error('secret'), 1], 'segment'],
|
||||
[1003, ['secret-url', 0], 'manifest'],
|
||||
[1003, ['secret-url', 2], 'license'],
|
||||
[1003, ['secret-url', 5], 'unknown'],
|
||||
[1001, ['secret-url', 404, null, {}, '1'], 'unknown'],
|
||||
[1001, ['secret-url', 404, null, {}, 999], 'unknown'],
|
||||
])(
|
||||
'reads documented network request slots for code %s',
|
||||
(code, data, stage) => {
|
||||
const evidence = exportsUnderTest.createShakaPlaybackEvidence?.(
|
||||
{ severity: 2, category: 1, code, data },
|
||||
'terminal'
|
||||
);
|
||||
expect(evidence?.stage).toBe(stage);
|
||||
expect(JSON.stringify(evidence)).not.toContain('secret');
|
||||
}
|
||||
);
|
||||
|
||||
it('matches the installed public Shaka 5.2.4 error contract', () => {
|
||||
const installed = getInstalledShakaContract();
|
||||
expect(installed.requestTypes).toEqual(
|
||||
expect.objectContaining(SHAKA_REQUEST_TYPE)
|
||||
);
|
||||
|
||||
expect(exportsUnderTest.SHAKA_DIAGNOSTIC_VERSION).toBe(
|
||||
installed.version
|
||||
@@ -89,7 +116,7 @@ describe('Shaka playback evidence', () => {
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
stage: 'manifest',
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
});
|
||||
@@ -361,6 +388,7 @@ import('shaka-player').then((module) => {
|
||||
severity: shaka.util.Error.Severity,
|
||||
category: shaka.util.Error.Category,
|
||||
code: shaka.util.Error.Code,
|
||||
requestTypes: shaka.net.NetworkingEngine.RequestType,
|
||||
}));
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SHAKA_ERROR_CATEGORY,
|
||||
SHAKA_ERROR_CODE,
|
||||
SHAKA_ERROR_SEVERITY,
|
||||
SHAKA_REQUEST_TYPE,
|
||||
} from './shaka-error-contract';
|
||||
import {
|
||||
SHAKA_AMBIGUOUS_MANIFEST_CODES,
|
||||
@@ -43,13 +44,41 @@ export function createShakaPlaybackEvidence(
|
||||
category,
|
||||
engineCode,
|
||||
disposition,
|
||||
stage: getStage(category, engineCode),
|
||||
stage: getNetworkStage(error) ?? getStage(category, engineCode),
|
||||
failure: getFailure(category, engineCode),
|
||||
};
|
||||
|
||||
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
|
||||
}
|
||||
|
||||
/** Public error.data request-type slots in Shaka 5.2.4; no URL inspection. */
|
||||
function getNetworkStage(error: Partial<ShakaErrorLike> | null | undefined) {
|
||||
if (
|
||||
error?.category !== SHAKA_ERROR_CATEGORY.NETWORK ||
|
||||
!Array.isArray(error.data)
|
||||
)
|
||||
return undefined;
|
||||
const slot =
|
||||
error.code === SHAKA_ERROR_CODE.BAD_HTTP_STATUS
|
||||
? 4
|
||||
: error.code === SHAKA_ERROR_CODE.HTTP_ERROR
|
||||
? 2
|
||||
: error.code === SHAKA_ERROR_CODE.TIMEOUT
|
||||
? 1
|
||||
: undefined;
|
||||
const type = slot === undefined ? undefined : error.data?.[slot];
|
||||
switch (type) {
|
||||
case SHAKA_REQUEST_TYPE.MANIFEST:
|
||||
return ShakaPlaybackStage.Manifest;
|
||||
case SHAKA_REQUEST_TYPE.SEGMENT:
|
||||
return ShakaPlaybackStage.Segment;
|
||||
case SHAKA_REQUEST_TYPE.LICENSE:
|
||||
return ShakaPlaybackStage.License;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverity(value: unknown): ShakaPlaybackEvidence['severity'] {
|
||||
if (value === SHAKA_ERROR_SEVERITY.RECOVERABLE) {
|
||||
return ShakaPlaybackSeverity.Recoverable;
|
||||
|
||||
@@ -306,6 +306,9 @@ describe('ArtPlayerSourceSession', () => {
|
||||
player
|
||||
);
|
||||
|
||||
Object.assign(hlsInstances[0], {
|
||||
levels: [{ audioCodec: 'mp4a.40.2', videoCodec: 'avc1.64001e' }],
|
||||
});
|
||||
hlsInstances[0].emit(MockHls.Events.ERROR, null, {
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
@@ -327,6 +330,8 @@ describe('ArtPlayerSourceSession', () => {
|
||||
expect(emitted).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'network-error',
|
||||
audioCodecs: ['mp4a.40.2'],
|
||||
videoCodecs: ['avc1.64001e'],
|
||||
source: 'hls',
|
||||
httpStatus: 503,
|
||||
hls: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createHlsPlaybackEvidence,
|
||||
createMpegTsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
collectPlaybackCodecs,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
|
||||
import type { WebVideoControlsAdapter } from '../player-controls';
|
||||
@@ -63,15 +64,13 @@ export class ArtPlayerSourceSession {
|
||||
private pendingControlsSource: WebVideoControlsSource = { kind: 'native' };
|
||||
private hls: Hls | null = null;
|
||||
private hlsManifestListener:
|
||||
| ((event: unknown, data: ManifestParsedData) => void)
|
||||
| null = null;
|
||||
((event: unknown, data: ManifestParsedData) => void) | null = null;
|
||||
private hlsErrorListener:
|
||||
| ((event: unknown, data: ErrorData) => void)
|
||||
| null = null;
|
||||
((event: unknown, data: ErrorData) => void) | null = null;
|
||||
private mpegTsPlayer: mpegts.Player | null = null;
|
||||
private mpegTsErrorListener:
|
||||
| ((type: unknown, details: unknown, info: unknown) => void)
|
||||
| null = null;
|
||||
((type: unknown, details: unknown, info: unknown) => void) | null =
|
||||
null;
|
||||
private shakaSession: ShakaVideoSession | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
@@ -197,7 +196,10 @@ export class ArtPlayerSourceSession {
|
||||
this.config.emitPlaybackIssue(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(type, details, info),
|
||||
this.createSourceMetadata(url, 'video/mp2t')
|
||||
{
|
||||
...this.createSourceMetadata(url, 'video/mp2t'),
|
||||
...collectPlaybackCodecs([engine.mediaInfo ?? {}]),
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -244,8 +246,7 @@ export class ArtPlayerSourceSession {
|
||||
private getShakaSession(): ShakaVideoSession {
|
||||
this.shakaSession ??= new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.ArtPlayer,
|
||||
emitPlaybackIssue: (issue) =>
|
||||
this.config.emitPlaybackIssue(issue),
|
||||
emitPlaybackIssue: (issue) => this.config.emitPlaybackIssue(issue),
|
||||
showCaptions: this.config.showCaptions,
|
||||
loadShaka: this.config.loadShaka,
|
||||
});
|
||||
@@ -322,7 +323,10 @@ export class ArtPlayerSourceSession {
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
createHlsPlaybackEvidence(data),
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
{
|
||||
...this.createSourceMetadata(url, 'application/x-mpegURL'),
|
||||
...collectPlaybackCodecs(this.hls?.levels ?? []),
|
||||
}
|
||||
);
|
||||
if (!issue) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js';
|
||||
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
import {
|
||||
emitFatalHlsPlaybackError,
|
||||
emitMpegTsPlaybackError,
|
||||
} from './html-video-player-diagnostics';
|
||||
|
||||
describe('HTML5 engine codec evidence', () => {
|
||||
it('keeps advertised HLS codecs on an ordinary segment network error', () => {
|
||||
const issues: PlaybackDiagnostic[] = [];
|
||||
emitFatalHlsPlaybackError(
|
||||
'https://example.test/live.m3u8',
|
||||
{
|
||||
fatal: true,
|
||||
type: ErrorTypes.NETWORK_ERROR,
|
||||
details: ErrorDetails.FRAG_LOAD_ERROR,
|
||||
} as ErrorData,
|
||||
(issue) => issues.push(issue),
|
||||
[{ audioCodec: 'mp4a.40.2', videoCodec: 'avc1.64001e' }]
|
||||
);
|
||||
expect(issues[0]).toMatchObject({
|
||||
code: 'network-error',
|
||||
audioCodecs: ['mp4a.40.2'],
|
||||
videoCodecs: ['avc1.64001e'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps MPEG-TS media-info codecs without copying its private fields', () => {
|
||||
const issues: PlaybackDiagnostic[] = [];
|
||||
emitMpegTsPlaybackError(
|
||||
'https://example.test/live.ts',
|
||||
{
|
||||
type: 'MediaError',
|
||||
details: 'MediaMSEError',
|
||||
info: {},
|
||||
},
|
||||
(issue) => issues.push(issue),
|
||||
{
|
||||
audioCodec: 'aac',
|
||||
videoCodec: 'h264',
|
||||
}
|
||||
);
|
||||
expect(issues[0]).toMatchObject({
|
||||
audioCodecs: ['aac'],
|
||||
videoCodecs: ['h264'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createHlsPlaybackEvidence,
|
||||
createMpegTsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
collectPlaybackCodecs,
|
||||
type PlaybackCodecTrack,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
|
||||
|
||||
@@ -59,11 +61,18 @@ export function emitUnsupportedHlsManifestCodecs(
|
||||
export function emitFatalHlsPlaybackError(
|
||||
url: string,
|
||||
data: ErrorData,
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void,
|
||||
levels: readonly PlaybackCodecTrack[] = []
|
||||
): void {
|
||||
const codecs = collectPlaybackCodecs(levels);
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
createHlsPlaybackEvidence(data),
|
||||
createHtml5SourceMetadata(url, 'application/x-mpegURL')
|
||||
createHtml5SourceMetadata(
|
||||
url,
|
||||
'application/x-mpegURL',
|
||||
codecs.audioCodecs,
|
||||
codecs.videoCodecs
|
||||
)
|
||||
);
|
||||
if (!issue) {
|
||||
return;
|
||||
@@ -75,16 +84,19 @@ export function emitFatalHlsPlaybackError(
|
||||
export function emitMpegTsPlaybackError(
|
||||
url: string,
|
||||
error: { type: unknown; details: unknown; info: unknown },
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void,
|
||||
mediaInfo: unknown = {}
|
||||
): void {
|
||||
const codecs = collectPlaybackCodecs([mediaInfo]);
|
||||
emitPlaybackIssue(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(
|
||||
error.type,
|
||||
error.details,
|
||||
error.info
|
||||
),
|
||||
createHtml5SourceMetadata(url, 'video/mp2t')
|
||||
createMpegTsPlaybackEvidence(error.type, error.details, error.info),
|
||||
createHtml5SourceMetadata(
|
||||
url,
|
||||
'video/mp2t',
|
||||
codecs.audioCodecs,
|
||||
codecs.videoCodecs
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -244,13 +244,16 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.videoPlayer.nativeElement
|
||||
);
|
||||
this.bindControlsSource({ kind: 'mpegts' });
|
||||
const engine = this.mpegtsPlayer;
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(type: string, details: string, info: unknown): void => {
|
||||
if (this.mpegtsPlayer !== engine) return;
|
||||
emitMpegTsPlaybackError(
|
||||
url,
|
||||
{ type, details, info },
|
||||
(issue) => this.playbackIssue.emit(issue)
|
||||
(issue) => this.playbackIssue.emit(issue),
|
||||
engine.mediaInfo
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -269,9 +272,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const hls = new Hls();
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
if (this.hls !== hls) return;
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (this.hls !== hls) return;
|
||||
this.handleHlsError(url, data);
|
||||
});
|
||||
hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
@@ -348,8 +353,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
emitFatalHlsPlaybackError(url, data, (issue) =>
|
||||
this.playbackIssue.emit(issue)
|
||||
emitFatalHlsPlaybackError(
|
||||
url,
|
||||
data,
|
||||
(issue) => this.playbackIssue.emit(issue),
|
||||
this.hls?.levels ?? []
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDrmSystem,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { PlaybackDiagnosticDetail } from './playback-diagnostic-view.util';
|
||||
|
||||
const DRM_LABELS: Record<PlaybackDrmSystem, string> = {
|
||||
widevine: 'Widevine',
|
||||
playready: 'PlayReady',
|
||||
clearkey: 'ClearKey',
|
||||
fairplay: 'FairPlay',
|
||||
};
|
||||
|
||||
const STAGE_KEYS: Readonly<Record<string, string>> = {
|
||||
manifest: 'MANIFEST',
|
||||
playlist: 'PLAYLIST',
|
||||
level: 'PLAYLIST',
|
||||
segment: 'SEGMENT',
|
||||
key: 'KEY',
|
||||
license: 'LICENSE',
|
||||
media: 'MEDIA',
|
||||
loader: 'LOADER',
|
||||
demux: 'DEMUX',
|
||||
'media-source': 'MEDIA',
|
||||
};
|
||||
|
||||
export function getDiagnosticEvidenceDetails(
|
||||
issue: PlaybackDiagnostic
|
||||
): PlaybackDiagnosticDetail[] {
|
||||
const stage =
|
||||
issue.shaka?.stage ??
|
||||
issue.hls?.stage ??
|
||||
issue.mpegTs?.stage ??
|
||||
issue.vhs?.stage;
|
||||
const stageKey = stage ? STAGE_KEYS[stage] : undefined;
|
||||
const drm = (issue.drmSystems ?? [])
|
||||
.map((system) => DRM_LABELS[system])
|
||||
.filter(Boolean);
|
||||
return [
|
||||
...(stage && stageKey
|
||||
? [
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
|
||||
value: stage,
|
||||
valueKey: `PLAYBACK_DIAGNOSTICS.STAGE_${stageKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(drm.length
|
||||
? [
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
|
||||
value: drm.join(', '),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
+24
-1
@@ -172,6 +172,23 @@
|
||||
</div>
|
||||
|
||||
<div class="web-player-diagnostic__utilities">
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="diagnosticReport()"
|
||||
(cdkCopyToClipboardCopied)="onReportCopied($event)"
|
||||
[matTooltip]="'PLAYBACK_DIAGNOSTICS.REPORT_PRIVACY' | translate"
|
||||
data-test-id="playback-copy-diagnostic"
|
||||
>
|
||||
<mat-icon aria-hidden="true">content_copy</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_DIAGNOSTIC' | translate }}
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-test-id="playback-copy-diagnostic-status"
|
||||
>
|
||||
{{ copyStatusKey() ? (copyStatusKey() | translate) : '' }}
|
||||
</span>
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
@@ -203,7 +220,13 @@
|
||||
) {
|
||||
<div class="web-player-diagnostic__detail-row">
|
||||
<dt>{{ detail.labelKey | translate }}</dt>
|
||||
<dd>{{ detail.value }}</dd>
|
||||
<dd>
|
||||
{{
|
||||
detail.valueKey
|
||||
? (detail.valueKey | translate)
|
||||
: detail.value
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
+34
@@ -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();
|
||||
|
||||
|
||||
+29
@@ -1,8 +1,13 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import packageJson from '@package';
|
||||
import { createDiagnosticReport } from './playback-diagnostic-report.util';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
@@ -78,6 +83,30 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly copyResult = signal<{
|
||||
issue: PlaybackDiagnostic;
|
||||
success: boolean;
|
||||
} | null>(null);
|
||||
readonly diagnosticReport = computed(() =>
|
||||
createDiagnosticReport(
|
||||
this.diagnostic(),
|
||||
packageJson.version,
|
||||
this.document.defaultView?.navigator.userAgent ?? ''
|
||||
)
|
||||
);
|
||||
readonly copyStatusKey = computed(() => {
|
||||
const result = this.copyResult();
|
||||
if (!result || result.issue !== this.diagnostic()) return '';
|
||||
return result.success
|
||||
? 'PLAYBACK_DIAGNOSTICS.REPORT_COPIED'
|
||||
: 'PLAYBACK_DIAGNOSTICS.COPY_FAILED';
|
||||
});
|
||||
|
||||
onReportCopied(success: boolean): void {
|
||||
this.copyResult.set({ issue: this.diagnostic(), success });
|
||||
}
|
||||
|
||||
readonly visibleAlternatives = computed(() =>
|
||||
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
|
||||
);
|
||||
|
||||
+93
@@ -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');
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
collectPlaybackCodecs,
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
PlaybackDrmSystem,
|
||||
PlaybackRuntimeSupport,
|
||||
SHAKA_ERROR_CODE,
|
||||
type PlaybackDiagnostic,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { getDiagnosticEvidence } from './playback-diagnostic-summary.util';
|
||||
|
||||
function allowed<T extends string>(
|
||||
value: unknown,
|
||||
choices: readonly T[]
|
||||
): T | undefined {
|
||||
return choices.find((choice) => choice === value);
|
||||
}
|
||||
function integer(value: unknown, min: number, max: number): number | undefined {
|
||||
return typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= min &&
|
||||
value <= max
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
function operatingSystem(agent: string): string {
|
||||
if (/Android/i.test(agent)) return 'Android';
|
||||
if (/iPhone|iPad|iPod/i.test(agent)) return 'iOS';
|
||||
if (/Windows/i.test(agent)) return 'Windows';
|
||||
if (/Macintosh|Mac OS X/i.test(agent)) return 'macOS';
|
||||
if (/CrOS/i.test(agent)) return 'ChromeOS';
|
||||
if (/Linux/i.test(agent)) return 'Linux';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Explicit allowlist: never serialize the diagnostic, source, or raw error. */
|
||||
export function createDiagnosticReport(
|
||||
issue: PlaybackDiagnostic,
|
||||
appVersion: string,
|
||||
userAgent: string
|
||||
): string {
|
||||
const evidence = getDiagnosticEvidence(issue);
|
||||
const codecs = collectPlaybackCodecs([
|
||||
...issue.videoCodecs.slice(0, 16).map((videoCodec) => ({ videoCodec })),
|
||||
...issue.audioCodecs.slice(0, 16).map((audioCodec) => ({ audioCodec })),
|
||||
]);
|
||||
const shakaCode =
|
||||
issue.source === 'shaka' ? issue.shaka?.engineCode : undefined;
|
||||
return JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
app: 'IPTVnator',
|
||||
appVersion:
|
||||
/^\d{1,4}\.\d{1,4}\.\d{1,4}(?:-(?:alpha|beta|rc)\.?\d{1,5})?$/.test(
|
||||
appVersion
|
||||
)
|
||||
? appVersion
|
||||
: 'unknown',
|
||||
os: operatingSystem(userAgent),
|
||||
runtime: /Electron\//i.test(userAgent) ? 'electron' : 'browser',
|
||||
code: allowed(issue.code, Object.values(PlaybackDiagnosticCode)),
|
||||
engine: allowed(
|
||||
issue.source,
|
||||
Object.values(PlaybackDiagnosticSource)
|
||||
),
|
||||
player: allowed(issue.player, Object.values(InlinePlaybackPlayer)),
|
||||
runtimeSupport: allowed(
|
||||
issue.runtimeSupport,
|
||||
Object.values(PlaybackRuntimeSupport)
|
||||
),
|
||||
stage: allowed(evidence?.stage, [
|
||||
'manifest',
|
||||
'playlist',
|
||||
'level',
|
||||
'segment',
|
||||
'key',
|
||||
'license',
|
||||
'media',
|
||||
'loader',
|
||||
'demux',
|
||||
'media-source',
|
||||
]),
|
||||
engineCode: Object.values(SHAKA_ERROR_CODE).find(
|
||||
(code) => code === shakaCode
|
||||
),
|
||||
nativeErrorCode: integer(issue.nativeErrorCode, 0, 99999),
|
||||
httpStatus: integer(
|
||||
evidence?.httpStatus ?? issue.httpStatus,
|
||||
100,
|
||||
599
|
||||
),
|
||||
container: allowed(issue.container, [
|
||||
'mpd',
|
||||
'dash',
|
||||
'hls',
|
||||
'm3u8',
|
||||
'mp4',
|
||||
'webm',
|
||||
'matroska',
|
||||
'mpegts',
|
||||
'mpeg-ts',
|
||||
'ts',
|
||||
'avi',
|
||||
'mov',
|
||||
'flv',
|
||||
'ogg',
|
||||
'mp3',
|
||||
'aac',
|
||||
]),
|
||||
...codecs,
|
||||
drmSystems: Object.values(PlaybackDrmSystem).filter((system) =>
|
||||
issue.drmSystems?.includes(system)
|
||||
),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
+68
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+60
@@ -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;
|
||||
}
|
||||
+55
-21
@@ -12,6 +12,39 @@ import {
|
||||
} from './playback-diagnostic-view.util';
|
||||
|
||||
describe('playback diagnostic view formatters', () => {
|
||||
it('shows separate source DRM and failure-stage facts', () => {
|
||||
const details = getDiagnosticDetails({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Shaka,
|
||||
sourceUrl: 'https://example.test/live.mpd',
|
||||
container: 'mpd',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
drmSystems: ['widevine', 'playready'],
|
||||
shaka: {
|
||||
severity: 'critical',
|
||||
category: 'network',
|
||||
engineCode: 1001,
|
||||
disposition: 'terminal',
|
||||
stage: 'segment',
|
||||
failure: 'network',
|
||||
httpStatus: 403,
|
||||
},
|
||||
});
|
||||
expect(details).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_STAGE',
|
||||
value: 'segment',
|
||||
}),
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_DRM_SYSTEMS',
|
||||
value: 'Widevine, PlayReady',
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
const issue: PlaybackDiagnostic = {
|
||||
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
@@ -47,8 +80,7 @@ describe('playback diagnostic view formatters', () => {
|
||||
{
|
||||
diagnostic: 'browser access',
|
||||
code: PlaybackDiagnosticCode.BrowserAccessError,
|
||||
desktopKey:
|
||||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
|
||||
desktopKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
|
||||
pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION',
|
||||
},
|
||||
{
|
||||
@@ -62,8 +94,7 @@ describe('playback diagnostic view formatters', () => {
|
||||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||||
desktopKey:
|
||||
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
|
||||
pwaKey:
|
||||
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
|
||||
pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
|
||||
},
|
||||
{
|
||||
diagnostic: 'media decode',
|
||||
@@ -85,18 +116,18 @@ describe('playback diagnostic view formatters', () => {
|
||||
supportsManagedExternalPlayers: false,
|
||||
expected: pwaKey,
|
||||
},
|
||||
])('preserves transferable $runtime copy', ({
|
||||
supportsManagedExternalPlayers,
|
||||
expected,
|
||||
}) => {
|
||||
expect(
|
||||
getDiagnosticDescriptionKey(
|
||||
diagnosticIssue,
|
||||
supportsManagedExternalPlayers,
|
||||
true
|
||||
)
|
||||
).toBe(expected);
|
||||
});
|
||||
])(
|
||||
'preserves transferable $runtime copy',
|
||||
({ supportsManagedExternalPlayers, expected }) => {
|
||||
expect(
|
||||
getDiagnosticDescriptionKey(
|
||||
diagnosticIssue,
|
||||
supportsManagedExternalPlayers,
|
||||
true
|
||||
)
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['desktop', true],
|
||||
@@ -131,9 +162,12 @@ describe('playback diagnostic view formatters', () => {
|
||||
PlaybackDiagnosticCode.DrmOrEncryption,
|
||||
'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION',
|
||||
],
|
||||
])('preserves neutral %s copy for protected playback', (_label, code, expected) => {
|
||||
expect(
|
||||
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
|
||||
).toBe(expected);
|
||||
});
|
||||
])(
|
||||
'preserves neutral %s copy for protected playback',
|
||||
(_label, code, expected) => {
|
||||
expect(
|
||||
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
PlaybackDiagnosticCode,
|
||||
getLikelyBrowserUnsupportedCodecLabels,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { getDiagnosticSummaryKey } from './playback-diagnostic-summary.util';
|
||||
import { getDiagnosticEvidenceDetails } from './playback-diagnostic-evidence-view.util';
|
||||
|
||||
const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
|
||||
PlaybackDiagnostic['code']
|
||||
@@ -16,6 +18,7 @@ const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
|
||||
export type PlaybackDiagnosticDetail = {
|
||||
readonly labelKey: string;
|
||||
readonly value: string;
|
||||
readonly valueKey?: string;
|
||||
};
|
||||
|
||||
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
@@ -27,6 +30,8 @@ export function getDiagnosticDescriptionKey(
|
||||
supportsManagedExternalPlayers: boolean,
|
||||
playbackExternallyTransferable: boolean
|
||||
): string {
|
||||
const summary = getDiagnosticSummaryKey(issue);
|
||||
if (summary) return summary;
|
||||
if (
|
||||
!playbackExternallyTransferable &&
|
||||
UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code)
|
||||
@@ -77,6 +82,7 @@ export function getDiagnosticDetails(
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
|
||||
value: formatDiagnosticSource(issue.source),
|
||||
},
|
||||
...getDiagnosticEvidenceDetails(issue),
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
|
||||
value: issue.container,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
collectPlaybackCodecs,
|
||||
getPlaybackDrmSystem,
|
||||
SHAKA_REQUEST_TYPE,
|
||||
type PlaybackDrmSystem,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type {
|
||||
ShakaNetworkingEngineLike,
|
||||
ShakaResponseFilter,
|
||||
} from './shaka-module.types';
|
||||
|
||||
const DASH_NAMESPACE = 'urn:mpeg:dash:schema:mpd:2011';
|
||||
const MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Observes existing responses only; owns no requests, URLs, keys or XML. */
|
||||
export class ShakaManifestMetadata {
|
||||
private active = true;
|
||||
private codecs = collectPlaybackCodecs([]);
|
||||
private drmSystems: PlaybackDrmSystem[] = [];
|
||||
private readonly configuredSystem: PlaybackDrmSystem | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly network: ShakaNetworkingEngineLike | null,
|
||||
licenseType?: string
|
||||
) {
|
||||
this.configuredSystem = getPlaybackDrmSystem(licenseType);
|
||||
network?.registerResponseFilter(this.onResponse);
|
||||
}
|
||||
|
||||
read() {
|
||||
return {
|
||||
...this.codecs,
|
||||
drmSystems: [
|
||||
...new Set([
|
||||
...this.drmSystems,
|
||||
...(this.configuredSystem ? [this.configuredSystem] : []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.active = false;
|
||||
this.network?.unregisterResponseFilter(this.onResponse);
|
||||
}
|
||||
|
||||
private readonly onResponse: ShakaResponseFilter = (type, response) => {
|
||||
if (!this.active || type !== SHAKA_REQUEST_TYPE.MANIFEST) return;
|
||||
try {
|
||||
if (response.data.byteLength > MAX_MANIFEST_BYTES) return;
|
||||
const xml = new TextDecoder().decode(response.data);
|
||||
if (/<!DOCTYPE/i.test(xml)) return;
|
||||
const document = new DOMParser().parseFromString(
|
||||
xml,
|
||||
'application/xml'
|
||||
);
|
||||
if (
|
||||
document.getElementsByTagName('parsererror').length ||
|
||||
document.documentElement.localName !== 'MPD' ||
|
||||
document.documentElement.namespaceURI !== DASH_NAMESPACE
|
||||
)
|
||||
return;
|
||||
|
||||
const elements: Element[] = [];
|
||||
for (const name of ['AdaptationSet', 'Representation']) {
|
||||
elements.push(
|
||||
...Array.from(
|
||||
document.getElementsByTagNameNS(DASH_NAMESPACE, name)
|
||||
).slice(0, 256)
|
||||
);
|
||||
}
|
||||
this.codecs = collectPlaybackCodecs(
|
||||
elements.map((element) => ({
|
||||
audioCodec: element.getAttribute('codecs'),
|
||||
videoCodec: element.getAttribute('codecs'),
|
||||
}))
|
||||
);
|
||||
this.drmSystems = [
|
||||
...new Set(
|
||||
Array.from(
|
||||
document.getElementsByTagNameNS(
|
||||
DASH_NAMESPACE,
|
||||
'ContentProtection'
|
||||
)
|
||||
)
|
||||
.slice(0, 256)
|
||||
.map((element) =>
|
||||
getPlaybackDrmSystem(
|
||||
element.getAttribute('schemeIdUri')
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(system): system is PlaybackDrmSystem =>
|
||||
system !== undefined
|
||||
)
|
||||
),
|
||||
];
|
||||
} catch {
|
||||
// Metadata is optional. Never turn inspection failure into a
|
||||
// network-filter failure or interfere with Shaka's own parser.
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,18 @@ export interface ShakaVariantTrackLike {
|
||||
|
||||
export type { ShakaErrorLike } from '@iptvnator/playback/util';
|
||||
|
||||
export type ShakaResponseFilter = (
|
||||
type: number,
|
||||
response: { data: BufferSource }
|
||||
) => void;
|
||||
|
||||
export interface ShakaNetworkingEngineLike {
|
||||
registerResponseFilter(filter: ShakaResponseFilter): void;
|
||||
unregisterResponseFilter(filter: ShakaResponseFilter): void;
|
||||
}
|
||||
|
||||
export interface ShakaPlayerLike {
|
||||
getNetworkingEngine?(): ShakaNetworkingEngineLike | null;
|
||||
attach(mediaElement: HTMLMediaElement): Promise<unknown>;
|
||||
configure(config: Record<string, unknown>): boolean;
|
||||
load(assetUri: string): Promise<unknown>;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
} from '@iptvnator/playback/util';
|
||||
import {
|
||||
createFakeShakaEnvironment,
|
||||
flushShakaMicrotasks as flush,
|
||||
} from './shaka-player-test-double';
|
||||
import { ShakaVideoSession } from './shaka-video-session';
|
||||
|
||||
type Filter = (type: number, response: { data: BufferSource }) => void;
|
||||
const manifest = `<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"><Period>
|
||||
<AdaptationSet contentType="video">
|
||||
<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"/>
|
||||
<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"/>
|
||||
<Representation codecs="avc1.64001e"/>
|
||||
<Representation codecs="https://secret.test/?token=secret"/>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet mimeType="audio/mp4" codecs="mp4a.40.29"/>
|
||||
<BaseURL>https://secret.test/?token=secret</BaseURL>
|
||||
</Period></MPD>`;
|
||||
|
||||
describe('Shaka diagnostic source evidence', () => {
|
||||
function setup() {
|
||||
const filters = new Set<Filter>();
|
||||
const issues: PlaybackDiagnostic[] = [];
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) =>
|
||||
Object.assign(player, {
|
||||
getNetworkingEngine: () => ({
|
||||
registerResponseFilter: (filter: Filter) =>
|
||||
filters.add(filter),
|
||||
unregisterResponseFilter: (filter: Filter) =>
|
||||
filters.delete(filter),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const session = new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: (issue) => issues.push(issue),
|
||||
loadShaka: environment.loader,
|
||||
});
|
||||
const respond = (xml: string, type = 0) => {
|
||||
const response = { data: new TextEncoder().encode(xml) };
|
||||
for (const filter of filters) filter(type, response);
|
||||
expect(new TextDecoder().decode(response.data)).toBe(xml);
|
||||
};
|
||||
return { filters, issues, environment, session, respond };
|
||||
}
|
||||
|
||||
it('keeps advertised codecs and DRM on a terminal startup failure', async () => {
|
||||
const { filters, issues, environment, session, respond } = setup();
|
||||
session.start(
|
||||
document.createElement('video'),
|
||||
'https://example.test/live.mpd'
|
||||
);
|
||||
await flush();
|
||||
respond(manifest);
|
||||
environment.instances[0].dispatch('error', {
|
||||
severity: 2,
|
||||
category: 6,
|
||||
code: 6001,
|
||||
});
|
||||
expect(issues[0]).toMatchObject({
|
||||
code: 'drm-or-encryption',
|
||||
videoCodecs: ['avc1.64001e'],
|
||||
audioCodecs: ['mp4a.40.29'],
|
||||
drmSystems: ['widevine', 'playready'],
|
||||
});
|
||||
expect(JSON.stringify(issues)).not.toContain('secret');
|
||||
expect(filters.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not let old response callbacks enrich the next channel', async () => {
|
||||
const { filters, issues, environment, session, respond } = setup();
|
||||
const video = document.createElement('video');
|
||||
session.start(video, 'https://example.test/first.mpd');
|
||||
await flush();
|
||||
const oldFilters = [...filters];
|
||||
respond(manifest);
|
||||
session.start(video, 'https://example.test/second.mpd');
|
||||
await flush();
|
||||
for (const filter of oldFilters)
|
||||
filter(0, { data: new TextEncoder().encode(manifest) });
|
||||
environment.instances[1].dispatch('error', {
|
||||
severity: 2,
|
||||
category: 1,
|
||||
code: 1003,
|
||||
});
|
||||
expect(issues[0].audioCodecs).toEqual([]);
|
||||
expect(issues[0].videoCodecs).toEqual([]);
|
||||
expect(issues[0]).not.toHaveProperty('drmSystems', [
|
||||
'widevine',
|
||||
'playready',
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains a configured ClearKey name without exposing keys', async () => {
|
||||
const { session, issues, environment } = setup();
|
||||
session.start(
|
||||
document.createElement('video'),
|
||||
'https://example.test/live.mpd',
|
||||
{
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { 'private-key-id': 'private-content-key' },
|
||||
}
|
||||
);
|
||||
await flush();
|
||||
environment.instances[0].dispatch('error', {
|
||||
severity: 2,
|
||||
category: 6,
|
||||
code: 6007,
|
||||
});
|
||||
expect(issues[0]).toHaveProperty('drmSystems', ['clearkey']);
|
||||
expect(JSON.stringify(issues)).not.toContain('private-');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed', '<MPD><broken>', 0],
|
||||
[
|
||||
'DTD-bearing',
|
||||
'<!DOCTYPE MPD [<!ENTITY label "secret">]>' + manifest,
|
||||
0,
|
||||
],
|
||||
['non-manifest response', manifest, 1],
|
||||
['oversized', manifest + ' '.repeat(2 * 1024 * 1024), 0],
|
||||
['non-DASH XML', manifest.replaceAll('MPD', 'other'), 0],
|
||||
])(
|
||||
'ignores %s without interfering with playback',
|
||||
async (_name, xml, type) => {
|
||||
const { issues, environment, session, respond } = setup();
|
||||
session.start(
|
||||
document.createElement('video'),
|
||||
'https://example.test/live.mpd'
|
||||
);
|
||||
await flush();
|
||||
expect(() => respond(xml, type)).not.toThrow();
|
||||
environment.instances[0].dispatch('error', {
|
||||
severity: 2,
|
||||
category: 4,
|
||||
code: 4001,
|
||||
});
|
||||
expect(issues[0].videoCodecs).toEqual([]);
|
||||
expect(issues[0].code).toBe('unknown-playback-error');
|
||||
}
|
||||
);
|
||||
|
||||
it('reports only a known configured DRM name, never its raw license value', () => {
|
||||
const { session, issues } = setup();
|
||||
session.start(
|
||||
document.createElement('video'),
|
||||
'https://example.test/live.mpd',
|
||||
{
|
||||
licenseType: 'com.widevine.alpha',
|
||||
supported: false,
|
||||
}
|
||||
);
|
||||
expect(issues[0]).toHaveProperty('drmSystems', ['widevine']);
|
||||
expect(JSON.stringify(issues)).not.toContain('com.widevine.alpha');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type PlaybackSourceMetadata,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
|
||||
import { ShakaManifestMetadata } from './shaka-manifest-metadata';
|
||||
import {
|
||||
loadShakaModule,
|
||||
type ShakaErrorLike,
|
||||
@@ -54,6 +55,7 @@ const PLAYER_REFRESH_EVENTS = [
|
||||
export class ShakaVideoSession {
|
||||
private module: ShakaModuleLike | null = null;
|
||||
private player: ShakaPlayerLike | null = null;
|
||||
private manifestMetadata: ShakaManifestMetadata | null = null;
|
||||
private playerErrorListener: ((event: Event) => void) | null = null;
|
||||
private playerRefreshListener: (() => void) | null = null;
|
||||
private readonly refreshListeners = new Set<() => void>();
|
||||
@@ -151,6 +153,10 @@ export class ShakaVideoSession {
|
||||
|
||||
const player = new module.Player();
|
||||
this.player = player;
|
||||
this.manifestMetadata = new ShakaManifestMetadata(
|
||||
player.getNetworkingEngine?.() ?? null,
|
||||
drm?.licenseType
|
||||
);
|
||||
this.bindPlayerListeners(player, generation, url);
|
||||
|
||||
if (drm?.clearKeys) {
|
||||
@@ -274,6 +280,8 @@ export class ShakaVideoSession {
|
||||
* {@link pendingTeardown} before attaching to the media element.
|
||||
*/
|
||||
private beginPlayerTeardown(): void {
|
||||
this.manifestMetadata?.destroy();
|
||||
this.manifestMetadata = null;
|
||||
const player = this.player;
|
||||
this.player = null;
|
||||
this.textSuppression.reset();
|
||||
@@ -316,6 +324,7 @@ export class ShakaVideoSession {
|
||||
|
||||
private createMetadata(url: string): PlaybackSourceMetadata {
|
||||
return createPlaybackSourceMetadata({
|
||||
...this.manifestMetadata?.read(),
|
||||
url,
|
||||
mimeType: DASH_MIME_TYPE,
|
||||
player: this.config.player,
|
||||
|
||||
@@ -113,26 +113,30 @@ describe('VjsMpegTsSession', () => {
|
||||
|
||||
it('emits structured HTTP evidence with Video.js source metadata', () => {
|
||||
const mpegTsPlayer = createMpegTsPlayer();
|
||||
Object.assign(mpegTsPlayer, {
|
||||
mediaInfo: {
|
||||
audioCodec: 'aac',
|
||||
videoCodec: 'h264',
|
||||
url: 'private-media-info-url',
|
||||
},
|
||||
});
|
||||
createPlayerMock.mockReturnValue(mpegTsPlayer);
|
||||
const video = document.createElement('video');
|
||||
const { session, emitPlaybackIssue } = createSession();
|
||||
const secret = 'vjs-mpegts-secret';
|
||||
|
||||
session.start('https://example.test/live/stream.ts', video);
|
||||
mpegTsPlayer.emit(
|
||||
'error',
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
{
|
||||
code: 503,
|
||||
msg: `Service Unavailable ${secret}`,
|
||||
headers: { Authorization: secret },
|
||||
}
|
||||
);
|
||||
mpegTsPlayer.emit('error', 'NetworkError', 'HttpStatusCodeInvalid', {
|
||||
code: 503,
|
||||
msg: `Service Unavailable ${secret}`,
|
||||
headers: { Authorization: secret },
|
||||
});
|
||||
|
||||
expect(emitPlaybackIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'network-error',
|
||||
audioCodecs: ['aac'],
|
||||
videoCodecs: ['h264'],
|
||||
source: 'mpegts',
|
||||
sourceUrl: 'https://example.test/live/stream.ts',
|
||||
player: 'videojs',
|
||||
@@ -145,6 +149,9 @@ describe('VjsMpegTsSession', () => {
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
|
||||
'private-media-info-url'
|
||||
);
|
||||
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
|
||||
secret
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
collectPlaybackCodecs,
|
||||
createMpegTsPlaybackEvidence,
|
||||
createPlaybackSourceMetadata,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
@@ -62,6 +63,7 @@ export class VjsMpegTsSession {
|
||||
classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(type, details, info),
|
||||
createPlaybackSourceMetadata({
|
||||
...collectPlaybackCodecs([engine.mediaInfo ?? {}]),
|
||||
url,
|
||||
mimeType: 'video/mp2t',
|
||||
player: InlinePlaybackPlayer.VideoJs,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyVhsPlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '@iptvnator/playback/util';
|
||||
import {
|
||||
getVideoJsPlaybackCodecs,
|
||||
hasActiveVhsSourceHandler,
|
||||
type VideoJsPlayer,
|
||||
type VideoPlayerSource,
|
||||
} from './vjs-player.types';
|
||||
|
||||
export function createVideoJsPlaybackDiagnostic(
|
||||
player: VideoJsPlayer,
|
||||
source: VideoPlayerSource | null | undefined,
|
||||
video: HTMLVideoElement
|
||||
) {
|
||||
const error = typeof player.error === 'function' ? player.error() : null;
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
...getVideoJsPlaybackCodecs(player),
|
||||
url: source?.src ?? video.currentSrc ?? '',
|
||||
mimeType: source?.type,
|
||||
player: InlinePlaybackPlayer.VideoJs,
|
||||
});
|
||||
return error && hasActiveVhsSourceHandler(player)
|
||||
? classifyVhsPlaybackIssue(error, metadata)
|
||||
: classifyNativePlaybackIssue(error ?? video.error, metadata);
|
||||
}
|
||||
@@ -17,13 +17,8 @@ import { createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import 'videojs-quality-selector-hls';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyVhsPlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { type PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
import { createVideoJsPlaybackDiagnostic } from './vjs-playback-diagnostics';
|
||||
import {
|
||||
type LegacyPlayerShortcuts,
|
||||
PlayerControlsComponent,
|
||||
@@ -52,7 +47,6 @@ import {
|
||||
type VideoPlayerOptions,
|
||||
type VideoPlayerSource,
|
||||
getVideoJsTechVideo,
|
||||
hasActiveVhsSourceHandler,
|
||||
} from './vjs-player.types';
|
||||
import { VjsVideoElementSession } from './vjs-video-element-session';
|
||||
|
||||
@@ -266,23 +260,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.videoSession.video() ??
|
||||
getVideoJsTechVideo(this.player) ??
|
||||
this.target().nativeElement;
|
||||
const playerError =
|
||||
typeof this.player.error === 'function'
|
||||
? this.player.error()
|
||||
: null;
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: source?.src ?? video.currentSrc ?? '',
|
||||
mimeType: source?.type,
|
||||
player: InlinePlaybackPlayer.VideoJs,
|
||||
});
|
||||
this.mpegTsSession.syncDuration();
|
||||
this.playbackIssue.emit(
|
||||
playerError && hasActiveVhsSourceHandler(this.player)
|
||||
? classifyVhsPlaybackIssue(playerError, metadata)
|
||||
: classifyNativePlaybackIssue(
|
||||
playerError ?? video.error,
|
||||
metadata
|
||||
)
|
||||
createVideoJsPlaybackDiagnostic(this.player, source, video)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,44 @@ import { type VideoJsPlayer, getVideoJsTechVideo } from './vjs-player.types';
|
||||
import * as playerTypes from './vjs-player.types';
|
||||
|
||||
describe('getVideoJsTechVideo', () => {
|
||||
it('reads safe codecs from the public VHS representations API', () => {
|
||||
const read = (
|
||||
playerTypes as unknown as {
|
||||
getVideoJsPlaybackCodecs: (player: unknown) => unknown;
|
||||
}
|
||||
).getVideoJsPlaybackCodecs;
|
||||
expect(read).toBeDefined();
|
||||
expect(
|
||||
read({
|
||||
tech: () => ({
|
||||
vhs: {
|
||||
representations: () => [
|
||||
{
|
||||
codecs: {
|
||||
video: 'avc1.64001e',
|
||||
audio: 'mp4a.40.2',
|
||||
},
|
||||
uri: 'secret-url',
|
||||
},
|
||||
{
|
||||
codecs: {
|
||||
video: 'https://secret.test',
|
||||
audio: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
).toEqual({ videoCodecs: ['avc1.64001e'], audioCodecs: ['mp4a.40.2'] });
|
||||
expect(
|
||||
read({
|
||||
tech: () => {
|
||||
throw new Error('disposed');
|
||||
},
|
||||
})
|
||||
).toEqual({ videoCodecs: [], audioCodecs: [] });
|
||||
});
|
||||
it('returns the current Video.js Tech video element', () => {
|
||||
const video = document.createElement('video');
|
||||
const tech = jest.fn(() => ({ el: () => video }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type videoJs from 'video.js';
|
||||
import type { NativePlaybackErrorInput } from '@iptvnator/playback/util';
|
||||
import { collectPlaybackCodecs } from '@iptvnator/playback/util';
|
||||
|
||||
export type VideoPlayerSource = {
|
||||
src: string;
|
||||
@@ -62,14 +63,16 @@ export type VideoJsQualityLevel = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type VideoJsQualityLevelList =
|
||||
VideoJsTrackList<VideoJsQualityLevel> & {
|
||||
selectedIndex?: number;
|
||||
};
|
||||
export type VideoJsQualityLevelList = VideoJsTrackList<VideoJsQualityLevel> & {
|
||||
selectedIndex?: number;
|
||||
};
|
||||
|
||||
export type VideoJsTech = {
|
||||
el?: () => Element | null;
|
||||
vhs?: {
|
||||
representations?: () => {
|
||||
codecs?: { audio?: unknown; video?: unknown };
|
||||
}[];
|
||||
playlists?: {
|
||||
main?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
|
||||
master?: { mediaGroups?: { AUDIO?: Record<string, unknown> } };
|
||||
@@ -125,3 +128,20 @@ export function hasActiveVhsSourceHandler(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVideoJsPlaybackCodecs(player: Pick<VideoJsPlayer, 'tech'>) {
|
||||
try {
|
||||
const variants =
|
||||
player
|
||||
.tech({ IWillNotUseThisInPlugins: true })
|
||||
?.vhs?.representations?.() ?? [];
|
||||
return collectPlaybackCodecs(
|
||||
variants.slice(0, 256).map((variant) => ({
|
||||
audioCodec: variant.codecs?.audio,
|
||||
videoCodec: variant.codecs?.video,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
return collectPlaybackCodecs([]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user