fix(core): cut user-facing errors and warnings to one line

This commit is contained in:
imSp4rky
2026-10-23 20:23:05 -06:00
parent dfc99783b5
commit 83514d12fe
45 changed files with 210 additions and 415 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ This value becomes the one and only API key that the server accepts. The server
If you start `unshackle serve` with no `api_secret` configured, and you have *not* passed `--no-key`, the command fails immediately with:
```
API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.
No 'api_secret' in the 'serve' section of your config
```
Choose a long, unpredictable value. The API key is the only thing standing between the internet and your CDM and download endpoints.
+1 -1
View File
@@ -668,7 +668,7 @@ remote session API and a good template for any client.
## Troubleshooting
!!! example "\"Could not connect to remote server ... Is it running?\""
!!! example "\"Could not connect to remote server at ...\""
The client could not reach the URL. Make sure that `unshackle serve` operates on
the server, the `url`/port in `remote_services` are correct (default port `8786`),
and any firewall or reverse proxy allows the connection.
+2 -2
View File
@@ -559,8 +559,7 @@ in unshackle itself.
!!! warning "Docker is required"
Gluetun needs a working [Docker](https://docs.docker.com/get-docker/) installation on
your `PATH`. If Docker is not found, the proxy provider fails to load with an error linking
to the install docs. The container also needs the `NET_ADMIN` capability and access
your `PATH`. If Docker is not found, the proxy provider fails to load. The container also needs the `NET_ADMIN` capability and access
to `/dev/net/tun` to bring up the tunnel. unshackle requests both automatically.
### Query format
@@ -643,6 +642,7 @@ proxy_providers:
- `surfshark`, `mullvad`, and `ivpn` also require `addresses`.
- `windscribe` additionally requires **both** `addresses` and `preshared_key`
(`preshared_key` may be an empty string, but the config key must be present).
Get both from the [Windscribe WireGuard config generator](https://windscribe.com/getconfig/wireguard).
- NordVPN and Proton VPN need only `private_key`.
=== "OpenVPN"
+8 -27
View File
@@ -652,19 +652,14 @@ def video(
def test_hybrid_fails_with_neither_a_base_nor_dv() -> None:
assert hybrid_gate_failure([video("sdr", Video.Range.SDR)], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
"Available ranges: SDR",
)
assert hybrid_gate_failure([], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
"Available ranges: none",
"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: SDR"
)
assert hybrid_gate_failure([], True) == "HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: none"
def test_hybrid_fails_with_dv_only() -> None:
assert hybrid_gate_failure([video("dv", Video.Range.DV)], True) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available",
"Available ranges: DV",
"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: DV"
)
@@ -681,27 +676,16 @@ def test_hybrid_goes_on_with_a_base_and_dv_whatever_the_flag() -> None:
def test_hybrid_fails_up_front_when_no_base_is_hevc() -> None:
"""dovi_tool injects into HEVC, so an all-AV1 title fails before it downloads anything."""
videos = [video("av1", Video.Range.HDR10P, codec=Video.Codec.AV1), video("dv", Video.Range.DV)]
assert hybrid_gate_failure(videos, False) == (
"HYBRID mode requires an HEVC HDR10/HDR10+ base, but none is available",
"Available HDR10/HDR10+ codecs: AV1",
)
assert hybrid_gate_failure(videos, False) == "HYBRID needs an HEVC HDR10/HDR10+ base, available codecs: AV1"
assert hybrid_gate_failure(videos + [video("h", Video.Range.HDR10, codec=None)], False) is None
@pytest.mark.parametrize(
"ranges, to_dv, detail",
[
(
[Video.Range.HDR10P],
False,
"Available ranges: HDR10P. Add --generate-dv to generate Dolby Vision from HDR10+.",
),
([Video.Range.HDR10], False, "Available ranges: HDR10"),
(
[Video.Range.HDR10],
True,
"Available ranges: HDR10. --generate-dv needs an HEVC HDR10+ track, and the service returned none.",
),
([Video.Range.HDR10P], False, "HDR10P; add --generate-dv to make DV from HDR10+"),
([Video.Range.HDR10], False, "HDR10"),
([Video.Range.HDR10], True, "HDR10; --generate-dv found no HEVC HDR10+ track"),
],
)
def test_hybrid_fails_without_dv_and_hints_at_the_flag_only_when_it_is_off(
@@ -709,10 +693,7 @@ def test_hybrid_fails_without_dv_and_hints_at_the_flag_only_when_it_is_off(
) -> None:
"""A service that fetches HYBRID by hand may never return HDR10+; the hint must not blame the user then."""
videos = [video(r.name, r) for r in ranges]
assert hybrid_gate_failure(videos, to_dv) == (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available",
detail,
)
assert hybrid_gate_failure(videos, to_dv) == f"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: {detail}"
@pytest.mark.parametrize("to_dv", [False, True])
+1 -1
View File
@@ -150,4 +150,4 @@ def test_a_download_stopped_by_a_click_exception_reports_only_its_message() -> N
lines = dl.failure_lines(error)
assert lines == [":x: Download Failed...", f" {error.format_message()}"]
assert "unexpected error" in dl.failure_lines(ValueError("boom"))[-1]
assert dl.failure_lines(ValueError("boom")) == [":x: Download Failed...", " ValueError: boom"]
+2 -2
View File
@@ -115,10 +115,10 @@ def test_other_audio_still_muxes_with_mkvmerge(tmp_path: Path, monkeypatch: pyte
tracks.mux("Title", delete=False, output_path=tmp_path / "out.muxed.mkv")
def test_a_missing_mp4box_names_the_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_a_missing_mp4box_names_the_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(binaries, "MP4Box", None, raising=False)
tracks = build_tracks(tmp_path, b"dtsx")
with pytest.raises(RuntimeError, match="gpac"):
with pytest.raises(RuntimeError, match="MP4Box"):
tracks.mux("Title", delete=False, output_path=tmp_path / "out.muxed.mkv")
+1 -3
View File
@@ -115,9 +115,7 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
(Exception("Not available in your region"), APIErrorCode.GEOFENCE),
(Exception("Service is geoblocked in GB"), APIErrorCode.GEOFENCE),
(
Exception(
"Service is not available in your region (GB). Pass --proxy with a proxy outside the blocked regions."
),
Exception("Service is not available in your region (GB)"),
APIErrorCode.GEOFENCE,
),
(Exception("Title not found"), APIErrorCode.NOT_FOUND),
+42 -81
View File
@@ -181,10 +181,7 @@ def validate_metadata_ids(
(flag, value) for flag, value in (("--tmdb", tmdb_id), ("--imdb", imdb_id), ("--tvdb", tvdb_id)) if value
]
if len(supplied) > 1:
raise click.UsageError(
f"{', '.join(flag for flag, _ in supplied)} cannot be used together. "
"Give one ID and unshackle resolves the others from it."
)
raise click.UsageError(f"{', '.join(flag for flag, _ in supplied)} cannot be used together; give one ID")
if not supplied:
return
@@ -197,9 +194,7 @@ def validate_metadata_ids(
if consumers:
keys = sorted({ID_PROVIDER_KEYS[cls.NAME] for cls in consumers if cls.NAME in ID_PROVIDER_KEYS})
raise click.UsageError(
f"{flag} needs the {consumers[0].NAME} provider. Set {' or '.join(keys)} in your config."
)
raise click.UsageError(f"{flag} needs the {consumers[0].NAME} provider: set {' or '.join(keys)} in your config")
names = sorted({cls.NAME for cls in providers.REGISTRY.values() if cls.ID_KIND == id_kind})
raise click.UsageError(
f"{flag} needs the {' or '.join(names)} provider, which your metadata_providers config leaves out."
@@ -241,39 +236,28 @@ def select_hybrid_bases(videos: Sequence[Video]) -> list[Video]:
return list(bases.values())
def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[tuple[str, str]]:
"""The (message, detail) that stops HYBRID for these selected videos, or None when it can go on."""
def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[str]:
"""The message that stops HYBRID for these selected videos, or None when it can go on."""
if any(v.native_hybrid for v in videos):
return None
hdr_tracks = [v for v in videos if v.range in (Video.Range.HDR10, Video.Range.HDR10P)]
base_tracks = [v for v in hdr_tracks if v.codec in (Video.Codec.HEVC, None)]
dv_tracks = [v for v in videos if v.range == Video.Range.DV]
has_hdr10p = any(v.range == Video.Range.HDR10P for v in base_tracks)
available_ranges = sorted(set(v.range.name for v in videos))
ranges = ", ".join(sorted(set(v.range.name for v in videos))) or "none"
missing = f"HYBRID needs HDR10/HDR10+ and DV tracks, available ranges: {ranges}"
if not hdr_tracks and not dv_tracks:
return (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but neither is available",
f"Available ranges: {', '.join(available_ranges) if available_ranges else 'none'}",
)
if not hdr_tracks:
return (
"HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only DV is available",
f"Available ranges: {', '.join(available_ranges)}",
)
return missing
if not base_tracks:
codecs = sorted(set(v.codec.name for v in hdr_tracks if v.codec))
return (
"HYBRID mode requires an HEVC HDR10/HDR10+ base, but none is available",
f"Available HDR10/HDR10+ codecs: {', '.join(codecs)}",
)
return f"HYBRID needs an HEVC HDR10/HDR10+ base, available codecs: {', '.join(codecs)}"
if not dv_tracks and not (generate_dv and has_hdr10p):
msg_detail = f"Available ranges: {', '.join(available_ranges)}"
if generate_dv:
msg_detail += ". --generate-dv needs an HEVC HDR10+ track, and the service returned none."
elif has_hdr10p:
msg_detail += ". Add --generate-dv to generate Dolby Vision from HDR10+."
return "HYBRID mode requires both HDR10/HDR10+ and DV tracks, but only HDR10 is available", msg_detail
return f"{missing}; --generate-dv found no HEVC HDR10+ track"
if has_hdr10p:
return f"{missing}; add --generate-dv to make DV from HDR10+"
return missing
return None
@@ -491,10 +475,7 @@ def apply_service_dl_overrides(ctx: click.Context, service_dl_config: dict[str,
ctx.params[name] = param.type_cast_value(ctx, value)
log.debug(f"Applied service dl override '{name}': {ctx.params[name]}")
except Exception as e:
log.warning(
f"Failed to apply service dl override '{name}'={value!r}: {e}. "
f"Check that the value is valid for this parameter."
)
log.warning(f"Failed to apply service dl override '{name}'={value!r}: {e}")
def download_tracks_in_passes(
@@ -1288,11 +1269,7 @@ class dl:
self.download_error: Optional[BaseException] = None
if not config.output_template:
raise click.ClickException(
"No 'output_template' configured in your unshackle.yaml.\n"
"Please add an 'output_template' section with movies, series, and songs/music templates.\n"
"See unshackle-example.yaml for examples."
)
raise click.ClickException("No 'output_template' in your unshackle.yaml")
self.service = Services.get_tag(ctx.invoked_subcommand)
self.vault_service = Services.get_vault_tag(self.service)
@@ -1335,7 +1312,7 @@ class dl:
)
if self.daily and not self.enrich:
self.log.warning("--daily fills air dates from TVDB only with --enrich. A service can set them itself.")
self.log.warning("--daily takes TVDB air dates only with --enrich")
# Initialize debug logger with service name if debug logging is enabled
if config.debug or logging.root.level == logging.DEBUG:
@@ -1800,7 +1777,7 @@ class dl:
("-fsl/--forced-s-lang", fsl_excl),
):
if any(token.lower() == "all" for token in excludes):
self.log.error(f"{flag}: 'all' cannot be excluded. Name the languages to exclude instead.")
self.log.error(f"{flag}: 'all' cannot be excluded")
sys.exit(1)
# exclusion-only use falls back to the flag's own default; -vl/-al have none, they cascade to -l
if lang_excl and not lang:
@@ -1910,7 +1887,7 @@ class dl:
if vbitrate_min > vbitrate_max:
vbitrate_min, vbitrate_max = vbitrate_max, vbitrate_min
except (ValueError, IndexError):
self.log.error("Invalid --vbitrate-range format. Use 'MIN-MAX' (e.g., '6000-7000').")
self.log.error("Invalid --vbitrate-range: expected MIN-MAX, such as 6000-7000")
sys.exit(1)
abitrate_min, abitrate_max = None, None
@@ -1926,7 +1903,7 @@ class dl:
if abitrate_min > abitrate_max:
abitrate_min, abitrate_max = abitrate_max, abitrate_min
except (ValueError, IndexError):
self.log.error("Invalid --abitrate-range format. Use 'MIN-MAX' (e.g., '128-256').")
self.log.error("Invalid --abitrate-range: expected MIN-MAX, such as 128-256")
sys.exit(1)
if not acodec:
@@ -1950,15 +1927,13 @@ class dl:
from unshackle.core.binaries import DoviTool
if not DoviTool:
self.log.error("Unable to run hybrid mode: dovi_tool not detected")
self.log.error("Please install dovi_tool from https://github.com/quietvoid/dovi_tool")
self.log.error("Unable to run hybrid mode: dovi_tool not found")
sys.exit(1)
if generate_dv:
from unshackle.core.binaries import HDR10PlusTool
if not HDR10PlusTool:
self.log.error("Unable to run --generate-dv: hdr10plus_tool not detected")
self.log.error("Please install hdr10plus_tool from https://github.com/quietvoid/hdr10plus_tool")
self.log.error("Unable to run --generate-dv: hdr10plus_tool not found")
sys.exit(1)
if cdm_only is None:
@@ -2077,13 +2052,13 @@ class dl:
if enrich_lang:
self.log.info(f"Original language from metadata: {enrich_lang}")
if not (enrich_title or enrich_year or enrich_lang):
self.log.warning("--enrich found no metadata. Is the provider's API key configured?")
self.log.warning("--enrich found no metadata")
elif missing := [
name
for name, value in (("title", enrich_title), ("year", enrich_year), ("language", enrich_lang))
if not value
]:
self.log.warning(f"--enrich source gave no {', '.join(missing)}. Those fields are unchanged.")
self.log.warning(f"--enrich source gave no {', '.join(missing)}, kept the service's values")
if isinstance(titles, (Series, Movies)):
for t in titles:
@@ -2868,12 +2843,10 @@ class dl:
self.log.info("No Dolby Vision track: Dolby Vision will be generated from HDR10+")
if gate_failure:
msg, msg_detail = gate_failure
other_ranges = [r for r in range_ if r != Video.Range.HYBRID]
if best_available and other_ranges:
self.log.warning(msg)
self.log.warning(
f"Continuing with remaining range(s): {', '.join(r.name for r in other_ranges)}"
f"{gate_failure}; continuing with {', '.join(r.name for r in other_ranges)}"
)
range_ = other_ranges
fallback_pool = pre_hybrid_videos
@@ -2902,8 +2875,7 @@ class dl:
fallback_selected.append(match)
title.tracks.videos = fallback_selected
else:
self.log.error(msg)
self.log.error(msg_detail)
self.log.error(gate_failure)
sys.exit(1)
fsl = resolve_sort_langs(forced_s_lang, title.language, exact=exact_lang)
@@ -3198,17 +3170,14 @@ class dl:
system_cdms = [self.cdm_for(system.lower(), height) for height in heights]
if not all(system_cdm and matches(system_cdm) for system_cdm in system_cdms):
hint = (
"--cdm selects one device, so remove it."
"drop --cdm, which selects one device"
if self.cdm_override
else f"Set cdm.{self.service}.{system.lower()} in the configuration."
else f"set cdm.{self.service}.{system.lower()}"
)
raise click.ClickException(f"--all-drm needs a {system} CDM for {self.service}. {hint}")
raise click.ClickException(f"--all-drm needs a {system} CDM for {self.service}: {hint}")
if is_remote_cdm(system_cdms[0]) and system not in self.all_drm_warned:
self.all_drm_warned.add(system)
self.log.warning(
f"The {system} CDM is remote. It can answer from its own key cache "
"and send no licence request."
)
self.log.warning(f"The {system} CDM is remote and can answer with cached content keys")
if hasattr(service, "resolve_server_keys"):
service.resolve_server_keys(title)
@@ -3510,10 +3479,7 @@ class dl:
has_repacked = True
events.emit(events.Types.TRACK_REPACKED, track=track)
if has_encrypted_sample_entry(track.path):
self.log.warning(
f"Track {track.id} still has an encrypted sample entry after repacking, so "
"decryption likely failed and the muxed output may be unplayable."
)
self.log.warning(f"Track {track.id} still has an encrypted sample entry after repack")
if has_repacked:
self.log.info("Repacked one or more tracks with FFMPEG")
@@ -4213,9 +4179,7 @@ class dl:
collisions = [slot for slot, count in Counter(renumbered).items() if count > 1]
if collisions:
self.log.error(
"TVDB's %s order does not cover %d of this series' episodes, and renumbering the rest "
"would give %d season/episode slot(s) two episodes each. Keeping the %s numbering "
"%s uses. Pick an order that covers the whole series.",
"TVDB's %s order misses %d episode(s) and puts two in %d slot(s), keeping the %s numbering of %s",
self.tvdb_order,
unmapped,
len(collisions),
@@ -4411,7 +4375,7 @@ class dl:
keys = len(doc.key_pool())
self.log.info(f"Saved the export to {namer.path}: {plural(len(doc.titles), 'title')}, {plural(keys, 'key')}")
if keys:
self.log.warning("The export holds content keys. Keep it private.")
self.log.warning("The export holds content keys: keep it private")
def export_entry(self, title: Title_T) -> mediaexport.Entry:
"""A mediaexport entry for ``title``: manifests, chapters, a small track list, no keys yet."""
@@ -4485,21 +4449,19 @@ class dl:
@staticmethod
def failure_lines(e: BaseException) -> list[str]:
"""Return the lines that report a failed download.
"""Return the header and the one line that report a failed download.
A ClickException is a deliberate stop with a message for the user, so it gets no
"unexpected error" line, and the caller prints no traceback for it. Neither does a
request that failed on every retry: it gets its retry notes instead.
A ClickException is a deliberate stop with a message for the user, so it is reported
without its type, and the caller prints no traceback for it. Neither does a request
that failed on every retry: its line ends with its retry notes.
"""
if isinstance(e, click.ClickException):
return [":x: Download Failed...", f" {e.format_message()}"]
if retries_exhausted(e):
return [":x: Download Failed...", f" {type(e).__name__}: {e}, {'; '.join(e.__notes__)}"]
lines = [":x: Download Failed...", f" {type(e).__name__}: {e}"]
if (returncode := getattr(e, "returncode", None)) is not None:
lines.append(f" Binary call failed, Process exit code: {returncode}")
lines.append(" An unexpected error occurred in one of the download workers.")
return lines
line = e.format_message()
else:
line = f"{type(e).__name__}: {e}"
if retries_exhausted(e):
line += f", {'; '.join(e.__notes__)}"
return [":x: Download Failed...", f" {line}"]
def decrypt_verified(
self,
@@ -4852,8 +4814,7 @@ class dl:
if not others and other_type.__name__ not in self.all_drm_warned:
self.all_drm_warned.add(other_type.__name__)
self.log.warning(
f"A track does not offer {other_type.__name__}, so it licenses with "
f"{type(drm).__name__} only. This warning shows one time: {track}"
f"Track {track} has no {other_type.__name__} DRM, licensing with {type(drm).__name__} only"
)
targets = [drm, *others[:1]]
+6 -6
View File
@@ -63,9 +63,9 @@ def new(
elif len(paths) == 2:
group_key, group_certificate = paths
if not group_key.is_file():
raise click.UsageError("group_key: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("group_key: Not a path to a file, or it does not exist.", ctx)
if not group_certificate.is_file():
raise click.UsageError("group_certificate: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("group_certificate: Not a path to a file, or it does not exist.", ctx)
device_dir = None
else:
raise click.UsageError(
@@ -73,9 +73,9 @@ def new(
ctx,
)
if encryption_key and not encryption_key.is_file():
raise click.UsageError("encryption_key: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("encryption_key: Not a path to a file, or it does not exist.", ctx)
if signing_key and not signing_key.is_file():
raise click.UsageError("signing_key: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("signing_key: Not a path to a file, or it does not exist.", ctx)
log = logging.getLogger("prd")
@@ -161,7 +161,7 @@ def reprovision_device(
) -> None:
"""Reprovision a PlayReady Device (.prd) file."""
if not prd_path.is_file():
raise click.UsageError("prd_path: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("prd_path: Not a path to a file, or it does not exist.", ctx)
log = logging.getLogger("prd")
log.info("Reprovisioning Playready Device (.prd) file, %s", prd_path.name)
@@ -234,7 +234,7 @@ def test(
"""Do a test of a PlayReady device on the Microsoft demo server."""
if not device.is_file():
raise click.UsageError("device: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("device: Not a path to a file, or it does not exist.", ctx)
log = logging.getLogger("prd")
+1 -3
View File
@@ -164,6 +164,4 @@ def result(service: Service, profile: Optional[str] = None, remote: bool = False
if search_results.children:
console.print(Padding(search_results, (0, 5)))
else:
console.print(
Padding("[bold text]No matches[/]\n[bright_black]Please check spelling and search again....[/]", (0, 5))
)
console.print(Padding("[bold text]No matches[/]", (0, 5)))
+3 -5
View File
@@ -135,9 +135,7 @@ def _prepare_serve(serve: dict, no_key: bool) -> Optional[str]:
"""
api_secret = None if no_key else serve.get("api_secret")
if not no_key and not api_secret:
raise ValueError(
"API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config."
)
raise ValueError("No 'api_secret' in the 'serve' section of your config")
for key, directory, pattern in (
("devices", config.directories.wvds, "*.wvd"),
@@ -213,8 +211,8 @@ def _build_cdm_config(
user_config["playready_devices"] = []
if serve_playready:
log.warning(
f'User "{key_id(user_key)}" has no "playready_devices" configured; PlayReady access disabled '
f"for this user. Available PlayReady devices: {prd_device_names}"
f'User "{key_id(user_key)}" has no "playready_devices", PlayReady is off for this user '
f"(available: {prd_device_names})"
)
serve_config["devices"] = wvd_devices
serve_config["playready_devices"] = prd_devices
+1 -2
View File
@@ -38,8 +38,7 @@ def tunnel_test(server: Optional[str]) -> None:
# one exit can answer over IPv4 here and IPv6 there, so a matching country also passes
if remote.get("ip") != local["ip"] and remote.get("country") != local.get("country"):
raise click.ClickException(
f"Step 3 failed: the server's requests exit at {remote.get('ip')} ({country}), "
f"not where this machine exits. Report this with the IP addresses."
f"Step 3 failed: the server's requests exit at {remote.get('ip')} ({country}), not at this machine's exit"
)
log.info(f"Step 3: the server's requests exit at {remote['ip']} ({country}), {remote.get('ms')} ms for one lookup")
log.info("The tunnel works. Add --local-proxy to a --remote command.")
+5 -5
View File
@@ -223,15 +223,15 @@ def new(
# TODO: Remove need for name, create name based on Client IDs ClientInfo values
name = unidecode(name.strip().lower().replace(" ", "_"))
except UnidecodeError as e:
raise click.UsageError(f"name: Failed to sanitize name, {e}", ctx)
raise click.UsageError(f"name: Failed to sanitise name, {e}", ctx)
if not name:
raise click.UsageError("name: Empty after sanitizing, please make sure the name is valid.", ctx)
raise click.UsageError("name: Empty after sanitising", ctx)
if not private_key.is_file():
raise click.UsageError("private_key: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("private_key: Not a path to a file, or it does not exist.", ctx)
if not client_id.is_file():
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("client_id: Not a path to a file, or it does not exist.", ctx)
if file_hashes and not file_hashes.is_file():
raise click.UsageError("file_hashes: Not a path to a file, or it doesn't exist.", ctx)
raise click.UsageError("file_hashes: Not a path to a file, or it does not exist.", ctx)
device = Device(
type_=DeviceTypes[type_.upper()],
+5 -9
View File
@@ -677,26 +677,22 @@ def perform_download(
except SystemExit as exc:
if exc.code != 0:
stdout_str = stdout_capture.getvalue()
stderr_str = stderr_capture.getvalue()
log.error(f"Download exited with code {exc.code}")
log.error(f"Stdout: {stdout_str}")
log.error(f"Stderr: {stderr_str}")
log.debug("Stdout: %s", stdout_capture.getvalue())
log.debug("Stderr: %s", stderr_capture.getvalue())
raise APIError(APIErrorCode.DOWNLOAD_ERROR, f"Download failed with exit code {exc.code}")
except Exception as exc: # noqa: BLE001 - propagate to caller
stdout_str = stdout_capture.getvalue()
stderr_str = stderr_capture.getvalue()
log.error(f"Download execution failed: {exc}")
log.error(f"Stdout: {stdout_str}")
log.error(f"Stderr: {stderr_str}")
log.debug("Stdout: %s", stdout_capture.getvalue())
log.debug("Stderr: %s", stderr_capture.getvalue())
raise
# dl.result() catches a download-worker exception, reports it, but returns normally (exit 0).
# It keeps the error in download_error, so the job isn't reported as completed with no output.
if (error := getattr(dl_instance, "download_error", None)) is not None:
# segment URLs carry signed CDN tokens (hmac, session IDs) that redact_text does not match
detail = redact_all(" ".join(line.strip() for line in dl_instance.failure_lines(error)[1:]))
detail = redact_all(dl_instance.failure_lines(error)[1].strip())
raise APIError(APIErrorCode.WORKER_ERROR, f"download worker failed: {detail}")
if progress_callback:
+16 -36
View File
@@ -271,8 +271,7 @@ def instantiate_service(
for k in cli_param_names & transport_keys & set(data):
if k not in ("service", "title_id", "proxy", "no_proxy") and data.get(k) is not None:
log.warning(
f"Ignoring flat '{sanitize_log(k)}' as a service option (it names a transport field); "
"update the client to send service options under 'service_params'"
f"Ignoring flat '{sanitize_log(k)}' as a service option, send it under 'service_params'"
)
return parent_ctx.invoke(service_module.cli, title=title, **extras)
@@ -1055,8 +1054,7 @@ def choose_session_cdm(
if data.get("cdm_type"):
return False, cap
raise capped_error(
f"The server CDM licenses {service} up to {cap}p for this API key. "
f"Set up a local CDM to license {wanted}p with your own device, or pass -q {cap}.",
f"The server CDM licenses {service} up to {cap}p for this API key, {wanted}p needs a local CDM",
cap,
)
@@ -1206,8 +1204,7 @@ def next_server_profile(service: str, region: Optional[str]) -> Optional[str]:
regions = ", ".join(covered.get("regions") or []) or "none"
raise APIError(
APIErrorCode.INVALID_INPUT,
f"No server account for {service} in region '{region or 'unknown'}'. "
f"Its accounts cover: {regions}. Pass --proxy with one of those country codes.",
f"No server account for {service} in region '{region or 'unknown'}', accounts cover: {regions}",
details={"service": service, "region": region, "regions": covered.get("regions") or []},
)
key = f"{service}:{','.join(p or '' for p in pool)}"
@@ -2160,7 +2157,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
"""
for banned in ("postscript", "post_script", "post_scripts"):
if data.get(banned):
return f"'{banned}' is not accepted over the API. Define post_scripts in unshackle.yaml."
return f"'{banned}' is not accepted over the API, only from unshackle.yaml"
# The worker reads cookies/{service}/{profile}.txt, so a profile must not be able to name a path.
try:
@@ -2267,10 +2264,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
supplied_ids = [name for name in ("tmdb_id", "imdb_id", "tvdb_id") if data.get(name)]
if len(supplied_ids) > 1:
return (
f"Cannot use multiple external IDs: {', '.join(supplied_ids)}. "
"Give one ID and unshackle resolves the others from it."
)
return f"Cannot use multiple external IDs: {', '.join(supplied_ids)}; give one ID"
exclusive_flags = []
if data.get("video_only"):
@@ -2340,7 +2334,7 @@ def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request
if not server_cdm_allowed(request, params.get("service")):
raise APIError(
APIErrorCode.FORBIDDEN,
"Download jobs license with the server CDM, which is not enabled for this key on this service.",
"Download jobs license with the server CDM, which is not enabled for this API key on this service",
)
cap = server_cdm_max_height(request, params.get("service"))
if cap is not None:
@@ -2350,27 +2344,16 @@ def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request
"""The job's own value for this parameter, or the ``serve:`` dl default that the job falls back to."""
return params[key] if key in params else serve_defaults.get(key)
capped = f"Download jobs license with the server CDM, up to {cap}p for this API key"
wanted = requested_height(effective("quality"))
if wanted is None or wanted > cap:
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
f"Pass a quality of {cap} or lower.",
cap,
)
raise capped_error(f"{capped}: pass a quality", cap)
if effective("best_available"):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Turn off best_available, because it can fall back to a track above that height.",
cap,
)
raise capped_error(f"{capped}: drop best_available", cap)
ranges = effective("range") or []
ranges = [ranges] if isinstance(ranges, str) else ranges
if any(str(getattr(r, "value", r)).upper() == "HYBRID" for r in ranges):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Remove HYBRID from range, because it always keeps the lowest DV track, whatever the quality.",
cap,
)
raise capped_error(f"{capped}: drop HYBRID", cap)
if params.get("proxy_download") is not None and not isinstance(params["proxy_download"], str):
raise APIError(APIErrorCode.INVALID_INPUT, "proxy_download must be a string.")
@@ -4571,7 +4554,7 @@ def resolve_handler_proxy(
):
raise APIError(
APIErrorCode.FORBIDDEN,
"A server-account service does not accept a client proxy URI. Pass a country code or nothing.",
"A server-account service takes a country code or no proxy, not a proxy URI",
details={"service": normalized_service},
)
@@ -4601,8 +4584,7 @@ def resolve_handler_proxy(
)
raise APIError(
APIErrorCode.INVALID_PROXY,
"This server does not resolve country codes into proxies. "
"Pass --proxy with a full proxy URI (http://, https:// or socks5://).",
"This server does not resolve country codes into proxies, pass a full proxy URI",
details={"service": normalized_service},
)
@@ -4628,8 +4610,7 @@ def resolve_handler_proxy(
if client_region.lower() in {x.lower() for x in geoblock}:
raise APIError(
APIErrorCode.GEOFENCE,
f"Service is not available in your region ({client_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions.",
f"Service is not available in your region ({client_region.upper()})",
details={"service": normalized_service},
)
@@ -4638,8 +4619,7 @@ def resolve_handler_proxy(
if server_region and not in_client_region:
raise APIError(
APIErrorCode.INVALID_PROXY,
"This server is in a different region from yours. Pass --proxy with a proxy "
"in the region you want, or pass --no-proxy to use the server's own connection.",
"This server is in a different region from yours, pass --proxy or --no-proxy",
details={"service": normalized_service},
)
elif in_client_region:
@@ -5070,7 +5050,7 @@ def handle_single_server_cdm(
if sources is not None:
sources.update(vault_hit[1])
return vault_hit[0]
raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no key for this track")
raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no content key for this track")
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_config = serve_user_config(api_key)
@@ -5350,7 +5330,7 @@ def license_response(
):
raise APIError(
APIErrorCode.FORBIDDEN,
"Server CDM licensing is not enabled for this key on this service. Use a local CDM (proxy mode).",
"Server CDM licensing is not enabled for this API key on this service",
)
if mode == "server_cdm" and track_ids:
+1 -4
View File
@@ -202,10 +202,7 @@ class InputBridge:
self._status = value
NO_USER_MESSAGE = (
"This login needs the user, and this request cannot reach the user. "
"Start a download job or a remote session to complete the authentication"
)
NO_USER_MESSAGE = "This login needs the user, and only a download job or a remote session can reach the user"
class NoUserBridge(InputBridge):
+2 -6
View File
@@ -24,10 +24,7 @@ from unshackle.core.tracks import Track
log = logging.getLogger("api.session")
DRAIN_WARNING = (
"This server restarts for an update when the open downloads finish. "
"This download continues. Start new downloads after the restart."
)
DRAIN_WARNING = "This server restarts for an update after the open downloads finish; this download continues"
@dataclass
@@ -359,8 +356,7 @@ def refuse_while_draining() -> None:
if get_session_store().draining:
raise APIError(
APIErrorCode.SERVER_DRAINING,
"This server restarts for an update and does not start new sessions or downloads now. "
"Try again in a few minutes.",
"This server restarts for an update and starts no new sessions or downloads",
retryable=True,
)
+2 -6
View File
@@ -67,18 +67,14 @@ class Cacher:
checksum = data["crc32"]
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
if calculated != checksum:
raise ValueError(
f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}"
)
raise ValueError(f"Cache payload checksum mismatch: {checksum} != calculated {calculated}")
cache.data = data["data"]
# The pickled `expiration` is local wall-clock and only right in the zone that wrote it.
expires_at = data.get("expires_at")
cache.expiration = datetime.fromtimestamp(expires_at) if expires_at is not None else data["expiration"]
cache.version = data["version"]
if cache.version != version:
raise ValueError(
f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}"
)
raise ValueError(f"The {self.service_tag} {key} cache is outdated, delete {cache.path}")
return cache
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
@@ -536,7 +536,7 @@ class DecryptLabsRemoteCDM:
session["decrypt_labs_session_id"] = data["session_id"]
return challenge
error_msg = f"Unexpected API response format. message_type={message_type}, available_fields={list(data.keys())}"
error_msg = f"Unexpected API response format: message_type={message_type}, available_fields={list(data.keys())}"
if data.get("message"):
error_msg = f"API response: {data['message']} - {error_msg}"
if "details" in data:
+2 -4
View File
@@ -144,7 +144,7 @@ class Config:
downloader_cfg = kwargs.get("downloader")
if downloader_cfg and downloader_cfg != "requests":
warnings.warn(
f"downloader '{downloader_cfg}' is deprecated. The unified requests downloader is now used.",
f"downloader '{downloader_cfg}' is deprecated, using the requests downloader",
DeprecationWarning,
stacklevel=2,
)
@@ -215,9 +215,7 @@ class Config:
if kwargs.get("scene_naming") is not None:
raise SystemExit(
"ERROR: The 'scene_naming' option has been removed.\n"
"Please configure 'output_template' in your unshackle.yaml instead.\n"
"See unshackle-example.yaml for examples."
"ERROR: The 'scene_naming' option was removed, use 'output_template' in your unshackle.yaml"
)
if self.output_template:
+1 -1
View File
@@ -75,7 +75,7 @@ class Credential:
credential = re.fullmatch(r"^([^:]+?):([^:]+?)(?::(.+))?$", text)
if credential:
return cls(*credential.groups())
raise ValueError("No credentials found in text string. Expecting the format `username:password`")
raise ValueError("No credentials in text string, expected `username:password`")
@classmethod
def load(cls, path: Path) -> Credential:
+1 -1
View File
@@ -154,7 +154,7 @@ def parse_speed_limit(value: Union[str, int, float, None]) -> Optional[float]:
return None
match = re.fullmatch(r"(\d*\.?\d+)\s*([kmg]b?|b)?", text)
if not match:
raise ValueError(f"Invalid speed limit {value!r}: use e.g. 500k, 5M, 1.5G, plain bytes/sec, or 'off'.")
raise ValueError(f"Invalid speed limit {value!r}: expected 500k, 5M, 1.5G, bytes per second or 'off'")
return float(match.group(1)) * SPEED_UNITS[match.group(2) or ""]
+16 -28
View File
@@ -17,7 +17,7 @@ from unshackle.core.config import config
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.drm import drm_from_dict, real_kids
from unshackle.core.manifests import DASH, HLS, ISM
from unshackle.core.manifests import DASH, HLS, ISM, MissingLanguageError
from unshackle.core.remote_service import (
RemoteService,
build_title,
@@ -123,17 +123,15 @@ def resolve_import_manifest_data(
try:
manifest = parser.from_url(url=url, session=session)
parsed = manifest.to_tracks(language=fallback_lang)
except MissingLanguageError:
raise click.ClickException(
f"No language for '{title}': the {manifest_type} manifest has none and Title.language is unset"
)
except ValueError as e:
if "Language information could not be derived" in str(e):
raise click.ClickException(
f"No language for '{title}': the {manifest_type} manifest has none and the "
f"service did not set Title.language."
)
raise click.ClickException(f"Failed to parse the {manifest_type} manifest for '{title}'. ({e})")
raise click.ClickException(f"Failed to parse the {manifest_type} manifest for '{title}': {e}")
except Exception as e:
raise click.ClickException(
f"Failed to fetch the {manifest_type} manifest for '{title}'. "
f"The manifest URL may have expired since export. ({e})"
f"Failed to fetch the {manifest_type} manifest for '{title}', the URL may have expired: {e}"
)
local_tracks = [*parsed.videos, *parsed.audio, *parsed.subtitles]
@@ -303,7 +301,7 @@ class ImportService:
except Exception as e:
if explicit:
raise click.ClickException(f"Failed to resolve proxy '{proxy_query}': {e}")
log.warning(f"Could not auto-select a proxy for export region '{region}': {e}. Continuing without proxy.")
log.warning(f"No proxy for export region '{region}', continuing without one: {e}")
proxy = None
if proxy:
@@ -386,28 +384,21 @@ class ImportService:
manifest = parser.from_url(url=manifest_url, session=self.session)
except Exception as e:
raise click.ClickException(
f"Failed to fetch the {manifest_type} manifest for '{title}'. "
f"The manifest URL may have expired since export. ({e})"
f"Failed to fetch the {manifest_type} manifest for '{title}', the URL may have expired: {e}"
)
language = fallback_language(title.language)
if language is None and isinstance(manifest, HLS):
language = hls_audio_language(manifest)
if language is None:
raise click.ClickException(
f"No language for '{title}': the export gives no title language and the HLS "
"manifest gives no audio language."
)
raise click.ClickException(f"No language for '{title}': the export and the HLS manifest have none")
try:
parsed = manifest.to_tracks(language=language)
except ValueError as e:
if "Language information could not be derived" in str(e):
raise click.ClickException(
f"No language for '{title}': the {manifest_type} manifest has none and the "
f"service did not set Title.language."
)
raise click.ClickException(f"Failed to parse the {manifest_type} manifest for '{title}'. ({e})")
except MissingLanguageError:
raise click.ClickException(
f"No language for '{title}': the {manifest_type} manifest has none and Title.language is unset"
)
except Exception as e:
raise click.ClickException(f"Failed to parse the {manifest_type} manifest for '{title}'. ({e})")
raise click.ClickException(f"Failed to parse the {manifest_type} manifest for '{title}': {e}")
for track in parsed:
tracks.add(track)
parsed_ids = {str(t.id) for t in tracks}
@@ -787,10 +778,7 @@ class ImportService:
}
)
subject = f"KID {', '.join(kids)}" if kids else f"track {track}"
return click.ClickException(
f"The export has no content key for {subject}, and no vault has it. An import does not "
"license through a CDM: export the title again with the tracks you need selected."
)
return click.ClickException(f"The export has no content key to decrypt {subject}, and no vault has it")
def decrypts_samples(self, track: AnyTrack) -> bool:
return False
+2 -2
View File
@@ -1,5 +1,5 @@
from .dash import DASH
from .dash import DASH, MissingLanguageError
from .hls import HLS
from .ism import ISM
__all__ = ("DASH", "HLS", "ISM")
__all__ = ("DASH", "HLS", "ISM", "MissingLanguageError")
+14 -6
View File
@@ -44,6 +44,19 @@ from unshackle.core.utils.redact import safe_display_url
from unshackle.core.utils.xml import load_xml
class MissingLanguageError(ValueError):
"""A manifest track has no language, and the fallback language is missing or invalid."""
def missing_language(where: str, fallback: Any) -> MissingLanguageError:
"""The MissingLanguageError for a track at ``where``, naming why the fallback did not apply."""
if fallback is None:
return MissingLanguageError(f"No language for {where}, and no fallback language was given")
if not tag_is_valid(str(fallback).strip()) or str(fallback).startswith("und"):
return MissingLanguageError(f"No language for {where}, and the fallback language {fallback} is invalid")
return MissingLanguageError(f"No language for {where}")
def append_segment(dst: BinaryIO, segment_file: Path, is_text_subtitle: bool) -> None:
"""Append one segment file to the output, with the text subtitle fixups when they apply."""
if is_text_subtitle:
@@ -286,12 +299,7 @@ class DASH:
track_lang = self.get_language(adaptation_set, rep, fallback=language)
if not track_lang:
msg = "Language information could not be derived from a Representation."
if language is None:
msg += " No fallback language was provided when calling DASH.to_tracks()."
elif not tag_is_valid((str(language) or "").strip()) or str(language).startswith("und"):
msg += f" The fallback language provided is also invalid: {language}"
raise ValueError(msg)
raise missing_language("a DASH Representation", language)
# for some reason it's incredibly common for services to not provide
# a good and actually unique track ID, sometimes because of the lang
+5 -12
View File
@@ -35,7 +35,7 @@ from unshackle.core.drm import DRM_T, ClearKey, MonaLisa, PlayReady, Widevine
from unshackle.core.drm.segment_decrypt import SegmentDecrypter, can_use
from unshackle.core.drm.verify import decrypt_track
from unshackle.core.events import events
from unshackle.core.manifests.dash import RollingMerge
from unshackle.core.manifests.dash import RollingMerge, missing_language
from unshackle.core.session import RnetResponse, RnetSession, utf8_text
from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Tracks, Video, resume
from unshackle.core.tracks.track import DRM_PREFERENCE_TYPES, assert_fragments_decrypted
@@ -305,12 +305,7 @@ class HLS:
None,
)
if not track_lang:
msg = "Language information could not be derived for a media."
if language is None:
msg += " No fallback language was provided when calling HLS.to_tracks()."
elif not tag_is_valid((str(language) or "").strip()) or str(language).startswith("und"):
msg += f" The fallback language provided is also invalid: {language}"
raise ValueError(msg)
raise missing_language("an HLS media", language)
tracks.add(
track_type(
@@ -664,8 +659,8 @@ class HLS:
hls_data = track.data.setdefault("hls", {})
if hls_data.get("unwanted_segments") and not unwanted_segments:
log.warning(
f"None of the {len(hls_data['unwanted_segments'])} segments that the export drops is in the "
f"playlist of {track.id}. The download keeps every segment."
f"None of the {len(hls_data['unwanted_segments'])} segments the export drops is in the "
f"playlist of {track.id}; the download keeps every segment"
)
hls_data["unwanted_segments"] = [s.absolute_uri for s in unwanted_segments]
@@ -1634,9 +1629,7 @@ class HLS:
except NotImplementedError:
unsupported_keys.append(key)
except Widevine.Exceptions.KIDNotFound:
logging.getLogger("HLS").warning(
"Skipped an EXT-X-SESSION-KEY entry that carries no Key ID. The media playlist must supply the key."
)
logging.getLogger("HLS").warning("Skipped an EXT-X-SESSION-KEY entry with no Key ID")
if not drm_objects and unsupported_keys:
logging.debug(
+2 -5
View File
@@ -23,7 +23,7 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY,
from unshackle.core.drm import DRM_T, PlayReady, Widevine
from unshackle.core.drm.verify import decrypt_track
from unshackle.core.events import events
from unshackle.core.manifests.dash import RollingMerge, append_segment
from unshackle.core.manifests.dash import RollingMerge, append_segment, missing_language
from unshackle.core.manifests.ism_init import (
build_init_segment,
parse_codec_private_data_vui,
@@ -251,10 +251,7 @@ class ISM:
track_lang = Language.get(lang)
if not track_lang and not language:
# Language is optional in MS-SSTR; video streams commonly omit it.
raise ValueError(
"Language information could not be derived from the manifest and no fallback "
"language was provided when calling ISM.to_tracks()."
)
raise missing_language("an ISM stream", language)
track_urls: list[str] = []
track_times: list[int] = []
+15 -29
View File
@@ -191,10 +191,7 @@ class Gluetun(Proxy):
verify_ip: Automatically make sure that the IP and region are correct after connection (default: True)
"""
if not binaries.Docker:
raise RuntimeError(
"Docker is not available. Please install Docker to use Gluetun proxy.\n"
"Visit: https://docs.docker.com/engine/install/"
)
raise RuntimeError("Docker not found, the Gluetun proxy needs it")
self.providers = providers or {}
self.base_port = base_port
@@ -247,14 +244,14 @@ class Gluetun(Proxy):
if len(parts) == 1:
return None
if len(parts) != 2:
raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')")
raise ValueError(f"Invalid query '{query}': expected provider:region, such as windscribe:us")
provider_name = parts[0].lower()
region = parts[1].lower()
if provider_name not in self.providers:
available = ", ".join(self.providers.keys())
raise ValueError(f"VPN provider '{provider_name}' not configured. Available providers: {available}")
raise ValueError(f"VPN provider '{provider_name}' not configured, available: {available}")
query_key = f"{provider_name}:{region}"
container_name = f"{self.container_prefix}-{provider_name}-{region}"
@@ -320,15 +317,9 @@ class Gluetun(Proxy):
if re.match(r"^[a-z]{2}$", region):
country = get_country_name(region)
if not country:
raise ValueError(
f"Country code '{region}' not recognized. "
f"Configure it in server_countries or use a valid ISO 3166-1 alpha-2 code."
)
raise ValueError(f"Country code '{region}' not recognized")
else:
raise ValueError(
f"Region '{region}' not recognized for provider '{provider_name}'. "
f"Configure it in server_countries or server_cities, or use a 2-letter country code."
)
raise ValueError(f"Region '{region}' not recognized for provider '{provider_name}'")
self.remove_stopped_container(container_name)
@@ -453,7 +444,7 @@ class Gluetun(Proxy):
credentials = config.get("credentials", {})
if vpn_type not in ["wireguard", "openvpn"]:
raise ValueError(f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'")
raise ValueError(f"Provider '{provider_name}': vpn_type '{vpn_type}' is not wireguard or openvpn")
if vpn_type == "wireguard":
# private_key is always required for WireGuard
@@ -468,16 +459,11 @@ class Gluetun(Proxy):
# Windscribe requires preshared_key (can be empty string, but must be set)
if provider_lower == "windscribe":
if "preshared_key" not in credentials:
raise ValueError(
f"Provider '{provider_name}': Windscribe WireGuard requires 'preshared_key' in credentials "
"(can be empty string, but must be set). Get it from windscribe.com/getconfig/wireguard"
)
if "addresses" not in credentials:
raise ValueError(
f"Provider '{provider_name}': Windscribe WireGuard requires 'addresses' in credentials. "
"Get it from windscribe.com/getconfig/wireguard"
)
for key in ("preshared_key", "addresses"):
if key not in credentials:
raise ValueError(
f"Provider '{provider_name}': Windscribe WireGuard needs '{key}' in credentials"
)
# Providers that require addresses (but not preshared_key)
elif provider_lower in ["surfshark", "mullvad", "ivpn"]:
@@ -539,8 +525,8 @@ class Gluetun(Proxy):
country_lower = country_code.lower()
if provider_name == "surfshark":
raise ValueError(
"Surfshark has no numbered servers. Map the region to a server name such as "
f"'{country_lower}-dal.prod.surfshark.com' in server_hostnames instead."
f"Surfshark has no numbered servers: map the region in server_hostnames, "
f"such as '{country_lower}-dal.prod.surfshark.com'"
)
hostname_formats = {
"nordvpn": f"{country_lower}{server_num}.nordvpn.com",
@@ -646,7 +632,7 @@ class Gluetun(Proxy):
message=f"Failed to pull Docker image: {gluetun_image}",
success=False,
)
raise RuntimeError(f"Failed to ensure Gluetun Docker image '{gluetun_image}' is available")
raise RuntimeError(f"Failed to pull the Gluetun proxy Docker image '{gluetun_image}'")
vpn_type = provider_config.get("vpn_type", "wireguard").lower()
credentials = provider_config.get("credentials", {})
@@ -1234,7 +1220,7 @@ class Gluetun(Proxy):
)
raise RuntimeError(
f"Failed to verify VPN IP for {container['provider']}:{container['region']} "
f"after {max_retries} attempts. Last error: {last_error}"
f"after {max_retries} attempts: {last_error}"
)
def remove_stopped_container(self, container_name: str) -> bool:
+1 -1
View File
@@ -37,7 +37,7 @@ class Hola(Proxy):
return None
ban = "Transaction error: temporary ban detected."
banned = ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.")
banned = ConnectionError("Hola temporarily banned your IP")
try:
# hola-proxy logs to stderr from another thread, so a merged stream splits the Login/Password lines.
result = subprocess.run(
+4 -10
View File
@@ -19,14 +19,11 @@ class NordVPN(Proxy):
The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/
"""
if not username:
raise ValueError("No Username was provided to the NordVPN Proxy Service.")
raise ValueError("No username for the NordVPN proxy provider")
if not password:
raise ValueError("No Password was provided to the NordVPN Proxy Service.")
raise ValueError("No password for the NordVPN proxy provider")
if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username:
raise ValueError(
"The Username and Password must be NordVPN Service Credentials, not your Login Credentials. "
"The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/"
)
raise ValueError("NordVPN needs its Service Credentials, not your login credentials")
if server_map is not None and not isinstance(server_map, dict):
raise TypeError(f"Expected server_map to be a dict mapping a region to a server ID, not '{server_map!r}'.")
@@ -101,10 +98,7 @@ class NordVPN(Proxy):
None,
)
if city_id is None:
raise ValueError(
f"No servers found in city '{city}' for country '{country['name']}'. "
"Try a different city or check the city name spelling."
)
raise ValueError(f"No servers found in city '{city}' for country '{country['name']}'")
if selector and is_ip(selector):
recommended_servers = self.get_all_servers(country["id"])
else:
+1 -2
View File
@@ -226,8 +226,7 @@ class ProtonVPN(Proxy):
self.load_session()
if not self.access_token:
log.error(
"Proton: no session available; export account.proton.me cookies to %s "
"or set proxy_providers.protonvpn.enable: true for the one-time TV login",
"Proton: no access token, export account.proton.me cookies to %s or set proxy_providers.protonvpn.enable",
self.cookie_path,
)
return None
+1 -1
View File
@@ -127,7 +127,7 @@ def initialize_proxy_providers(raise_errors: bool = False, quiet: bool = False)
"""
proxy_providers = load_proxy_providers(("gluetun",), raise_errors, quiet)
if not quiet and not proxy_providers:
log.warning("No proxy providers were loaded. Check your proxy provider configuration in unshackle.yaml")
log.warning("No proxy providers were loaded")
return proxy_providers
+5 -11
View File
@@ -91,14 +91,11 @@ class SurfsharkVPN(Proxy):
The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn
"""
if not username:
raise ValueError("No Username was provided to the SurfsharkVPN Proxy Service.")
raise ValueError("No username for the SurfsharkVPN proxy provider")
if not password:
raise ValueError("No Password was provided to the SurfsharkVPN Proxy Service.")
raise ValueError("No password for the SurfsharkVPN proxy provider")
if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username:
raise ValueError(
"The Username and Password must be SurfsharkVPN Service Credentials, not your Login Credentials. "
"The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn"
)
raise ValueError("SurfsharkVPN needs its Service Credentials, not your login credentials")
if server_map is not None and not isinstance(server_map, dict):
raise TypeError(
@@ -113,8 +110,7 @@ class SurfsharkVPN(Proxy):
if not server or server.isdigit():
# A warning, not an error: one stale entry must not disable the whole proxy provider.
log.warning(
"SurfsharkVPN: ignoring server_map entry '%s: %s'. Use a server name such as 'us-dal'. "
"SurfsharkVPN no longer uses numeric server IDs.",
"SurfsharkVPN: ignoring server_map entry '%s: %s', use a server name such as 'us-dal'",
region,
server,
)
@@ -240,9 +236,7 @@ class SurfsharkVPN(Proxy):
x for location, x in located if location.startswith(wanted)
]
if not servers:
raise ValueError(
f"No servers found in city '{city}'. Try a different city or check the city name spelling."
)
raise ValueError(f"No servers found in city '{city}'")
return servers
+3 -6
View File
@@ -21,9 +21,9 @@ class WindscribeVPN(Proxy):
code with a city ("ca:toronto"), and unshackle uses a pinned hostname before any server lookup.
"""
if not username:
raise ValueError("No Username was provided to the WindscribeVPN Proxy Service.")
raise ValueError("No username for the WindscribeVPN proxy provider")
if not password:
raise ValueError("No Password was provided to the WindscribeVPN Proxy Service.")
raise ValueError("No password for the WindscribeVPN proxy provider")
if server_map is not None and not isinstance(server_map, dict):
raise TypeError(f"Expected server_map to be a dict mapping a region to a hostname, not '{server_map!r}'.")
@@ -158,10 +158,7 @@ class WindscribeVPN(Proxy):
if hostnames:
return random.choice(hostnames)
elif city:
raise ValueError(
f"No servers found in city '{city}' for country code '{country_code}'. "
"Try a different city or check the city name spelling."
)
raise ValueError(f"No servers found in city '{city}' for country code '{country_code}'")
return None
+17 -37
View File
@@ -156,9 +156,7 @@ class RemoteClient:
if isinstance(e.args[0] if e.args else None, urllib3.exceptions.ReadTimeoutError):
raise self.timed_out(endpoint) from None
server_url = safe_display_url(self.server_url)
raise RemoteError(
f"Could not connect to remote server at {server_url}. Is it running? (unshackle serve)", "CONNECT"
) from None
raise RemoteError(f"Could not connect to remote server at {server_url}", "CONNECT") from None
except requests.Timeout:
raise self.timed_out(endpoint) from None
# servers differ on which header carries the key, so retry the rest before giving up
@@ -196,8 +194,7 @@ class RemoteClient:
return {}
if self.tunnel and self.tunnel.lost:
raise RemoteError(
"The tunnel to the server closed, so the server can no longer send its requests through this "
"machine. Run the command again.",
"The tunnel to the server closed",
"TUNNEL_CLOSED",
)
raise RemoteError(f"Server error [{error_code}]: {error_msg}", error_code)
@@ -205,8 +202,7 @@ class RemoteClient:
def timed_out(self, endpoint: str) -> RemoteError:
return RemoteError(
f"Request to remote server timed out after {self.timeout}s: {endpoint}. "
"Raise 'timeout' for this server in remote_services.",
f"Request to remote server timed out after {self.timeout}s: {endpoint}",
"TIMEOUT",
)
@@ -727,8 +723,7 @@ def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Tit
)
else:
raise click.ClickException(
f"The remote server sent a title of type {title_type!r}, which this client does not support. "
"Update unshackle on both ends so they agree on the title types."
f"The remote server sent an unknown title type {title_type!r}; unshackle versions differ"
)
apply_title_fields(title, info)
return title
@@ -744,10 +739,7 @@ def resolve_auth_headers(svc: dict, server_name: str) -> list[str]:
or not auth_headers
or not all(isinstance(h, str) and h.strip() for h in auth_headers)
):
raise click.ClickException(
f"Remote service '{server_name}': 'auth_headers' must be a list of header names, e.g.\n\n"
' auth_headers: ["Authorization"]'
)
raise click.ClickException(f"Remote service '{server_name}': 'auth_headers' must be a list of header names")
resolved = [h.strip() for h in auth_headers]
seen = {h.lower() for h in resolved}
resolved.extend(h for h in DEFAULT_AUTH_HEADERS if h.lower() not in seen)
@@ -764,19 +756,13 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]:
"""
remote_services = config.remote_services
if not remote_services:
raise click.ClickException(
"No remote services configured. Add 'remote_services' to your unshackle.yaml:\n\n"
" remote_services:\n"
" my_server:\n"
' url: "https://server:8080"\n'
' api_key: "your-api-key"'
)
raise click.ClickException("No 'remote_services' in your unshackle.yaml")
if server_name:
svc = remote_services.get(server_name)
if not svc:
available = ", ".join(remote_services.keys())
raise click.ClickException(f"Remote service '{server_name}' not found. Available: {available}")
raise click.ClickException(f"Remote service '{server_name}' not found, available: {available}")
services = svc.get("services", {})
services["_server_cdm"] = svc.get("server_cdm")
services["_auth_headers"] = resolve_auth_headers(svc, server_name)
@@ -795,7 +781,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]:
return svc["url"], svc.get("api_key", ""), services
available = ", ".join(remote_services.keys())
raise click.ClickException(f"Multiple remote services configured. Use --server to select one: {available}")
raise click.ClickException(f"Multiple remote services configured, pick one with --server: {available}")
REMOTE_LIST_TTL = 300.0
@@ -958,7 +944,7 @@ def start_tunnel(client: RemoteClient) -> TunnelClient:
try:
tunnel.start()
except ConnectionError as e:
raise click.ClickException(f"--local-proxy needs a tunnel to the server. {e}")
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}")
client.tunnel = tunnel
return tunnel
@@ -1240,10 +1226,7 @@ class RemoteService:
def require_reachable_proxy(self, proxy: Optional[str]) -> None:
"""Refuse a proxy on this machine when the server is elsewhere: the server cannot reach it."""
if proxy and is_loopback(proxy) and not is_loopback(self.client.server_url):
raise click.ClickException(
"That proxy is on your machine, so --remote cannot use it. "
"To send the server's requests through this machine's connection, use --local-proxy without --proxy."
)
raise click.ClickException("--remote cannot use a proxy on this machine; use --local-proxy without --proxy")
def tunnel(self) -> str:
"""The ``tunnel:<id>`` proxy to send the server for ``--local-proxy``, opening the tunnel the first time."""
@@ -1297,9 +1280,8 @@ class RemoteService:
self.log.info(f"Using a '{regions[0]}' proxy to match a server account ({', '.join(regions)})")
except click.ClickException:
self.log.warning(
f"The server's accounts work in {', '.join(regions)} and you are in {client_region}. "
"There is no local proxy for that region. Pass --proxy yourself if the server "
"rejects the remote session"
f"No local proxy for the server's account region ({', '.join(regions)}), "
f"you are in {client_region}"
)
elif not no_proxy and proxy:
resolved_proxy = resolve_remote_proxy_arg(server_proxy_arg(self.ctx.parent.params), providers)
@@ -1830,25 +1812,23 @@ class RemoteService:
else "no vault, neither the server's nor yours"
)
raise click.ClickException(
f"The key for {track} is in {vaults}. This session runs on the server's device, which "
"licenses nothing, so drop the service's server-identity option to license it with your own device."
f"The content key for {track} is in {vaults}; drop the service's server-identity option to license it locally"
)
cap = refusal.get("max_height")
if cap:
why = f"The server CDM licenses up to {cap}p for this key"
why = f"The server CDM licenses up to {cap}p for this API key"
elif getattr(self, "_server_vault", False):
why = "The server vault holds no key for this track"
why = "The server vault holds no content key for this track"
else:
why = "The server CDM refused this track"
cdm = self.local_cdm
if cdm is None:
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it with your own device.")
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it")
drm_class = "PlayReady" if is_playready_cdm(cdm) else "Widevine"
if track.drm and not any(d.__class__.__name__ == drm_class for d in track.drm):
height = getattr(track, "height", None)
raise click.ClickException(
f"{why}, and {track} carries no {drm_class} DRM for your local device to license. "
f"Pass -q {height or 'with that height'} so the session starts on your own device."
f"{why}, and {track} has no {drm_class} DRM for your local CDM; pass -q {height or 'with that height'}"
)
track.drm_preference = drm_class.lower()
self.client_licensed.add(str(track.id))
+3 -8
View File
@@ -252,8 +252,7 @@ class Service(metaclass=ABCMeta):
self.log.info("Service is not Geoblocked in your region")
elif not (fence_targets := [x for x in self.GEOFENCE if self.is_region_allowed(x)]):
raise click.ClickException(
f"Service is not available in {whose} region ({current_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions."
f"Service is not available in {whose} region ({current_region.upper()})"
)
else:
requested_proxy = fence_targets[0] # first is likely main region
@@ -264,10 +263,7 @@ class Service(metaclass=ABCMeta):
proxy_provider, proxy = pick_proxy(ctx.obj.proxy_providers, requested_proxy)
self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}")
except ValueError:
self.log.warning(
f"No proxy available for {requested_proxy}. "
f"Pass --proxy with a proxy in {requested_proxy}, or the request can fail."
)
self.log.warning(f"No proxy available for {requested_proxy}")
except click.ClickException:
raise
except Exception as e:
@@ -290,8 +286,7 @@ class Service(metaclass=ABCMeta):
# Only GEOBLOCK applies here: an explicit --proxy outside GEOFENCE stays the user's call.
if exit_region and any(x.lower() == exit_region.lower() for x in self.GEOBLOCK):
raise click.ClickException(
f"Service is not available in {whose} proxy's region ({exit_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions."
f"Service is not available in {whose} proxy's region ({exit_region.upper()})"
)
else:
# No proxy, use cached IP info for title caching (non-critical)
+3 -7
View File
@@ -332,10 +332,7 @@ def check_load_errors() -> None:
"""
if DIRTY_REPOS:
joined = "\n".join(f" - {p}" for p in DIRTY_REPOS)
raise click.ClickException(
"Service repo has local changes - refusing to refresh so your edits are not lost.\n"
"Commit and push them to the upstream repo (or revert them), then retry:\n" + joined
)
raise click.ClickException("Service repo has local changes, not refreshing:\n" + joined)
global SUMMARY_LOGGED
if not SUMMARY_LOGGED:
SUMMARY_LOGGED = True
@@ -422,7 +419,7 @@ class Services(click.Group):
raise click.ClickException(
f"There are no Services added yet, therefore the '{name}' Service could not be found."
)
raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}")
raise click.ClickException(f"{e}, available: {', '.join(available_services)}")
if hasattr(service, "cli"):
cli = service.cli
@@ -518,8 +515,7 @@ class Services(click.Group):
if services:
available = ", ".join(sorted(svc["tag"] for svc in services))
raise click.ClickException(
f"The remote server does not offer a service named '{name}'. "
f"It offers these services: {available}"
f"The remote server does not offer a service named '{name}', available: {available}"
)
raise click.ClickException("The remote server offers no services to your API key.")
tag = svc_info["tag"]
+1 -5
View File
@@ -52,11 +52,7 @@ def resolve_impersonate(browser: str) -> rnet.Impersonate:
preset = getattr(rnet.Impersonate, browser, None)
if preset is not None:
return preset
raise ValueError(
f"Unknown impersonate preset: {browser!r}. "
f"Use exact rnet preset names like 'Chrome131', 'OkHttp4_12', 'Edge101'. "
f"See rnet.Impersonate for all available presets."
)
raise ValueError(f"Unknown impersonate preset {browser!r}, expected an rnet.Impersonate name such as 'Chrome131'")
METHOD_MAP: dict[str, rnet.Method] = {
+3 -9
View File
@@ -386,10 +386,7 @@ class Hybrid:
message="Video has no container duration; RPU keeps the active area of the DV track",
context={"input": str(input_video)},
)
self.log.warning(
"Video has no container duration, skipping crop detection. "
"The RPU keeps the active area of the DV track."
)
self.log.warning("Video has no container duration, skipping crop detection")
return None
duration = float(duration_value)
@@ -665,7 +662,7 @@ class Hybrid:
return
if not HDR10PlusTool:
raise ValueError("HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.")
raise ValueError("HDR10Plus_tool not found, HDR10+ to DV conversion needs it")
try:
run_step(
@@ -817,10 +814,7 @@ class Hybrid:
json.dump(generator_config, f)
if profile == "A":
self.log.warning(
"The HDR10+ metadata is Profile A, which is often less reliable than Profile B. "
"Check the Dolby Vision result before you keep it."
)
self.log.warning("The HDR10+ metadata is Profile A, less reliable than Profile B")
try:
dovi.generate(
+1 -2
View File
@@ -1128,8 +1128,7 @@ class Subtitle(Track):
except (IOError, OSError) as e:
if "is not valid subtitle file" in str(e):
log = logging.getLogger("Subtitle")
log.warning(f"Failed to strip SDH from {self.path.name}: {e}")
log.warning("Continuing without SDH stripping for this subtitle")
log.warning(f"Failed to strip SDH from {self.path.name}, keeping it as is: {e}")
else:
raise
+2 -6
View File
@@ -346,9 +346,7 @@ def assert_fragments_decrypted(path: Path) -> None:
first_offset=first_offset,
)
raise ValueError(
f"{path.name}: {surviving}/{total} fragment(s) still encrypted after decryption (first at "
f"byte {first_offset}). The decrypt tool skipped them silently, so check "
f"tfhd.sample_description_index against the stsd entry count in the init segment."
f"{path.name}: {surviving}/{total} fragment(s) still encrypted after decryption, first at byte {first_offset}"
)
@@ -529,9 +527,7 @@ class Track:
return slice(None)
low, high = (starts[0] + seconds * timescale for seconds in window)
if low > starts[-1] and len(starts) > 1:
raise click.ClickException(
"The title ends before the sample window starts. Use --sample START-END with an earlier window."
)
raise click.ClickException("The title ends before the sample window starts, use an earlier --sample window")
first = max(bisect_right(starts, low) - 1, 0)
self.sample_start = (starts[first] - starts[0]) / timescale
return slice(first, max(bisect_left(starts, high) + self.sample_tail, first + 1))
+2 -13
View File
@@ -237,14 +237,7 @@ class Tracks:
for track in flatten(tracks):
if self.exists(by_id=track.id):
if not warn_only:
raise ValueError(
"One or more of the provided Tracks is a duplicate. "
"Track IDs must be unique but accurate using static values. The "
"value should stay the same no matter when you request the same "
"content. Use a value that has relation to the track content "
"itself and is static or permanent and not random/RNG data that "
"wont change each refresh or conflict in edge cases."
)
raise ValueError(f"Duplicate track ID {track.id}: track IDs must be unique and stable")
duplicates += 1
continue
@@ -573,11 +566,7 @@ class Tracks:
"""
mp4box = getattr(binaries, "MP4Box", None)
if not mp4box:
raise RuntimeError(
"MP4Box (GPAC) is required to mux DTS-UHD (DTS:X Profile 2) audio but was not found. "
"Install it with your package manager (e.g. `apt install gpac`), then check it with "
"`unshackle env check`."
)
raise RuntimeError("MP4Box (GPAC) not found, muxing DTS-UHD (DTS:X Profile 2) audio needs it")
progress = progress or partial(lambda **kwargs: None)
+2 -2
View File
@@ -495,7 +495,7 @@ class SlowDelayRange(click.ParamType):
match = re.match(r"^(\d+)-(\d+)$", str(value))
if not match:
self.fail(f"'{value}' is not a valid range. Use format: MIN-MAX (e.g., 20-40)", param, ctx)
self.fail(f"'{value}' is not a valid range, expected MIN-MAX such as 20-40", param, ctx)
low, high = int(match.group(1)), int(match.group(2))
if low < 5:
@@ -521,7 +521,7 @@ class SampleRange(click.ParamType):
match = re.match(r"^(\d+)-(\d+)$", str(value))
if not match or int(match.group(1)) >= int(match.group(2)):
self.fail(f"'{value}' is not a valid sample window. Use format: START-END (e.g., 60-120)", param, ctx)
self.fail(f"'{value}' is not a valid sample window, expected START-END such as 60-120", param, ctx)
return (int(match.group(1)), int(match.group(2)))
+1 -4
View File
@@ -288,10 +288,7 @@ def verify_proxy_exit(session: requests.Session) -> dict:
if len(errors) < len(build_providers()):
log.warning("No geolocation API returned the proxy exit IP, so the exit IP check does not occur")
return {}
raise ConnectionError(
f"Proxy check failed: no IP lookup got through the proxy ({type(errors[-1]).__name__}). "
"The proxy is down or refused the connection."
)
raise ConnectionError(f"Proxy check failed: no IP lookup got through the proxy ({type(errors[-1]).__name__})")
exit_ip = info.get("ip")
server_ip = (get_ip_info(cached=True) or {}).get("ip")
# The cached IP can be up to 24h old: a machine that was on a VPN when it cached its IP,
+1 -2
View File
@@ -229,8 +229,7 @@ def dispatch(event: str, mode: str, context: dict[str, str], overrides: Sequence
injected = _data_injected_flag(tokens, argv)
if injected is not None:
log.warning(
"Skipping post-script: a substituted value expands to the option-like token %r; "
"prefix the variable (--opt={var}) so service metadata cannot forge a flag",
"Skipping post-script: a substituted value expands to the option-like token %r",
redact_text(redact_path(injected)),
)
continue