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