mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +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),
|
||||
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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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.FLAC, "flac", ".flac"),
|
||||
(Audio.Codec.OPUS, "libopus", ".opus"),
|
||||
(Audio.Codec.MP3, "libmp3lame", ".mp3"),
|
||||
],
|
||||
)
|
||||
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.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user