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