diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index eca1cc97..f13f305f 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -298,7 +298,7 @@ closed-caption descriptors. The relevant enums: `Audio` adds `bitrate`, `channels`, `joc` (Joint Object Coding, the Atmos indicator), and `descriptive`. `Audio.Codec` covers `AAC`, `AC3` (`DD`), `EC3` (`DD+`), `AC4`, -`OPUS`, `OGG` (Vorbis), `DTS`, `DTSX`, `DTSHD`, `MPEGH`, `ALAC`, and `FLAC`. The `atmos` property is `True` when +`OPUS`, `OGG` (Vorbis), `DTS`, `DTSX`, `DTSHD`, `MPEGH`, `ALAC`, `FLAC`, and `MP3`. The `atmos` property is `True` when `joc` is set (or when `extra` flags it). ### `Subtitle` @@ -388,7 +388,7 @@ A typical `get_tracks` therefore looks like fetching the manifest text with ## DRM hooks If a track carries DRM, the framework licenses and decrypts it, but it needs your -service to talk to the platform's license server. unshackle recognizes five DRM systems: +service to talk to the platform's license server. unshackle recognizes six DRM systems: | DRM class | System | License flow | |---|---|---| @@ -397,6 +397,7 @@ service to talk to the platform's license server. unshackle recognizes five DRM | `ClearKeyCENC` | W3C `org.w3.clearkey` over CENC | JSON KID request → JWK set; no CDM | | `ClearKey` | HLS AES-128 | content key fetched directly from a URI; no CDM, no challenge | | `MonaLisa` | Proprietary WASM DRM | keys extracted locally; no license server | +| `BlowfishStripe` | Blowfish CBC stripe cipher | the service gives the per-track key; no CDM, no license server | !!! warning "`ClearKey` vs `ClearKeyCENC`" Despite the shared prefix these are entirely different systems. `ClearKey` is HLS diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 74788de2..63174b55 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -74,7 +74,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...] | `-p`, `--profile` | - | Profile for credentials and cookies. | | `-q`, `--quality` | best | Resolution(s), comma-separated, e.g. `1080,720`. `-q 1080` also matches anamorphic tracks by 16:9 canvas. | | `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`/`H264`, `HEVC`/`H.265`/`H265`, `VC1`/`VC-1`, `VP8`, `VP9`, `AV1`. | -| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`/`AC-4`, `OPUS`, `OGG`/`VORB`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. | +| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`/`AC-4`, `OPUS`, `OGG`/`VORB`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`, `MP3`. | | `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. | | `-ab`, `--abitrate` | highest | Exact audio bitrate in kbps. | | `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 6ae3fcdb..5ace9c3d 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -288,6 +288,7 @@ bitrate for the top renditions, overriding the manifest value: | `MPEGH` | `MPEG-H` | MPEG-H 3D Audio | | `ALAC` | `ALAC` | Apple Lossless | | `FLAC` | `FLAC` | FLAC | +| `MP3` | `MP3` | MPEG-1 Audio Layer III | unshackle accepts names, values, and a few aliases: `eac3` and `ddp` both give `EC3`, and `vorbis` gives `OGG`. diff --git a/docs/guide/drm-and-cdm.md b/docs/guide/drm-and-cdm.md index b9df2c1e..b3a92c4f 100644 --- a/docs/guide/drm-and-cdm.md +++ b/docs/guide/drm-and-cdm.md @@ -41,7 +41,7 @@ then decrypts the file with those keys. ## Supported DRM systems -unshackle implements five DRM systems. Only the first two need a device file and a +unshackle implements six DRM systems. Only the first two need a device file and a license-server handshake. The rest are special cases. | System | Device file | License flow | Decryption | @@ -51,6 +51,7 @@ license-server handshake. The rest are special cases. | **ClearKey (CENC)**, W3C EME `org.w3.clearkey` | none | JSON `{"kids":[...],"type":"temporary"}` → license server returns a JWK Set. No CDM. | shaka-packager / mp4decrypt | | **ClearKey (HLS)**, AES-128 `EXT-X-KEY` | none | Key fetched directly from a URI or `data:` URL. No CDM, no challenge. | in-process AES-CBC | | **MonaLisa** (proprietary, WASM) | `.mld` | Ticket from the service API; keys extracted locally via WASM. No license server. | ML-Worker binary + AES-ECB, per segment | +| **Blowfish stripe** | none | The service gives the per-track key. No CDM, no license server. | in-process Blowfish-CBC on every third 2048-byte chunk | !!! warning "Two different ClearKeys" "ClearKey (HLS)" and "ClearKey (CENC)" are entirely different systems that happen diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 6e4c31d4..9706023e 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -236,6 +236,7 @@ final path. | FLAC | `.flac` | | Opus | `.opus` | | Vorbis | `.ogg` | +| MP3 | `.mp3` | | AAC, ALAC, and all other codecs | `.m4a` | unshackle re-encodes FLAC in this step. The audio does not change, but the new file states its diff --git a/docs/index.md b/docs/index.md index 363f344b..c7676889 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ at a title and it will: | Manifests | DASH, HLS, and Smooth Streaming (ISM) | | DRM | Widevine and PlayReady, with ClearKey support | | Video | AVC, HEVC, VC-1, VP8, VP9, AV1; SDR, HLG, HDR10, HDR10+, Dolby Vision, and Dolby Vision **hybrid** merging | -| Audio | AAC, AC-3, E-AC-3, AC-4, Opus, Vorbis, DTS, DTS-X, ALAC, FLAC; Dolby Atmos handling; per-channel-layout selection | +| Audio | AAC, AC-3, E-AC-3, AC-4, Opus, Vorbis, DTS, DTS-X, ALAC, FLAC, MP3; Dolby Atmos handling; per-channel-layout selection | | Subtitles | SRT, WebVTT, ASS/SSA, TTML and more, with optional SDH stripping and format conversion | | Key vaults | SQLite, MySQL, and HTTP/API vaults to store and share content keys | | Proxies | Basic proxies plus Control D, NordVPN, ProtonVPN, Surfshark, Windscribe, ExpressVPN, Gluetun, and Hola providers | diff --git a/tests/core/test_blowfish_stripe.py b/tests/core/test_blowfish_stripe.py new file mode 100644 index 00000000..f48f03a9 --- /dev/null +++ b/tests/core/test_blowfish_stripe.py @@ -0,0 +1,112 @@ +"""Tests for the Blowfish CBC stripe cipher (``unshackle.core.drm.blowfish_stripe``).""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from functools import partial +from pathlib import Path +from typing import Any + +import pytest +import requests +from Cryptodome.Cipher import Blowfish + +from unshackle.core.api.handlers import serialize_audio_track, serialize_drm +from unshackle.core.config import config +from unshackle.core.constants import DOWNLOAD_CANCELLED +from unshackle.core.drm import BlowfishStripe +from unshackle.core.remote_service import build_tracks, reconstruct_drm +from unshackle.core.tracks import Audio, Track +from unshackle.core.tracks import track as track_module + +KEY = bytes.fromhex("00112233445566778899aabbccddeeff") +CHUNK = BlowfishStripe.CHUNK + + +def stripe_encrypt(data: bytes, key: bytes = KEY) -> bytes: + """Reference encryption: every third full chunk, each with a fresh CBC run.""" + out = bytearray(data) + for offset in range(0, len(data), CHUNK * 3): + chunk = data[offset : offset + CHUNK] + if len(chunk) == CHUNK: + out[offset : offset + CHUNK] = Blowfish.new(key, Blowfish.MODE_CBC, iv=BlowfishStripe.IV).encrypt(chunk) + return bytes(out) + + +def test_decrypt_round_trip(tmp_path: Path) -> None: + plain = os.urandom(CHUNK * 10 + 123) + path = tmp_path / "track.flac" + path.write_bytes(stripe_encrypt(plain)) + assert path.read_bytes() != plain + + BlowfishStripe(KEY).decrypt(path) + + assert path.read_bytes() == plain + assert list(tmp_path.iterdir()) == [path] + + +def test_short_trailing_chunk_untouched(tmp_path: Path) -> None: + # the last stripe starts at CHUNK * 3 and holds only 100 bytes, so it stays clear + plain = os.urandom(CHUNK * 3 + 100) + encrypted = stripe_encrypt(plain) + assert encrypted[CHUNK * 3 :] == plain[CHUNK * 3 :] + path = tmp_path / "track.mp3" + path.write_bytes(encrypted) + + BlowfishStripe(KEY).decrypt(path) + + assert path.read_bytes() == plain + + +def test_serialize_round_trip_keeps_key() -> None: + serialized = serialize_drm([BlowfishStripe(KEY)]) + assert serialized == [{"type": "blowfishstripe", "key": KEY.hex()}] + + rebuilt = reconstruct_drm(serialized) + + assert len(rebuilt) == 1 + assert isinstance(rebuilt[0], BlowfishStripe) + assert rebuilt[0].key == KEY + + +def test_remote_tracks_keep_the_stripe_drm() -> None: + track = Audio( + id_="a1", url="https://example.test/a1", language="en", codec=Audio.Codec.FLAC, drm=[BlowfishStripe(KEY)] + ) + + rebuilt = build_tracks({"audio": [serialize_audio_track(track, include_url=True)]}) + + assert isinstance(rebuilt.audio[0].drm[0], BlowfishStripe) + assert rebuilt.audio[0].drm[0].key == KEY + + +def test_url_download_decrypts_once_without_a_kid_probe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config.directories, "temp", tmp_path) + monkeypatch.setattr(Track, "get_key_id", lambda *a, **k: pytest.fail("a stripe-cipher file has no KID to probe")) + monkeypatch.setattr( + track_module, "assert_fragments_decrypted", lambda path: pytest.fail("a stripe-cipher file has no fragments") + ) + plain = os.urandom(CHUNK * 7 + 5) + + def downloader(**kwargs: Any) -> Iterator[dict[str, Any]]: + (Path(kwargs["output_dir"]) / kwargs["filename"]).write_bytes(stripe_encrypt(plain)) + yield {"file_downloaded": Path(kwargs["output_dir"]) / kwargs["filename"]} + + track = Audio( + id_="a1", + url="https://example.test/a1.flac", + language="en", + codec=Audio.Codec.FLAC, + drm=[BlowfishStripe(KEY)], + downloader=downloader, + ) + try: + track.download( + session=requests.Session(), prepare_drm=partial(lambda *a, **kw: None), progress=partial(lambda **kw: None) + ) + finally: + DOWNLOAD_CANCELLED.clear() + + assert track.path is not None and track.path.read_bytes() == plain + assert track.drm is None diff --git a/tests/core/test_music_container.py b/tests/core/test_music_container.py index b4fee39e..b2ec2168 100644 --- a/tests/core/test_music_container.py +++ b/tests/core/test_music_container.py @@ -67,6 +67,7 @@ def header_length(path: Path) -> float: (Audio.Codec.ALAC, "alac", ".m4a"), (Audio.Codec.FLAC, "flac", ".flac"), (Audio.Codec.OPUS, "libopus", ".opus"), + (Audio.Codec.MP3, "libmp3lame", ".mp3"), ], ) def test_remux_states_the_full_length_in_the_codec_container( diff --git a/tests/remote/unit/test_server_cdm_key_verification.py b/tests/remote/unit/test_server_cdm_key_verification.py index 113c4f98..adba0d39 100644 --- a/tests/remote/unit/test_server_cdm_key_verification.py +++ b/tests/remote/unit/test_server_cdm_key_verification.py @@ -15,6 +15,7 @@ import pytest from unshackle.core.api import handlers from unshackle.core.api.errors import APIError from unshackle.core.api.session_store import SessionEntry +from unshackle.core.drm import BlowfishStripe from unshackle.core.remote_service import RemoteService, ServerVault pytestmark = pytest.mark.unit @@ -245,3 +246,30 @@ def test_a_cdm_licence_unflags_a_pair_a_client_reported(monkeypatch, tmp_path): handlers.cache_to_vaults({KID.hex: "ab" * 16}, "SVC") assert not local.is_bad_key(KID, "ab" * 16) assert local.get_key(KID, "SVC") == "ab" * 16 + + +async def test_a_stripe_cipher_track_is_clear_and_never_licensed(monkeypatch): + """A track whose only DRM needs no licence goes to clear_tracks and keeps its DRM for the client.""" + track = SimpleNamespace(id="aud", drm=[BlowfishStripe(bytes(16))]) + session = SessionEntry(session_id="sess", service_tag="SVC", service_instance=SimpleNamespace()) + session.tracks = {"aud": track} + + async def validated(sid, req): + return session + + monkeypatch.setattr(handlers, "get_validated_session", validated) + monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine") + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: None) + monkeypatch.setattr(handlers, "handle_single_server_cdm", lambda *a, **k: pytest.fail("licensed a clear track")) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + resp = await handlers.session_license_handler( + {"mode": "server_cdm", "track_ids": ["aud"], "drm_type": "widevine"}, "sess", None + ) + + assert json.loads(resp.body)["clear_tracks"] == ["aud"] + assert isinstance(track.drm[0], BlowfishStripe) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 533d871d..c327623e 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -30,7 +30,7 @@ from unshackle.core.cacher import Cacher from unshackle.core.cdm.detect import cdm_type_stub from unshackle.core.config import config, load_yaml from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP -from unshackle.core.drm import cbcs +from unshackle.core.drm import BlowfishStripe, cbcs from unshackle.core.providers.anilist import parse_anilist_ref from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy from unshackle.core.services import Services @@ -1632,6 +1632,8 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]: drm_info = {} drm_class = drm.__class__.__name__ drm_info["type"] = drm_class.lower() + if isinstance(drm, BlowfishStripe): + drm_info["key"] = drm.key.hex() # pywidevine exposes dumps(); pyplayready's PSSH does not, so PlayReady falls back # to the base64 it was built from. Without it the client drops every PlayReady @@ -5424,8 +5426,8 @@ def license_response( svc_session = getattr(service, "session", None) init_data = fetch_init_segment(track, svc_session) ensure_track_drm(track, svc_session, init_data) - if not track.drm: - log.info(f"Track {sanitize_log(tid[:12])} carries no DRM, so it has no keys to resolve") + if not track.drm or all(isinstance(d, BlowfishStripe) for d in track.drm): + log.info(f"Track {sanitize_log(tid[:12])} needs no licence, so it has no keys to resolve") clear_tracks.append(tid) continue diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 62acfaf8..e5802489 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -904,7 +904,7 @@ async def download(request: web.Request) -> web.Response: - type: array items: type: string - description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, DD, EC3, DD+, EAC3, DDP, AC4, AC-4, OPUS, OGG, VORB, VORBIS, DTS, DTSX, DTS-X, DTSHD, DTS-HD, MPEGH, MPEG-H, ALAC, FLAC (default - None) + description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, DD, EC3, DD+, EAC3, DDP, AC4, AC-4, OPUS, OGG, VORB, VORBIS, DTS, DTSX, DTS-X, DTSHD, DTS-HD, MPEGH, MPEG-H, ALAC, FLAC, MP3 (default - None) vbitrate: type: integer description: Video bitrate in kbps (default - None) diff --git a/unshackle/core/drm/__init__.py b/unshackle/core/drm/__init__.py index 2a56e7d0..3792a13c 100644 --- a/unshackle/core/drm/__init__.py +++ b/unshackle/core/drm/__init__.py @@ -2,12 +2,14 @@ import base64 from typing import Any, Union from uuid import UUID +from unshackle.core.drm.blowfish_stripe import BlowfishStripe from unshackle.core.drm.clearkey import ClearKey from unshackle.core.drm.clearkey_cenc import ClearKeyCENC from unshackle.core.drm.monalisa import MonaLisa from unshackle.core.drm.playready import PlayReady from unshackle.core.drm.widevine import Widevine +# BlowfishStripe stays out: the licensing paths typed on DRM_T never receive a DRM that needs no licence DRM_T = Union[ClearKey, ClearKeyCENC, Widevine, PlayReady, MonaLisa] @@ -61,6 +63,7 @@ def own_kids(drm: Any) -> list[UUID]: __all__ = ( + "BlowfishStripe", "ClearKey", "ClearKeyCENC", "Widevine", diff --git a/unshackle/core/drm/blowfish_stripe.py b/unshackle/core/drm/blowfish_stripe.py new file mode 100644 index 00000000..29530f0a --- /dev/null +++ b/unshackle/core/drm/blowfish_stripe.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Union + +from Cryptodome.Cipher import Blowfish + +from unshackle.core.utilities import log_event + + +class BlowfishStripe: + """Blowfish CBC stripe cipher: the service encrypts every third full 2048-byte chunk and leaves the rest clear. + + Each encrypted chunk is its own CBC run with a fixed IV. The service gives the content key, so there is no licence. + """ + + CHUNK = 2048 + IV = bytes(range(8)) + + def __init__(self, key: Union[bytes, str]): + self.key: bytes = bytes.fromhex(key) if isinstance(key, str) else key + + def decrypt_chunk(self, chunk: bytes) -> bytes: + """Decrypt one encrypted chunk.""" + return Blowfish.new(self.key, Blowfish.MODE_CBC, iv=self.IV).decrypt(chunk) # nosec B304 + + def decrypt(self, path: Path) -> None: + """Decrypt the file to a new file that then replaces it. A short trailing chunk stays clear.""" + if not path or not path.exists(): + raise ValueError("Tried to decrypt a file that does not exist.") + log_event( + "drm_decrypt", + level="DEBUG", + message=f"Decrypting {path.name} with the Blowfish stripe cipher", + drm_type="BlowfishStripe", + file=path.name, + ) + decrypted_path = path.with_suffix(f".decrypted{path.suffix}") + with path.open("rb") as src, decrypted_path.open("wb") as dst: + while stripe := src.read(self.CHUNK * 3): + if len(stripe) >= self.CHUNK: + stripe = self.decrypt_chunk(stripe[: self.CHUNK]) + stripe[self.CHUNK :] + dst.write(stripe) + decrypted_path.replace(path) + + +__all__ = ("BlowfishStripe",) diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 123f2c47..2584c11a 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -39,7 +39,7 @@ from unshackle.core.config import config from unshackle.core.console import console, prompt_user, show_notice from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential -from unshackle.core.drm import cbcs, real_kids +from unshackle.core.drm import BlowfishStripe, cbcs, real_kids from unshackle.core.proxies.resolve import is_loopback from unshackle.core.search_result import SearchResult from unshackle.core.session import MAX_BACKOFF @@ -328,14 +328,16 @@ def reconstruct_drm(drm_list: Optional[list]) -> list: """Reconstruct DRM objects from serialized API data.""" if not drm_list: return [] - result = [] + result: list[Any] = [] for drm_info in drm_list: drm_type = drm_info.get("type", "") pssh_str = drm_info.get("pssh") - if not pssh_str: + if not pssh_str and drm_type != "blowfishstripe": continue try: - if drm_type == "widevine": + if drm_type == "blowfishstripe": + result.append(BlowfishStripe(drm_info["key"])) + elif drm_type == "widevine": from pywidevine.pssh import PSSH as WidevinePSSH from unshackle.core.drm import Widevine diff --git a/unshackle/core/tracks/audio.py b/unshackle/core/tracks/audio.py index 3f4f0041..5b152bdc 100644 --- a/unshackle/core/tracks/audio.py +++ b/unshackle/core/tracks/audio.py @@ -44,6 +44,7 @@ class Audio(Track): MPEGH = "MPEG-H" # https://en.wikipedia.org/wiki/MPEG-H_3D_Audio ALAC = "ALAC" # https://en.wikipedia.org/wiki/Apple_Lossless_Audio_Codec FLAC = "FLAC" # https://en.wikipedia.org/wiki/FLAC + MP3 = "MP3" # https://en.wikipedia.org/wiki/MP3 @property def extension(self) -> str: @@ -282,6 +283,7 @@ class Audio(Track): Audio.Codec.FLAC: ".flac", Audio.Codec.OPUS: ".opus", Audio.Codec.OGG: ".ogg", + Audio.Codec.MP3: ".mp3", } extension = containers.get(self.codec, ".m4a") original_path = self.path diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 2bbd195d..b4b7b8a1 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -27,7 +27,7 @@ from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm from unshackle.core.config import config from unshackle.core.constants import DOWNLOAD_ALL_DRM, DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, DownloadCancelled from unshackle.core.downloaders import requests -from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine +from unshackle.core.drm import DRM_T, BlowfishStripe, ClearKeyCENC, PlayReady, Widevine from unshackle.core.drm.verify import decrypt_track, has_encrypted_entry, senc_protects_samples from unshackle.core.events import events from unshackle.core.session import MAX_BACKOFF, RnetSession, utf8_text @@ -811,8 +811,8 @@ class Track: log.debug("No Widevine or PlayReady PSSH was found for this track, is it DRM free?") if self.drm: - track_kid = self.get_key_id(session=session) drm = self.get_drm_for_cdm(cdm) + track_kid = None if isinstance(drm, BlowfishStripe) else self.get_key_id(session=session) if isinstance(drm, Widevine): if not prepare_drm: raise ValueError("prepare_drm func must be supplied to use Widevine DRM") @@ -879,7 +879,8 @@ class Track: if drm: progress(downloaded="Decrypting", completed=0, total=None) decrypt_track(drm, save_path, prepare_drm, track_kid) - assert_fragments_decrypted(save_path) + if not isinstance(drm, BlowfishStripe): + assert_fragments_decrypted(save_path) self.drm = None events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None) progress(downloaded="Decrypted", completed=100, total=100)