From 5d98ca49f95437720235ff37c59e384307fcf9b6 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:59:37 +0100 Subject: [PATCH 1/3] A copy-protected video stays in the page's own player, and the toolbar says DRM On vimeo.com/863362136 (upstream #456's video, copy-protected since) FastStream took the page's player away and gave one that sat at readyState 0 behind its DRM message. The background already reads a stream's master and first variant for the length; the variant's keys now tell whether its segments are copy-protected (StreamLength.hlsProtected, by the rule the player decrypts by, Decryptable.mjs; DASH: dashProtected). - hasVideoSource opens no player when the stream the player would play is protected (the longest, as main.mjs picks: not any protected one, or the film's clear ad won the pick); the toolbar button shows "DRM" and why. - openInMpv refuses a protected stream as it refuses a Firefox VPN one, after waiting for an unread manifest; the allowlist's MPV then sends no later stream. - Two new strings in all 16 locales (glm's translations, German reworded). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- chrome/_locales/de/messages.json | 6 ++ chrome/_locales/en/messages.json | 6 ++ chrome/_locales/es/messages.json | 6 ++ chrome/_locales/fr/messages.json | 6 ++ chrome/_locales/id/messages.json | 6 ++ chrome/_locales/it/messages.json | 6 ++ chrome/_locales/ja/messages.json | 6 ++ chrome/_locales/ko/messages.json | 6 ++ chrome/_locales/ms/messages.json | 6 ++ chrome/_locales/nl/messages.json | 6 ++ chrome/_locales/pl/messages.json | 6 ++ chrome/_locales/pt_BR/messages.json | 6 ++ chrome/_locales/ru/messages.json | 6 ++ chrome/_locales/tr/messages.json | 6 ++ chrome/_locales/zh_CN/messages.json | 6 ++ chrome/_locales/zh_TW/messages.json | 6 ++ chrome/background/BackgroundUtils.mjs | 9 ++- chrome/background/StreamLengths.mjs | 41 +++++++++-- chrome/background/TabTracker.mjs | 11 ++- chrome/background/background.mjs | 68 +++++++++++++++--- .../players/hls/HLSFragmentRequester.mjs | 12 +--- chrome/player/utils/Decryptable.mjs | 23 ++++++ chrome/player/utils/StreamLength.mjs | 71 ++++++++++++++++++ combined-locales.json | 36 ++++++++++ docs/notes/player.md | 35 +++++++++ tests/unit/HLSEncryptedPlaylists.test.mjs | 62 ++++++++++++++++ tests/unit/StreamLength.test.mjs | 63 ++++++++++++++++ tests/unit/StreamLengths.test.mjs | 70 ++++++++++++++++++ tests/unit/backgroundDetection.test.mjs | 72 ++++++++++++++++++- tests/unit/backgroundMpv.test.mjs | 52 ++++++++++++++ tests/unit/typeChecked.test.mjs | 1 + 32 files changed, 694 insertions(+), 30 deletions(-) create mode 100644 chrome/player/utils/Decryptable.mjs diff --git a/README.md b/README.md index 39dfee391..45f65654e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ To use the player, simply: Notes: - Live HLS and DASH streams play. -- This player will not function with DRM protected content. This is intended. Please be mindful of how you use this tool. FastStream should not be used to infringe copyright. +- This player will not function with DRM protected content. This is intended. Please be mindful of how you use this tool. FastStream should not be used to infringe copyright. On a page whose video is copy-protected, FastStream leaves it to the site's own player, and mpv mode sends it nowhere: the toolbar button shows "DRM" and says why. - Please report bugs, accessibility problems and feature requests on the issue tracker: https://github.com/Nawid3333/FastStream/issues - For your privacy, this extension **does not collect telemetry** and has no server of its own. Besides the sites whose videos you play, it connects only to OpenSubtitles when you search for subtitles, and to GitHub for updates. Everything it runs ships inside the add-on. The details, permission by permission, are in [docs/privacy-policy.md](docs/privacy-policy.md). - The default maximum size for predownloading is 5 GB. This can be changed in the settings page (0 = predownload nothing, empty = no limit). Where the downloaded video is kept, in RAM or on the disk, is your choice too: see [RAM and disk](#ram-and-disk). diff --git a/chrome/_locales/de/messages.json b/chrome/_locales/de/messages.json index 977e8924a..0b823c4ce 100644 --- a/chrome/_locales/de/messages.json +++ b/chrome/_locales/de/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - der Stream wurde nicht geöffnet: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Dieses Video ist durch DRM geschützt, und FastStream kann es nicht abspielen: Es bleibt im Player der Website" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - der mpv-Helfer auf diesem Computer ist veraltet: Führe \"Update mpv\" aus dem Startmenü aus (in einem FastStream-Checkout: update-local.cmd oder native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Dieses Video wird über Firefox VPN übertragen, und mpv kann Firefox VPN nicht nutzen: Die Seite würde mpv ablehnen. Um es in mpv anzusehen, schalte Firefox VPN für diese Seite aus und lade die Seite neu." }, + "player_mpv_drm": { + "message": "Dieses Video ist durch DRM geschützt, und mpv kann es nicht abspielen: Es bleibt im Player der Website." + }, "mpv_episode_loading": { "message": "wird in mpv geladen" }, diff --git a/chrome/_locales/en/messages.json b/chrome/_locales/en/messages.json index 4b5bc25d6..f7482e8f7 100644 --- a/chrome/_locales/en/messages.json +++ b/chrome/_locales/en/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - the stream did not open: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - This video is copy-protected (DRM), and FastStream cannot play it: the site's own player keeps it" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - the mpv helper on this computer is out of date: run \"Update mpv\" from the Start menu (in a FastStream checkout: update-local.cmd or native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "This video comes through Firefox VPN, and mpv cannot use Firefox VPN: the site would refuse mpv. To watch it in mpv, turn Firefox VPN off for this site and reload the page." }, + "player_mpv_drm": { + "message": "This video is copy-protected (DRM), and mpv cannot play it: the site's own player keeps it." + }, "mpv_episode_loading": { "message": "loading in mpv" }, diff --git a/chrome/_locales/es/messages.json b/chrome/_locales/es/messages.json index aa6f57ff4..fae78f806 100644 --- a/chrome/_locales/es/messages.json +++ b/chrome/_locales/es/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - el stream no se abrió: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Este video está protegido contra copia (DRM) y FastStream no puede reproducirlo: se lo queda el reproductor del sitio" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - el programa de conexión de mpv en este equipo está desactualizado: ejecuta «Update mpv» desde el menú Inicio (en una copia del repositorio de FastStream: update-local.cmd o native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Este video proviene de Firefox VPN, y mpv no puede usar Firefox VPN: el sitio rechazaría a mpv. Para verlo en mpv, desactiva Firefox VPN para este sitio y recarga la página." }, + "player_mpv_drm": { + "message": "Este video está protegido contra copia (DRM) y mpv no puede reproducirlo: se lo queda el reproductor del sitio." + }, "mpv_episode_loading": { "message": "cargando en mpv" }, diff --git a/chrome/_locales/fr/messages.json b/chrome/_locales/fr/messages.json index 669cc318d..a6823ae97 100644 --- a/chrome/_locales/fr/messages.json +++ b/chrome/_locales/fr/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - le flux ne s'est pas ouvert : $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Cette vid�ro est prot�rg�re contre la copie (DRM), et FastStream ne peut pas la lire : le lecteur du site la garde" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - l'assistant mpv de cet ordinateur est obsolète : lancez « Update mpv » depuis le menu Démarrer (dans une copie du dépôt FastStream : update-local.cmd ou native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Cette vidéo provient de Firefox VPN, et mpv ne peut pas utiliser Firefox VPN : le site refuserait mpv. Pour la regarder dans mpv, désactivez Firefox VPN pour ce site et rechargez la page." }, + "player_mpv_drm": { + "message": "Cette vid�ro est prot�rg�re contre la copie (DRM), et mpv ne peut pas la lire : le lecteur du site la garde." + }, "mpv_episode_loading": { "message": "chargement dans mpv" }, diff --git a/chrome/_locales/id/messages.json b/chrome/_locales/id/messages.json index b0861745f..4e46005bd 100644 --- a/chrome/_locales/id/messages.json +++ b/chrome/_locales/id/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - stream tidak terbuka: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Video ini dilindungi hak cipta (DRM), dan FastStream tidak dapat memutarnya: video diserahkan kepada pemutar situs" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - program penghubung mpv di komputer ini sudah usang: jalankan \"Update mpv\" dari menu Start (di salinan repositori FastStream: update-local.cmd atau native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Video ini berasal dari Firefox VPN, dan mpv tidak dapat menggunakan Firefox VPN: situs akan menolak mpv. Untuk menontonnya di mpv, matikan Firefox VPN untuk situs ini dan muat ulang halaman." }, + "player_mpv_drm": { + "message": "Video ini dilindungi hak cipta (DRM), dan mpv tidak dapat memutarnya: video diserahkan kepada pemutar situs." + }, "mpv_episode_loading": { "message": "memuat di mpv" }, diff --git a/chrome/_locales/it/messages.json b/chrome/_locales/it/messages.json index 04168c34f..e75633a0c 100644 --- a/chrome/_locales/it/messages.json +++ b/chrome/_locales/it/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - lo stream non si è aperto: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Questo video è protetto da DRM e FastStream non può riprodurlo: resta nel lettore del sito web" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - il programma di supporto mpv su questo computer non è aggiornato: esegui \"Update mpv\" dal menu Start (in una copia del repository di FastStream: update-local.cmd o native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Questo video viene trasmesso tramite Firefox VPN e mpv non può usare Firefox VPN: il sito rifiuterebbe mpv. Per guardarlo in mpv, disattiva Firefox VPN per questo sito e ricarica la pagina." }, + "player_mpv_drm": { + "message": "Questo video è protetto da DRM e mpv non può riprodurlo: resta nel lettore del sito web." + }, "mpv_episode_loading": { "message": "caricamento in mpv" }, diff --git a/chrome/_locales/ja/messages.json b/chrome/_locales/ja/messages.json index a8abe6698..d2873bfe0 100644 --- a/chrome/_locales/ja/messages.json +++ b/chrome/_locales/ja/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - ストリームを開けませんでした: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��FastStream O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - このコンピューターの mpv ヘルパーが古くなっています: スタートメニューの「Update mpv」を実行してください(FastStream リポジトリのチェックアウトでは update-local.cmd または native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "この動画は Firefox VPN 経由で送られてきますが、mpv は Firefox VPN を使用できません。そのためサイトは mpv を拒否します。mpv で視聴するには、このサイトで Firefox VPN をオフにして、ページを再読み込みしてください。" }, + "player_mpv_drm": { + "message": "O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��mpv O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��O��" + }, "mpv_episode_loading": { "message": "mpvで読み込み中" }, diff --git a/chrome/_locales/ko/messages.json b/chrome/_locales/ko/messages.json index 9dade9395..44d916e4a 100644 --- a/chrome/_locales/ko/messages.json +++ b/chrome/_locales/ko/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - 스트림을 열지 못했습니다: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� FastStreamy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 이 컴퓨터의 mpv 도우미가 오래되었습니다: 시작 메뉴에서 'Update mpv'를 실행하세요(FastStream 저장소 체크아웃에서는 update-local.cmd 또는 native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "이 비디오는 Firefox VPN을 통해 전송되며, mpv는 Firefox VPN을 사용할 수 없습니다: 사이트에서 mpv를 거부할 것입니다. mpv에서 시청하려면 이 사이트에 대해 Firefox VPN을 끄고 페이지를 새로 고침하세요." }, + "player_mpv_drm": { + "message": "yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� mpvy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��." + }, "mpv_episode_loading": { "message": "mpv에서 로드 중" }, diff --git a/chrome/_locales/ms/messages.json b/chrome/_locales/ms/messages.json index 9f5ce3fd7..e69ec691b 100644 --- a/chrome/_locales/ms/messages.json +++ b/chrome/_locales/ms/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - strim tidak dibuka: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Video ini dilindungi daripada penyalinan (DRM), dan FastStream tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - pembantu mpv pada komputer ini sudah lapuk: jalankan \"Update mpv\" dari menu Mula (dalam salinan repositori FastStream: update-local.cmd atau native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Firefox VPN membawa video ini, dan mpv tidak boleh menggunakan Firefox VPN: tapak akan menolak mpv. Untuk menontonnya dalam mpv, matikan Firefox VPN untuk tapak ini dan muat semula halaman." }, + "player_mpv_drm": { + "message": "Video ini dilindungi daripada penyalinan (DRM), dan mpv tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri." + }, "mpv_episode_loading": { "message": "memuat dalam mpv" }, diff --git a/chrome/_locales/nl/messages.json b/chrome/_locales/nl/messages.json index d16fdda69..694e1cce0 100644 --- a/chrome/_locales/nl/messages.json +++ b/chrome/_locales/nl/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - de stream is niet geopend: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Deze video is kopieerbeveiligd (DRM) en FastStream kan hem niet afspelen: de eigen speler van de site houdt hem vast" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream – MPV – het mpv-hulpje op deze computer is verouderd: voer ‘Update mpv’ uit via het Startmenu (in een FastStream-checkout: update-local.cmd of native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Deze video komt binnen via Firefox VPN, en mpv kan Firefox VPN niet gebruiken: de site zou mpv weigeren. Om de video in mpv te bekijken, schakel Firefox VPN uit voor deze site en herlaad de pagina." }, + "player_mpv_drm": { + "message": "Deze video is kopieerbeveiligd (DRM) en mpv kan hem niet afspelen: de eigen speler van de site houdt hem vast." + }, "mpv_episode_loading": { "message": "laden in mpv" }, diff --git a/chrome/_locales/pl/messages.json b/chrome/_locales/pl/messages.json index ecec579a4..4640a927c 100644 --- a/chrome/_locales/pl/messages.json +++ b/chrome/_locales/pl/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - nie otwarto strumienia: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - To wideo jest chronione przed kopiowaniem (DRM), a FastStream nie może go odtworzyć: pozostaje w odtwarzaczu strony" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - host mpv na tym komputerze jest nieaktualny: uruchom „Update mpv” z menu Start (w kopii repozytorium FastStream: update-local.cmd lub native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Firefox VPN przesyła to wideo, a mpv nie może korzystać z Firefox VPN: witryna odrzuciłaby mpv. Aby obejrzeć go w mpv, wyłącz Firefox VPN dla tej witryny i odśwież stronę." }, + "player_mpv_drm": { + "message": "To wideo jest chronione przed kopiowaniem (DRM), a mpv nie może go odtworzyć: pozostaje w odtwarzaczu strony." + }, "mpv_episode_loading": { "message": "ładowanie w mpv" }, diff --git a/chrome/_locales/pt_BR/messages.json b/chrome/_locales/pt_BR/messages.json index 4fc08cecc..1cb9c6a57 100644 --- a/chrome/_locales/pt_BR/messages.json +++ b/chrome/_locales/pt_BR/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - o stream não abriu: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Este vídeo �r protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: o reprodutor do próprio site fica com ele" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - o auxiliar do mpv neste computador está desatualizado: execute “Update mpv” no menu Iniciar (em uma cópia do repositório do FastStream: update-local.cmd ou native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "O Firefox VPN carrega este vídeo, e o mpv não pode usar o Firefox VPN: o site recusaria o mpv. Para assistir no mpv, desative o Firefox VPN para este site e recarregue a página." }, + "player_mpv_drm": { + "message": "Este vídeo �r protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: o reprodutor do próprio site fica com ele." + }, "mpv_episode_loading": { "message": "carregando no mpv" }, diff --git a/chrome/_locales/ru/messages.json b/chrome/_locales/ru/messages.json index 058730bc1..6572797a1 100644 --- a/chrome/_locales/ru/messages.json +++ b/chrome/_locales/ru/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - поток не открылся: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc FastStream d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - mpv-хост на этом компьютере устарел: запустите «Update mpv» из меню «Пуск» (в копии репозитория FastStream: update-local.cmd или native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Это видео передаётся через Firefox VPN, а mpv не может использовать Firefox VPN: сайт откажет mpv. Чтобы посмотреть его в mpv, отключите Firefox VPN для этого сайта и перезагрузите страницу." }, + "player_mpv_drm": { + "message": "d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc mpv d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�." + }, "mpv_episode_loading": { "message": "загрузка в mpv" }, diff --git a/chrome/_locales/tr/messages.json b/chrome/_locales/tr/messages.json index aaef1862e..70453222e 100644 --- a/chrome/_locales/tr/messages.json +++ b/chrome/_locales/tr/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - akış açılmadı: $1" }, + "extension_toggle_label_drm": { + "message": "FastStream - Bu video kopya korumalı (DRM) ve FastStream onu oynatamıyor: sitenin kendi oynatıcısında kalıyor" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - bu bilgisayardaki mpv yardımcısı güncel değil: Başlat menüsünden \"Update mpv\" öğesini çalıştırın (bir FastStream depo kopyasında: update-local.cmd veya native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "Bu video Firefox VPN üzerinden geliyor ve mpv Firefox VPN'i kullanamıyor: site mpv'yi reddeder. mpv'de izlemek için bu sitede Firefox VPN'i kapatın ve sayfayı yeniden yükleyin." }, + "player_mpv_drm": { + "message": "Bu video kopya korumalı (DRM) ve mpv onu oynatamıyor: sitenin kendi oynatıcısında kalıyor." + }, "mpv_episode_loading": { "message": "mpv'de yükleniyor" }, diff --git a/chrome/_locales/zh_CN/messages.json b/chrome/_locales/zh_CN/messages.json index 2ffdac41d..920f0d263 100644 --- a/chrome/_locales/zh_CN/messages.json +++ b/chrome/_locales/zh_CN/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - 流未能打开:$1" }, + "extension_toggle_label_drm": { + "message": "FastStream - 此_��U��O��_���O�o�O护'��DRM'��'��FastStream 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 这台电脑上的 mpv 辅助程序已过时:请运行开始菜单中的“Update mpv”(在 FastStream 仓库的检出副本中:update-local.cmd 或 native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "此视频通过 Firefox VPN 传输,而 mpv 无法使用 Firefox VPN:网站会拒绝 mpv。要在 mpv 中观看,请为此网站关闭 Firefox VPN 并重新加载页面。" }, + "player_mpv_drm": { + "message": "此_��U��O��_���O�o�O护'��DRM'��'��mpv 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝O��" + }, "mpv_episode_loading": { "message": "正在 mpv 中加载" }, diff --git a/chrome/_locales/zh_TW/messages.json b/chrome/_locales/zh_TW/messages.json index 58ded3f28..24d57911b 100644 --- a/chrome/_locales/zh_TW/messages.json +++ b/chrome/_locales/zh_TW/messages.json @@ -1088,6 +1088,9 @@ "extension_toggle_label_mpv_failed": { "message": "FastStream - MPV - 串流未能開啟:$1" }, + "extension_toggle_label_drm": { + "message": "FastStream - 此O��_��有U���Ao�O_�A'��DRM'��'��FastStream _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝" + }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 這台電腦上的 mpv 主機已過時:請執行「開始」功能表中的「Update mpv」(在 FastStream 儲存庫的簽出副本中:update-local.cmd 或 native-host\\install.ps1)" }, @@ -1196,6 +1199,9 @@ "player_mpv_vpn": { "message": "此影片透過 Firefox VPN 傳輸,而 mpv 無法使用 Firefox VPN:網站會拒絕 mpv。要在 mpv 中觀看,請為此網站關閉 Firefox VPN 並重新載入頁面。" }, + "player_mpv_drm": { + "message": "此O��_��有U���Ao�O_�A'��DRM'��'��mpv _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝O��" + }, "mpv_episode_loading": { "message": "正在 mpv 中載入" }, diff --git a/chrome/background/BackgroundUtils.mjs b/chrome/background/BackgroundUtils.mjs index 9b608fc9b..9e74f2d1e 100644 --- a/chrome/background/BackgroundUtils.mjs +++ b/chrome/background/BackgroundUtils.mjs @@ -170,12 +170,17 @@ export class BackgroundUtils { tabId: tab.tabId, }); } else if (tab.isOn) { + // A copy-protected video stays in the page's own player (tab.protectedVideo): on, and + // no player opened, the button says why. chrome.action.setBadgeText({ - text: 'On', + text: tab.protectedVideo ? 'DRM' : 'On', tabId: tab.tabId, }); chrome.action.setTitle({ - title: chrome.i18n.getMessage('extension_toggle_label') || 'Toggle FastStream', + title: tab.protectedVideo ? + chrome.i18n.getMessage('extension_toggle_label_drm') || + 'FastStream - This video is copy-protected (DRM), and FastStream cannot play it: the site\'s own player keeps it' : + chrome.i18n.getMessage('extension_toggle_label') || 'Toggle FastStream', tabId: tab.tabId, }); chrome.action.setIcon({ diff --git a/chrome/background/StreamLengths.mjs b/chrome/background/StreamLengths.mjs index b4d52e051..3bf809d50 100644 --- a/chrome/background/StreamLengths.mjs +++ b/chrome/background/StreamLengths.mjs @@ -45,6 +45,9 @@ export class StreamLengths { this.setHeaders = setHeaders; /** @type {Map}>} */ this.known = new Map(); + // The streams read as copy-protected (isProtected), by the URL detected: one of the known. + /** @type {Set} */ + this.protectedUrls = new Set(); // The HLS master playlists read (by the URL detected), with their variants and their audio's // and subtitles' playlists (absolute URLs), and the master of each: hlsQualities. /** @type {Map} */ @@ -144,6 +147,18 @@ export class StreamLengths { return this.known.get(url)?.duration; } + /** + * Whether a stream was read as copy-protected (DRM): an HLS stream whose media playlist + * has segments FastStream cannot decrypt (StreamLength.hlsProtected), its master's read + * through its variant's; a DASH one whose video is (StreamLength.dashProtected). Neither + * FastStream's player nor mpv plays it, and the page's own player does: it stays. + * @param {string} url - A stream's URL. + * @return {boolean} False too while it is not read yet. + */ + isProtected(url) { + return this.protectedUrls.has(url); + } + /** * The lengths of a page's sources, as lengthOf tells them, but a file not read yet, or * not readable, counts as a piece of a stream when another of them was read as one and @@ -199,7 +214,9 @@ export class StreamLengths { }); this.known.set(source.url, entry); if (this.known.size > MAX_KNOWN) { - this.known.delete(/** @type {string} */ (this.known.keys().next().value)); + const oldest = /** @type {string} */ (this.known.keys().next().value); + this.known.delete(oldest); + this.protectedUrls.delete(oldest); } return entry.promise; } @@ -285,15 +302,24 @@ export class StreamLengths { let url = source.url; for (let read = 0; read < MAX_PLAYLIST_READS; read++) { const response = await this.get(url, request, null); - const playlist = response && StreamLength.fromHls(response.text()); + if (!response) { + return null; + } + const text = response.text(); + const playlist = StreamLength.fromHls(text); if (!playlist) { return null; } if ('duration' in playlist) { + // A media playlist: the stream's, or its master's first variant's. Its keys tell + // whether its segments are copy-protected; the master names none of them. + if (StreamLength.hlsProtected(text)) { + this.protectedUrls.add(source.url); + } return playlist.duration; } if (read === 0) { - this.noteMaster(source.url, response.text(), response.url); + this.noteMaster(source.url, text, response.url); } url = new URL(playlist.variant, response.url).href; } @@ -302,7 +328,14 @@ export class StreamLengths { if (source.mode === PlayerModes.ACCELERATED_DASH) { const response = await this.get(source.url, request, null); - return response && StreamLength.fromDash(response.text()); + if (!response) { + return null; + } + const text = response.text(); + if (StreamLength.dashProtected(text)) { + this.protectedUrls.add(source.url); + } + return StreamLength.fromDash(text); } if (source.mode === PlayerModes.ACCELERATED_MP4 || source.mode === PlayerModes.DIRECT) { diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index 515dd6125..7cd9e402c 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -317,6 +317,11 @@ export class TabHolder { // again (no "!": an older helper still plays the stream, 2026-10-13). /** @type {boolean} */ this.mpvHostOutdated = false; + // Whether the page's video is copy-protected (StreamLengths.isProtected), and so stays + // in the page's own player: the toolbar button says so ("DRM", and why in its tooltip), + // where nothing else would tell why no player opened. False again on a new page. + /** @type {boolean} */ + this.protectedVideo = false; // Which video decoder the mpv this tab handed its stream to uses (MpvBackend's // MpvDecoder), for the toolbar button's tooltip; null until mpv has said. /** @type {?import('./MpvBackend.mjs').MpvDecoder} */ @@ -374,6 +379,8 @@ export class TabHolder { // surviving hostname changes (upstream semantics for regexMatched). this.mpvAutoOpened = false; this.mpvSentUrls.clear(); + // The toolbar's Off too: its next On looks at the page's video again (hasVideoSource). + this.protectedVideo = false; // A user's play whose stream has not been detected yet, and the last // stream a play sent, which keeps a player that calls play() twice from // opening a second window. A number from the constructor on, which runs this. @@ -704,7 +711,7 @@ const TabStateKeyPrefix = 'tabState:'; // wake opens a second mpv window for a page already handed off. mpvOnPlay too, // or a woken background forwards the page's first stream after all. mpvError and // mpvHostOutdated, or the toolbar's "!" for a failed hand-off and its tooltip for an outdated -// host went at the wake; mpvDecoder, or its tooltip forgot which decoder mpv uses. And a play still waiting for its +// host went at the wake; protectedVideo, or its "DRM"; mpvDecoder, or its tooltip forgot which decoder mpv uses. And a play still waiting for its // stream (mpvPlayPendingUntil, mpvPlayedVideo) and the last one sent (mpvLastPlaySend, // which keeps a player's second play() from opening a second window). mpvTurnedOff, or the // MPV Allowlist started MPV again on the site the user had turned it off on. playerPageFrames, @@ -713,7 +720,7 @@ const TabStateKeyPrefix = 'tabState:'; // a TabHolder - frames, detected sources - describes the current page and is rebuilt as // that page makes requests. const PersistedTabFields = ['url', 'isOn', 'isMpv', 'mpvOnPlay', 'regexMatched', 'mpvMatched', 'mpvAutoOpened', - 'mpvError', 'mpvHostOutdated', 'mpvDecoder', 'mpvPlayPendingUntil', 'mpvPlayedVideo', 'mpvLastPlaySend', + 'mpvError', 'mpvHostOutdated', 'protectedVideo', 'mpvDecoder', 'mpvPlayPendingUntil', 'mpvPlayedVideo', 'mpvLastPlaySend', 'mpvTurnedOff', 'windowStateBefore', 'carriedFullscreenFrameId', 'carryAwaited', 'playerPageFrames']; export class TabTracker { diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index b3fdc58fb..59dfa95d7 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -203,7 +203,8 @@ const CaptionsAnswerWaitMs = 12000; // (getPageQualities). It reads them from what it holds: milliseconds, unless its scripts are busy. const PageQualitiesWaitMs = 1000; // The longest a hand-off to mpv waits for an HLS master playlist to be read, for its -// qualities (hlsQualityChoice); the read starts when the stream is detected. +// qualities (hlsQualityChoice), and for any manifest, for its copy protection (openInMpv); +// the read starts when the stream is detected. const HlsQualitiesWaitMs = 1500; // How long a video's file the page told as it loaded (content.js on loadedmetadata, a // LOADED_MEDIA with live set) waits before it is taken in. The request for it, when there @@ -848,6 +849,8 @@ chrome.tabs.onUpdated.addListener(async (tabid, changeInfo, tabobj) => { tab.mpvPlayPendingUntil = 0; tab.mpvPlayedVideo = null; tab.mpvPlayChecking = null; + // The page before's copy-protected video, not this one's (hasVideoSource). + tab.protectedVideo = false; clearTimeout(tab.urlStartTimer); chrome.tabs.sendMessage(tabid, { @@ -2248,23 +2251,44 @@ function modeFromQuery(url) { /** * What a hand-off to mpv answers: MpvBackend.openStream's answer, or openInMpv's refusal * of a stream Firefox VPN carries. - * @typedef {Awaited>|{ok: false, vpn: true, error: string}} MpvHandOff + * @typedef {Awaited>|{ok: false, vpn: true, error: string}| + * {ok: false, drm: true, error: string}} MpvHandOff */ /** * Whether a frame's streams include one that may show a video, and not only playlists of * stills: the seek bar's thumbnails, which a page may ask for just before its video - * (StreamLength STILLS_LENGTH). The playlists' lengths are read first, when they are not - * yet; a read takes one request, the one the player would make next. + * (StreamLength STILLS_LENGTH). Nor when the one the player would play is copy-protected + * (DRM, StreamLengths.isProtected): its player loaded it and showed nothing, in place of the + * page's, which plays it (Vimeo, 2026-10-28); the page keeps its own, and the toolbar button + * says why (tab.protectedVideo). The one it would play, as it picks (main.mjs: the + * longest, StreamPick.pick), not any protected one: the film's ads and trailers are clear, + * and one of them played in its place. The manifests' lengths are read first, when they are + * not yet; a read takes one request, the one the player would make next. * @param {FrameHolder} frame - The frame a player would open in. - * @return {Promise} True unless every one of its streams is a playlist of stills. + * @return {Promise} True unless every one of its streams is a playlist of stills, or + * the one it would play is copy-protected. */ async function hasVideoSource(frame) { - const playlists = () => collectSources(frame).sources.filter((source) => source.mode === PlayerModes.ACCELERATED_HLS); - await Lengths.settle(playlists, SourceLengthWaitMs); + const manifests = () => collectSources(frame).sources.filter((source) => + source.mode === PlayerModes.ACCELERATED_HLS || source.mode === PlayerModes.ACCELERATED_DASH); + await Lengths.settle(manifests, SourceLengthWaitMs); const sources = collectSources(frame).sources; const lengths = Lengths.lengthsOf(sources); - return lengths.some((length) => length !== STILLS_LENGTH); + if (!lengths.some((length) => length !== STILLS_LENGTH)) { + return false; + } + const played = StreamPick.pick(StreamLength.longest(sources.map((source, i) => ({...source, duration: lengths[i]})))); + if (Lengths.isProtected(played.url)) { + if (Logging) console.log('Copy-protected, left to the page\'s player:', played.url); + if (!frame.tab.protectedVideo) { + frame.tab.protectedVideo = true; + BackgroundUtils.updateTabIcon(frame.tab); + Tabs.saveTabState(frame.tab); + } + return false; + } + return true; } /** @@ -3143,8 +3167,9 @@ function autoOpenInMpv(tab, url, headers) { followMpvDecoder(tab, result); if (result.ok) { pauseAfterHandOff(tab, result); - } else { - // The host never launched mpv, so let the next stream try. + } else if (!('drm' in result)) { + // The host never launched mpv, so let the next stream try. Not after a copy-protected + // video's: the page's next stream would be one of its pieces, or an ad's. tab.mpvAutoOpened = false; Tabs.saveTabState(tab); } @@ -4475,7 +4500,8 @@ async function hlsQualityChoice(tab, url) { * window decides which of the VPN's hosts count (VpnProxyMirror.throughBrowserVpn). * @param {string} url - The stream. * @param {...*} rest - openStream's other arguments. - * @return {Promise} openStream's answer, or {ok: false, vpn: true, error}. + * @return {Promise} openStream's answer, or {ok: false, vpn: true, error}, or + * {ok: false, drm: true, error}. */ async function openInMpv(tabId, url, ...rest) { let incognito; @@ -4513,6 +4539,26 @@ async function openInMpv(tabId, url, ...rest) { if (choice.list) rest[5] = {...(rest[5] || {}), qualities: choice.list}; } } + // Copy-protected (DRM, StreamLengths.isProtected): mpv has no key system either, and its + // window stayed black where the page's player plays the video. Its manifest's read tells, + // which started when it was detected, with the tab on; a master's tells for its variants. + // An HLS stream's is waited for above (hlsQualityChoice) when it gives the qualities; the + // player's button gives its own, a DASH stream none, and a stream detected while the tab + // was off was not read: waited for here, as long. + const tracked = tabId !== undefined && tabId >= 0 && Tabs.getTab(tabId) ? + tabSources(Tabs.getTab(tabId)).find((source) => source.url === detected) : null; + if (tracked && (tracked.mode === PlayerModes.ACCELERATED_HLS || tracked.mode === PlayerModes.ACCELERATED_DASH)) { + await Lengths.settle(() => [tracked], HlsQualitiesWaitMs); + } + if (Lengths.isProtected(detected) || Lengths.isProtected(url)) { + if (Logging) console.log('[MPV] not handed off, copy-protected:', url); + return { + ok: false, + drm: true, + error: chrome.i18n.getMessage('player_mpv_drm') || + 'This video is copy-protected (DRM), and mpv cannot play it: the site\'s own player keeps it.', + }; + } if (VpnMirror.throughBrowserVpn(detected, incognito) || VpnMirror.throughBrowserVpn(url, incognito)) { if (Logging) console.log('[MPV] not handed off, Firefox VPN carries it:', url); return Promise.resolve({ diff --git a/chrome/player/players/hls/HLSFragmentRequester.mjs b/chrome/player/players/hls/HLSFragmentRequester.mjs index d763c27f4..14f56385c 100644 --- a/chrome/player/players/hls/HLSFragmentRequester.mjs +++ b/chrome/player/players/hls/HLSFragmentRequester.mjs @@ -1,16 +1,9 @@ // @ts-check import {DefaultPlayerEvents} from '../../enums/DefaultPlayerEvents.mjs'; import {DownloadStatus} from '../../enums/DownloadStatus.mjs'; +import {isDecryptable} from '../../utils/Decryptable.mjs'; import {HLSDecrypter} from './HLSDecrypter.mjs'; -// The encryption FastStream decrypts itself (decrypter-worker.js: AES-CBC, the key's own -// bytes): whole segments under an AES-128 or AES-256 key the playlist names. hls.js also -// hands out decryptdata for SAMPLE-AES, where only the samples are encrypted, for -// AES-256-CTR, and for the DRM key formats (Widevine, PlayReady, FairPlay); decrypting -// those as whole AES-CBC segments gave garbage or nothing, and a generic load error -// instead of the DRM message. -const DECRYPTABLE_METHODS = ['AES-128', 'AES-256']; - /** @typedef {import('../../modules/hls.mjs').Fragment} HlsFragment */ /** @typedef {import('../../network/DownloadEntry.mjs').DownloadEntry} DownloadEntry */ /** @typedef {import('../../network/FetchLoader.mjs').LoaderStats} LoaderStats */ @@ -53,7 +46,8 @@ export class HLSFragmentRequester { const takeOver = (frag) => { if (!frag?.encrypted) return; const decryptdata = frag.decryptdata; - if (decryptdata?.keyFormat === 'identity' && DECRYPTABLE_METHODS.includes(decryptdata.method)) { + // The rule the background's read of the playlist goes by too (StreamLength.hlsProtected). + if (decryptdata && isDecryptable(decryptdata.method, decryptdata.keyFormat)) { frag.fs_oldcryptdata = decryptdata; frag.fs_oldlevelKeys = frag.levelkeys; } else { diff --git a/chrome/player/utils/Decryptable.mjs b/chrome/player/utils/Decryptable.mjs new file mode 100644 index 000000000..49ecfec46 --- /dev/null +++ b/chrome/player/utils/Decryptable.mjs @@ -0,0 +1,23 @@ +// @ts-check + +// The encryption FastStream decrypts itself (decrypter-worker.js: AES-CBC, the key's own +// bytes): whole segments under an AES-128 or AES-256 key the playlist names. hls.js also +// hands out decryptdata for SAMPLE-AES, where only the samples are encrypted, for +// AES-256-CTR, and for the DRM key formats (Widevine, PlayReady, FairPlay); decrypting +// those as whole AES-CBC segments gave garbage or nothing, and a generic load error +// instead of the DRM message. +export const DECRYPTABLE_METHODS = ['AES-128', 'AES-256']; + +/** + * Whether FastStream decrypts segments under an HLS key itself. The player, which takes the + * decryption over from hls.js (HLSFragmentRequester.takeOverDecryption), and the background, + * which reads the playlist before a player opens (StreamLength.hlsProtected), ask this one + * rule: decided apart, a playlist one of them took for DRM and the other did not either + * kept the page from a player that plays it, or gave it one that plays nothing. + * @param {string|undefined} method - The key's METHOD. + * @param {string|undefined} keyFormat - Its KEYFORMAT ('identity' when the playlist names none). + * @return {boolean} + */ +export function isDecryptable(method, keyFormat) { + return keyFormat === 'identity' && DECRYPTABLE_METHODS.includes(/** @type {string} */ (method)); +} diff --git a/chrome/player/utils/StreamLength.mjs b/chrome/player/utils/StreamLength.mjs index 03b7c1fe9..41517f5d3 100644 --- a/chrome/player/utils/StreamLength.mjs +++ b/chrome/player/utils/StreamLength.mjs @@ -9,6 +9,8 @@ * background's MPV picks rank by what they tell. */ +import {isDecryptable} from './Decryptable.mjs'; + // A stream whose length could not be read ranks as this long: above a known-short one (an // ad, an intro), below a known film or episode. A length is unknown when the server turned // the read away, or the file keeps it where a few reads do not reach. @@ -140,6 +142,75 @@ export class StreamLength { return variants.length > 0 ? {variants, renditions, media} : null; } + /** + * Whether an HLS media playlist has segments encrypted in a way FastStream cannot decrypt: + * copy protection (Widevine, PlayReady, FairPlay) or SAMPLE-AES. Its player loads them and + * shows nothing, where the page's own player plays them (Vimeo, 2026-10-28: SAMPLE-AES-CTR + * under a Widevine and a PlayReady key). The keys in force are kept as hls.js keeps them, + * one per KEYFORMAT until a METHOD=NONE, and a segment is decrypted with the identity one + * when there is one, as the player takes them (HLSFragmentRequester.takeOverDecryption); + * an AES-128 key beside a DRM one plays. A master playlist tells nothing here: it names + * its variants' keys in their own playlists, and an EXT-X-SESSION-KEY may name one that + * none of them uses. + * @param {string} text - The playlist. + * @return {boolean} + */ + static hlsProtected(text) { + /** @type {Map} */ + const keys = new Map(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith('#EXT-X-KEY:')) { + const attributes = StreamLength.hlsAttributes(line.slice('#EXT-X-KEY:'.length)); + if (attributes.METHOD === 'NONE') { + keys.clear(); + } else if (attributes.METHOD) { + keys.set(attributes.KEYFORMAT || 'identity', attributes.METHOD); + } + } else if (keys.size > 0 && line !== '' && (!line.startsWith('#') || line.startsWith('#EXT-X-MAP:')) && + !isDecryptable(keys.get('identity'), 'identity')) { + // A segment, or an init segment, under keys none of which FastStream decrypts. + return true; + } + } + return false; + } + + /** + * Whether a DASH manifest's video is copy-protected (ContentProtection: CENC under + * Widevine, PlayReady, ClearKey): FastStream has no key system, and its player stops at + * the first encrypted sample (DashPlayer's needkey). Every video adaptation set has to + * be, as a clear one beside them plays; with none of them told apart as video (no + * contentType, mimeType or size), every adaptation set. + * @param {string} text - The MPD. + * @return {boolean} + */ + static dashProtected(text) { + const sets = [...text.matchAll(/<(?:[\w-]+:)?AdaptationSet\b([^>]*)>([\s\S]*?)<\/(?:[\w-]+:)?AdaptationSet\s*>/g)] + .map((match) => ({ + video: /\bcontentType\s*=\s*["']video["']|\bmimeType\s*=\s*["']video\/|\bwidth\s*=/i.test(match[1] + match[2]), + protected: /<(?:[\w-]+:)?ContentProtection\b/.test(match[2]), + })); + const videos = sets.filter((set) => set.video); + const judged = videos.length > 0 ? videos : sets; + return judged.length > 0 && judged.every((set) => set.protected); + } + + /** + * An HLS tag's attribute list (RFC 8216, 4.2): NAME=value, the value quoted or not. A + * quoted one may hold commas (a key's data: URI). + * @param {string} text - What follows the tag's colon. + * @return {Object} The values by name, quotes taken off. + */ + static hlsAttributes(text) { + /** @type {Object} */ + const attributes = {}; + for (const match of text.matchAll(/([A-Za-z0-9-]+)\s*=\s*("[^"]*"|[^,]*)/g)) { + attributes[match[1].toUpperCase()] = match[2].trim().replace(/^"(.*)"$/, '$1'); + } + return attributes; + } + /** * Reads a DASH manifest. * @param {string} text - The MPD. diff --git a/combined-locales.json b/combined-locales.json index 38f892d64..fcf1f103c 100644 --- a/combined-locales.json +++ b/combined-locales.json @@ -6533,6 +6533,24 @@ "zh_CN": "FastStream - MPV - 流未能打开:$1", "zh_TW": "FastStream - MPV - 串流未能開啟:$1" }, + "extension_toggle_label_drm": { + "en": "FastStream - This video is copy-protected (DRM), and FastStream cannot play it: the site's own player keeps it", + "de": "FastStream - Dieses Video ist durch DRM geschützt, und FastStream kann es nicht abspielen: Es bleibt im Player der Website", + "es": "FastStream - Este video está protegido contra copia (DRM) y FastStream no puede reproducirlo: se lo queda el reproductor del sitio", + "fr": "FastStream - Cette vid�ro est prot�rg�re contre la copie (DRM), et FastStream ne peut pas la lire : le lecteur du site la garde", + "id": "FastStream - Video ini dilindungi hak cipta (DRM), dan FastStream tidak dapat memutarnya: video diserahkan kepada pemutar situs", + "it": "FastStream - Questo video è protetto da DRM e FastStream non può riprodurlo: resta nel lettore del sito web", + "ja": "FastStream - O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��FastStream O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��", + "ko": "FastStream - yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� FastStreamy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��", + "ms": "FastStream - Video ini dilindungi daripada penyalinan (DRM), dan FastStream tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri", + "nl": "FastStream - Deze video is kopieerbeveiligd (DRM) en FastStream kan hem niet afspelen: de eigen speler van de site houdt hem vast", + "pl": "FastStream - To wideo jest chronione przed kopiowaniem (DRM), a FastStream nie może go odtworzyć: pozostaje w odtwarzaczu strony", + "pt_BR": "FastStream - Este vídeo �r protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: o reprodutor do próprio site fica com ele", + "ru": "FastStream - d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc FastStream d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�", + "tr": "FastStream - Bu video kopya korumalı (DRM) ve FastStream onu oynatamıyor: sitenin kendi oynatıcısında kalıyor", + "zh_CN": "FastStream - 此_��U��O��_���O�o�O护'��DRM'��'��FastStream 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝", + "zh_TW": "FastStream - 此O��_��有U���Ao�O_�A'��DRM'��'��FastStream _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝" + }, "extension_toggle_label_mpv_outdated": { "en": "FastStream - MPV - the mpv helper on this computer is out of date: run \"Update mpv\" from the Start menu (in a FastStream checkout: update-local.cmd or native-host\\install.ps1)", "de": "FastStream - MPV - der mpv-Helfer auf diesem Computer ist veraltet: Führe \"Update mpv\" aus dem Startmenü aus (in einem FastStream-Checkout: update-local.cmd oder native-host\\install.ps1)", @@ -7181,6 +7199,24 @@ "zh_CN": "此视频通过 Firefox VPN 传输,而 mpv 无法使用 Firefox VPN:网站会拒绝 mpv。要在 mpv 中观看,请为此网站关闭 Firefox VPN 并重新加载页面。", "zh_TW": "此影片透過 Firefox VPN 傳輸,而 mpv 無法使用 Firefox VPN:網站會拒絕 mpv。要在 mpv 中觀看,請為此網站關閉 Firefox VPN 並重新載入頁面。" }, + "player_mpv_drm": { + "en": "This video is copy-protected (DRM), and mpv cannot play it: the site's own player keeps it.", + "de": "Dieses Video ist durch DRM geschützt, und mpv kann es nicht abspielen: Es bleibt im Player der Website.", + "es": "Este video está protegido contra copia (DRM) y mpv no puede reproducirlo: se lo queda el reproductor del sitio.", + "fr": "Cette vid�ro est prot�rg�re contre la copie (DRM), et mpv ne peut pas la lire : le lecteur du site la garde.", + "id": "Video ini dilindungi hak cipta (DRM), dan mpv tidak dapat memutarnya: video diserahkan kepada pemutar situs.", + "it": "Questo video è protetto da DRM e mpv non può riprodurlo: resta nel lettore del sito web.", + "ja": "O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��mpv O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��O��", + "ko": "yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� mpvy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��.", + "ms": "Video ini dilindungi daripada penyalinan (DRM), dan mpv tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri.", + "nl": "Deze video is kopieerbeveiligd (DRM) en mpv kan hem niet afspelen: de eigen speler van de site houdt hem vast.", + "pl": "To wideo jest chronione przed kopiowaniem (DRM), a mpv nie może go odtworzyć: pozostaje w odtwarzaczu strony.", + "pt_BR": "Este vídeo �r protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: o reprodutor do próprio site fica com ele.", + "ru": "d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc mpv d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�.", + "tr": "Bu video kopya korumalı (DRM) ve mpv onu oynatamıyor: sitenin kendi oynatıcısında kalıyor.", + "zh_CN": "此_��U��O��_���O�o�O护'��DRM'��'��mpv 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝O��", + "zh_TW": "此O��_��有U���Ao�O_�A'��DRM'��'��mpv _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝O��" + }, "mpv_episode_loading": { "en": "loading in mpv", "de": "wird in mpv geladen", diff --git a/docs/notes/player.md b/docs/notes/player.md index b92cfd6ce..e90379bc1 100644 --- a/docs/notes/player.md +++ b/docs/notes/player.md @@ -629,3 +629,38 @@ side by side with the two after it (MP4: a 3 s stall right after the seek). player's own playback requests are never held unless it is paused and yielding. Yield is re-evaluated on each peer message and on the client's tick, play, pause, seek and visibilitychange. A scrub sends the same state at most every 250 ms. + +## A copy-protected video stays in the page's player (2026-10-28) + +Found while testing upstream #456 (saving from vimeo.com/863362136 looped a part, 1.3.70): that +video is copy-protected now. FastStream took it over, its player loaded the manifest and sat at +readyState 0 behind "This video is copy-protected (DRM)…", where Vimeo's own player plays it. + +- **What Vimeo sends** (Firefox 159, measured): a master playlist with no key at all, and + variant and audio playlists with two keys each, both `METHOD=SAMPLE-AES-CTR`: Widevine + (`KEYFORMAT="urn:uuid:edef8ba9-…"`, the PSSH as a `data:` URI) and PlayReady. The URL names + `/drm/cenc,derivedv2,…/`, which the rule does not go by. +- **Where it is told.** The background already reads a stream's master and its first variant + for the length (StreamLengths, `MAX_PLAYLIST_READS = 2`), so the variant's keys come with no + request of their own. `StreamLength.hlsProtected` keeps the keys in force as hls.js does (one + per KEYFORMAT until `METHOD=NONE`), and a segment plays when the identity key is one FastStream + decrypts (`Decryptable.mjs`: AES-128, AES-256), which is the rule + `HLSFragmentRequester.takeOverDecryption` goes by too: `HLSEncryptedPlaylists.test.mjs` runs + both on the same playlists through hls.js's parser. One deliberate difference: a key format + hls.js does not know, which it drops and plays as clear (still encrypted, it shows nothing). + `EXT-X-SESSION-KEY` decides nothing: it may name a key no variant uses. DASH: + `StreamLength.dashProtected`, every video adaptation set under ContentProtection (a clear one + beside them plays). +- **What changes.** `hasVideoSource` (every in-page open: auto-enable, the toolbar's On, a + stream found while on) opens no player when the stream the player would play is protected: + that one, as main.mjs picks (the longest, `StreamPick.pick`), not any protected one, or a + film's clear ad won the pick. It now waits for DASH manifests' reads too, as for HLS. The + toolbar button shows "DRM" and why (`tab.protectedVideo`, cleared on a new page and at Off). + `openInMpv` refuses a protected stream as it refuses a Firefox VPN one, after waiting for an + unread manifest (`HlsQualitiesWaitMs`); the allowlist's MPV then sends the page's next stream + neither (an ad's, or a piece of the film). +- **Left as it was.** A stream the background could not tell (its variant's read failed, a key + that comes later in a live playlist) still opens the player, which says DRM on hls.js's or + dash.js's word (NEED_KEY), as before. +- **Live, 2026-10-28:** player.vimeo.com/video/863362136 kept Vimeo's player, badge "DRM"; + vimeo.com/22439234 (clear) was still taken over and played. diff --git a/tests/unit/HLSEncryptedPlaylists.test.mjs b/tests/unit/HLSEncryptedPlaylists.test.mjs index 5e3e1f0e6..704bcaaaa 100644 --- a/tests/unit/HLSEncryptedPlaylists.test.mjs +++ b/tests/unit/HLSEncryptedPlaylists.test.mjs @@ -8,6 +8,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {DownloadStatus} from '../../chrome/player/enums/DownloadStatus.mjs'; import {HLSFragment} from '../../chrome/player/players/hls/HLSFragment.mjs'; import {HLSFragmentRequester} from '../../chrome/player/players/hls/HLSFragmentRequester.mjs'; +import {StreamLength} from '../../chrome/player/utils/StreamLength.mjs'; // HLSPlayer.trackUpdated hands each playlist to HLSFragmentRequester.takeOverDecryption, // which takes the decryption of its AES-128 segments over from hls.js; HLSFragmentRequester @@ -194,3 +195,64 @@ describe('HLSFragmentRequester, an init segment under the playlist\'s key', () = expect(fragment.status).toBe(DownloadStatus.WAITING); }); }); + +// The background reads a playlist before any player opens (StreamLength.hlsProtected), and +// leaves one it takes for copy-protected to the page's own player. Its rule has to be the +// player's: taken for DRM where the player decrypts it, the page kept a stream FastStream +// plays; taken for clear where the player cannot, the player opened and showed nothing. +describe('StreamLength.hlsProtected, as the player decides', () => { + const WIDEVINE = 'METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,AAAANHBzc2g=",KEYID=0x46d7,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"'; + const PLAYREADY = 'METHOD=SAMPLE-AES-CTR,URI="data:text/plain;charset=UTF-16;base64,BgIAAA==",KEYFORMAT="com.microsoft.playready"'; + const FAIRPLAY = 'METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1",URI="skd://key42"'; + + /** + * A media playlist: its lines between the header and the end, segments where 'seg' is. + * @param {string[]} body - EXT-X-KEY attribute lists ('KEY:...'), and 'seg' for a segment. + * @return {string} + */ + function playlist(body) { + let n = 0; + const lines = body.flatMap((line) => line === 'seg' ? ['#EXTINF:4,', `seg${n++}.m4s`] : [`#EXT-X-${line}`]); + return ['#EXTM3U', '#EXT-X-VERSION:7', '#EXT-X-TARGETDURATION:4', '#EXT-X-MEDIA-SEQUENCE:0', + ...lines, '#EXT-X-ENDLIST', ''].join('\n'); + } + + const cases = { + 'AES-128': ['KEY:' + AES_128, 'MAP:URI="init.mp4"', 'seg', 'seg'], + 'AES-256': [`KEY:METHOD=AES-256,URI="key.bin",IV=0x${IV_HEX}`, 'seg', 'seg'], + 'no key': ['MAP:URI="init.mp4"', 'seg', 'seg'], + 'METHOD=NONE': ['KEY:METHOD=NONE', 'seg', 'seg'], + 'AES-128, then none': ['KEY:' + AES_128, 'seg', 'KEY:METHOD=NONE', 'seg'], + 'AES-128 beside a FairPlay key': ['KEY:' + AES_128, 'KEY:' + FAIRPLAY, 'seg', 'seg'], + 'SAMPLE-AES': [`KEY:METHOD=SAMPLE-AES,URI="key.bin",IV=0x${IV_HEX}`, 'seg', 'seg'], + 'AES-256-CTR': [`KEY:METHOD=AES-256-CTR,URI="key.bin",IV=0x${IV_HEX}`, 'seg', 'seg'], + 'FairPlay': ['KEY:' + FAIRPLAY, 'seg', 'seg'], + 'Widevine alone': ['KEY:' + WIDEVINE.replace('SAMPLE-AES-CTR', 'SAMPLE-AES-CENC'), 'MAP:URI="init.mp4"', 'seg'], + 'Vimeo: Widevine and PlayReady': ['KEY:' + WIDEVINE, 'KEY:' + PLAYREADY, 'MAP:URI="init.mp4"', 'seg', 'seg'], + 'clear, then Widevine': ['seg', 'KEY:' + WIDEVINE, 'seg'], + }; + + for (const [name, body] of Object.entries(cases)) { + it(name, () => { + const text = playlist(body); + const fragments = M3U8Parser.parseLevelPlaylist(text, 'http://127.0.0.1/stream/index.m3u8', 0, 'main', 0, null).fragments; + const playerDecrypts = HLSFragmentRequester.takeOverDecryption(fragments); + expect(StreamLength.hlsProtected(text)).toBe(!playerDecrypts); + }); + } + + it('reads Vimeo\'s and FairPlay\'s as protected, and AES-128 beside FairPlay as clear', () => { + // The table above holds only if these come out as measured, whatever takeOverDecryption says. + expect(StreamLength.hlsProtected(playlist(cases['Vimeo: Widevine and PlayReady']))).toBe(true); + expect(StreamLength.hlsProtected(playlist(cases['FairPlay']))).toBe(true); + expect(StreamLength.hlsProtected(playlist(cases['AES-128 beside a FairPlay key']))).toBe(false); + }); + + it('takes a key format hls.js does not know for one FastStream cannot decrypt', () => { + // hls.js drops such a key ("Ignoring unsupported EXT-X-KEY") and hands the segments on as + // clear: still encrypted, they play nothing. The one place the two rules part, on purpose. + const text = playlist(['KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.example.drm",URI="skd://x"', 'seg', 'seg']); + expect(StreamLength.hlsProtected(text)).toBe(true); + }); +}); diff --git a/tests/unit/StreamLength.test.mjs b/tests/unit/StreamLength.test.mjs index 450625d37..b71f10a3d 100644 --- a/tests/unit/StreamLength.test.mjs +++ b/tests/unit/StreamLength.test.mjs @@ -376,6 +376,69 @@ describe('StreamLength.fromDash', () => { }); }); +describe('StreamLength.dashProtected', () => { + const CENC = '' + + 'AAAA'; + const mpd = (...sets) => `${sets.join('')}`; + const set = (attributes, inner) => `${inner}`; + + it('tells video under CENC, at the adaptation set or at each representation', () => { + expect(StreamLength.dashProtected(mpd(set('contentType="video"', CENC + '')))).toBe(true); + expect(StreamLength.dashProtected(mpd(set('mimeType="video/mp4"', + `${CENC}`)))).toBe(true); + // ClearKey is a key system FastStream has no more than the others. + expect(StreamLength.dashProtected(mpd(set('contentType="video"', + '')))).toBe(true); + }); + + it('takes a clear video set beside a protected one for playable, and a protected audio beside clear video', () => { + expect(StreamLength.dashProtected(mpd( + set('contentType="video"', CENC + ''), + set('contentType="video"', '')))).toBe(false); + expect(StreamLength.dashProtected(mpd( + set('contentType="video"', ''), + set('contentType="audio"', CENC + '')))).toBe(false); + }); + + it('judges every set when none tells it is video, and namespaced ones too', () => { + expect(StreamLength.dashProtected(mpd(set('id="0"', CENC + '')))).toBe(true); + expect(StreamLength.dashProtected(mpd(set('id="0"', CENC), set('id="1"', '')))).toBe(false); + expect(StreamLength.dashProtected('' + + '')).toBe(true); + }); + + it('takes an MPD with no protection, or no adaptation set, for clear', () => { + expect(StreamLength.dashProtected(mpd(set('contentType="video"', '')))).toBe(false); + expect(StreamLength.dashProtected(mpd())).toBe(false); + expect(StreamLength.dashProtected('ContentProtection')).toBe(false); + }); +}); + +describe('StreamLength.hlsAttributes', () => { + it('reads quoted and plain values, a quoted one with commas in it', () => { + expect(StreamLength.hlsAttributes('METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,AA,BB==",KEYFORMAT="urn:uuid:x"')) + .toEqual({METHOD: 'SAMPLE-AES-CTR', URI: 'data:text/plain;base64,AA,BB==', KEYFORMAT: 'urn:uuid:x'}); + expect(StreamLength.hlsAttributes('method=AES-128, IV=0x01')).toEqual({METHOD: 'AES-128', IV: '0x01'}); + }); +}); + +describe('StreamLength.hlsProtected', () => { + it('reads a playlist with CRLF line ends and spaces as one without', () => { + const text = '#EXTM3U\r\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://k",KEYFORMAT="com.apple.streamingkeydelivery"\r\n #EXTINF:4,\r\nseg0.ts\r\n'; + expect(StreamLength.hlsProtected(text)).toBe(true); + }); + + it('takes a master playlist, its session key whatever, for clear: its variants tell', () => { + const master = '#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES,URI="skd://k",KEYFORMAT="com.apple.streamingkeydelivery"\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=1\nv/index.m3u8\n'; + expect(StreamLength.hlsProtected(master)).toBe(false); + }); + + it('takes a key that comes after the last segment for clear', () => { + expect(StreamLength.hlsProtected('#EXTM3U\n#EXTINF:4,\nseg0.ts\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI="k"\n')).toBe(false); + }); +}); + describe('StreamLength.parseIsoDuration', () => { it('reads the parts MPDs use', () => { expect(StreamLength.parseIsoDuration('PT1.5S')).toBe(1.5); diff --git a/tests/unit/StreamLengths.test.mjs b/tests/unit/StreamLengths.test.mjs index 1f042828a..66887272e 100644 --- a/tests/unit/StreamLengths.test.mjs +++ b/tests/unit/StreamLengths.test.mjs @@ -204,6 +204,76 @@ describe('StreamLengths', () => { expect(await lengths.probe({url: 'https://cdn.example/a.mpd', mode: PlayerModes.ACCELERATED_DASH})).toBe(1440); }); + describe('copy protection', () => { + // Vimeo's, measured 2026-10-28 (vimeo.com/863362136): a master with no key, and variants + // whose segments are SAMPLE-AES-CTR under a Widevine and a PlayReady key. + const WIDEVINE = '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,AAAANHBzc2g=",KEYID=0x46d7,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"'; + const PLAYREADY = '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;charset=UTF-16;base64,BgIAAA==",' + + 'KEYFORMAT="com.microsoft.playready"'; + const master = '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a",NAME="English",URI="audio/media.m3u8"\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=6320976,RESOLUTION=1920x1080,AUDIO="a"\nv1080/media.m3u8\n'; + const variant = (keys) => `#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-PLAYLIST-TYPE:VOD\n${keys}` + + '#EXT-X-MAP:URI="init.mp4"\n#EXTINF:6,\nseg1.mp4\n#EXTINF:6,\nseg2.mp4\n#EXT-X-ENDLIST\n'; + + it('tells an HLS stream whose variant is copy-protected, by its master\'s URL, its length read all the same', async () => { + const {fetch} = server({ + 'https://cdn.example/v/master.m3u8': {body: master}, + 'https://cdn.example/v/v1080/media.m3u8': {body: variant(`${WIDEVINE}\n${PLAYREADY}\n`)}, + }); + const lengths = new StreamLengths({fetch}); + expect(lengths.isProtected('https://cdn.example/v/master.m3u8')).toBe(false); + expect(await lengths.probe({url: 'https://cdn.example/v/master.m3u8', mode: HLS})).toBe(12); + expect(lengths.isProtected('https://cdn.example/v/master.m3u8')).toBe(true); + }); + + it('tells a media playlist detected by itself', async () => { + const {fetch} = server({'https://cdn.example/v/v1080/media.m3u8': {body: variant(`${WIDEVINE}\n`)}}); + const lengths = new StreamLengths({fetch}); + await lengths.probe({url: 'https://cdn.example/v/v1080/media.m3u8', mode: HLS}); + expect(lengths.isProtected('https://cdn.example/v/v1080/media.m3u8')).toBe(true); + }); + + it('takes AES-128, which FastStream decrypts itself, and a stream with no key, for clear', async () => { + const {fetch} = server({ + 'https://cdn.example/aes.m3u8': {body: variant('#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x01\n')}, + 'https://cdn.example/clear.m3u8': {body: variant('')}, + }); + const lengths = new StreamLengths({fetch}); + await lengths.probe({url: 'https://cdn.example/aes.m3u8', mode: HLS}); + await lengths.probe({url: 'https://cdn.example/clear.m3u8', mode: HLS}); + expect(lengths.isProtected('https://cdn.example/aes.m3u8')).toBe(false); + expect(lengths.isProtected('https://cdn.example/clear.m3u8')).toBe(false); + }); + + it('tells a DASH stream whose video is copy-protected', async () => { + const mpd = (protection) => '' + + `${protection}` + + ''; + const {fetch} = server({ + 'https://cdn.example/drm.mpd': {body: mpd('')}, + 'https://cdn.example/clear.mpd': {body: mpd('')}, + }); + const lengths = new StreamLengths({fetch}); + expect(await lengths.probe({url: 'https://cdn.example/drm.mpd', mode: PlayerModes.ACCELERATED_DASH})).toBe(1440); + await lengths.probe({url: 'https://cdn.example/clear.mpd', mode: PlayerModes.ACCELERATED_DASH}); + expect(lengths.isProtected('https://cdn.example/drm.mpd')).toBe(true); + expect(lengths.isProtected('https://cdn.example/clear.mpd')).toBe(false); + }); + + it('forgets a protected stream with its length, past the limit', async () => { + const {fetch} = server({'https://cdn.example/drm.m3u8': {body: variant(`${WIDEVINE}\n`)}}); + const lengths = new StreamLengths({fetch}); + await lengths.probe({url: 'https://cdn.example/drm.m3u8', mode: HLS}); + for (let i = 0; i < 300; i++) { + await lengths.probe({url: `https://cdn.example/${i}.m3u8`, mode: HLS}); + } + expect(lengths.lengthOf('https://cdn.example/drm.m3u8')).toBeUndefined(); + expect(lengths.isProtected('https://cdn.example/drm.m3u8')).toBe(false); + expect(lengths.protectedUrls.size).toBe(0); + }); + }); + it('reads an MP4\'s length from a movie header after its media data, in two ranges', async () => { const mdat = box('mdat', new Uint8Array(200000)); const file = concat(ftyp, mdat, moov(1500)); diff --git a/tests/unit/backgroundDetection.test.mjs b/tests/unit/backgroundDetection.test.mjs index 41c295d64..6f0b414da 100644 --- a/tests/unit/backgroundDetection.test.mjs +++ b/tests/unit/backgroundDetection.test.mjs @@ -1,6 +1,6 @@ import {afterEach, describe, expect, it} from 'vitest'; -import {loadBackground} from './backgroundHarness.mjs'; +import {hlsPlaylist, loadBackground, response} from './backgroundHarness.mjs'; // What the background detects from a page's requests (onBeforeSendHeaders, then // onHeadersReceived), and what it keeps of it, driven through its real listeners. @@ -329,3 +329,73 @@ describe('a stream fetched with a header of the page\'s own', () => { expect(sources[0].headers).toEqual([{name: 'Referer', value: 'https://site.test/'}, {name: 'X-Token', value: 'abc'}]); }); }); + +// A copy-protected video (DRM) stays in the page's own player: FastStream's loaded it and +// showed nothing (Vimeo, 2026-10-28). The background reads the master and its first variant +// for the length anyway (StreamLengths), and the variant's keys tell. +describe('a copy-protected video', () => { + const FILM = 'https://cdn.test/film/master.m3u8'; + const TRAILER = 'https://cdn.test/trailer/master.m3u8'; + const AD = 'https://ads.test/ad/index.m3u8'; + const WIDEVINE = '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,AAAANHBzc2g=",' + + 'KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"'; + const master = (dir) => `#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1920x1080\nhttps://cdn.test/${dir}/1080.m3u8\n`; + const drm = (seconds) => hlsPlaylist(seconds).replace('#EXT-X-VERSION:3', `#EXT-X-VERSION:3\n${WIDEVINE}`); + + /** + * An auto-enabled page that asks for these streams, one after another. + * @param {Object} network - The playlists by URL. + * @param {string[]} streams - What the page asks for. + */ + async function page(network, streams) { + bg = await loadBackground({ + options: {autoEnableURLs: ['https://site.test/']}, tabs: [{id: 1, url: PAGE}], + fetch: async (url) => network[url] === undefined ? response('', 404) : response(network[url]), + }); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'page-1'); + for (const url of streams) { + await bg.request({tabId: 1, type: 'xmlhttprequest', url}); + } + // Past the opening delay and the wait for the lengths. + await bg.wait(3000); + } + + it('opens no player in place of the page\'s, and the toolbar button says why', async () => { + await page({[FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': drm(1400)}, [FILM]); + expect(bg.sent('OPEN_PLAYER')).toEqual([]); + expect(bg.badges.get(1)).toBe('DRM'); + expect(bg.titles.get(1)).toContain('copy-protected'); + }); + + it('nor for the page\'s clear ad: the film is what the player would play', async () => { + // Left out of the pick, the copy-protected film let the ad win it, and the ad played. + await page({ + [AD]: hlsPlaylist(30), + [FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': drm(1400), + }, [AD, FILM]); + expect(bg.sent('OPEN_PLAYER')).toEqual([]); + }); + + it('is a copy-protected trailer beside a clear film, which opens', async () => { + await page({ + [TRAILER]: master('trailer'), 'https://cdn.test/trailer/1080.m3u8': drm(120), + [FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': hlsPlaylist(1400), + }, [TRAILER, FILM]); + expect(bg.sent('OPEN_PLAYER')).toHaveLength(1); + expect(bg.badges.get(1)).toBe('On'); + }); + + it('opens the player for an AES-128 stream, which FastStream decrypts itself', async () => { + const aes = hlsPlaylist(1400).replace('#EXT-X-VERSION:3', '#EXT-X-VERSION:3\n#EXT-X-KEY:METHOD=AES-128,URI="key.bin"'); + await page({[FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': aes}, [FILM]); + expect(bg.sent('OPEN_PLAYER')).toHaveLength(1); + }); + + it('is forgotten on the next page', async () => { + await page({[FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': drm(1400)}, [FILM]); + expect(bg.badges.get(1)).toBe('DRM'); + await bg.navigated(1, 'https://site.test/watch/2'); + expect(bg.badges.get(1)).toBe('On'); + }); +}); diff --git a/tests/unit/backgroundMpv.test.mjs b/tests/unit/backgroundMpv.test.mjs index 97883f0bf..86a6ce397 100644 --- a/tests/unit/backgroundMpv.test.mjs +++ b/tests/unit/backgroundMpv.test.mjs @@ -2735,3 +2735,55 @@ describe('MPV mode and an HLS stream whose master playlist offers several qualit expect(bg.toMpv()).toEqual([variant(480)]); }); }); + +describe('a copy-protected stream (DRM)', () => { + // mpv has no key system either: its window stayed black, where the page's player plays + // the video. Nothing is handed off, and the reason shows where a failed hand-off does. + const WIDEVINE = '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,AAAANHBzc2g=",' + + 'KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"'; + const VARIANT = 'https://cdn.test/episode/1080.m3u8'; + const network = { + [EPISODE]: `#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=1920x1080\n${VARIANT}\n`, + [VARIANT]: hlsPlaylist(1400).replace('#EXT-X-VERSION:3', `#EXT-X-VERSION:3\n${WIDEVINE}`), + [AD]: hlsPlaylist(30), + }; + + it('is not handed to mpv by the allowlist, nor the page\'s next stream, and the toolbar says why', async () => { + bg = await loadBackground({options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, tabs: [{id: 1, url: PAGE}], + fetch: playlists(network, 50)}); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'episode'); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(2000); + expect(bg.toMpv()).toEqual([]); + expect(bg.badges.get(1)).toBe('!'); + expect(bg.titles.get(1)).toContain('copy-protected'); + // The page's next stream is an ad's, or one of the film's pieces: not the video. + await bg.request({tabId: 1, url: AD}); + await bg.wait(2000); + expect(bg.toMpv()).toEqual([]); + }); + + it('is not sent by the player\'s mpv button, whose answer says why, read only then', async () => { + // Detected with the tab off: nothing read it. The button's hand-off reads it first. + bg = await loadBackground({options: {mpvMode: true}, tabs: [{id: 1, url: PAGE}], fetch: playlists(network, 50)}); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'episode'); + await bg.request({tabId: 1, url: EPISODE}); + const answer = bg.message({type: 'MPV_OPEN', url: EPISODE, headers: [], qualities: []}, + {tabId: 1, frameId: 0, url: 'moz-extension://bg-test/player/index.html'}); + await bg.wait(1000); + expect(await answer).toMatchObject({ok: false, drm: true}); + expect(bg.native.filter((m) => m.type === 'open')).toEqual([]); + }); + + it('leaves a clear stream as it was', async () => { + bg = await loadBackground({options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, tabs: [{id: 1, url: PAGE}], + fetch: playlists({[EPISODE]: hlsPlaylist(1400)}, 50)}); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'episode'); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(2000); + expect(bg.toMpv()).toHaveLength(1); + }); +}); diff --git a/tests/unit/typeChecked.test.mjs b/tests/unit/typeChecked.test.mjs index 9c717c1ad..a9dab51fe 100644 --- a/tests/unit/typeChecked.test.mjs +++ b/tests/unit/typeChecked.test.mjs @@ -170,6 +170,7 @@ const CHECKED = [ 'chrome/player/utils/AudioUtils.mjs', 'chrome/player/utils/BlobManager.mjs', 'chrome/player/utils/BrokenMedia.mjs', + 'chrome/player/utils/Decryptable.mjs', 'chrome/player/utils/EnvUtils.mjs', 'chrome/player/utils/FastStreamArchiveUtils.mjs', 'chrome/player/utils/InterfaceUtils.mjs', From 39152aad2b178f1b7ec1d2767f7126510d702dfd Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:13:02 +0100 Subject: [PATCH 2/3] Review fixes: no "DRM" over a FastStream player, DASH waited for at both gates, no orphan mark From glm's reviews of #606, each checked against the code: - The badge: a frame's copy-protected video branded the tab while another frame's player opened, or played. noteProtectedVideo sets no mark while a player is there or opening, and openPlayer clears it. - StreamLengths.noteProtection: a finished read sets the mark either way, and none for a URL forgotten while it was read. - Tests for a DASH manifest read slower than the opening delay, and one read only when the player's mpv button sends it; README says "!" for mpv mode. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- chrome/background/StreamLengths.mjs | 23 +++++++--- chrome/background/background.mjs | 29 ++++++++++--- docs/notes/player.md | 7 +-- tests/unit/StreamLengths.test.mjs | 19 +++++++++ tests/unit/backgroundDetection.test.mjs | 57 +++++++++++++++++++++++-- tests/unit/backgroundMpv.test.mjs | 18 ++++++++ 7 files changed, 137 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 45f65654e..dde23712f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ To use the player, simply: Notes: - Live HLS and DASH streams play. -- This player will not function with DRM protected content. This is intended. Please be mindful of how you use this tool. FastStream should not be used to infringe copyright. On a page whose video is copy-protected, FastStream leaves it to the site's own player, and mpv mode sends it nowhere: the toolbar button shows "DRM" and says why. +- This player will not function with DRM protected content. This is intended. Please be mindful of how you use this tool. FastStream should not be used to infringe copyright. When its manifest says a page's video is copy-protected, FastStream leaves it to the site's own player (the toolbar button shows "DRM" and says why), and mpv mode does not send it to mpv (the button's "!" says why). - Please report bugs, accessibility problems and feature requests on the issue tracker: https://github.com/Nawid3333/FastStream/issues - For your privacy, this extension **does not collect telemetry** and has no server of its own. Besides the sites whose videos you play, it connects only to OpenSubtitles when you search for subtitles, and to GitHub for updates. Everything it runs ships inside the add-on. The details, permission by permission, are in [docs/privacy-policy.md](docs/privacy-policy.md). - The default maximum size for predownloading is 5 GB. This can be changed in the settings page (0 = predownload nothing, empty = no limit). Where the downloaded video is kept, in RAM or on the disk, is your choice too: see [RAM and disk](#ram-and-disk). diff --git a/chrome/background/StreamLengths.mjs b/chrome/background/StreamLengths.mjs index 3bf809d50..38f0a1575 100644 --- a/chrome/background/StreamLengths.mjs +++ b/chrome/background/StreamLengths.mjs @@ -159,6 +159,21 @@ export class StreamLengths { return this.protectedUrls.has(url); } + /** + * Notes what a finished read told of a stream's copy protection, either way. Not for a URL + * forgotten while it was read (MAX_KNOWN): nothing would forget its mark, and the stream, + * detected again and read as clear, still counted as protected (glm's review of #606). + * @param {string} url - The stream's URL, as detected. + * @param {boolean} copyProtected - What its manifest says. + */ + noteProtection(url, copyProtected) { + if (copyProtected && this.known.has(url)) { + this.protectedUrls.add(url); + } else { + this.protectedUrls.delete(url); + } + } + /** * The lengths of a page's sources, as lengthOf tells them, but a file not read yet, or * not readable, counts as a piece of a stream when another of them was read as one and @@ -313,9 +328,7 @@ export class StreamLengths { if ('duration' in playlist) { // A media playlist: the stream's, or its master's first variant's. Its keys tell // whether its segments are copy-protected; the master names none of them. - if (StreamLength.hlsProtected(text)) { - this.protectedUrls.add(source.url); - } + this.noteProtection(source.url, StreamLength.hlsProtected(text)); return playlist.duration; } if (read === 0) { @@ -332,9 +345,7 @@ export class StreamLengths { return null; } const text = response.text(); - if (StreamLength.dashProtected(text)) { - this.protectedUrls.add(source.url); - } + this.noteProtection(source.url, StreamLength.dashProtected(text)); return StreamLength.fromDash(text); } diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 59dfa95d7..5720e21f6 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -2281,16 +2281,33 @@ async function hasVideoSource(frame) { const played = StreamPick.pick(StreamLength.longest(sources.map((source, i) => ({...source, duration: lengths[i]})))); if (Lengths.isProtected(played.url)) { if (Logging) console.log('Copy-protected, left to the page\'s player:', played.url); - if (!frame.tab.protectedVideo) { - frame.tab.protectedVideo = true; - BackgroundUtils.updateTabIcon(frame.tab); - Tabs.saveTabState(frame.tab); - } + noteProtectedVideo(frame.tab, true); return false; } return true; } +/** + * Whether the toolbar button says the tab's video stays in the page's own player for being + * copy-protected (tab.protectedVideo, hasVideoSource): "DRM", and why. Not while one of the + * tab's FastStream players is there, which plays the video the user watches: a stream the page + * asked for after it opened, or one of another frame, said "DRM" over it; a player that opens + * says so no more (glm's review of #606). + * @param {TabHolder} tab - The tab. + * @param {boolean} copyProtected - Whether its video stays in the page's player for it. + */ +function noteProtectedVideo(tab, copyProtected) { + // One still opening too: each frame's player opens on a timer of its own, in either order. + if (copyProtected && [...tab.getFrames()].some((frame) => frame.isPlayer || frame.playerOpening)) { + return; + } + if (tab.protectedVideo !== copyProtected) { + tab.protectedVideo = copyProtected; + BackgroundUtils.updateTabIcon(tab); + Tabs.saveTabState(tab); + } +} + /** * Whether this background knows a stream of the tab, outside its players. * @param {TabHolder} tab - The tab. @@ -2867,6 +2884,8 @@ async function openPlayer(frame) { // Named in content.js's report of a player iframe the page took out before it loaded // (PLAYER_OPEN_GONE), so a report of an earlier attempt never ends this one. frame.playerOpeningAttempt = ++playerOpeningAttempts; + // Another frame's video, or the page's next, is one FastStream plays: no "DRM" over it. + noteProtectedVideo(frame.tab, false); if (Logging) console.log('Opening player', frame); diff --git a/docs/notes/player.md b/docs/notes/player.md index e90379bc1..4d5d047bd 100644 --- a/docs/notes/player.md +++ b/docs/notes/player.md @@ -656,9 +656,10 @@ readyState 0 behind "This video is copy-protected (DRM)…", where Vimeo's own p that one, as main.mjs picks (the longest, `StreamPick.pick`), not any protected one, or a film's clear ad won the pick. It now waits for DASH manifests' reads too, as for HLS. The toolbar button shows "DRM" and why (`tab.protectedVideo`, cleared on a new page and at Off). - `openInMpv` refuses a protected stream as it refuses a Firefox VPN one, after waiting for an - unread manifest (`HlsQualitiesWaitMs`); the allowlist's MPV then sends the page's next stream - neither (an ad's, or a piece of the film). + `openInMpv` refuses a protected stream as it refuses a Firefox VPN one ("!" and the reason in + the tooltip), after waiting for an unread manifest (`HlsQualitiesWaitMs`); the allowlist's MPV + then sends the page's next stream neither (an ad's, or a piece of the film). A read sets the + mark either way, and none for a URL forgotten while it was read (`noteProtection`). - **Left as it was.** A stream the background could not tell (its variant's read failed, a key that comes later in a live playlist) still opens the player, which says DRM on hls.js's or dash.js's word (NEED_KEY), as before. diff --git a/tests/unit/StreamLengths.test.mjs b/tests/unit/StreamLengths.test.mjs index 66887272e..a6d3d8f5d 100644 --- a/tests/unit/StreamLengths.test.mjs +++ b/tests/unit/StreamLengths.test.mjs @@ -272,6 +272,25 @@ describe('StreamLengths', () => { expect(lengths.isProtected('https://cdn.example/drm.m3u8')).toBe(false); expect(lengths.protectedUrls.size).toBe(0); }); + + it('keeps no mark of a read that ends after its URL was forgotten', async () => { + // Marked then, nothing forgot the mark: the stream, detected again and read as clear, + // still counted as protected. + let answer; + const slow = new Promise((resolve) => { + answer = resolve; + }); + const lengths = new StreamLengths({fetch: async (url) => url === 'https://cdn.example/drm.m3u8' ? + slow : new Response('', {status: 404})}); + const read = lengths.probe({url: 'https://cdn.example/drm.m3u8', mode: HLS}); + for (let i = 0; i < 300; i++) { + await lengths.probe({url: `https://cdn.example/${i}.m3u8`, mode: HLS}); + } + answer(new Response(variant(`${WIDEVINE}\n`), {status: 200})); + await read; + expect(lengths.isProtected('https://cdn.example/drm.m3u8')).toBe(false); + expect(lengths.protectedUrls.size).toBe(0); + }); }); it('reads an MP4\'s length from a movie header after its media data, in two ranges', async () => { diff --git a/tests/unit/backgroundDetection.test.mjs b/tests/unit/backgroundDetection.test.mjs index 6f0b414da..7eeacae50 100644 --- a/tests/unit/backgroundDetection.test.mjs +++ b/tests/unit/backgroundDetection.test.mjs @@ -346,16 +346,19 @@ describe('a copy-protected video', () => { * An auto-enabled page that asks for these streams, one after another. * @param {Object} network - The playlists by URL. * @param {string[]} streams - What the page asks for. + * @param {number} [delayMs] - How long the background's reads take. */ - async function page(network, streams) { + async function page(network, streams, delayMs = 0) { bg = await loadBackground({ options: {autoEnableURLs: ['https://site.test/']}, tabs: [{id: 1, url: PAGE}], - fetch: async (url) => network[url] === undefined ? response('', 404) : response(network[url]), + fetch: (url) => new Promise((resolve) => setTimeout(() => + resolve(network[url] === undefined ? response('', 404) : response(network[url])), delayMs)), }); await bg.navigated(1, PAGE); await bg.frameAdded(1, 0, PAGE, 'page-1'); for (const url of streams) { - await bg.request({tabId: 1, type: 'xmlhttprequest', url}); + const type = url.endsWith('.mpd') ? 'application/dash+xml' : 'application/vnd.apple.mpegurl'; + await bg.request({tabId: 1, type: 'xmlhttprequest', url}, {responseHeaders: [{name: 'Content-Type', value: type}]}); } // Past the opening delay and the wait for the lengths. await bg.wait(3000); @@ -392,6 +395,54 @@ describe('a copy-protected video', () => { expect(bg.sent('OPEN_PLAYER')).toHaveLength(1); }); + it('is a DASH stream too, its manifest read slower than the opening delay', async () => { + // The read answers after replaceDelay (500 ms), within the wait for the lengths: not + // waited for, the stream counted as clear, and the player opened on it. + const mpd = '' + + '' + + ''; + await page({'https://cdn.test/film/manifest.mpd': mpd}, ['https://cdn.test/film/manifest.mpd'], 1200); + expect(bg.sent('OPEN_PLAYER')).toEqual([]); + expect(bg.badges.get(1)).toBe('DRM'); + }); + + it('says nothing over FastStream\'s player when another frame asks for a copy-protected stream', async () => { + // A frame with a player takes no more streams (onHeadersReceived), another frame does: an + // embed's copy-protected trailer gets no player, and no "DRM" over the film FastStream plays. + await page({ + [FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': hlsPlaylist(1400), + [TRAILER]: master('trailer'), 'https://cdn.test/trailer/1080.m3u8': drm(3000), + }, [FILM]); + expect(bg.sent('OPEN_PLAYER')).toHaveLength(1); + await bg.message({type: 'PLAYER_LOADED', url: PLAYER_IN_PAGE, parentFrameId: 0}, {tabId: 1, frameId: 5}); + await bg.frameAdded(1, 7, 'https://embed.test/e/1', 'embed-1'); + await bg.request({tabId: 1, frameId: 7, parentFrameId: 0, type: 'xmlhttprequest', url: TRAILER}, + {responseHeaders: [{name: 'Content-Type', value: 'application/vnd.apple.mpegurl'}]}); + await bg.wait(3000); + expect(bg.sent('OPEN_PLAYER')).toHaveLength(1); + expect(bg.badges.get(1)).toBe('On'); + }); + + it('says nothing either when another frame\'s clear video opens a player', async () => { + bg = await loadBackground({ + options: {autoEnableURLs: ['https://site.test/']}, tabs: [{id: 1, url: PAGE}], + fetch: async (url) => { + const network = {[FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': drm(1400), + [TRAILER]: master('trailer'), 'https://cdn.test/trailer/1080.m3u8': hlsPlaylist(1400)}; + return network[url] === undefined ? response('', 404) : response(network[url]); + }, + }); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'page-1'); + await bg.frameAdded(1, 7, 'https://embed.test/e/1', 'embed-1'); + const hls = {responseHeaders: [{name: 'Content-Type', value: 'application/vnd.apple.mpegurl'}]}; + await bg.request({tabId: 1, type: 'xmlhttprequest', url: FILM}, hls); + await bg.request({tabId: 1, frameId: 7, parentFrameId: 0, type: 'xmlhttprequest', url: TRAILER}, hls); + await bg.wait(3000); + expect(bg.sent('OPEN_PLAYER').map((m) => m.frameId)).toEqual([7]); + expect(bg.badges.get(1)).toBe('On'); + }); + it('is forgotten on the next page', async () => { await page({[FILM]: master('film'), 'https://cdn.test/film/1080.m3u8': drm(1400)}, [FILM]); expect(bg.badges.get(1)).toBe('DRM'); diff --git a/tests/unit/backgroundMpv.test.mjs b/tests/unit/backgroundMpv.test.mjs index 86a6ce397..ba75b1e74 100644 --- a/tests/unit/backgroundMpv.test.mjs +++ b/tests/unit/backgroundMpv.test.mjs @@ -2786,4 +2786,22 @@ describe('a copy-protected stream (DRM)', () => { await bg.wait(2000); expect(bg.toMpv()).toHaveLength(1); }); + + it('is a DASH stream too, read only when the player\'s button sends it', async () => { + // Detected with the tab off, nothing read it; openInMpv waits for no DASH manifest for + // qualities (hlsQualityChoice is HLS only), so it waits for this one's read itself. + const MPD = 'https://cdn.test/episode/manifest.mpd'; + const mpd = '' + + '' + + ''; + bg = await loadBackground({options: {mpvMode: true}, tabs: [{id: 1, url: PAGE}], fetch: playlists({[MPD]: mpd}, 300)}); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'episode'); + await bg.request({tabId: 1, url: MPD}, {responseHeaders: [{name: 'Content-Type', value: 'application/dash+xml'}]}); + const answer = bg.message({type: 'MPV_OPEN', url: MPD, headers: [], qualities: []}, + {tabId: 1, frameId: 0, url: 'moz-extension://bg-test/player/index.html'}); + await bg.wait(1000); + expect(await answer).toMatchObject({ok: false, drm: true}); + expect(bg.native.filter((m) => m.type === 'open')).toEqual([]); + }); }); From ce90307f9d44f8cad545db241df5d396a1041ef8 Mon Sep 17 00:00:00 2001 From: Nawid3333 <102778966+Nawid3333@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:41:36 +0100 Subject: [PATCH 3/3] Fix the garbled DRM strings in 7 locales, and test for garbled text The glm translations of extension_toggle_label_drm and player_mpv_drm came back read in the wrong encoding: fr, ja, ko, pt_BR, ru, zh_CN and zh_TW held U+FFFD where letters were. Rewritten by hand in each locale's own words for "player"; id's "hak cipta" (copyright) is now "dilindungi dari penyalinan" (copy protection). The locale test now fails on any message with U+FFFD; it failed on these 7. Co-Authored-By: Claude Opus 5.5 --- chrome/_locales/fr/messages.json | 4 ++-- chrome/_locales/id/messages.json | 4 ++-- chrome/_locales/ja/messages.json | 4 ++-- chrome/_locales/ko/messages.json | 4 ++-- chrome/_locales/pt_BR/messages.json | 4 ++-- chrome/_locales/ru/messages.json | 4 ++-- chrome/_locales/zh_CN/messages.json | 4 ++-- chrome/_locales/zh_TW/messages.json | 4 ++-- combined-locales.json | 32 ++++++++++++++--------------- tests/unit/localeKeys.test.mjs | 6 +++++- 10 files changed, 37 insertions(+), 33 deletions(-) diff --git a/chrome/_locales/fr/messages.json b/chrome/_locales/fr/messages.json index a6823ae97..6f1d723a9 100644 --- a/chrome/_locales/fr/messages.json +++ b/chrome/_locales/fr/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - le flux ne s'est pas ouvert : $1" }, "extension_toggle_label_drm": { - "message": "FastStream - Cette vid�ro est prot�rg�re contre la copie (DRM), et FastStream ne peut pas la lire : le lecteur du site la garde" + "message": "FastStream - Cette vidéo est protégée contre la copie (DRM), et FastStream ne peut pas la lire : elle reste dans le lecteur du site" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - l'assistant mpv de cet ordinateur est obsolète : lancez « Update mpv » depuis le menu Démarrer (dans une copie du dépôt FastStream : update-local.cmd ou native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "Cette vidéo provient de Firefox VPN, et mpv ne peut pas utiliser Firefox VPN : le site refuserait mpv. Pour la regarder dans mpv, désactivez Firefox VPN pour ce site et rechargez la page." }, "player_mpv_drm": { - "message": "Cette vid�ro est prot�rg�re contre la copie (DRM), et mpv ne peut pas la lire : le lecteur du site la garde." + "message": "Cette vidéo est protégée contre la copie (DRM), et mpv ne peut pas la lire : elle reste dans le lecteur du site." }, "mpv_episode_loading": { "message": "chargement dans mpv" diff --git a/chrome/_locales/id/messages.json b/chrome/_locales/id/messages.json index 4e46005bd..1b3c2c2b6 100644 --- a/chrome/_locales/id/messages.json +++ b/chrome/_locales/id/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - stream tidak terbuka: $1" }, "extension_toggle_label_drm": { - "message": "FastStream - Video ini dilindungi hak cipta (DRM), dan FastStream tidak dapat memutarnya: video diserahkan kepada pemutar situs" + "message": "FastStream - Video ini dilindungi dari penyalinan (DRM), dan FastStream tidak dapat memutarnya: video tetap di pemutar situs" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - program penghubung mpv di komputer ini sudah usang: jalankan \"Update mpv\" dari menu Start (di salinan repositori FastStream: update-local.cmd atau native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "Video ini berasal dari Firefox VPN, dan mpv tidak dapat menggunakan Firefox VPN: situs akan menolak mpv. Untuk menontonnya di mpv, matikan Firefox VPN untuk situs ini dan muat ulang halaman." }, "player_mpv_drm": { - "message": "Video ini dilindungi hak cipta (DRM), dan mpv tidak dapat memutarnya: video diserahkan kepada pemutar situs." + "message": "Video ini dilindungi dari penyalinan (DRM), dan mpv tidak dapat memutarnya: video tetap di pemutar situs." }, "mpv_episode_loading": { "message": "memuat di mpv" diff --git a/chrome/_locales/ja/messages.json b/chrome/_locales/ja/messages.json index d2873bfe0..afd067249 100644 --- a/chrome/_locales/ja/messages.json +++ b/chrome/_locales/ja/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - ストリームを開けませんでした: $1" }, "extension_toggle_label_drm": { - "message": "FastStream - O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��FastStream O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��" + "message": "FastStream - この動画はコピー保護(DRM)されているため、FastStream では再生できません。サイト自身のプレーヤーで再生されます" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - このコンピューターの mpv ヘルパーが古くなっています: スタートメニューの「Update mpv」を実行してください(FastStream リポジトリのチェックアウトでは update-local.cmd または native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "この動画は Firefox VPN 経由で送られてきますが、mpv は Firefox VPN を使用できません。そのためサイトは mpv を拒否します。mpv で視聴するには、このサイトで Firefox VPN をオフにして、ページを再読み込みしてください。" }, "player_mpv_drm": { - "message": "O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��mpv O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��O��" + "message": "この動画はコピー保護(DRM)されているため、mpv では再生できません。サイト自身のプレーヤーで再生されます。" }, "mpv_episode_loading": { "message": "mpvで読み込み中" diff --git a/chrome/_locales/ko/messages.json b/chrome/_locales/ko/messages.json index 44d916e4a..81f126761 100644 --- a/chrome/_locales/ko/messages.json +++ b/chrome/_locales/ko/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - 스트림을 열지 못했습니다: $1" }, "extension_toggle_label_drm": { - "message": "FastStream - yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� FastStreamy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��" + "message": "FastStream - 이 비디오는 복사 방지(DRM)되어 있어 FastStream에서 재생할 수 없습니다. 사이트 자체 플레이어에서 재생됩니다" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 이 컴퓨터의 mpv 도우미가 오래되었습니다: 시작 메뉴에서 'Update mpv'를 실행하세요(FastStream 저장소 체크아웃에서는 update-local.cmd 또는 native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "이 비디오는 Firefox VPN을 통해 전송되며, mpv는 Firefox VPN을 사용할 수 없습니다: 사이트에서 mpv를 거부할 것입니다. mpv에서 시청하려면 이 사이트에 대해 Firefox VPN을 끄고 페이지를 새로 고침하세요." }, "player_mpv_drm": { - "message": "yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� mpvy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��." + "message": "이 비디오는 복사 방지(DRM)되어 있어 mpv에서 재생할 수 없습니다. 사이트 자체 플레이어에서 재생됩니다." }, "mpv_episode_loading": { "message": "mpv에서 로드 중" diff --git a/chrome/_locales/pt_BR/messages.json b/chrome/_locales/pt_BR/messages.json index 1cb9c6a57..3068ef0e7 100644 --- a/chrome/_locales/pt_BR/messages.json +++ b/chrome/_locales/pt_BR/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - o stream não abriu: $1" }, "extension_toggle_label_drm": { - "message": "FastStream - Este vídeo �r protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: o reprodutor do próprio site fica com ele" + "message": "FastStream - Este vídeo é protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: ele fica no reprodutor do próprio site" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - o auxiliar do mpv neste computador está desatualizado: execute “Update mpv” no menu Iniciar (em uma cópia do repositório do FastStream: update-local.cmd ou native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "O Firefox VPN carrega este vídeo, e o mpv não pode usar o Firefox VPN: o site recusaria o mpv. Para assistir no mpv, desative o Firefox VPN para este site e recarregue a página." }, "player_mpv_drm": { - "message": "Este vídeo �r protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: o reprodutor do próprio site fica com ele." + "message": "Este vídeo é protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: ele fica no reprodutor do próprio site." }, "mpv_episode_loading": { "message": "carregando no mpv" diff --git a/chrome/_locales/ru/messages.json b/chrome/_locales/ru/messages.json index 6572797a1..673cf1d24 100644 --- a/chrome/_locales/ru/messages.json +++ b/chrome/_locales/ru/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - поток не открылся: $1" }, "extension_toggle_label_drm": { - "message": "FastStream - d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc FastStream d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�" + "message": "FastStream - Это видео защищено от копирования (DRM), и FastStream не может его воспроизвести: оно остаётся в плеере сайта" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - mpv-хост на этом компьютере устарел: запустите «Update mpv» из меню «Пуск» (в копии репозитория FastStream: update-local.cmd или native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "Это видео передаётся через Firefox VPN, а mpv не может использовать Firefox VPN: сайт откажет mpv. Чтобы посмотреть его в mpv, отключите Firefox VPN для этого сайта и перезагрузите страницу." }, "player_mpv_drm": { - "message": "d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc mpv d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�." + "message": "Это видео защищено от копирования (DRM), и mpv не может его воспроизвести: оно остаётся в плеере сайта." }, "mpv_episode_loading": { "message": "загрузка в mpv" diff --git a/chrome/_locales/zh_CN/messages.json b/chrome/_locales/zh_CN/messages.json index 920f0d263..41f83de6d 100644 --- a/chrome/_locales/zh_CN/messages.json +++ b/chrome/_locales/zh_CN/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - 流未能打开:$1" }, "extension_toggle_label_drm": { - "message": "FastStream - 此_��U��O��_���O�o�O护'��DRM'��'��FastStream 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝" + "message": "FastStream - 此视频受复制保护(DRM),FastStream 无法播放:它会留在网站自己的播放器中" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 这台电脑上的 mpv 辅助程序已过时:请运行开始菜单中的“Update mpv”(在 FastStream 仓库的检出副本中:update-local.cmd 或 native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "此视频通过 Firefox VPN 传输,而 mpv 无法使用 Firefox VPN:网站会拒绝 mpv。要在 mpv 中观看,请为此网站关闭 Firefox VPN 并重新加载页面。" }, "player_mpv_drm": { - "message": "此_��U��O��_���O�o�O护'��DRM'��'��mpv 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝O��" + "message": "此视频受复制保护(DRM),mpv 无法播放:它会留在网站自己的播放器中。" }, "mpv_episode_loading": { "message": "正在 mpv 中加载" diff --git a/chrome/_locales/zh_TW/messages.json b/chrome/_locales/zh_TW/messages.json index 24d57911b..869d72eb8 100644 --- a/chrome/_locales/zh_TW/messages.json +++ b/chrome/_locales/zh_TW/messages.json @@ -1089,7 +1089,7 @@ "message": "FastStream - MPV - 串流未能開啟:$1" }, "extension_toggle_label_drm": { - "message": "FastStream - 此O��_��有U���Ao�O_�A'��DRM'��'��FastStream _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝" + "message": "FastStream - 此影片受複製保護(DRM),FastStream 無法播放:它會留在網站自己的播放器中" }, "extension_toggle_label_mpv_outdated": { "message": "FastStream - MPV - 這台電腦上的 mpv 主機已過時:請執行「開始」功能表中的「Update mpv」(在 FastStream 儲存庫的簽出副本中:update-local.cmd 或 native-host\\install.ps1)" @@ -1200,7 +1200,7 @@ "message": "此影片透過 Firefox VPN 傳輸,而 mpv 無法使用 Firefox VPN:網站會拒絕 mpv。要在 mpv 中觀看,請為此網站關閉 Firefox VPN 並重新載入頁面。" }, "player_mpv_drm": { - "message": "此O��_��有U���Ao�O_�A'��DRM'��'��mpv _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝O��" + "message": "此影片受複製保護(DRM),mpv 無法播放:它會留在網站自己的播放器中。" }, "mpv_episode_loading": { "message": "正在 mpv 中載入" diff --git a/combined-locales.json b/combined-locales.json index fcf1f103c..e70e2c06a 100644 --- a/combined-locales.json +++ b/combined-locales.json @@ -6537,19 +6537,19 @@ "en": "FastStream - This video is copy-protected (DRM), and FastStream cannot play it: the site's own player keeps it", "de": "FastStream - Dieses Video ist durch DRM geschützt, und FastStream kann es nicht abspielen: Es bleibt im Player der Website", "es": "FastStream - Este video está protegido contra copia (DRM) y FastStream no puede reproducirlo: se lo queda el reproductor del sitio", - "fr": "FastStream - Cette vid�ro est prot�rg�re contre la copie (DRM), et FastStream ne peut pas la lire : le lecteur du site la garde", - "id": "FastStream - Video ini dilindungi hak cipta (DRM), dan FastStream tidak dapat memutarnya: video diserahkan kepada pemutar situs", + "fr": "FastStream - Cette vidéo est protégée contre la copie (DRM), et FastStream ne peut pas la lire : elle reste dans le lecteur du site", + "id": "FastStream - Video ini dilindungi dari penyalinan (DRM), dan FastStream tidak dapat memutarnya: video tetap di pemutar situs", "it": "FastStream - Questo video è protetto da DRM e FastStream non può riprodurlo: resta nel lettore del sito web", - "ja": "FastStream - O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��FastStream O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��", - "ko": "FastStream - yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� FastStreamy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��", + "ja": "FastStream - この動画はコピー保護(DRM)されているため、FastStream では再生できません。サイト自身のプレーヤーで再生されます", + "ko": "FastStream - 이 비디오는 복사 방지(DRM)되어 있어 FastStream에서 재생할 수 없습니다. 사이트 자체 플레이어에서 재생됩니다", "ms": "FastStream - Video ini dilindungi daripada penyalinan (DRM), dan FastStream tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri", "nl": "FastStream - Deze video is kopieerbeveiligd (DRM) en FastStream kan hem niet afspelen: de eigen speler van de site houdt hem vast", "pl": "FastStream - To wideo jest chronione przed kopiowaniem (DRM), a FastStream nie może go odtworzyć: pozostaje w odtwarzaczu strony", - "pt_BR": "FastStream - Este vídeo �r protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: o reprodutor do próprio site fica com ele", - "ru": "FastStream - d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc FastStream d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�", + "pt_BR": "FastStream - Este vídeo é protegido contra cópia (DRM), e o FastStream não pode reproduzi-lo: ele fica no reprodutor do próprio site", + "ru": "FastStream - Это видео защищено от копирования (DRM), и FastStream не может его воспроизвести: оно остаётся в плеере сайта", "tr": "FastStream - Bu video kopya korumalı (DRM) ve FastStream onu oynatamıyor: sitenin kendi oynatıcısında kalıyor", - "zh_CN": "FastStream - 此_��U��O��_���O�o�O护'��DRM'��'��FastStream 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝", - "zh_TW": "FastStream - 此O��_��有U���Ao�O_�A'��DRM'��'��FastStream _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝" + "zh_CN": "FastStream - 此视频受复制保护(DRM),FastStream 无法播放:它会留在网站自己的播放器中", + "zh_TW": "FastStream - 此影片受複製保護(DRM),FastStream 無法播放:它會留在網站自己的播放器中" }, "extension_toggle_label_mpv_outdated": { "en": "FastStream - MPV - the mpv helper on this computer is out of date: run \"Update mpv\" from the Start menu (in a FastStream checkout: update-local.cmd or native-host\\install.ps1)", @@ -7203,19 +7203,19 @@ "en": "This video is copy-protected (DRM), and mpv cannot play it: the site's own player keeps it.", "de": "Dieses Video ist durch DRM geschützt, und mpv kann es nicht abspielen: Es bleibt im Player der Website.", "es": "Este video está protegido contra copia (DRM) y mpv no puede reproducirlo: se lo queda el reproductor del sitio.", - "fr": "Cette vid�ro est prot�rg�re contre la copie (DRM), et mpv ne peut pas la lire : le lecteur du site la garde.", - "id": "Video ini dilindungi hak cipta (DRM), dan mpv tidak dapat memutarnya: video diserahkan kepada pemutar situs.", + "fr": "Cette vidéo est protégée contre la copie (DRM), et mpv ne peut pas la lire : elle reste dans le lecteur du site.", + "id": "Video ini dilindungi dari penyalinan (DRM), dan mpv tidak dapat memutarnya: video tetap di pemutar situs.", "it": "Questo video è protetto da DRM e mpv non può riprodurlo: resta nel lettore del sito web.", - "ja": "O��O��O��_��O��O��O��O��O��O��O��'��DRM'��O��O��O��O��O��O��O��O��O��O��mpv O��O��O��_��O��O��O��O�oO��O��O�AO��O��O��標準O��O��O��O��O��O��O��O��_A�O��O��_��O��O��O��O��O��", - "ko": "yO� U��U��y��U�� U�Ay�� U�ry��(DRM)U�� y��y�rU��y�� yx�y�� mpvy��y�� yx�y�OY�� y�� y��y�AU��U��. y��yO�Y�cyO� yx�y�� Y��U��yO�y��U�� U��y�� yx�y�OY�rU��U��.", + "ja": "この動画はコピー保護(DRM)されているため、mpv では再生できません。サイト自身のプレーヤーで再生されます。", + "ko": "이 비디오는 복사 방지(DRM)되어 있어 mpv에서 재생할 수 없습니다. 사이트 자체 플레이어에서 재생됩니다.", "ms": "Video ini dilindungi daripada penyalinan (DRM), dan mpv tidak boleh memainkannya: video diserahkan kepada pemain tapak itu sendiri.", "nl": "Deze video is kopieerbeveiligd (DRM) en mpv kan hem niet afspelen: de eigen speler van de site houdt hem vast.", "pl": "To wideo jest chronione przed kopiowaniem (DRM), a mpv nie może go odtworzyć: pozostaje w odtwarzaczu strony.", - "pt_BR": "Este vídeo �r protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: o reprodutor do próprio site fica com ele.", - "ru": "d�D�d� d�dcd�dAd� dAd�D�dcD�dAd�d� d�D� d�d�d�dcD�d�d�d�d�dcD� (DRM), dc mpv d�dA d�d�dAdAD� dAd�d� d�d�D�d�D�d�dcdAd�dAD�D�dc: d�d�d� d�D�D�d�D�D�D�D� d� d�d�dAdAD�dA D�d�d�D�d�.", + "pt_BR": "Este vídeo é protegido contra cópia (DRM), e o mpv não pode reproduzi-lo: ele fica no reprodutor do próprio site.", + "ru": "Это видео защищено от копирования (DRM), и mpv не может его воспроизвести: оно остаётся в плеере сайта.", "tr": "Bu video kopya korumalı (DRM) ve mpv onu oynatamıyor: sitenin kendi oynatıcısında kalıyor.", - "zh_CN": "此_��U��O��_���O�o�O护'��DRM'��'��mpv 无法播攝'��_��_��_��_��Oc�_��播攝O��_��_��播攝O��", - "zh_TW": "此O��_��有U���Ao�O_�A'��DRM'��'��mpv _��法播攝'��_��_A�_��本_��_��播攝O��_��_��播攝O��" + "zh_CN": "此视频受复制保护(DRM),mpv 无法播放:它会留在网站自己的播放器中。", + "zh_TW": "此影片受複製保護(DRM),mpv 無法播放:它會留在網站自己的播放器中。" }, "mpv_episode_loading": { "en": "loading in mpv", diff --git a/tests/unit/localeKeys.test.mjs b/tests/unit/localeKeys.test.mjs index d6893c550..193dab94c 100644 --- a/tests/unit/localeKeys.test.mjs +++ b/tests/unit/localeKeys.test.mjs @@ -145,7 +145,7 @@ describe('Every locale', () => { // text, and one whose $1/$2 differ from English's loses or garbles what is put in. const placeholders = (message) => (message.match(/[$]\d/g) || []).sort().join(' '); for (const locale of fs.readdirSync(path.join(chromeDir, '_locales'))) { - it(`${locale} has English's keys, each with English's placeholders`, () => { + it(`${locale} has English's keys, each with English's placeholders and none garbled`, () => { const messages = JSON.parse(fs.readFileSync(path.join(chromeDir, '_locales', locale, 'messages.json'), 'utf8')); expect(Object.keys(messages).sort()).toEqual(Object.keys(english).sort()); const empty = Object.keys(messages).filter((key) => !messages[key].message?.trim()); @@ -153,6 +153,10 @@ describe('Every locale', () => { const differing = Object.keys(english).filter((key) => Object.hasOwn(messages, key) && placeholders(messages[key].message) !== placeholders(english[key].message)); expect(differing).toEqual([]); + // U+FFFD is what a text read in the wrong encoding keeps of the letters it could not + // read: 7 locales' translations came back as "vid�ro" and worse (2026-10-28). + const garbled = Object.keys(messages).filter((key) => messages[key].message?.includes('�')); + expect(garbled).toEqual([]); }); } });