feat(drm): add blowfish stripe cipher and mp3 audio codec

BlowfishStripe decrypts on the client after download, so a --remote client gets clear audio.
This commit is contained in:
imSp4rky
2026-10-26 18:59:42 -06:00
parent ffeb58f808
commit 06f87dafa3
16 changed files with 218 additions and 16 deletions
+3 -2
View File
@@ -298,7 +298,7 @@ closed-caption descriptors. The relevant enums:
`Audio` adds `bitrate`, `channels`, `joc` (Joint Object Coding, the Atmos indicator), `Audio` adds `bitrate`, `channels`, `joc` (Joint Object Coding, the Atmos indicator),
and `descriptive`. `Audio.Codec` covers `AAC`, `AC3` (`DD`), `EC3` (`DD+`), `AC4`, 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). `joc` is set (or when `extra` flags it).
### `Subtitle` ### `Subtitle`
@@ -388,7 +388,7 @@ A typical `get_tracks` therefore looks like fetching the manifest text with
## DRM hooks ## DRM hooks
If a track carries DRM, the framework licenses and decrypts it, but it needs your 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 | | 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 | | `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 | | `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 | | `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`" !!! warning "`ClearKey` vs `ClearKeyCENC`"
Despite the shared prefix these are entirely different systems. `ClearKey` is HLS Despite the shared prefix these are entirely different systems. `ClearKey` is HLS
+1 -1
View File
@@ -74,7 +74,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| `-p`, `--profile` | - | Profile for credentials and cookies. | | `-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. | | `-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`. | | `-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. | | `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. |
| `-ab`, `--abitrate` | highest | Exact audio 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`. | | `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
+1
View File
@@ -288,6 +288,7 @@ bitrate for the top renditions, overriding the manifest value:
| `MPEGH` | `MPEG-H` | MPEG-H 3D Audio | | `MPEGH` | `MPEG-H` | MPEG-H 3D Audio |
| `ALAC` | `ALAC` | Apple Lossless | | `ALAC` | `ALAC` | Apple Lossless |
| `FLAC` | `FLAC` | FLAC | | `FLAC` | `FLAC` | FLAC |
| `MP3` | `MP3` | MPEG-1 Audio Layer III |
unshackle accepts names, values, and a few aliases: `eac3` and `ddp` both give `EC3`, unshackle accepts names, values, and a few aliases: `eac3` and `ddp` both give `EC3`,
and `vorbis` gives `OGG`. and `vorbis` gives `OGG`.
+2 -1
View File
@@ -41,7 +41,7 @@ then decrypts the file with those keys.
## Supported DRM systems ## 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. license-server handshake. The rest are special cases.
| System | Device file | License flow | Decryption | | 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 (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 | | **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 | | **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" !!! warning "Two different ClearKeys"
"ClearKey (HLS)" and "ClearKey (CENC)" are entirely different systems that happen "ClearKey (HLS)" and "ClearKey (CENC)" are entirely different systems that happen
+1
View File
@@ -236,6 +236,7 @@ final path.
| FLAC | `.flac` | | FLAC | `.flac` |
| Opus | `.opus` | | Opus | `.opus` |
| Vorbis | `.ogg` | | Vorbis | `.ogg` |
| MP3 | `.mp3` |
| AAC, ALAC, and all other codecs | `.m4a` | | 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 unshackle re-encodes FLAC in this step. The audio does not change, but the new file states its
+1 -1
View File
@@ -38,7 +38,7 @@ at a title and it will:
| Manifests | DASH, HLS, and Smooth Streaming (ISM) | | Manifests | DASH, HLS, and Smooth Streaming (ISM) |
| DRM | Widevine and PlayReady, with ClearKey support | | 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 | | 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 | | 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 | | 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 | | Proxies | Basic proxies plus Control D, NordVPN, ProtonVPN, Surfshark, Windscribe, ExpressVPN, Gluetun, and Hola providers |
+112
View File
@@ -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
+1
View File
@@ -67,6 +67,7 @@ def header_length(path: Path) -> float:
(Audio.Codec.ALAC, "alac", ".m4a"), (Audio.Codec.ALAC, "alac", ".m4a"),
(Audio.Codec.FLAC, "flac", ".flac"), (Audio.Codec.FLAC, "flac", ".flac"),
(Audio.Codec.OPUS, "libopus", ".opus"), (Audio.Codec.OPUS, "libopus", ".opus"),
(Audio.Codec.MP3, "libmp3lame", ".mp3"),
], ],
) )
def test_remux_states_the_full_length_in_the_codec_container( def test_remux_states_the_full_length_in_the_codec_container(
@@ -15,6 +15,7 @@ import pytest
from unshackle.core.api import handlers from unshackle.core.api import handlers
from unshackle.core.api.errors import APIError from unshackle.core.api.errors import APIError
from unshackle.core.api.session_store import SessionEntry from unshackle.core.api.session_store import SessionEntry
from unshackle.core.drm import BlowfishStripe
from unshackle.core.remote_service import RemoteService, ServerVault from unshackle.core.remote_service import RemoteService, ServerVault
pytestmark = pytest.mark.unit 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") handlers.cache_to_vaults({KID.hex: "ab" * 16}, "SVC")
assert not local.is_bad_key(KID, "ab" * 16) assert not local.is_bad_key(KID, "ab" * 16)
assert local.get_key(KID, "SVC") == "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)
+5 -3
View File
@@ -30,7 +30,7 @@ from unshackle.core.cacher import Cacher
from unshackle.core.cdm.detect import cdm_type_stub from unshackle.core.cdm.detect import cdm_type_stub
from unshackle.core.config import config, load_yaml 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.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.providers.anilist import parse_anilist_ref
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
from unshackle.core.services import Services from unshackle.core.services import Services
@@ -1632,6 +1632,8 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
drm_info = {} drm_info = {}
drm_class = drm.__class__.__name__ drm_class = drm.__class__.__name__
drm_info["type"] = drm_class.lower() 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 # 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 # 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) svc_session = getattr(service, "session", None)
init_data = fetch_init_segment(track, svc_session) init_data = fetch_init_segment(track, svc_session)
ensure_track_drm(track, svc_session, init_data) ensure_track_drm(track, svc_session, init_data)
if not track.drm: if not track.drm or all(isinstance(d, BlowfishStripe) for d in track.drm):
log.info(f"Track {sanitize_log(tid[:12])} carries no DRM, so it has no keys to resolve") log.info(f"Track {sanitize_log(tid[:12])} needs no licence, so it has no keys to resolve")
clear_tracks.append(tid) clear_tracks.append(tid)
continue continue
+1 -1
View File
@@ -904,7 +904,7 @@ async def download(request: web.Request) -> web.Response:
- type: array - type: array
items: items:
type: string 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: vbitrate:
type: integer type: integer
description: Video bitrate in kbps (default - None) description: Video bitrate in kbps (default - None)
+3
View File
@@ -2,12 +2,14 @@ import base64
from typing import Any, Union from typing import Any, Union
from uuid import UUID from uuid import UUID
from unshackle.core.drm.blowfish_stripe import BlowfishStripe
from unshackle.core.drm.clearkey import ClearKey from unshackle.core.drm.clearkey import ClearKey
from unshackle.core.drm.clearkey_cenc import ClearKeyCENC from unshackle.core.drm.clearkey_cenc import ClearKeyCENC
from unshackle.core.drm.monalisa import MonaLisa from unshackle.core.drm.monalisa import MonaLisa
from unshackle.core.drm.playready import PlayReady from unshackle.core.drm.playready import PlayReady
from unshackle.core.drm.widevine import Widevine 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] DRM_T = Union[ClearKey, ClearKeyCENC, Widevine, PlayReady, MonaLisa]
@@ -61,6 +63,7 @@ def own_kids(drm: Any) -> list[UUID]:
__all__ = ( __all__ = (
"BlowfishStripe",
"ClearKey", "ClearKey",
"ClearKeyCENC", "ClearKeyCENC",
"Widevine", "Widevine",
+47
View File
@@ -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",)
+6 -4
View File
@@ -39,7 +39,7 @@ from unshackle.core.config import config
from unshackle.core.console import console, prompt_user, show_notice from unshackle.core.console import console, prompt_user, show_notice
from unshackle.core.constants import AnyTrack from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential 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.proxies.resolve import is_loopback
from unshackle.core.search_result import SearchResult from unshackle.core.search_result import SearchResult
from unshackle.core.session import MAX_BACKOFF 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.""" """Reconstruct DRM objects from serialized API data."""
if not drm_list: if not drm_list:
return [] return []
result = [] result: list[Any] = []
for drm_info in drm_list: for drm_info in drm_list:
drm_type = drm_info.get("type", "") drm_type = drm_info.get("type", "")
pssh_str = drm_info.get("pssh") pssh_str = drm_info.get("pssh")
if not pssh_str: if not pssh_str and drm_type != "blowfishstripe":
continue continue
try: 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 pywidevine.pssh import PSSH as WidevinePSSH
from unshackle.core.drm import Widevine from unshackle.core.drm import Widevine
+2
View File
@@ -44,6 +44,7 @@ class Audio(Track):
MPEGH = "MPEG-H" # https://en.wikipedia.org/wiki/MPEG-H_3D_Audio MPEGH = "MPEG-H" # https://en.wikipedia.org/wiki/MPEG-H_3D_Audio
ALAC = "ALAC" # https://en.wikipedia.org/wiki/Apple_Lossless_Audio_Codec ALAC = "ALAC" # https://en.wikipedia.org/wiki/Apple_Lossless_Audio_Codec
FLAC = "FLAC" # https://en.wikipedia.org/wiki/FLAC FLAC = "FLAC" # https://en.wikipedia.org/wiki/FLAC
MP3 = "MP3" # https://en.wikipedia.org/wiki/MP3
@property @property
def extension(self) -> str: def extension(self) -> str:
@@ -282,6 +283,7 @@ class Audio(Track):
Audio.Codec.FLAC: ".flac", Audio.Codec.FLAC: ".flac",
Audio.Codec.OPUS: ".opus", Audio.Codec.OPUS: ".opus",
Audio.Codec.OGG: ".ogg", Audio.Codec.OGG: ".ogg",
Audio.Codec.MP3: ".mp3",
} }
extension = containers.get(self.codec, ".m4a") extension = containers.get(self.codec, ".m4a")
original_path = self.path original_path = self.path
+4 -3
View File
@@ -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.config import config
from unshackle.core.constants import DOWNLOAD_ALL_DRM, DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, DownloadCancelled from unshackle.core.constants import DOWNLOAD_ALL_DRM, DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, DownloadCancelled
from unshackle.core.downloaders import requests 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.drm.verify import decrypt_track, has_encrypted_entry, senc_protects_samples
from unshackle.core.events import events from unshackle.core.events import events
from unshackle.core.session import MAX_BACKOFF, RnetSession, utf8_text 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?") log.debug("No Widevine or PlayReady PSSH was found for this track, is it DRM free?")
if self.drm: if self.drm:
track_kid = self.get_key_id(session=session)
drm = self.get_drm_for_cdm(cdm) 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 isinstance(drm, Widevine):
if not prepare_drm: if not prepare_drm:
raise ValueError("prepare_drm func must be supplied to use Widevine DRM") raise ValueError("prepare_drm func must be supplied to use Widevine DRM")
@@ -879,7 +879,8 @@ class Track:
if drm: if drm:
progress(downloaded="Decrypting", completed=0, total=None) progress(downloaded="Decrypting", completed=0, total=None)
decrypt_track(drm, save_path, prepare_drm, track_kid) 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 self.drm = None
events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None) events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None)
progress(downloaded="Decrypted", completed=100, total=100) progress(downloaded="Decrypted", completed=100, total=100)