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