style: clean up prose

This commit is contained in:
imSp4rky
2026-08-19 13:45:58 -06:00
parent 54adb82746
commit 29d0828c33
40 changed files with 109 additions and 110 deletions
+2 -2
View File
@@ -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**
+2 -2
View File
@@ -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.
+18 -18
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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"):
+9 -9
View File
@@ -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.
+15 -15
View File
@@ -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
+1 -1
View File
@@ -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.
"""
+3 -3
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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")
+2 -2
View File
@@ -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",
)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
-1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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:
+2 -2
View File
@@ -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),
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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:
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
"""
+5 -5
View File
@@ -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})
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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={...})``.
"""
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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:
+1 -1
View File
@@ -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>[^/?#]+)"
+1 -1
View File
@@ -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.