mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +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:
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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]]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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()],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ""]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user