mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user