mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
style: clean up prose
This commit is contained in:
@@ -3,7 +3,7 @@ name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: Sp5rky
|
||||
assignees: imSp4rky
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -57,7 +57,7 @@ Add any other context about the problem here.
|
||||
|
||||
---
|
||||
|
||||
**⚠️ Important:**
|
||||
**Important:**
|
||||
|
||||
- **DO NOT include service-specific implementation code** unless you have explicit rights to share it
|
||||
- **DO NOT share credentials, API keys, WVD files, or authentication tokens**
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: Sp5rky
|
||||
assignees: imSp4rky
|
||||
---
|
||||
|
||||
**Feature Category**
|
||||
@@ -49,5 +49,5 @@ Add any other context or screenshots about the feature request here.
|
||||
|
||||
---
|
||||
|
||||
**⚠️ Note:**
|
||||
**Note:**
|
||||
This project focuses on the core framework and tooling. Service-specific feature requests should focus on what the framework should support, not specific service implementations.
|
||||
|
||||
@@ -16,24 +16,24 @@ directories:
|
||||
|
||||
| Key | Type | Default | Overridable | Purpose |
|
||||
|-----|------|---------|:-----------:|---------|
|
||||
| `downloads` | path | `<repo>/downloads` | ✅ | Default output directory for finished files. |
|
||||
| `temp` | path | `<repo>/temp` | ✅ | Temporary working files during download/decrypt/mux. |
|
||||
| `cache` | path | `<data>/cache` | ✅ | Generic cache, title cache, and the update-check store. |
|
||||
| `cookies` | path | `<data>/cookies` | ✅ | Per-service cookie files (and VPN cookie files). |
|
||||
| `logs` | path | `<data>/logs` | ✅ | Log files. |
|
||||
| `exports` | path | `<data>/exports` | ✅ | Export JSON files. |
|
||||
| `wvds` | path | `<data>/WVDs` | ✅ | Widevine device files (`.wvd`). |
|
||||
| `prds` | path | `<data>/PRDs` | ✅ | PlayReady device files (`.prd`). |
|
||||
| `dcsl` | path | `<data>/DCSL` | ✅ | DCSL data. |
|
||||
| `commands` | path | `unshackle/commands` | ✅ | CLI command modules. |
|
||||
| `services` | list \| path | `[unshackle/services]` | ✅ | Service search paths and/or remote repo specs (see below). |
|
||||
| `vaults` | path | `unshackle/vaults` | ✅ | Vault backend modules. |
|
||||
| `fonts` | path | `unshackle/fonts` | ✅ | Bundled fonts. |
|
||||
| `user_configs` | path | `unshackle/` | ❌ protected | Where `unshackle.yaml` lives. |
|
||||
| `data` | path | `unshackle/` | ❌ protected | Base for the data subdirectories above. |
|
||||
| `core_dir` | path | `unshackle/core` | ❌ protected | Package core. |
|
||||
| `namespace_dir` | path | `unshackle/` | ❌ protected | Package root. |
|
||||
| `app_dirs` | - | `AppDirs("unshackle", False)` | ❌ protected | Internal AppDirs instance. |
|
||||
| `downloads` | path | `<repo>/downloads` | Yes | Default output directory for finished files. |
|
||||
| `temp` | path | `<repo>/temp` | Yes | Temporary working files during download/decrypt/mux. |
|
||||
| `cache` | path | `<data>/cache` | Yes | Generic cache, title cache, and the update-check store. |
|
||||
| `cookies` | path | `<data>/cookies` | Yes | Per-service cookie files (and VPN cookie files). |
|
||||
| `logs` | path | `<data>/logs` | Yes | Log files. |
|
||||
| `exports` | path | `<data>/exports` | Yes | Export JSON files. |
|
||||
| `wvds` | path | `<data>/WVDs` | Yes | Widevine device files (`.wvd`). |
|
||||
| `prds` | path | `<data>/PRDs` | Yes | PlayReady device files (`.prd`). |
|
||||
| `dcsl` | path | `<data>/DCSL` | Yes | DCSL data. |
|
||||
| `commands` | path | `unshackle/commands` | Yes | CLI command modules. |
|
||||
| `services` | list \| path | `[unshackle/services]` | Yes | Service search paths and/or remote repo specs (see below). |
|
||||
| `vaults` | path | `unshackle/vaults` | Yes | Vault backend modules. |
|
||||
| `fonts` | path | `unshackle/fonts` | Yes | Bundled fonts. |
|
||||
| `user_configs` | path | `unshackle/` | No protected | Where `unshackle.yaml` lives. |
|
||||
| `data` | path | `unshackle/` | No protected | Base for the data subdirectories above. |
|
||||
| `core_dir` | path | `unshackle/core` | No protected | Package core. |
|
||||
| `namespace_dir` | path | `unshackle/` | No protected | Package root. |
|
||||
| `app_dirs` | - | `AppDirs("unshackle", False)` | No protected | Internal AppDirs instance. |
|
||||
|
||||
!!! note "The `services` directory is special"
|
||||
`services` may be a **list**, and each entry can be either a local directory or a
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
site_name: unshackle
|
||||
site_description: Movie, TV, and Music archival software — user guide and REST API reference
|
||||
site_description: Movie, TV, and Music archival software. User guide and REST API reference
|
||||
site_url: https://docs.unshackle.dev/
|
||||
repo_url: https://github.com/unshackle-dl/unshackle
|
||||
repo_name: unshackle-dl/unshackle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for SubtitleCodecChoice — notably the ``original`` keep-source sentinel that
|
||||
"""Tests for SubtitleCodecChoice, notably the ``original`` keep-source sentinel that
|
||||
services set via the ``sub_format`` override (must not be rejected as an invalid codec)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ``dl.write_export`` — the ``--export`` JSON sidecar.
|
||||
"""Tests for ``dl.write_export``, the ``--export`` JSON sidecar.
|
||||
|
||||
Regression: DRM-free tracks never pass through ``prepare_drm``, so ``write_export``
|
||||
must accept ``drm=None`` (and DRM systems without ``to_dict``/``content_keys`` such
|
||||
|
||||
@@ -61,7 +61,7 @@ VIDEO_HEVC_SDR_CPD = (
|
||||
)
|
||||
# Real Dolby Vision (dvhe, DoViProfile "stn") CodecPrivateData from a Smooth
|
||||
# manifest: NALs arrive SPS,PPS,VPS (VPS last) and the VUI colour triple is
|
||||
# Unspecified (2,2,2) — DV is signalled by FourCC only, never by CICP.
|
||||
# Unspecified (2,2,2): DV is signalled by FourCC only, never by CICP.
|
||||
VIDEO_HEVC_DV_CPD = (
|
||||
"00000001420101022000000300B00000030000030096A001E020021C4D9457B91CAF016E0404042800001F480002EE0401F4E1"
|
||||
"15EE7E0001312D00002FAF0C80000000014401C1ACBE0EC90000000140010C01FFFF022000000300B00000030000030096"
|
||||
@@ -295,7 +295,7 @@ def test_ec3_without_dolby_guid_builds_bare_entry():
|
||||
|
||||
|
||||
def test_aac_codec_private_data_synthesis_matches_real_manifest():
|
||||
# 48 kHz stereo AAC-LC must produce 0x1190 — the exact ASC real manifests carry.
|
||||
# 48 kHz stereo AAC-LC must produce 0x1190, the exact ASC real manifests carry.
|
||||
assert synthesize_aac_codec_private_data("AACL", 48000, 2).hex() == "1190"
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ``apply_service_dl_overrides`` — precedence: CLI/env > service dl > global dl > defaults."""
|
||||
"""Tests for ``apply_service_dl_overrides``. Precedence: CLI/env > service dl > global dl > defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ def test_changed_services_up_to_date(monkeypatch):
|
||||
|
||||
@pytest.mark.parametrize("order", [["a", "b"], ["b", "a"]])
|
||||
def test_collision_first_source_wins(tmp_path, order):
|
||||
"""Discovery dedupes by tag honoring list order — the FIRST source to define a tag wins,
|
||||
"""Discovery dedupes by tag honoring list order: the FIRST source to define a tag wins,
|
||||
whether it's a local dir or a repo clone; later duplicates are shadowed."""
|
||||
dirs = {}
|
||||
for name in ("a", "b"):
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
Test suite for the unshackle remote-services subsystem:
|
||||
|
||||
- `unshackle/core/remote_service.py` — `RemoteClient`, `RemoteService`, helpers
|
||||
- `unshackle/core/api/` — routes, handlers, session_store, input_bridge,
|
||||
- `unshackle/core/remote_service.py`: `RemoteClient`, `RemoteService`, helpers
|
||||
- `unshackle/core/api/`: routes, handlers, session_store, input_bridge,
|
||||
download_manager, errors, compression
|
||||
- `unshackle/commands/serve.py` — CLI surface
|
||||
- `unshackle/commands/serve.py`: CLI surface
|
||||
|
||||
Two tiers:
|
||||
|
||||
@@ -38,7 +38,7 @@ The suite can either:
|
||||
`--server-url http://host:port`.
|
||||
|
||||
Spawn mode is controlled by `--spawn-serve {auto, always, never}` (default
|
||||
`auto` — spawn only when `--server-url` is empty).
|
||||
`auto` spawns only when `--server-url` is empty).
|
||||
|
||||
### Auto-spawn (recommended)
|
||||
|
||||
@@ -78,7 +78,7 @@ services you have access to. Schema (see the example file for full docs):
|
||||
services:
|
||||
EXAMPLE:
|
||||
title_url: "..." # required
|
||||
series_url: "..." # optional — overrides movie target when set
|
||||
series_url: "..." # optional, overrides movie target when set
|
||||
target_season: 1
|
||||
target_episode: 1
|
||||
search_query: "..."
|
||||
@@ -114,9 +114,9 @@ uv run pytest tests/remote -v --live
|
||||
|
||||
## Markers
|
||||
|
||||
- `unit` — fast, mocked (default)
|
||||
- `live` — needs `--live`
|
||||
- `slow` — hits real services; combine with `--live`
|
||||
- `unit`: fast, mocked (default)
|
||||
- `live`: needs `--live`
|
||||
- `slow`: hits real services; combine with `--live`
|
||||
|
||||
Run only fast unit tests:
|
||||
|
||||
@@ -127,4 +127,4 @@ uv run pytest tests/remote -m "unit and not slow"
|
||||
## Adding a new service to e2e
|
||||
|
||||
1. Add a block under `services:` in your local `fixtures.yaml`.
|
||||
2. No new Python needed — every e2e test is parametrized over the YAML.
|
||||
2. No new Python needed; every e2e test is parametrized over the YAML.
|
||||
|
||||
@@ -8,39 +8,39 @@
|
||||
# unshackle (matches the service directory name, e.g. EXAMPLE). The keys
|
||||
# below are read by the e2e tests:
|
||||
#
|
||||
# title_url (required) — movie URL/slug/ID accepted by the service
|
||||
# series_url (optional) — series URL/slug/ID; when set, the
|
||||
# title_url (required): movie URL/slug/ID accepted by the service
|
||||
# series_url (optional): series URL/slug/ID; when set, the
|
||||
# quality + license + download tests
|
||||
# target target_season/target_episode
|
||||
# instead of the movie
|
||||
# target_season (optional, default 1)
|
||||
# target_episode (optional, default 1)
|
||||
# search_query (optional) — string to send to /api/search
|
||||
# requires_auth (optional, default true) — informational
|
||||
# server_cdm (optional, default false) — informational
|
||||
# drm (optional) — informational ("widevine" / "playready")
|
||||
# search_query (optional): string to send to /api/search
|
||||
# requires_auth (optional, default true): informational
|
||||
# server_cdm (optional, default false): informational
|
||||
# drm (optional): informational ("widevine" / "playready")
|
||||
#
|
||||
# expected_quality (optional) — when present, the quality test
|
||||
# expected_quality (optional): when present, the quality test
|
||||
# asserts these limits against the
|
||||
# discovered video tracks
|
||||
# min_height (optional) — max video.height >= this
|
||||
# min_codecs (optional) — codec names that must be present
|
||||
# min_height (optional): max video.height >= this
|
||||
# min_codecs (optional): codec names that must be present
|
||||
# (AVC, HEVC, VC1, VP8, VP9, AV1)
|
||||
# min_ranges (optional) — range names that must be present
|
||||
# min_ranges (optional): range names that must be present
|
||||
# (SDR, HDR10, HDR10P, DV, HLG, HYBRID)
|
||||
# min_track_count (optional) — at least this many video tracks
|
||||
# min_track_count (optional): at least this many video tracks
|
||||
#
|
||||
# runs_download_test (optional, default false) — when true, the
|
||||
# runs_download_test (optional, default false): when true, the
|
||||
# download smoke test
|
||||
# runs for this service
|
||||
# runs_license_test (optional, default false) — when true, the
|
||||
# runs_license_test (optional, default false): when true, the
|
||||
# license test runs for
|
||||
# this service
|
||||
# license_drm (optional, default "widevine") — DRM type the
|
||||
# license_drm (optional, default "widevine"): DRM type the
|
||||
# license test
|
||||
# requests ("widevine"
|
||||
# or "playready")
|
||||
# license_quality (optional, default 1080) — pixel height of the
|
||||
# license_quality (optional, default 1080): pixel height of the
|
||||
# track the license test
|
||||
# targets
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ For any fixture service with ``runs_download_test: true``, this test:
|
||||
4. Fetches the manifest URL with the resolved headers.
|
||||
5. Asserts the body is non-empty and looks like DASH/HLS.
|
||||
|
||||
It does NOT decrypt or mux — that requires a full local CDM. It proves
|
||||
It does NOT decrypt or mux; that requires a full local CDM. It proves
|
||||
the end-to-end pipeline up to CDN reachability for the selected quality.
|
||||
"""
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def create_session(http_session, server_url: str, service: str, title_id: str) -
|
||||
payload = {
|
||||
"service": service,
|
||||
"title_id": title_id,
|
||||
# Quality knobs — send enum names the server's session_create parser accepts.
|
||||
# Quality knobs: send enum names the server's session_create parser accepts.
|
||||
"range_": ["SDR", "HDR10", "DV"],
|
||||
"vcodec": ["AVC", "HEVC"],
|
||||
"best_available": True,
|
||||
@@ -115,13 +115,13 @@ def test_track_quality_meets_expected(http_session, server_url: str, service_cas
|
||||
tracks = r.json()
|
||||
video = tracks.get("video") or []
|
||||
summary = summarize(video)
|
||||
# Print summary even when test passes — discovery aid.
|
||||
# Print summary even when the test passes, as a discovery aid.
|
||||
with capsys.disabled():
|
||||
print(f"\n[{service}] target={kind} '{target.get('name')}' -> {summary}")
|
||||
|
||||
expected = conf.get("expected_quality") or {}
|
||||
if not expected:
|
||||
pytest.skip(f"{service}: no expected_quality in fixtures.yaml — discovered={summary}")
|
||||
pytest.skip(f"{service}: no expected_quality in fixtures.yaml; discovered={summary}")
|
||||
|
||||
if "min_height" in expected:
|
||||
assert summary["max_height"] >= expected["min_height"], (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit tests for DownloadJob + DownloadQueueManager state machine.
|
||||
|
||||
These tests focus on the queue manager's data layer (create/get/list/cancel/
|
||||
cleanup/serialize) — they do not exercise the actual subprocess download path.
|
||||
cleanup/serialize); they do not exercise the actual subprocess download path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_api_error_default_http_status_per_code() -> None:
|
||||
APIErrorCode.NETWORK_ERROR: 503,
|
||||
APIErrorCode.SERVICE_UNAVAILABLE: 503,
|
||||
}
|
||||
# Lock that the cases above are the *complete* live set — a new/removed code trips this.
|
||||
# Lock that the cases above are the *complete* live set; a new/removed code trips this.
|
||||
assert {c.name for c in cases} == {c.name for c in APIErrorCode}
|
||||
for code, expected in cases.items():
|
||||
assert APIError(code, "x").http_status == expected, code
|
||||
@@ -142,7 +142,7 @@ def test_handle_api_exception_categorizes_generic() -> None:
|
||||
assert body["error_code"] == "NETWORK_ERROR"
|
||||
|
||||
|
||||
# Locks the FULL observable response — (http status, error_code, retryable) — that
|
||||
# Locks the FULL observable response (http status, error_code, retryable) that
|
||||
# a client sees for a generic (non-APIError) exception flowing through the
|
||||
# handle_api_exception funnel that every route's `except Exception` path calls.
|
||||
# This is the equivalence reference: after the categorize_exception collapse,
|
||||
|
||||
@@ -190,5 +190,5 @@ async def test_auth_middleware_accepts_known_key(make_app, aiohttp_client) -> No
|
||||
app["config"]["users"]["good-key"] = {"devices": []}
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/api/session/nonexistent", headers={"X-Secret-Key": "good-key"})
|
||||
# Auth passed; handler then 404s the session — anything other than 401 is fine here.
|
||||
# Auth passed; handler then 404s the session; anything other than 401 is fine here.
|
||||
assert resp.status != 401
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Covers the selection primitives that back `-r ...,DV,HYBRID` downloads:
|
||||
|
||||
- ``Tracks.select_hybrid`` — picks the HDR base layer per resolution and the
|
||||
- ``Tracks.select_hybrid``: picks the HDR base layer per resolution and the
|
||||
single lowest DV track used as a hybrid ingredient.
|
||||
- ``Tracks.merge_video_selections`` — de-duplicates the ingredient/deliverable
|
||||
- ``Tracks.merge_video_selections``: de-duplicates the ingredient/deliverable
|
||||
overlap so a DV track that is chosen as both is not muxed/downloaded twice.
|
||||
- ``Tracks.partition_hybrid_videos`` — splits the ladder into hybrid-ingredient
|
||||
- ``Tracks.partition_hybrid_videos``: splits the ladder into hybrid-ingredient
|
||||
candidates and the standalone-deliverable pool; HDR10/HDR10+/DV only enter
|
||||
the pool when their range was explicitly requested alongside HYBRID.
|
||||
- ``Tracks.flag_hybrid_ingredients`` — marks ingredient-only tracks with
|
||||
- ``Tracks.flag_hybrid_ingredients``: marks ingredient-only tracks with
|
||||
``hybrid_base_only`` so the standalone mux loop skips them.
|
||||
|
||||
The remaining ``dl`` glue (the Cartesian deliverable product and the mux loop)
|
||||
@@ -169,7 +169,7 @@ def flagged(tracks: list[Video]) -> set[str]:
|
||||
|
||||
def test_flag_hybrid_only_flags_base_and_ingredient_dv(ladder: list[Video]) -> None:
|
||||
"""`-r HYBRID`: no deliverables, so the base and the ingredient DV are both
|
||||
skipped by the standalone mux loop — only the hybrid output remains."""
|
||||
skipped by the standalone mux loop; only the hybrid output remains."""
|
||||
hybrid_selected = list(filter(Tracks().select_hybrid(ladder, [1080]), ladder))
|
||||
Tracks.flag_hybrid_ingredients(hybrid_selected, [])
|
||||
assert flagged(ladder) == {"hdr10p-1080", "dv-360"}
|
||||
@@ -177,7 +177,7 @@ def test_flag_hybrid_only_flags_base_and_ingredient_dv(ladder: list[Video]) -> N
|
||||
|
||||
def test_flag_hybrid_plus_hdr10p_keeps_base_deliverable(ladder: list[Video]) -> None:
|
||||
"""`-r HYBRID,HDR10P`: the base is also an explicit deliverable, only the
|
||||
ingredient DV is skipped — hybrid + standalone HDR10+ are muxed."""
|
||||
ingredient DV is skipped; hybrid + standalone HDR10+ are muxed."""
|
||||
hybrid_selected = list(filter(Tracks().select_hybrid(ladder, [1080]), ladder))
|
||||
base = next(t for t in ladder if t.id == "hdr10p-1080")
|
||||
Tracks.flag_hybrid_ingredients(hybrid_selected, [base])
|
||||
@@ -186,7 +186,7 @@ def test_flag_hybrid_plus_hdr10p_keeps_base_deliverable(ladder: list[Video]) ->
|
||||
|
||||
def test_flag_hybrid_plus_hdr10p_and_dv_keeps_both_deliverables(ladder: list[Video]) -> None:
|
||||
"""`-r HYBRID,HDR10P,DV`: best DV is a deliverable, lowest DV stays
|
||||
ingredient-only — hybrid + HDR10+ + DV are muxed."""
|
||||
ingredient-only; hybrid + HDR10+ + DV are muxed."""
|
||||
hybrid_selected = list(filter(Tracks().select_hybrid(ladder, [1080]), ladder))
|
||||
base = next(t for t in ladder if t.id == "hdr10p-1080")
|
||||
best_dv = next(t for t in ladder if t.id == "dv-1080")
|
||||
|
||||
@@ -23,7 +23,7 @@ from unshackle.core.utilities import close_debug_logger, init_debug_logger
|
||||
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
||||
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.")
|
||||
def main(version: bool, debug: bool) -> None:
|
||||
"""unshackle—Modular Movie, TV, and Music Archival Software."""
|
||||
"""unshackle: Modular Movie, TV, and Music Archival Software."""
|
||||
debug_logging_enabled = debug or config.debug
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -80,7 +80,7 @@ def main(version: bool, debug: bool) -> None:
|
||||
latest_version = UpdateChecker.check_for_updates_sync(__version__)
|
||||
if latest_version:
|
||||
console.print(
|
||||
f"\n[yellow]⚠️ Update available![/yellow] "
|
||||
f"\n[yellow]Update available![/yellow] "
|
||||
f"Current: {__version__} → Latest: [green]{latest_version}[/green]",
|
||||
justify="center",
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Instantiates a CDM object (local or remote) given a resolved device name.
|
||||
Name resolution (quality-based, profile-based, DRM-type) is the caller's
|
||||
responsibility — this module only handles the instantiation step.
|
||||
responsibility; this module only handles the instantiation step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -640,7 +640,7 @@ def download(
|
||||
RnetSession preserves TLS fingerprinting for services that need it.
|
||||
segmented: If downloads are segments or parts of one bigger file.
|
||||
part_offset: Byte offset to write at within a pre-allocated file. When set
|
||||
(with `part_end`), enables part mode for parallel ranged downloads —
|
||||
(with `part_end`), enables part mode for parallel ranged downloads:
|
||||
no truncate, no skip-if-exists, no control file; emits only `advance`
|
||||
events; retries resume mid-part via Range.
|
||||
part_end: Inclusive end byte of the part. Required when `part_offset` is set.
|
||||
|
||||
@@ -872,7 +872,6 @@ def describe(code: int) -> str | None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: one check, signed and unsigned forms resolve to the same entry
|
||||
assert describe(0x8004C065) == describe(-2147172251)
|
||||
assert "DEVCERT_REVOKED" in describe(-2147172251)
|
||||
assert describe(-1021) is None # non-DRM_E service code
|
||||
|
||||
@@ -1177,7 +1177,7 @@ class DASH:
|
||||
drm.append(PlayReady(pssh=pr_pssh, kid=kid, pssh_b64=pr_pssh_b64))
|
||||
|
||||
elif urn == ClearKeyCENC.urn:
|
||||
# W3C EME ClearKey (org.w3.clearkey) — match the scheme UUID alone,
|
||||
# W3C EME ClearKey (org.w3.clearkey): match the scheme UUID alone,
|
||||
# value="ClearKey1.0" is spec'd (DASH-IF CCP) but not required in the wild
|
||||
kid_attr = protection.get("default_KID") or protection.get("{urn:mpeg:cenc:2013}default_KID")
|
||||
kid = None
|
||||
|
||||
@@ -355,7 +355,7 @@ class HLS:
|
||||
|
||||
seg_uri = urljoin(variant_url, variant.segments[0].uri)
|
||||
|
||||
# Download only the first 8KB — SPS is always near the start of the first TS packet
|
||||
# Download only the first 8KB: SPS is always near the start of the first TS packet
|
||||
res = session.get(seg_uri, headers={"Range": "bytes=0-8191"})
|
||||
data = res.content
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ class ISM:
|
||||
iv_size=iv_size,
|
||||
)
|
||||
except (NotImplementedError, ValueError, struct.error) as e:
|
||||
# Unsupported codec, malformed CodecPrivateData or out-of-range field —
|
||||
# Unsupported codec, malformed CodecPrivateData or out-of-range field:
|
||||
# fall back to raw concatenation rather than aborting the download.
|
||||
log_event(
|
||||
"manifest_ism_init_unsupported",
|
||||
|
||||
@@ -46,7 +46,7 @@ TRACK_IN_MOVIE = 0x2
|
||||
TRACK_IN_PREVIEW = 0x4
|
||||
SELF_CONTAINED = 0x1
|
||||
|
||||
# Fixed creation/modification time — deterministic output (no wall clock).
|
||||
# Fixed creation/modification time for deterministic output (no wall clock).
|
||||
EPOCH = 0
|
||||
|
||||
NAL_START_CODE = b"\x00\x00\x00\x01"
|
||||
@@ -96,7 +96,7 @@ def split_nal_units(codec_private_data: bytes) -> list[bytes]:
|
||||
def remove_emulation_prevention(data: bytes) -> bytes:
|
||||
"""Strip H.26x emulation-prevention bytes (the 0x03 in any 00 00 03 run).
|
||||
|
||||
The byte after a consumed escape is data — even another 0x03 — so the scan
|
||||
The byte after a consumed escape is data, even another 0x03, so the scan
|
||||
must skip past it rather than re-examine (a naive trailing-window check
|
||||
over-strips consecutive escapes and shifts every later bit position).
|
||||
"""
|
||||
@@ -611,10 +611,10 @@ def build_dec3(codec_private_data: bytes) -> Optional[bytes]:
|
||||
"""Build a dec3 (EC-3 specific) box from Smooth EC-3 CodecPrivateData.
|
||||
|
||||
Smooth EC-3 CodecPrivateData ([MS-SSTR] AudioTag 65534) serializes a
|
||||
WAVEFORMATEXTENSIBLE — sometimes the full structure, sometimes only its
|
||||
extension (samples-per-block + channel mask + DD+ SubFormat GUID) — with
|
||||
WAVEFORMATEXTENSIBLE, sometimes the full structure, sometimes only its
|
||||
extension (samples-per-block + channel mask + DD+ SubFormat GUID), with
|
||||
the raw dec3 payload (ETSI TS 102 366 F.6) after the GUID. Returns None
|
||||
when the GUID is absent — decoders still sync from EC-3 frames in mdat.
|
||||
when the GUID is absent; decoders still sync from EC-3 frames in mdat.
|
||||
"""
|
||||
guid_at = codec_private_data.find(DOLBY_DIGITAL_PLUS_GUID)
|
||||
if guid_at != -1 and len(codec_private_data) > guid_at + 16:
|
||||
|
||||
@@ -431,7 +431,7 @@ def validate_enrichments(
|
||||
tmdb_votes[provider_name] = ext.tmdb_id
|
||||
|
||||
if len(set(tmdb_votes.values())) <= 1:
|
||||
return enrichments # all agree or only one voted — no conflict
|
||||
return enrichments # all agree or only one voted, no conflict
|
||||
|
||||
value_counts = Counter(tmdb_votes.values())
|
||||
most_common_val, most_common_count = value_counts.most_common(1)[0]
|
||||
@@ -439,7 +439,7 @@ def validate_enrichments(
|
||||
if most_common_count > 1:
|
||||
anchor_tmdb_id = most_common_val
|
||||
else:
|
||||
# No majority — pick the most authoritative provider
|
||||
# No majority, so pick the most authoritative provider
|
||||
best_provider = min(
|
||||
tmdb_votes.keys(),
|
||||
key=lambda name: authority.get(name, 99),
|
||||
|
||||
@@ -206,7 +206,7 @@ class TVDBProvider(MetadataProvider):
|
||||
return cached
|
||||
|
||||
episodes: list[dict] = []
|
||||
for page in range(20): # ponytail: hard page cap, 500 eps/page covers any real series
|
||||
for page in range(20):
|
||||
data = self.api_get(f"/series/{tvdb_id}/episodes/{order}", {"page": page})
|
||||
if data is None and page:
|
||||
# a partial listing would renumber episodes wrongly
|
||||
|
||||
@@ -24,7 +24,7 @@ class ProtonVPN(Proxy):
|
||||
Proton VPN HTTPS proxy provider.
|
||||
|
||||
Authenticates via TV login (a self-sustaining session cached at <cache>/vpn/protonvpn.json, the
|
||||
only refreshable kind) or an exported account.proton.me cookie session (AUTH-<UID>, access only —
|
||||
only refreshable kind) or an exported account.proton.me cookie session (AUTH-<UID>, access only,
|
||||
re-export on expiry). Resolves a server by country/city, mints short-lived proxy credentials, and
|
||||
returns an authenticated HTTPS proxy URL (https://user:pass@server:4443).
|
||||
|
||||
|
||||
@@ -736,7 +736,7 @@ class RemoteService:
|
||||
def create_drm_stub(drm_type: str, kid_hexes: list[str]) -> Any:
|
||||
"""Create a DRM object stub matching the type the server actually used.
|
||||
|
||||
For server_cdm mode, this is only used for display — keys are already
|
||||
For server_cdm mode, this is only used for display; keys are already
|
||||
resolved. We build a minimal DRM object that holds content_keys.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
@@ -95,12 +95,12 @@ def resolve_service_repo(spec: str, *, ttl: int = DEFAULT_TTL, force: bool = Fal
|
||||
force_sync(dest, stamp)
|
||||
elif is_stale(stamp, ttl):
|
||||
pull(dest, stamp)
|
||||
return dest # ponytail: git IS the cache layer — no bespoke fetch/integrity code
|
||||
return dest
|
||||
|
||||
|
||||
def clone(url: str, branch: Optional[str], dest: Path, stamp: Path) -> Optional[Path]:
|
||||
if not binaries.Git:
|
||||
log.error("git not found on PATH — cannot fetch service repo %s", url)
|
||||
log.error("git not found on PATH; cannot fetch service repo %s", url)
|
||||
return None
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
args = [str(binaries.Git), "clone", "--depth", "1"]
|
||||
@@ -122,7 +122,7 @@ def pull(dest: Path, stamp: Path) -> None:
|
||||
if not binaries.Git:
|
||||
write_stamp(stamp)
|
||||
return
|
||||
# never clobber local edits — refuse to refresh a dirty clone and let the caller exit cleanly
|
||||
# never clobber local edits: refuse to refresh a dirty clone and let the caller exit cleanly
|
||||
if is_dirty(dest):
|
||||
raise DirtyServiceRepo(dest)
|
||||
try:
|
||||
|
||||
@@ -755,11 +755,11 @@ class RnetSession:
|
||||
return self.request(method, url, **send_kwargs)
|
||||
|
||||
def mount(self, prefix: str, adapter: Any) -> None:
|
||||
"""No-op — rnet handles TLS and connection pooling natively."""
|
||||
"""No-op; rnet handles TLS and connection pooling natively."""
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""No-op — rnet manages its own resources."""
|
||||
"""No-op; rnet manages its own resources."""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ hvc1, with DV advertised only via SUPPLEMENTAL-CODECS. The fMP4 carries valid DV
|
||||
but the container does not signal DV, so muxing the MP4 directly produces an MKV that
|
||||
mediainfo and DV-capable TVs see as plain HDR10/HDR10+.
|
||||
|
||||
The RPU is already valid — only the container's DV signaling is lost. Demuxing the
|
||||
The RPU is already valid; only the container's DV signaling is lost. Demuxing the
|
||||
elementary HEVC stream (ffmpeg -c:v copy) exposes the in-stream RPU to mkvmerge, which then
|
||||
signals DV in the muxed MKV. No dovi_tool extract/inject round-trip is needed.
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ requested pair and orders them by rank; ``run_conversion`` tries each in turn (a
|
||||
fallback chain) until one succeeds.
|
||||
|
||||
The public entry point stays ``Subtitle.convert`` / ``Subtitle.strip_hearing_impaired`` in
|
||||
subtitle.py — this module only holds the selection + conversion logic so subtitle.py keeps
|
||||
subtitle.py; this module only holds the selection + conversion logic so subtitle.py keeps
|
||||
the codec enum, ``parse``, sanitizers and cue helpers (the collaborators backends reuse).
|
||||
"""
|
||||
|
||||
@@ -113,7 +113,7 @@ def subtitleedit_args(
|
||||
|
||||
|
||||
# Styled SubStation formats flattened to SRT lose positioning/colours/italics.
|
||||
# Never performed automatically — only when the user explicitly forces a target format.
|
||||
# Never performed automatically, only when the user explicitly forces a target format.
|
||||
LOSSY_DOWNCONVERTS: frozenset[tuple[Codec, Codec]] = frozenset(
|
||||
{
|
||||
(Codec.SubStationAlpha, Codec.SubRip),
|
||||
@@ -188,7 +188,7 @@ class SubtitleEditBackend:
|
||||
|
||||
|
||||
class Pysubs2Backend:
|
||||
"""pysubs2 — pure Python, broad format support, best fidelity for SSA/ASS (native style model)."""
|
||||
"""pysubs2: pure Python, broad format support, best fidelity for SSA/ASS (native style model)."""
|
||||
|
||||
name = "pysubs2"
|
||||
formats = frozenset(PYSUBS2_FORMATS)
|
||||
@@ -209,7 +209,7 @@ class Pysubs2Backend:
|
||||
|
||||
|
||||
class SubbyBackend:
|
||||
"""subby — purpose-built for streaming subs. WebVTT/fVTT/SAMI -> SRT + CommonIssuesFixer cleanup."""
|
||||
"""subby: purpose-built for streaming subs. WebVTT/fVTT/SAMI -> SRT + CommonIssuesFixer cleanup."""
|
||||
|
||||
name = "subby"
|
||||
reads = frozenset({Codec.WebVTT, Codec.fVTT, Codec.SAMI})
|
||||
@@ -248,7 +248,7 @@ class SubbyBackend:
|
||||
|
||||
|
||||
class PycaptionBackend:
|
||||
"""pycaption — last resort. Note: flattens positioning/italics (devine #39), so ranked last."""
|
||||
"""pycaption: last resort. Flattens positioning/italics (devine #39), so ranked last."""
|
||||
|
||||
name = "pycaption"
|
||||
reads = frozenset({Codec.SubRip, Codec.TimedTextMarkupLang, Codec.WebVTT, Codec.SAMI, Codec.fTTML, Codec.fVTT})
|
||||
|
||||
@@ -667,7 +667,7 @@ class Track:
|
||||
def base_kwargs_from_dict(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the shared Track constructor kwargs from a ``to_dict()`` payload.
|
||||
|
||||
DRM is not reconstructed here — ``to_dict`` does not serialise it, and the import
|
||||
DRM is not reconstructed here: ``to_dict`` does not serialise it, and the import
|
||||
flow attaches the licensed DRM + content keys separately.
|
||||
"""
|
||||
return {
|
||||
|
||||
@@ -433,7 +433,7 @@ class Video(Track):
|
||||
if not vui:
|
||||
return None
|
||||
|
||||
# Skip the rewrite when the bitstream VUI already matches the target — avoids a full
|
||||
# Skip the rewrite when the bitstream VUI already matches the target, which avoids a full
|
||||
# bitstream pass on services that already ship correct colour metadata
|
||||
expected = {
|
||||
Video.Range.HDR10: ("bt2020", "smpte2084", "bt2020nc"),
|
||||
@@ -602,7 +602,7 @@ class Video(Track):
|
||||
does it actually call ccextractor.
|
||||
|
||||
Even though there is a possibility of more than one c608 box, only one
|
||||
can actually be extracted. Not only that but it's very possible this
|
||||
can actually be extracted. It is also very possible this
|
||||
needs to be done before any decryption as the decryption may destroy
|
||||
some of the metadata.
|
||||
|
||||
|
||||
@@ -1162,7 +1162,7 @@ def get_debug_logger() -> Optional[DebugLogger]:
|
||||
def log_event(operation: str, *, level: str = "DEBUG", message: str = "", **kwargs: Any) -> None:
|
||||
"""Emit a single structured debug-log entry. No-op when debug logging is disabled.
|
||||
|
||||
The canonical one-shot logging primitive — replaces the
|
||||
The canonical one-shot logging primitive. Replaces the
|
||||
``if dl := get_debug_logger(): dl.log(...)`` guard boilerplate. To add logging to a new
|
||||
feature, call ``log_event("my_feature_event", message="...", context={...})``.
|
||||
"""
|
||||
|
||||
@@ -59,7 +59,7 @@ def measure_real_bitrate(
|
||||
Single-file tracks are measured exactly. Segmented tracks probe up to
|
||||
``samples`` segments spread across the track and extrapolate; byte-range
|
||||
segments need no request. Returns bits/sec, or ``None`` if it cannot be
|
||||
measured. Never raises — a probe failure must not abort a download.
|
||||
measured. Never raises; a probe failure must not abort a download.
|
||||
"""
|
||||
from unshackle.core.tracks.track import Track
|
||||
|
||||
@@ -74,7 +74,7 @@ def measure_real_bitrate(
|
||||
# Descriptor.URL: a single file. Some services parse a DASH
|
||||
# manifest then collapse each representation to its single BaseURL and
|
||||
# flip the descriptor to URL, leaving the manifest (and its duration) in
|
||||
# track.data — recover the duration from there, else probe the file.
|
||||
# track.data: recover the duration from there, else probe the file.
|
||||
segments = extract_url(track, session, log=log)
|
||||
if not segments:
|
||||
log.debug(f"{track.id}: cannot measure real bitrate (no known duration)")
|
||||
@@ -130,7 +130,7 @@ def apply_real_bitrates(
|
||||
declared-bitrate tracks per group are probed, in parallel. Each group is then
|
||||
extended downward: while the lowest probed bitrate in a group sits below the
|
||||
next unprobed track's declared bitrate (so that track could outrank a probed
|
||||
one), the next track is probed too — until the probed set is safely above the
|
||||
one), the next track is probed too, until the probed set is safely above the
|
||||
rest. Unprobed tracks keep their manifest-declared bitrate.
|
||||
"""
|
||||
groups: defaultdict[Hashable, list["Track"]] = defaultdict(list)
|
||||
@@ -182,7 +182,7 @@ def dedupe(segments: list[Segment]) -> list[Segment]:
|
||||
Collapse segments that address the same bytes so each object is measured once.
|
||||
|
||||
Manifests sometimes wrap a single file in several segment entries sharing one
|
||||
URL — with no byte range (a ``SegmentTemplate`` whose media pattern has no
|
||||
URL, with no byte range (a ``SegmentTemplate`` whose media pattern has no
|
||||
``$Number$``) or with the same range. Each resolves to the whole file, so
|
||||
counting them all would multiply the size by the segment count. Segments
|
||||
sharing the same ``(url, byte_range)`` are merged into one entry whose duration
|
||||
|
||||
@@ -97,7 +97,7 @@ def lookup_session(source: Optional[requests.Session]) -> requests.Session:
|
||||
Build a plain, retry-free requests session for IP geolocation.
|
||||
|
||||
Geolocation needs no TLS fingerprinting, so we skip the impersonated rnet
|
||||
session and the base session's urllib3 retry loop — both retry 429 internally,
|
||||
session and the base session's urllib3 retry loop. Both retry 429 internally,
|
||||
which hides the response and defeats fast provider handover. With a bare session
|
||||
a 429 comes straight back so we can move to the next provider immediately. Only
|
||||
the proxy is carried over so proxied lookups still report the proxy's exit IP.
|
||||
@@ -139,7 +139,7 @@ def fetch_ipinfo(session: requests.Session) -> Optional[dict]:
|
||||
|
||||
|
||||
def fetch_ip_api_in(session: requests.Session) -> Optional[dict]:
|
||||
"""ip-api.in has no /me endpoint — resolve IP via ipify first, then look it up."""
|
||||
"""ip-api.in has no /me endpoint, so resolve IP via ipify first, then look it up."""
|
||||
ip_resp = session.get("https://api.ipify.org", timeout=REQUEST_TIMEOUT)
|
||||
if ip_resp.status_code == 429:
|
||||
raise RateLimited()
|
||||
@@ -198,7 +198,7 @@ def get_ip_info(
|
||||
returned info reflects the proxy's exit IP. Auth headers for ipinfo
|
||||
are sent per-request; never mutated onto session.headers.
|
||||
cached: When True, read/write a 24h Cacher-backed entry. Use only for
|
||||
local IP lookups — never with a proxied session.
|
||||
local IP lookups, never with a proxied session.
|
||||
"""
|
||||
cache = None
|
||||
if cached:
|
||||
|
||||
@@ -75,7 +75,7 @@ class MUSIC_EXAMPLE(Service):
|
||||
# GEOFENCE: regions required; the framework warns/blocks if proxy region mismatches.
|
||||
GEOFENCE = ("US", "UK")
|
||||
# TITLE_RE: named groups (?P<...>) parsed in get_titles(). Accepts URL or bare id.
|
||||
# A music catalogue is addressed by several kinds of thing, not just one, so `kind`
|
||||
# A music catalogue is addressed by several kinds of thing, so `kind`
|
||||
# is part of the identifier and decides which endpoint get_titles() asks.
|
||||
TITLE_RE = (
|
||||
r"^(?:https?://(?:www\.)?musicdomain\.com/)?(?P<kind>track|album|playlist|artist)/(?P<content_id>[^/?#]+)"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# This is a MINIMAL starter. Every option has a sane default, so unshackle runs
|
||||
# even with most of this file empty or removed.
|
||||
#
|
||||
# The full, canonical configuration reference lives in docs/ — start at
|
||||
# The full, canonical configuration reference lives in docs/. Start at
|
||||
# docs/README.md. Each section below points to the doc that documents it.
|
||||
|
||||
# Group or username appended to download filenames after a dash.
|
||||
|
||||
Reference in New Issue
Block a user