From 21d5f1c5e1f1def62ab1c45be0289c31efbf4968 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sat, 15 Aug 2026 22:07:41 -0600 Subject: [PATCH] refactor(core)!: drop leading underscores from internal definitions Renames 314 defs and 41 module/class-level constants. Keeps 11 names underscored where the stripped form collides in scope, plus 5.5.0 shims for the three symbols external service repos call. --- docs/dev/creating-a-service.md | 10 +- docs/dev/rest-api/remote-sessions.md | 2 +- tests/core/test_anilist_provider.py | 6 +- tests/core/test_cdm_detect.py | 14 +- tests/core/test_clearkey.py | 10 +- tests/core/test_code_hash.py | 2 +- tests/core/test_disable_metadata.py | 2 +- tests/core/test_dual_multi_naming.py | 2 +- tests/core/test_episode_absolute.py | 26 +- tests/core/test_episode_air_date.py | 14 +- tests/core/test_episode_part.py | 4 +- tests/core/test_fill_air_dates.py | 34 +-- tests/core/test_imdb_provider.py | 34 +-- tests/core/test_ism_drm_systemid.py | 55 +++++ tests/core/test_ism_init.py | 24 +- tests/core/test_ism_parse.py | 11 + tests/core/test_metadata_id_validation.py | 28 +-- tests/core/test_resolve_by_ids.py | 32 +-- tests/core/test_service_repo.py | 30 +-- tests/core/test_tag_rules_integration.py | 6 +- tests/core/test_tvdb.py | 54 ++-- tests/downloaders/test_content_encoding.py | 8 +- tests/downloaders/test_failure_surfacing.py | 10 +- tests/downloaders/test_speed_limit.py | 6 +- .../test_overlapping_audio_dedup.py | 28 +-- tests/remote/conftest.py | 16 +- tests/remote/e2e/conftest.py | 4 +- tests/remote/e2e/test_live_download.py | 12 +- tests/remote/e2e/test_live_license.py | 12 +- tests/remote/e2e/test_live_quality.py | 20 +- .../remote/e2e/test_live_session_lifecycle.py | 26 +- tests/remote/unit/test_api_episode_part.py | 84 +++---- tests/remote/unit/test_api_title_fields.py | 62 ++--- tests/remote/unit/test_compression.py | 10 +- .../unit/test_download_gates_redaction.py | 14 +- tests/remote/unit/test_handlers_serialize.py | 33 ++- tests/remote/unit/test_progress_sink.py | 12 +- tests/remote/unit/test_remote_resolve.py | 12 +- .../unit/test_remote_service_helpers.py | 68 ++--- tests/remote/unit/test_routes.py | 24 +- tests/remote/unit/test_server_cdm_gate.py | 8 +- tests/remote/unit/test_session_store.py | 4 +- tests/tracks/test_encrypted_sample_entry.py | 46 ++-- tests/tracks/test_fragment_decrypted.py | 76 +++--- tests/tracks/test_subtitle_convert.py | 2 +- tests/tracks/test_subtitle_sdh_strip.py | 70 ++++++ tests/tracks/test_subtitle_skip.py | 2 +- unshackle/commands/dl.py | 8 +- unshackle/commands/util.py | 8 +- unshackle/core/__init__.py | 10 +- unshackle/core/api/download_manager.py | 68 ++--- unshackle/core/api/download_worker.py | 14 +- unshackle/core/api/errors.py | 4 +- unshackle/core/api/handlers.py | 233 +++++++++--------- unshackle/core/api/session_store.py | 36 +-- unshackle/core/cdm/custom_remote_cdm.py | 80 +++--- unshackle/core/cdm/decrypt_labs_remote_cdm.py | 29 +-- unshackle/core/cdm/loader.py | 18 +- unshackle/core/cdm/monalisa/monalisa_cdm.py | 34 +-- unshackle/core/commands.py | 8 +- unshackle/core/config.py | 4 +- unshackle/core/console.py | 4 +- unshackle/core/downloaders/requests.py | 64 ++--- unshackle/core/drm/clearkey.py | 6 +- unshackle/core/drm/monalisa.py | 6 +- unshackle/core/drm/playready.py | 36 +-- unshackle/core/drm/widevine.py | 8 +- unshackle/core/import_service.py | 22 +- unshackle/core/manifests/dash.py | 36 +-- unshackle/core/manifests/hls.py | 22 +- unshackle/core/manifests/ism.py | 22 +- unshackle/core/music/integrity.py | 28 +-- unshackle/core/music/planner.py | 54 ++-- unshackle/core/music/renderer.py | 110 ++++----- unshackle/core/music/tagger.py | 94 +++---- unshackle/core/providers/__init__.py | 36 +-- unshackle/core/providers/_base.py | 6 +- unshackle/core/providers/anilist.py | 26 +- unshackle/core/providers/imdb.py | 24 +- unshackle/core/providers/omdb.py | 26 +- unshackle/core/providers/simkl.py | 4 +- unshackle/core/providers/tmdb.py | 30 +-- unshackle/core/providers/tvdb.py | 64 ++--- unshackle/core/proxies/gluetun.py | 110 ++++----- unshackle/core/remote_service.py | 112 +++++---- unshackle/core/service.py | 7 +- unshackle/core/service_repo.py | 60 ++--- unshackle/core/services.py | 90 ++++--- unshackle/core/session.py | 56 ++--- unshackle/core/title_cacher.py | 8 +- unshackle/core/titles/episode.py | 38 +-- unshackle/core/titles/movie.py | 8 +- unshackle/core/titles/music.py | 8 +- unshackle/core/titles/song.py | 8 +- unshackle/core/titles/title.py | 5 +- unshackle/core/tracks/hybrid.py | 8 +- unshackle/core/tracks/subtitle.py | 39 +-- unshackle/core/tracks/track.py | 22 +- unshackle/core/tracks/video.py | 14 +- unshackle/core/update_checker.py | 39 +-- unshackle/core/utilities.py | 28 +-- unshackle/core/utils/bitrate.py | 6 +- unshackle/core/utils/click_types.py | 4 +- unshackle/core/utils/dovi.py | 12 +- unshackle/core/utils/language_tags.py | 4 +- unshackle/core/utils/redact.py | 22 +- unshackle/core/utils/tag_rules.py | 32 +-- unshackle/core/utils/tags.py | 4 +- unshackle/core/utils/template_formatter.py | 8 +- unshackle/core/utils/webvtt.py | 12 +- unshackle/core/vaults.py | 12 +- unshackle/services/EXAMPLE/__init__.py | 12 +- unshackle/utils/base58.py | 4 +- unshackle/utils/base62.py | 12 +- unshackle/vaults/MySQL.py | 4 +- unshackle/vaults/SQLite.py | 4 +- 116 files changed, 1671 insertions(+), 1492 deletions(-) create mode 100644 tests/core/test_ism_drm_systemid.py create mode 100644 tests/tracks/test_subtitle_sdh_strip.py diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index da867629..91547c3a 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -429,22 +429,22 @@ Each exposes `from_url(url, session=...)` (or `from_text(text, url)`) and then token. Some services deliver a **separate manifest per codec/range**. The base class -provides `_get_tracks_for_variants(title, fetch_fn)` to fan out over every +provides `get_tracks_for_variants(title, fetch_fn)` to fan out over every codec×range in the `TrackRequest`, including `HYBRID` (fetch HDR10 + DV and merge) and `--best-available` skip-on-error handling: ```python def get_tracks(self, title: Title_T) -> Tracks: - def _fetch_variant(title, codec, range_) -> Tracks: + def fetch_variant(title, codec, range_) -> Tracks: vcodec = "H265" if codec == Video.Codec.HEVC else "H264" - return self._fetch_dash_manifest(title, vcodec=vcodec, range_=range_) - return self._get_tracks_for_variants(title, _fetch_variant) + return self.fetch_dash_manifest(title, vcodec=vcodec, range_=range_) + return self.get_tracks_for_variants(title, fetch_variant) ``` After parsing, you may **correct** track metadata the manifest gets wrong: stamp the real `video.range`, fix odd audio channel counts, mark descriptive audio, add hand-built subtitles or an `Attachment` (e.g. cover art). Do *not* -filter for resolution/bitrate. See `_fetch_dash_manifest` in the EXAMPLE service +filter for resolution/bitrate. See `fetch_dash_manifest` in the EXAMPLE service for a thorough demonstration. !!! note "Flip HDR10 → HDR10+ by hand when you know the platform embeds it" diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 0923e38a..e68311dd 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -391,7 +391,7 @@ and base64-encoding, **excluding** `titles_*` files) and returns them under a Source: `unshackle/core/remote_service.py`. This is the canonical consumer of the session API and a good template for any client. -- **`RemoteClient._request`** sets `X-Secret-Key` and `User-Agent: unshackle/`, +- **`RemoteClient.request`** sets `X-Secret-Key` and `User-Agent: unshackle/`, uses a 120s timeout for `POST` and 30s for `GET`/`DELETE`, and treats any `status_code >= 400` as fatal: it logs `Server error []: ` and raises `SystemExit(1)`. diff --git a/tests/core/test_anilist_provider.py b/tests/core/test_anilist_provider.py index 99e0fea7..cc24e682 100644 --- a/tests/core/test_anilist_provider.py +++ b/tests/core/test_anilist_provider.py @@ -50,7 +50,7 @@ def _provider(monkeypatch: pytest.MonkeyPatch, body: dict) -> tuple[AniListProvi @pytest.fixture(autouse=True) -def _default_title_language(monkeypatch: pytest.MonkeyPatch) -> None: +def default_title_language(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(config, "anilist_title_language", "english") @@ -263,12 +263,12 @@ def test_the_provider_needs_no_api_key() -> None: def test_a_cached_node_rebuilds_with_the_read_time_title_language(monkeypatch: pytest.MonkeyPatch) -> None: """The raw node is cached; which title variant it yields follows the config at read time.""" - from unshackle.core.providers import _cached_to_result + from unshackle.core.providers import cached_to_result node = {**ONE_PIECE, "title": {"romaji": "WAN PIISU", "english": "One Piece", "native": "ワンピース"}} monkeypatch.setattr(config, "anilist_title_language", "native") - result = _cached_to_result(node, "anilist", "tv") + result = cached_to_result(node, "anilist", "tv") assert result is not None assert result.title == "ワンピース" diff --git a/tests/core/test_cdm_detect.py b/tests/core/test_cdm_detect.py index 612f2a1c..55c0736f 100644 --- a/tests/core/test_cdm_detect.py +++ b/tests/core/test_cdm_detect.py @@ -24,7 +24,7 @@ from unshackle.core.cdm.detect import ( is_remote_widevine_cdm, is_widevine_cdm, ) -from unshackle.core.cdm.loader import _stamp_remote +from unshackle.core.cdm.loader import stamp_remote pytestmark = pytest.mark.unit @@ -125,7 +125,7 @@ def test_custom_remote_cdm_classification(device, expect_pr): def test_stamp_remote_sets_attrs_and_returns_same_object(): obj = SimpleNamespace() - out = _stamp_remote(obj, "playready") + out = stamp_remote(obj, "playready") assert out is obj assert obj.drm == "playready" assert obj.is_remote_cdm is True @@ -136,14 +136,14 @@ def test_stamp_remote_is_best_effort_on_unsettable_object(): __slots__ = () obj = Slotted() - out = _stamp_remote(obj, "widevine") # must not raise + out = stamp_remote(obj, "widevine") # must not raise assert out is obj assert not hasattr(obj, "drm") def test_load_remote_decryptlabs_playready_is_stamped(): cdm_api = {"name": "dl-pr", "type": "decrypt_labs", "secret": "x", "device_name": "SL3000"} - cdm = loader_mod._load_remote_cdm(dict(cdm_api), "dl-pr", "AMZN", None) + cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-pr", "AMZN", None) assert cdm.drm == "playready" assert cdm.is_remote_cdm is True assert is_remote_playready_cdm(cdm) @@ -151,7 +151,7 @@ def test_load_remote_decryptlabs_playready_is_stamped(): def test_load_remote_decryptlabs_widevine_is_stamped(): cdm_api = {"name": "dl-wv", "type": "decrypt_labs", "secret": "x", "device_name": "ChromeCDM"} - cdm = loader_mod._load_remote_cdm(dict(cdm_api), "dl-wv", "AMZN", None) + cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-wv", "AMZN", None) assert cdm.drm == "widevine" assert is_remote_widevine_cdm(cdm) @@ -165,7 +165,7 @@ def test_load_remote_native_playready_is_stamped(monkeypatch): monkeypatch.setattr(prmod, "RemoteCdm", FakePlayReadyRemote) cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"} - cdm = loader_mod._load_remote_cdm(dict(cdm_api), "pr", "AMZN", None) + cdm = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "AMZN", None) assert isinstance(cdm, FakePlayReadyRemote) assert cdm.drm == "playready" assert cdm.is_remote_cdm is True @@ -181,7 +181,7 @@ def test_load_remote_native_widevine_is_stamped(monkeypatch): monkeypatch.setattr(wvmod, "RemoteCdm", FakeWidevineRemote) cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"} - cdm = loader_mod._load_remote_cdm(dict(cdm_api), "wv", "AMZN", None) + cdm = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "AMZN", None) assert isinstance(cdm, FakeWidevineRemote) assert cdm.drm == "widevine" assert is_remote_widevine_cdm(cdm) diff --git a/tests/core/test_clearkey.py b/tests/core/test_clearkey.py index f3df8116..437fd6e9 100644 --- a/tests/core/test_clearkey.py +++ b/tests/core/test_clearkey.py @@ -88,7 +88,7 @@ def test_warns_once_per_instance(tmp_path, caplog) -> None: assert len(warnings) == 1 -def _data_key(raw: bytes, base64_encoded: bool = True) -> Key: +def data_key(raw: bytes, base64_encoded: bool = True) -> Key: if base64_encoded: uri = "data:text/plain;base64," + base64.b64encode(raw).decode("ascii") else: @@ -97,24 +97,24 @@ def _data_key(raw: bytes, base64_encoded: bool = True) -> Key: def test_data_uri_key_decodes_and_validates_length() -> None: - drm = ClearKey.from_m3u_key(_data_key(KEY)) + drm = ClearKey.from_m3u_key(data_key(KEY)) assert drm.key == KEY def test_data_uri_key_wrong_length_rejected() -> None: with pytest.raises(ValueError, match="Unexpected Length"): - ClearKey.from_m3u_key(_data_key(b"\x00" * 8)) + ClearKey.from_m3u_key(data_key(b"\x00" * 8)) def test_raw_data_uri_key_materialises_and_validates() -> None: # A raw (non-base64) data: payload must be taken as literal key bytes, not hex-parsed. - drm = ClearKey.from_m3u_key(_data_key(KEY, base64_encoded=False)) + drm = ClearKey.from_m3u_key(data_key(KEY, base64_encoded=False)) assert drm.key == KEY def test_raw_data_uri_key_wrong_length_rejected() -> None: with pytest.raises(ValueError, match="Unexpected Length"): - ClearKey.from_m3u_key(_data_key(b"\x01" * 8, base64_encoded=False)) + ClearKey.from_m3u_key(data_key(b"\x01" * 8, base64_encoded=False)) def test_data_uri_split_tolerates_commas_in_payload() -> None: diff --git a/tests/core/test_code_hash.py b/tests/core/test_code_hash.py index 68ee6d92..759b8e6e 100644 --- a/tests/core/test_code_hash.py +++ b/tests/core/test_code_hash.py @@ -70,5 +70,5 @@ def test_unreadable_source_yields_empty_string() -> None: def test_unwalkable_source_yields_empty_string(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A dir os.walk cannot enter must yield "", not a valid-looking hash of fewer files.""" (tmp_path / "__main__.py").write_bytes(b"") - monkeypatch.setattr(unshackle.core, "_PKG", tmp_path) + monkeypatch.setattr(unshackle.core, "PKG", tmp_path) assert code_hash() == "" diff --git a/tests/core/test_disable_metadata.py b/tests/core/test_disable_metadata.py index 0bf47394..90ada3fc 100644 --- a/tests/core/test_disable_metadata.py +++ b/tests/core/test_disable_metadata.py @@ -35,7 +35,7 @@ class FakeProvider(MetadataProvider): @pytest.fixture(autouse=True) -def _disabled(monkeypatch: pytest.MonkeyPatch) -> None: +def disabled(monkeypatch: pytest.MonkeyPatch) -> None: """Turn metadata off and keep every test off the network.""" monkeypatch.setattr(config, "disable_metadata", True) monkeypatch.setattr(config, "metadata_providers", []) diff --git a/tests/core/test_dual_multi_naming.py b/tests/core/test_dual_multi_naming.py index 6e385275..b5b8f839 100644 --- a/tests/core/test_dual_multi_naming.py +++ b/tests/core/test_dual_multi_naming.py @@ -47,7 +47,7 @@ def count_mode(monkeypatch): def ctx(language, languages) -> dict: - return make_movie(language)._build_base_template_context(make_media_info(languages)) + return make_movie(language).build_base_template_context(make_media_info(languages)) def test_strict_dual_with_original(strict_mode): diff --git a/tests/core/test_episode_absolute.py b/tests/core/test_episode_absolute.py index d3a76988..52b21f35 100644 --- a/tests/core/test_episode_absolute.py +++ b/tests/core/test_episode_absolute.py @@ -90,15 +90,15 @@ def test_old_cache_entry_reads_as_absolute_less(): def test_absolute_token_is_three_wide(reset_template): - assert make_episode(absolute=7)._build_template_context(StubMediaInfo())["absolute"] == "007" + assert make_episode(absolute=7).build_template_context(StubMediaInfo())["absolute"] == "007" def test_absolute_token_does_not_truncate_past_three_digits(reset_template): - assert make_episode(absolute=1042)._build_template_context(StubMediaInfo())["absolute"] == "1042" + assert make_episode(absolute=1042).build_template_context(StubMediaInfo())["absolute"] == "1042" def test_absolute_token_is_empty_without_a_value(reset_template): - assert make_episode()._build_template_context(StubMediaInfo())["absolute"] == "" + assert make_episode().build_template_context(StubMediaInfo())["absolute"] == "" def test_filename_renders_the_absolute_number(reset_template): @@ -112,7 +112,7 @@ def test_optional_absolute_token_drops_out(reset_template): def test_absolute_is_a_known_template_variable(reset_template, recwarn): - reset_template._validate_output_templates() + reset_template.validate_output_templates() assert not [w for w in recwarn.list if "absolute" in str(w.message)] @@ -139,7 +139,7 @@ class _FakeProvider: return self.mapping -def _fill( +def fill( monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict, tvdb_id=73871, provider=True, order="official" ): from unshackle.commands.dl import dl @@ -163,7 +163,7 @@ MAPPING = {(1, 1): (1, 1, "One"), (1, 2): (1, 2, "Two"), (2, 1): (1, 3, "Three") def test_fill_populates_missing_absolute_numbers(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=1, number=1), make_episode(season=1, number=2), make_episode(season=2, number=1)] - titles, fake, _ = _fill(monkeypatch, episodes, MAPPING) + titles, fake, _ = fill(monkeypatch, episodes, MAPPING) assert [t.absolute for t in titles] == [1, 2, 3] assert fake.orders == ["absolute"] @@ -171,45 +171,45 @@ def test_fill_populates_missing_absolute_numbers(monkeypatch: pytest.MonkeyPatch def test_a_service_already_in_absolute_order_fills_from_its_own_numbers(monkeypatch: pytest.MonkeyPatch) -> None: """get_order_map returns {} for the same-order case; the numbers are their own fill.""" episodes = [make_episode(season=1, number=26), make_episode(season=1, number=27)] - titles, fake, _ = _fill(monkeypatch, episodes, {}, order="absolute") + titles, fake, _ = fill(monkeypatch, episodes, {}, order="absolute") assert [t.absolute for t in titles] == [26, 27] assert fake.orders == [] def test_fill_never_touches_season_or_number(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=2, number=1)] - titles, _, _ = _fill(monkeypatch, episodes, MAPPING) + titles, _, _ = fill(monkeypatch, episodes, MAPPING) assert [(t.season, t.number) for t in titles] == [(2, 1)] def test_a_service_set_absolute_number_wins(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=1, number=1, absolute=99), make_episode(season=1, number=2)] - titles, _, _ = _fill(monkeypatch, episodes, MAPPING) + titles, _, _ = fill(monkeypatch, episodes, MAPPING) assert [t.absolute for t in titles] == [99, 2] def test_an_unmapped_episode_keeps_no_absolute_number(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=9, number=9)] - titles, _, _ = _fill(monkeypatch, episodes, MAPPING) + titles, _, _ = fill(monkeypatch, episodes, MAPPING) assert titles[0].absolute is None def test_fill_skips_silently_without_an_api_key(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=1, number=1)] - titles, _, logged = _fill(monkeypatch, episodes, MAPPING, provider=False) + titles, _, logged = fill(monkeypatch, episodes, MAPPING, provider=False) assert titles[0].absolute is None assert [level for level, _ in logged] == ["debug"] def test_fill_skips_silently_without_a_tvdb_id(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=1, number=1)] - titles, _, logged = _fill(monkeypatch, episodes, MAPPING, tvdb_id=None) + titles, _, logged = fill(monkeypatch, episodes, MAPPING, tvdb_id=None) assert titles[0].absolute is None assert [level for level, _ in logged] == ["debug"] def test_fill_skips_silently_when_tvdb_has_no_absolute_order(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(season=1, number=1)] - titles, _, logged = _fill(monkeypatch, episodes, {}) + titles, _, logged = fill(monkeypatch, episodes, {}) assert titles[0].absolute is None assert [level for level, _ in logged] == ["debug"] diff --git a/tests/core/test_episode_air_date.py b/tests/core/test_episode_air_date.py index 87794e4e..693898c2 100644 --- a/tests/core/test_episode_air_date.py +++ b/tests/core/test_episode_air_date.py @@ -47,7 +47,7 @@ def reset_template(): def test_air_date_replaces_season_episode_dotted(reset_template): ep = make_episode(air_date=date(2024, 6, 30)) - ctx = ep._build_template_context(StubMediaInfo()) + ctx = ep.build_template_context(StubMediaInfo()) assert ctx["season_episode"] == "2024.06.30" assert ctx["season"] == "2024.06.30" assert ctx["episode"] == "" @@ -58,19 +58,19 @@ def test_air_date_replaces_season_episode_dotted(reset_template): def test_air_date_uses_space_separator(reset_template): reset_template.output_template = {"series": "{title} {season_episode} {quality}-{tag}"} ep = make_episode(air_date=date(2024, 6, 30)) - assert ep._build_template_context(StubMediaInfo())["season_episode"] == "2024 06 30" + assert ep.build_template_context(StubMediaInfo())["season_episode"] == "2024 06 30" def test_iso_string_air_date_coerced(reset_template): ep = make_episode(air_date="2024-06-30") assert isinstance(ep.air_date, date) - assert ep._build_template_context(StubMediaInfo())["season_episode"] == "2024.06.30" + assert ep.build_template_context(StubMediaInfo())["season_episode"] == "2024.06.30" def test_year_always_dropped_in_file_when_dated(reset_template): """The air date is the sole date in the filename; a distinct year is dropped too (deterministic).""" ep = make_episode(year=2010, air_date=date(2013, 10, 30)) - ctx = ep._build_template_context(StubMediaInfo()) + ctx = ep.build_template_context(StubMediaInfo()) assert ctx["year"] == "" assert ctx["season_episode"] == "2013.10.30" @@ -78,7 +78,7 @@ def test_year_always_dropped_in_file_when_dated(reset_template): def test_yearless_title_takes_year_from_date(reset_template): """A title with no year still names cleanly; the date supplies the year.""" ep = make_episode(year=None, air_date=date(2026, 6, 28)) - ctx = ep._build_template_context(StubMediaInfo()) + ctx = ep.build_template_context(StubMediaInfo()) assert ctx["year"] == "" assert ctx["season_episode"] == "2026.06.28" @@ -87,13 +87,13 @@ def test_datetime_normalized_to_date(reset_template): """A datetime (subclass of date) must not leak its time into the {date} token.""" ep = make_episode(air_date=datetime(2024, 6, 30, 13, 0, 0)) assert type(ep.air_date) is date - assert ep._build_template_context(StubMediaInfo())["date"] == "2024-06-30" + assert ep.build_template_context(StubMediaInfo())["date"] == "2024-06-30" def test_no_air_date_is_unchanged(reset_template): """Regression: without air_date, naming is exactly as before (presentation-only field).""" ep = make_episode() - ctx = ep._build_template_context(StubMediaInfo()) + ctx = ep.build_template_context(StubMediaInfo()) assert ctx["season_episode"] == "S2024E181" assert ctx["season"] == "S2024" assert ctx["episode"] == "E181" diff --git a/tests/core/test_episode_part.py b/tests/core/test_episode_part.py index b4d1e749..d166c31f 100644 --- a/tests/core/test_episode_part.py +++ b/tests/core/test_episode_part.py @@ -144,7 +144,7 @@ def test_spaced_template_uses_spaces(reset_template): def test_template_context_leaves_the_season_token_alone(reset_template): - ctx = make_episode(part=2)._build_template_context(StubMediaInfo()) + ctx = make_episode(part=2).build_template_context(StubMediaInfo()) assert ctx["season"] == "S01" assert ctx["episode"] == "E01.Part.2" assert ctx["season_episode"] == "S01E01.Part.2" @@ -152,7 +152,7 @@ def test_template_context_leaves_the_season_token_alone(reset_template): def test_part_token_is_empty_without_a_part(reset_template): - assert make_episode()._build_template_context(StubMediaInfo())["part"] == "" + assert make_episode().build_template_context(StubMediaInfo())["part"] == "" def test_parts_share_one_season_folder(reset_template): diff --git a/tests/core/test_fill_air_dates.py b/tests/core/test_fill_air_dates.py index fcca4b81..101f4b1c 100644 --- a/tests/core/test_fill_air_dates.py +++ b/tests/core/test_fill_air_dates.py @@ -39,7 +39,7 @@ class _FakeProvider: return self.episodes -def _fill( +def fill( monkeypatch: pytest.MonkeyPatch, titles: list, episodes: list, @@ -74,93 +74,93 @@ LISTING = [tvdb_episode(1, 1, "2026-08-10"), tvdb_episode(1, 2, "2026-08-11")] def test_fill_populates_missing_air_dates(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(number=1), make_episode(number=2)] - series, logged = _fill(monkeypatch, episodes, LISTING) + series, logged = fill(monkeypatch, episodes, LISTING) assert [t.air_date for t in series] == [date(2026, 8, 10), date(2026, 8, 11)] assert [level for level, _ in logged] == ["info"] def test_a_service_set_air_date_wins(monkeypatch: pytest.MonkeyPatch) -> None: episodes = [make_episode(number=1, air_date="1999-01-02"), make_episode(number=2)] - series, _ = _fill(monkeypatch, episodes, LISTING) + series, _ = fill(monkeypatch, episodes, LISTING) assert [t.air_date for t in series] == [date(1999, 1, 2), date(2026, 8, 11)] def test_fill_never_touches_season_or_number(monkeypatch: pytest.MonkeyPatch) -> None: - series, _ = _fill(monkeypatch, [make_episode(number=2)], LISTING) + series, _ = fill(monkeypatch, [make_episode(number=2)], LISTING) assert [(t.season, t.number) for t in series] == [(1, 2)] def test_an_unmapped_episode_keeps_no_air_date(monkeypatch: pytest.MonkeyPatch) -> None: - series, _ = _fill(monkeypatch, [make_episode(season=9, number=9)], LISTING) + series, _ = fill(monkeypatch, [make_episode(season=9, number=9)], LISTING) assert series[0].air_date is None @pytest.mark.parametrize("aired", [None, "", "not-a-date", "2026-13-40"]) def test_an_unusable_aired_value_is_skipped(monkeypatch: pytest.MonkeyPatch, aired) -> None: - series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, aired)]) + series, _ = fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, aired)]) assert series[0].air_date is None def test_a_pre_1970_date_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: - series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "1900-01-01")]) + series, _ = fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "1900-01-01")]) assert series[0].air_date is None def test_a_future_placeholder_date_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: future = (date.today() + timedelta(days=30)).isoformat() - series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, future)]) + series, _ = fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, future)]) assert series[0].air_date is None def test_tomorrow_still_fills(monkeypatch: pytest.MonkeyPatch) -> None: tomorrow = date.today() + timedelta(days=1) - series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, tomorrow.isoformat())]) + series, _ = fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, tomorrow.isoformat())]) assert series[0].air_date == tomorrow def test_a_datetime_stamped_aired_value_keeps_the_date(monkeypatch: pytest.MonkeyPatch) -> None: - series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "2026-08-10T20:00:00Z")]) + series, _ = fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "2026-08-10T20:00:00Z")]) assert series[0].air_date == date(2026, 8, 10) def test_fill_skips_silently_without_an_api_key(monkeypatch: pytest.MonkeyPatch) -> None: - series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, provider=False) + series, logged = fill(monkeypatch, [make_episode(number=1)], LISTING, provider=False) assert series[0].air_date is None assert [level for level, _ in logged] == ["debug"] def test_fill_skips_silently_without_a_tvdb_id(monkeypatch: pytest.MonkeyPatch) -> None: - series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, tvdb_id=None) + series, logged = fill(monkeypatch, [make_episode(number=1)], LISTING, tvdb_id=None) assert series[0].air_date is None assert [level for level, _ in logged] == ["debug"] def test_fill_skips_silently_when_tvdb_lists_no_episodes(monkeypatch: pytest.MonkeyPatch) -> None: - series, logged = _fill(monkeypatch, [make_episode(number=1)], []) + series, logged = fill(monkeypatch, [make_episode(number=1)], []) assert series[0].air_date is None assert [level for level, _ in logged] == ["debug"] def test_nothing_is_filled_when_no_title_is_daily(monkeypatch: pytest.MonkeyPatch) -> None: - series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False) + series, logged = fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False) assert series[0].air_date is None assert logged == [] def test_the_service_daily_flag_fills_without_the_cli_flag(monkeypatch: pytest.MonkeyPatch) -> None: - series, _ = _fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False, service_daily=True) + series, _ = fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False, service_daily=True) assert series[0].air_date == date(2026, 8, 10) def test_a_per_title_daily_false_is_skipped_while_others_fill(monkeypatch: pytest.MonkeyPatch) -> None: first, second = make_episode(number=1), make_episode(number=2) first.daily = False - series, _ = _fill(monkeypatch, [first, second], LISTING, daily=False, service_daily=True) + series, _ = fill(monkeypatch, [first, second], LISTING, daily=False, service_daily=True) assert [t.air_date for t in series] == [None, date(2026, 8, 11)] def test_the_cli_flag_overrides_a_per_title_daily_false(monkeypatch: pytest.MonkeyPatch) -> None: episode = make_episode(number=1) episode.daily = False - series, _ = _fill(monkeypatch, [episode], LISTING, daily=True) + series, _ = fill(monkeypatch, [episode], LISTING, daily=True) assert series[0].air_date == date(2026, 8, 10) diff --git a/tests/core/test_imdb_provider.py b/tests/core/test_imdb_provider.py index f042f226..e8a06b3f 100644 --- a/tests/core/test_imdb_provider.py +++ b/tests/core/test_imdb_provider.py @@ -18,7 +18,7 @@ class _Response: return self._payload -def _node( +def node( imdb_id: str = "tt3581920", text: str = "The Last of Us", year: Optional[int] = 2023, @@ -67,14 +67,14 @@ def test_is_available_without_any_key() -> None: def test_cache_hit_uses_one_get(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [{"data": {"title": _node()}}]) + p, session = _provider(monkeypatch, [{"data": {"title": node()}}]) result = p.get_by_id("tt3581920", "tv") assert result is not None and result.title == "The Last of Us" assert len(session.gets) == 1 and session.posts == [] def test_persisted_query_miss_falls_back_to_one_post(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [NOT_FOUND, {"data": {"title": _node()}}]) + p, session = _provider(monkeypatch, [NOT_FOUND, {"data": {"title": node()}}]) result = p.get_by_id("tt3581920", "tv") assert result is not None and result.external_ids.imdb_id == "tt3581920" assert len(session.gets) == 1 and len(session.posts) == 1 @@ -86,14 +86,14 @@ def test_persisted_query_miss_falls_back_to_one_post(monkeypatch: pytest.MonkeyP def test_get_params_carry_no_spaces(monkeypatch: pytest.MonkeyPatch) -> None: """IMDb's GraphQL gateway decodes "+" literally, so spaced JSON would come back as a 400.""" - p, session = _provider(monkeypatch, [{"data": {"title": _node()}}]) + p, session = _provider(monkeypatch, [{"data": {"title": node()}}]) p.get_by_id("tt3581920", "tv") assert " " not in session.gets[0]["extensions"] assert " " not in session.gets[0]["variables"] def test_get_by_id_maps_every_field(monkeypatch: pytest.MonkeyPatch) -> None: - p, _ = _provider(monkeypatch, [{"data": {"title": _node()}}]) + p, _ = _provider(monkeypatch, [{"data": {"title": node()}}]) result = p.get_by_id("tt3581920", "tv") assert result is not None assert (result.title, result.year, result.kind) == ("The Last of Us", 2023, "tv") @@ -105,7 +105,7 @@ def test_get_by_id_maps_every_field(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize("title_type", ["movie", "tvSeries", "videoGame"]) def test_caller_kind_wins_over_the_response_title_type(monkeypatch: pytest.MonkeyPatch, title_type: str) -> None: """kind stays the caller's, so it cannot desync from the tmdb_kind resolve_by_ids seeds.""" - p, _ = _provider(monkeypatch, [{"data": {"title": _node(title_type=title_type)}}]) + p, _ = _provider(monkeypatch, [{"data": {"title": node(title_type=title_type)}}]) result = p.get_by_id("tt1375666", "tv") assert result is not None and result.kind == "tv" @@ -116,12 +116,12 @@ def test_get_by_id_returns_none_on_no_answer(monkeypatch: pytest.MonkeyPatch, pa assert p.get_by_id("tt3581920", "tv") is None -def _edges(*nodes: dict) -> dict: +def edges(*nodes: dict) -> dict: return {"data": {"advancedTitleSearch": {"edges": [{"node": {"title": n}} for n in nodes]}}} def test_search_uses_its_own_operation_and_one_get_on_a_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [_edges(_node())]) + p, session = _provider(monkeypatch, [edges(node())]) result = p.search("The Last of Us", 2023, "tv") assert result is not None and result.external_ids.imdb_id == "tt3581920" assert session.gets[0]["operationName"] == "UnshackleTitleSearch" @@ -129,7 +129,7 @@ def test_search_uses_its_own_operation_and_one_get_on_a_cache_hit(monkeypatch: p def test_search_query_text_carries_the_constraints(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [NOT_FOUND, _edges(_node())]) + p, session = _provider(monkeypatch, [NOT_FOUND, edges(node())]) p.search("The Last of Us", 2023, "tv") query = session.posts[0]["query"] assert 'searchTerm: "The Last of Us"' in query @@ -138,7 +138,7 @@ def test_search_query_text_carries_the_constraints(monkeypatch: pytest.MonkeyPat def test_search_omits_the_year_range_when_unknown(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [NOT_FOUND, _edges(_node(title_type="movie"))]) + p, session = _provider(monkeypatch, [NOT_FOUND, edges(node(title_type="movie"))]) p.search("Inception", None, "movie") query = session.posts[0]["query"] assert "releaseDateConstraint" not in query @@ -146,26 +146,26 @@ def test_search_omits_the_year_range_when_unknown(monkeypatch: pytest.MonkeyPatc def test_search_picks_the_closest_title_not_the_first(monkeypatch: pytest.MonkeyPatch) -> None: - noise = _node(imdb_id="tt27478132", text="Making of: The Last of Us") - exact = _node() - p, _ = _provider(monkeypatch, [_edges(noise, exact)]) + noise = node(imdb_id="tt27478132", text="Making of: The Last of Us") + exact = node() + p, _ = _provider(monkeypatch, [edges(noise, exact)]) result = p.search("The Last of Us", 2023, "tv") assert result is not None and result.external_ids.imdb_id == "tt3581920" def test_search_rejects_a_title_that_is_not_a_close_match(monkeypatch: pytest.MonkeyPatch) -> None: - p, _ = _provider(monkeypatch, [_edges(_node(imdb_id="tt999", text="Something Else Entirely"))]) + p, _ = _provider(monkeypatch, [edges(node(imdb_id="tt999", text="Something Else Entirely"))]) assert p.search("The Last of Us", 2023, "tv") is None def test_search_survives_a_null_language_block(monkeypatch: pytest.MonkeyPatch) -> None: - p, _ = _provider(monkeypatch, [_edges(_node(languages=None))]) + p, _ = _provider(monkeypatch, [edges(node(languages=None))]) result = p.search("The Last of Us", 2023, "tv") assert result is not None and result.original_language is None def test_search_returns_none_when_nothing_comes_back(monkeypatch: pytest.MonkeyPatch) -> None: - p, _ = _provider(monkeypatch, [_edges()]) + p, _ = _provider(monkeypatch, [edges()]) assert p.search("The Last of Us", 2023, "tv") is None @@ -174,7 +174,7 @@ def test_escape_graphql_closes_the_string_injection() -> None: def test_search_term_with_a_quote_stays_inside_the_string(monkeypatch: pytest.MonkeyPatch) -> None: - p, session = _provider(monkeypatch, [NOT_FOUND, _edges(_node(text='The "Best" Show'))]) + p, session = _provider(monkeypatch, [NOT_FOUND, edges(node(text='The "Best" Show'))]) p.search('The "Best" Show', None, "tv") assert 'searchTerm: "The \\"Best\\" Show"' in session.posts[0]["query"] diff --git a/tests/core/test_ism_drm_systemid.py b/tests/core/test_ism_drm_systemid.py new file mode 100644 index 00000000..56615968 --- /dev/null +++ b/tests/core/test_ism_drm_systemid.py @@ -0,0 +1,55 @@ +"""ProtectionHeader SystemID parsing. + +MS-SSTR manifests commonly write the GUID in registry format +("{EDEF8BA9-...}"); a bare-GUID comparison silently drops the DRM. +""" + +from __future__ import annotations + +from uuid import UUID + +import pytest +from pywidevine import PSSH + +from unshackle.core.drm import Widevine +from unshackle.core.manifests.ism import ISM + +WIDEVINE_PSSH_B64 = PSSH.new(key_ids=["00112233445566778899aabbccddeeff"], system_id=PSSH.SystemId.Widevine).dumps() + +MANIFEST = """ + + + {pssh} + + + + + + +""" + + +@pytest.mark.parametrize( + "system_id", + [ + "EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED", + "{EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED}", + ], +) +def test_get_drm_resolves_braced_and_bare_system_ids(system_id): + manifest = MANIFEST.format(system_id=system_id, pssh=WIDEVINE_PSSH_B64) + ism = ISM.from_text(manifest, "https://example.test/Manifest") + drm = ISM.get_drm(ism.manifest.xpath(".//ProtectionHeader")) + assert len(drm) == 1 + assert isinstance(drm[0], Widevine) + assert drm[0].kid == UUID("00112233-4455-6677-8899-aabbccddeeff") + + +def test_get_drm_warns_on_unparseable_header(caplog): + manifest = MANIFEST.format(system_id="EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED", pssh="!!!not-base64!!!") + ism = ISM.from_text(manifest, "https://example.test/Manifest") + with caplog.at_level("WARNING", logger="ISM"): + drm = ISM.get_drm(ism.manifest.xpath(".//ProtectionHeader")) + assert drm == [] + assert any("unparseable Widevine" in r.message for r in caplog.records) diff --git a/tests/core/test_ism_init.py b/tests/core/test_ism_init.py index 0b9378eb..4d149468 100644 --- a/tests/core/test_ism_init.py +++ b/tests/core/test_ism_init.py @@ -581,7 +581,7 @@ def test_hvcc_profile_tier_level_is_nonzero(): assert level_idc != 0 -def _piff_fragment(flags: int = 0x0, override: bool = False) -> bytes: +def piff_fragment(flags: int = 0x0, override: bool = False) -> bytes: """Minimal moof+mdat fragment carrying sample encryption in the PIFF uuid box.""" ivs = b"".join(bytes([i]) * 8 for i in range(1, 3)) senc_payload = struct.pack(">I", 2) + ivs @@ -599,7 +599,7 @@ def _piff_fragment(flags: int = 0x0, override: bool = False) -> bytes: def test_piff_senc_rewritten_to_cenc_senc(): - frag = _piff_fragment() + frag = piff_fragment() out = piff_senc_to_cenc(frag) # trun's data_offset is moof-relative; shrinking the box misaims decryption assert len(out) == len(frag) @@ -612,7 +612,7 @@ def test_piff_senc_rewritten_to_cenc_senc(): def test_piff_senc_override_header_stripped(): - frag = _piff_fragment(override=True) + frag = piff_fragment(override=True) out = piff_senc_to_cenc(frag) assert len(out) == len(frag) assert PIFF_SENC_UUID not in out @@ -624,7 +624,7 @@ def test_piff_senc_override_header_stripped(): def test_piff_subsample_flag_survives_override_strip(): # losing 0x2 is silent: no subsample map, so clear NAL headers get encrypted too - frag = _piff_fragment(flags=0x2, override=True) + frag = piff_fragment(flags=0x2, override=True) out = piff_senc_to_cenc(frag) senc = out.index(b"senc") flags = int.from_bytes(out[senc + 5 : senc + 8], "big") @@ -648,7 +648,7 @@ def test_piff_rewrite_skips_traf_that_already_has_senc(): def test_piff_rewrite_covers_every_moof_and_traf(): - frag = _piff_fragment() + _piff_fragment() + frag = piff_fragment() + piff_fragment() out = piff_senc_to_cenc(frag) assert len(out) == len(frag) assert PIFF_SENC_UUID not in out @@ -683,8 +683,8 @@ def traf_children_tile_exactly(frag: bytes) -> bool: def test_piff_rewrite_keeps_traf_children_tiling_exactly(): # a mis-sized senc still "decrypts", just off the wrong bytes, so assert the sizes too - assert traf_children_tile_exactly(_piff_fragment()) - for frag in (_piff_fragment(), _piff_fragment(flags=0x2), _piff_fragment(flags=0x2, override=True)): + assert traf_children_tile_exactly(piff_fragment()) + for frag in (piff_fragment(), piff_fragment(flags=0x2), piff_fragment(flags=0x2, override=True)): assert traf_children_tile_exactly(piff_senc_to_cenc(frag)), "rewritten traf no longer tiles" @@ -700,7 +700,7 @@ def test_piff_rewrite_refuses_override_iv_size_mismatch(): def test_piff_rewrite_never_grows_output_on_malformed_boxes(): # A declared size running past the buffer would extend the output over the mdat. - good = _piff_fragment() + good = piff_fragment() truncations = [good[:n] for n in range(8, len(good), 7)] oversized = bytearray(good) struct.pack_into(">I", oversized, 0, len(good) + 4096) # moof claims more than exists @@ -709,7 +709,7 @@ def test_piff_rewrite_never_grows_output_on_malformed_boxes(): assert len(out) == len(frag), f"output grew: {len(frag)} -> {len(out)}" -def _text_fragment(seq: int, begin: str, end: str, text: str, sdi: int) -> bytes: +def text_fragment(seq: int, begin: str, end: str, text: str, sdi: int) -> bytes: """A single-sample fTTML moof+mdat, mirroring what a Smooth text stream serves.""" payload = ( "" @@ -771,7 +771,7 @@ def test_pymp4_consumes_whole_init_and_reaches_fragments(): init = build_init_segment( stream_type="text", fourcc="TTML", codec_private_data="", duration=6_000_000_000, language="eng" ) - data = init + _text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1) + data = init + text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1) types = [b.type for b in MP4.parse_stream(BytesIO(data))] assert types == [b"ftyp", b"moov", b"moof", b"mdat"] @@ -784,8 +784,8 @@ def test_fttml_subtitle_parse_yields_cues(): ) data = ( init - + _text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1) - + _text_fragment(2, "00:00:03.000", "00:00:04.000", "second", sdi=2) + + text_fragment(1, "00:00:01.000", "00:00:02.000", "first", sdi=1) + + text_fragment(2, "00:00:03.000", "00:00:04.000", "second", sdi=2) ) caption_set = Subtitle.parse(data, Subtitle.Codec.fTTML) cues = [c for lang in caption_set.get_languages() for c in caption_set.get_captions(lang)] diff --git a/tests/core/test_ism_parse.py b/tests/core/test_ism_parse.py index 0f0aa28b..80bb27fc 100644 --- a/tests/core/test_ism_parse.py +++ b/tests/core/test_ism_parse.py @@ -28,6 +28,17 @@ def test_placeholder_spellings_repeat_and_audiotag_fallback() -> None: assert audio.codec == Audio.Codec.AAC # AudioTag 255 means AACL +def test_duration_fallback_when_next_fragment_omits_time() -> None: + # A d-less whose successor also omits t must fall back to the manifest + # duration (int(None) used to raise TypeError past the except clause). + sparse = MANIFEST.replace('', '') + tracks = ISM.from_text(sparse, "https://cdn.example/x.ism/manifest").to_tracks() + assert tracks.audio[0].data["ism"]["segments"] == [ + "https://cdn.example/x.ism/QualityLevels(128000)/Fragments(audio=0)", + "https://cdn.example/x.ism/QualityLevels(128000)/Fragments(audio=60000000)", + ] + + def test_missing_language_without_fallback_raises() -> None: # MS-SSTR Language is optional; video StreamIndexes commonly omit it. no_lang = MANIFEST.replace(' Language="en"', "") diff --git a/tests/core/test_metadata_id_validation.py b/tests/core/test_metadata_id_validation.py index 909b57f6..db13890e 100644 --- a/tests/core/test_metadata_id_validation.py +++ b/tests/core/test_metadata_id_validation.py @@ -11,11 +11,11 @@ from unshackle.core.config import config @pytest.fixture(autouse=True) -def _default_order(monkeypatch: pytest.MonkeyPatch) -> None: +def default_order(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(config, "metadata_providers", []) -def _with_keys(monkeypatch: pytest.MonkeyPatch, **keys: str) -> None: +def with_keys(monkeypatch: pytest.MonkeyPatch, **keys: str) -> None: for name in ("tmdb_api_key", "tvdb_api_key", "omdb_api_key", "simkl_client_id"): monkeypatch.setattr(config, name, keys.get(name, "")) @@ -38,7 +38,7 @@ def test_no_ids_is_fine() -> None: def test_imdb_alone_passes_on_the_keyless_provider(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) assert validate_metadata_ids(None, "tt1375666", None) is None @@ -46,26 +46,26 @@ def test_imdb_alone_passes_on_the_keyless_provider(monkeypatch: pytest.MonkeyPat def test_tmdb_without_its_key_names_the_key(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(27205, None, None) assert "--tmdb" in str(exc.value) and "tmdb_api_key" in str(exc.value) def test_tvdb_without_its_key_names_the_key(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(None, None, 73871) assert "--tvdb" in str(exc.value) and "tvdb_api_key" in str(exc.value) def test_tmdb_with_its_key_passes(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch, tmdb_api_key="k") + with_keys(monkeypatch, tmdb_api_key="k") assert validate_metadata_ids(27205, None, None) is None def test_a_provider_filtered_out_of_the_order_points_at_the_config(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch, tmdb_api_key="k") + with_keys(monkeypatch, tmdb_api_key="k") monkeypatch.setattr(config, "metadata_providers", ["imdb", "omdb"]) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(27205, None, None) @@ -73,7 +73,7 @@ def test_a_provider_filtered_out_of_the_order_points_at_the_config(monkeypatch: def test_imdb_is_unresolvable_when_the_order_drops_both_of_its_providers(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) monkeypatch.setattr(config, "metadata_providers", ["tmdb", "tvdb"]) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(None, "tt1375666", None) @@ -83,7 +83,7 @@ def test_imdb_is_unresolvable_when_the_order_drops_both_of_its_providers(monkeyp def test_one_kind_keeping_the_provider_is_enough(monkeypatch: pytest.MonkeyPatch) -> None: """A per-kind order can drop tvdb for movies and keep it for tv, so this must not error.""" - _with_keys(monkeypatch, tvdb_api_key="k") + with_keys(monkeypatch, tvdb_api_key="k") monkeypatch.setattr(config, "metadata_providers", {"tv": ["tvdb"], "movie": ["tmdb"]}) assert validate_metadata_ids(None, None, 73871) is None @@ -119,17 +119,17 @@ def test_api_still_reports_a_malformed_id_first() -> None: @pytest.mark.parametrize("value", [21, "21", "mal:21"]) def test_anilist_alone_passes_without_any_key(monkeypatch: pytest.MonkeyPatch, value: object) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) assert validate_metadata_ids(None, None, None, value) is None def test_anilist_pairs_with_one_other_id(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) assert validate_metadata_ids(None, "tt1375666", None, 21) is None def test_anilist_does_not_excuse_two_western_ids(monkeypatch: pytest.MonkeyPatch) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(27205, "tt1375666", None, 21) assert "cannot be used together" in str(exc.value) @@ -137,7 +137,7 @@ def test_anilist_does_not_excuse_two_western_ids(monkeypatch: pytest.MonkeyPatch @pytest.mark.parametrize("value", ["tt1375666", "anilist:21", "mal:", "abc", "-3"]) def test_a_malformed_anilist_value_is_refused(monkeypatch: pytest.MonkeyPatch, value: str) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(None, None, None, value) assert "--anilist" in str(exc.value) @@ -165,7 +165,7 @@ def test_api_refuses_a_malformed_anilist_id(value: object) -> None: @pytest.mark.parametrize("value", ["0", "mal:0", "²", "21.5"]) def test_a_non_positive_or_non_decimal_anilist_value_is_refused(monkeypatch: pytest.MonkeyPatch, value: str) -> None: - _with_keys(monkeypatch) + with_keys(monkeypatch) with pytest.raises(click.UsageError) as exc: validate_metadata_ids(None, None, None, value) assert "--anilist" in str(exc.value) diff --git a/tests/core/test_resolve_by_ids.py b/tests/core/test_resolve_by_ids.py index 009548e4..a9821c52 100644 --- a/tests/core/test_resolve_by_ids.py +++ b/tests/core/test_resolve_by_ids.py @@ -38,7 +38,7 @@ class FakeProvider(MetadataProvider): @pytest.fixture(autouse=True) -def _isolate(monkeypatch: pytest.MonkeyPatch) -> None: +def isolate(monkeypatch: pytest.MonkeyPatch) -> None: """Keep every test off the network and on the default provider order.""" monkeypatch.setattr(config, "metadata_providers", []) monkeypatch.setattr(providers, "fetch_external_ids", lambda *a, **kw: ExternalIds()) @@ -50,7 +50,7 @@ def _isolate(monkeypatch: pytest.MonkeyPatch) -> None: ) -def _patch_providers(monkeypatch: pytest.MonkeyPatch, available: dict[str, FakeProvider]) -> None: +def patch_providers(monkeypatch: pytest.MonkeyPatch, available: dict[str, FakeProvider]) -> None: monkeypatch.setattr(providers, "get_provider", lambda name: available.get(name)) @@ -60,7 +60,7 @@ def test_supplied_ids_survive_a_providers_own_answer(monkeypatch: pytest.MonkeyP "tmdb", MetadataResult(title="Inception", year=2010, kind="movie", external_ids=ExternalIds(imdb_id="tt9999999")), ) - _patch_providers(monkeypatch, {"tmdb": tmdb}) + patch_providers(monkeypatch, {"tmdb": tmdb}) result = providers.resolve_by_ids(27205, "tt1375666", kind="movie") @@ -73,7 +73,7 @@ def test_supplied_ids_survive_a_providers_own_answer(monkeypatch: pytest.MonkeyP def test_imdb_falls_through_to_omdb(monkeypatch: pytest.MonkeyPatch) -> None: imdb = FakeProvider("imdb", raises=True) omdb = FakeProvider("omdb", MetadataResult(title="Inception", year=2010, kind="movie")) - _patch_providers(monkeypatch, {"imdb": imdb, "omdb": omdb}) + patch_providers(monkeypatch, {"imdb": imdb, "omdb": omdb}) result = providers.resolve_by_ids(imdb_id="tt1375666", kind="movie") @@ -84,7 +84,7 @@ def test_imdb_falls_through_to_omdb(monkeypatch: pytest.MonkeyPatch) -> None: def test_all_providers_down_synthesizes_a_result(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) result = providers.resolve_by_ids(tvdb_id=73871, title="Futurama", year=1999, kind="tv") @@ -97,7 +97,7 @@ def test_all_providers_down_synthesizes_a_result(monkeypatch: pytest.MonkeyPatch def test_no_ids_searches(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[tuple] = [] monkeypatch.setattr(providers, "search_metadata", lambda *a, **kw: calls.append(a) or None) - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) providers.resolve_by_ids(title="Futurama", year=1999, kind="tv") @@ -105,7 +105,7 @@ def test_no_ids_searches(monkeypatch: pytest.MonkeyPatch) -> None: def test_no_ids_and_no_title_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) assert providers.resolve_by_ids(kind="tv") is None @@ -113,7 +113,7 @@ def test_per_kind_order_puts_tvdb_first_for_tv(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(config, "metadata_providers", {"tv": ["tvdb", "tmdb", "imdb", "omdb"]}) tvdb = FakeProvider("tvdb", MetadataResult(title="Futurama", year=1999, kind="tv")) tmdb = FakeProvider("tmdb", MetadataResult(title="Wrong Show", year=2020, kind="tv")) - _patch_providers(monkeypatch, {"tvdb": tvdb, "tmdb": tmdb}) + patch_providers(monkeypatch, {"tvdb": tvdb, "tmdb": tmdb}) result = providers.resolve_by_ids(615, tvdb_id=73871, kind="tv") @@ -140,7 +140,7 @@ def test_an_anilist_id_reaches_the_anilist_provider(monkeypatch: pytest.MonkeyPa MetadataResult(title="One Piece", year=1999, kind="tv", external_ids=ExternalIds(anilist_id=21)), ) tmdb = FakeProvider("tmdb", MetadataResult(title="Wrong Show", year=2020, kind="tv")) - _patch_providers(monkeypatch, {"anilist": anilist, "tmdb": tmdb}) + patch_providers(monkeypatch, {"anilist": anilist, "tmdb": tmdb}) result = providers.resolve_by_ids(anilist_id=21, kind="tv") @@ -152,7 +152,7 @@ def test_an_anilist_id_reaches_the_anilist_provider(monkeypatch: pytest.MonkeyPa def test_a_supplied_anilist_id_survives_a_provider_miss(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) result = providers.resolve_by_ids(anilist_id="21", title="One Piece", kind="tv") @@ -162,7 +162,7 @@ def test_a_supplied_anilist_id_survives_a_provider_miss(monkeypatch: pytest.Monk def test_a_mal_reference_carries_only_what_the_lookup_answered(monkeypatch: pytest.MonkeyPatch) -> None: anilist = FakeProvider("anilist", MetadataResult(title="One Piece", external_ids=ExternalIds(anilist_id=21))) - _patch_providers(monkeypatch, {"anilist": anilist}) + patch_providers(monkeypatch, {"anilist": anilist}) result = providers.resolve_by_ids(anilist_id="mal:21", kind="tv") @@ -172,7 +172,7 @@ def test_a_mal_reference_carries_only_what_the_lookup_answered(monkeypatch: pyte def test_a_mal_reference_that_misses_leaves_no_anilist_id(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) result = providers.resolve_by_ids(anilist_id="mal:21", title="One Piece", kind="tv") @@ -183,7 +183,7 @@ def test_a_mal_reference_that_misses_leaves_no_anilist_id(monkeypatch: pytest.Mo def test_anilist_combines_with_a_western_id(monkeypatch: pytest.MonkeyPatch) -> None: tmdb = FakeProvider("tmdb", MetadataResult(title="One Piece", year=1999, kind="tv")) anilist = FakeProvider("anilist", MetadataResult(title="One Piece", external_ids=ExternalIds(anilist_id=21))) - _patch_providers(monkeypatch, {"tmdb": tmdb, "anilist": anilist}) + patch_providers(monkeypatch, {"tmdb": tmdb, "anilist": anilist}) result = providers.resolve_by_ids(37854, anilist_id=21, kind="tv") @@ -215,7 +215,7 @@ def test_an_anime_hint_falls_through_to_the_normal_order_on_a_miss(monkeypatch: """anilist is asked first but knows no imdb ids, so imdb still answers.""" anilist = FakeProvider("anilist", None) imdb = FakeProvider("imdb", MetadataResult(title="One Piece", year=1999, kind="tv")) - _patch_providers(monkeypatch, {"anilist": anilist, "imdb": imdb}) + patch_providers(monkeypatch, {"anilist": anilist, "imdb": imdb}) result = providers.resolve_by_ids(imdb_id="tt0388629", kind="tv", anime=True) @@ -229,7 +229,7 @@ def test_an_anime_hint_falls_through_to_the_normal_order_on_a_miss(monkeypatch: def test_the_anime_hint_reaches_search(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[dict] = [] monkeypatch.setattr(providers, "search_metadata", lambda *a, **kw: calls.append({"args": a}) or None) - _patch_providers(monkeypatch, {}) + patch_providers(monkeypatch, {}) providers.resolve_by_ids(title="One Piece", year=1999, kind="tv", anime=True) @@ -242,7 +242,7 @@ def test_all_supplied_ids_survive_together(monkeypatch: pytest.MonkeyPatch) -> N "tvdb", MetadataResult(title="One Piece", year=1999, kind="tv", external_ids=ExternalIds(tvdb_id=81797)), ) - _patch_providers(monkeypatch, {"tvdb": tvdb}) + patch_providers(monkeypatch, {"tvdb": tvdb}) result = providers.resolve_by_ids(tvdb_id=81797, anilist_id="mal:21", imdb_id="tt0388629", kind="tv") diff --git a/tests/core/test_service_repo.py b/tests/core/test_service_repo.py index 05ee84f1..5d90d3b9 100644 --- a/tests/core/test_service_repo.py +++ b/tests/core/test_service_repo.py @@ -66,11 +66,11 @@ def test_repos_base_uses_configured_local_dir(monkeypatch, tmp_path): def test_is_stale(tmp_path): stamp = tmp_path / "stamp" - assert service_repo._is_stale(stamp, ttl=10) is True # missing → stale + assert service_repo.is_stale(stamp, ttl=10) is True # missing → stale stamp.write_text(str(time.time())) - assert service_repo._is_stale(stamp, ttl=10) is False # fresh + assert service_repo.is_stale(stamp, ttl=10) is False # fresh stamp.write_text(str(time.time() - 100)) - assert service_repo._is_stale(stamp, ttl=10) is True # old + assert service_repo.is_stale(stamp, ttl=10) is True # old def test_clone_invokes_git(monkeypatch, tmp_path): @@ -110,7 +110,7 @@ def test_fresh_clone_skips_pull(monkeypatch, tmp_path): assert service_repo.resolve_service_repo("a/b") == dest -def _git_stub(porcelain=b"", ahead=b"0"): +def git_stub(porcelain=b"", ahead=b"0"): """Fake subprocess.run for git: dirty checks return given output, pull succeeds.""" def run(args, **kw): @@ -126,20 +126,20 @@ def _git_stub(porcelain=b"", ahead=b"0"): def test_is_dirty_uncommitted(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") - monkeypatch.setattr(service_repo.subprocess, "run", _git_stub(porcelain=b" M EXAMPLE/__init__.py\n")) - assert service_repo._is_dirty(Path("/x")) is True + monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b" M EXAMPLE/__init__.py\n")) + assert service_repo.is_dirty(Path("/x")) is True def test_is_dirty_commits_ahead(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") - monkeypatch.setattr(service_repo.subprocess, "run", _git_stub(porcelain=b"", ahead=b"2")) - assert service_repo._is_dirty(Path("/x")) is True + monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b"", ahead=b"2")) + assert service_repo.is_dirty(Path("/x")) is True def test_is_dirty_clean(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") - monkeypatch.setattr(service_repo.subprocess, "run", _git_stub(porcelain=b"", ahead=b"0")) - assert service_repo._is_dirty(Path("/x")) is False + monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b"", ahead=b"0")) + assert service_repo.is_dirty(Path("/x")) is False def test_is_dirty_ignores_untracked_files(monkeypatch): @@ -153,7 +153,7 @@ def test_is_dirty_ignores_untracked_files(monkeypatch): return subprocess.CompletedProcess(args, 0, out, b"") monkeypatch.setattr(service_repo.subprocess, "run", run) - service_repo._is_dirty(Path("/x")) + service_repo.is_dirty(Path("/x")) status_args = next(a for a in seen if a[3] == "status") assert "--untracked-files=no" in status_args @@ -165,7 +165,7 @@ def test_auto_refresh_refuses_dirty_clone(monkeypatch, tmp_path): service_repo.stamp_for(dest).write_text("0") # stale → refresh due monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) monkeypatch.setattr(service_repo.binaries, "Git", "git") - monkeypatch.setattr(service_repo.subprocess, "run", _git_stub(porcelain=b" M f\n")) + monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b" M f\n")) with pytest.raises(service_repo.DirtyServiceRepo) as ei: service_repo.resolve_service_repo("a/b") assert ei.value.path == dest @@ -201,7 +201,7 @@ def test_changed_services_groups_by_top_dir(monkeypatch): return subprocess.CompletedProcess(args, 0, out, b"") monkeypatch.setattr(service_repo.subprocess, "run", run) - lines = service_repo._changed_services(Path("/x"), "aaa", "bbb") + lines = service_repo.changed_services(Path("/x"), "aaa", "bbb") assert "+ NEW (added)" in lines assert "~ OLD (modified)" in lines assert "- GONE (removed)" in lines @@ -215,7 +215,7 @@ def test_local_dirty_services_lists_edited_dirs(monkeypatch): "run", lambda *a, **k: subprocess.CompletedProcess(a, 0, b"CR/__init__.py\nCR/config.yaml\nNF/__init__.py\n", b""), ) - assert service_repo._local_dirty_services(Path("/x")) == ["CR", "NF"] + assert service_repo.local_dirty_services(Path("/x")) == ["CR", "NF"] def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path): @@ -241,7 +241,7 @@ def test_changed_services_up_to_date(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") # same HEAD before/after → no diff computed at all monkeypatch.setattr(service_repo.subprocess, "run", lambda *a, **k: pytest.fail("no git diff when HEAD unchanged")) - assert service_repo._changed_services(Path("/x"), "same", "same") == [] + assert service_repo.changed_services(Path("/x"), "same", "same") == [] @pytest.mark.parametrize("order", [["a", "b"], ["b", "a"]]) diff --git a/tests/core/test_tag_rules_integration.py b/tests/core/test_tag_rules_integration.py index 32e3e5fd..fda75cc8 100644 --- a/tests/core/test_tag_rules_integration.py +++ b/tests/core/test_tag_rules_integration.py @@ -61,7 +61,7 @@ def clean_config(monkeypatch: pytest.MonkeyPatch) -> None: def context_for(rules: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch) -> dict: monkeypatch.setattr(config, "tag_rules", rules) - return make_movie()._build_base_template_context(make_media_info(["en"])) + return make_movie().build_base_template_context(make_media_info(["en"])) def test_matching_rule_replaces_the_tag(monkeypatch: pytest.MonkeyPatch) -> None: @@ -90,7 +90,7 @@ def test_rules_run_after_lang_tag(monkeypatch: pytest.MonkeyPatch) -> None: movie = make_movie() movie.tracks = TrackStub(audio=[types.SimpleNamespace(language=Language.get("ja"))], subtitles=[]) # type: ignore[assignment] - context = movie._build_base_template_context(make_media_info(["ja"])) + context = movie.build_base_template_context(make_media_info(["ja"])) assert context["lang_tag"] == "SUBBED" assert context["tag"] == "SUBGRP" @@ -113,7 +113,7 @@ def test_a_quality_rule_matches_the_built_context(monkeypatch: pytest.MonkeyPatc ) media_info = types.SimpleNamespace(video_tracks=[video], audio_tracks=[make_audio("en")]) - context = make_movie()._build_base_template_context(media_info) + context = make_movie().build_base_template_context(media_info) assert context["quality"] == "2160p" assert context["video"] == "H.265" diff --git a/tests/core/test_tvdb.py b/tests/core/test_tvdb.py index 32eb2836..571ffaa3 100644 --- a/tests/core/test_tvdb.py +++ b/tests/core/test_tvdb.py @@ -6,8 +6,8 @@ import pytest from unshackle.core import providers from unshackle.core.config import config -from unshackle.core.providers.tvdb import TVDBProvider, _ids_from_remote, _pick_match -from unshackle.core.utils.tags import _build_tags_from_ids +from unshackle.core.providers.tvdb import TVDBProvider, ids_from_remote, pick_match +from unshackle.core.utils.tags import build_tags_from_ids REMOTE_IDS = [ {"id": "tt0149460", "type": 2, "sourceName": "IMDB"}, @@ -31,19 +31,19 @@ DVD = [ def test_ids_from_remote() -> None: - ext = _ids_from_remote(REMOTE_IDS, 73871) + ext = ids_from_remote(REMOTE_IDS, 73871) assert ext.imdb_id == "tt0149460" assert ext.tmdb_id == 615 assert ext.tvdb_id == 73871 -def _stub(p: TVDBProvider, monkeypatch: pytest.MonkeyPatch) -> None: +def stub(p: TVDBProvider, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(p, "get_episodes", lambda _id, order: {"official": AIRED, "dvd": DVD}.get(order, [])) def test_order_map_joins_on_episode_id(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() - _stub(p, monkeypatch) + stub(p, monkeypatch) mapping = p.get_order_map(73871, "dvd") assert mapping[(1, 9)] == (1, 9, "Hell Is Other Robots") @@ -55,7 +55,7 @@ def test_order_map_joins_on_episode_id(monkeypatch: pytest.MonkeyPatch) -> None: def test_order_map_reverses_with_source_order(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() - _stub(p, monkeypatch) + stub(p, monkeypatch) mapping = p.get_order_map(73871, "official", source_order="dvd") assert mapping[(1, 10)] == (2, 1, "A Flight to Remember") @@ -67,13 +67,13 @@ def test_order_map_is_noop_when_orders_match() -> None: def test_order_map_empty_when_order_missing(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() - _stub(p, monkeypatch) + stub(p, monkeypatch) assert p.get_order_map(73871, "alternate") == {} def test_detect_order_picks_the_order_the_numbering_matches(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() - _stub(p, monkeypatch) + stub(p, monkeypatch) # a service may number a series in a non-aired order, so detection must not assume aired assert p.detect_order(73871, [(1, 10), (1, 11)]) == "dvd" assert p.detect_order(73871, [(2, 1), (2, 2), (2, 3)]) == "official" @@ -94,27 +94,27 @@ BSG = [ def test_pick_match_prefers_the_right_era() -> None: # exact title match on the 1978 series must not beat the era the caller asked for - best, _, _ = _pick_match(BSG, "Battlestar Galactica", 2004) + best, _, _ = pick_match(BSG, "Battlestar Galactica", 2004) assert best["tvdb_id"] == "73545" - best, _, _ = _pick_match(BSG, "Battlestar Galactica", 1978) + best, _, _ = pick_match(BSG, "Battlestar Galactica", 1978) assert best["tvdb_id"] == "71173" def test_pick_match_without_a_year_scores_on_title_alone() -> None: - best, _, _ = _pick_match(BSG, "Battlestar Galactica", None) + best, _, _ = pick_match(BSG, "Battlestar Galactica", None) assert best["tvdb_id"] == "71173" def test_pick_match_returns_nothing_when_no_year_fits() -> None: - assert _pick_match(BSG, "Battlestar Galactica", 1995)[0] is None + assert pick_match(BSG, "Battlestar Galactica", 1995)[0] is None def test_tvdb2_tag_follows_the_matroska_spec() -> None: # TVDB2 IDs are prefixed by entity type: matroska.org/technical/tagging.html from unshackle.core.providers import ExternalIds - assert _build_tags_from_ids(ExternalIds(tvdb_id=73871), "tv")["TVDB2"] == "series/73871" - assert _build_tags_from_ids(ExternalIds(tvdb_id=113), "movie")["TVDB2"] == "movies/113" + assert build_tags_from_ids(ExternalIds(tvdb_id=73871), "tv")["TVDB2"] == "series/73871" + assert build_tags_from_ids(ExternalIds(tvdb_id=113), "movie")["TVDB2"] == "movies/113" def test_provider_order_defaults(monkeypatch: pytest.MonkeyPatch) -> None: @@ -131,10 +131,10 @@ def test_provider_order_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None def test_enrichment_authority_is_fixed(monkeypatch: pytest.MonkeyPatch) -> None: # search order must not demote TMDB below SIMKL for cross-validation tie-breaks monkeypatch.setattr(config, "metadata_providers", []) - assert providers._enrichment_providers() == ["tmdb", "simkl", "tvdb"] + assert providers.enrichment_providers() == ["tmdb", "simkl", "tvdb"] # `metadata_providers` filters the set but does not re-rank the trust order monkeypatch.setattr(config, "metadata_providers", ["tvdb", "simkl"]) - assert providers._enrichment_providers() == ["simkl", "tvdb"] + assert providers.enrichment_providers() == ["simkl", "tvdb"] def test_cached_tvdb_result_keeps_enriched_ids() -> None: @@ -144,7 +144,7 @@ def test_cached_tvdb_result_keeps_enriched_ids() -> None: "year": "1999", "_enriched_ids": {"imdb_id": "tt0149460", "tmdb_id": 615}, } - result = providers._cached_to_result(cached, "tvdb", "tv") + result = providers.cached_to_result(cached, "tvdb", "tv") assert result is not None assert result.external_ids.tvdb_id == 73871 assert result.external_ids.imdb_id == "tt0149460" @@ -152,7 +152,7 @@ def test_cached_tvdb_result_keeps_enriched_ids() -> None: # remote_ids stay authoritative over the enriched copies cached["remote_ids"] = REMOTE_IDS cached["_enriched_ids"] = {"imdb_id": "tt9999999", "tmdb_id": 1} - result = providers._cached_to_result(cached, "tvdb", "tv") + result = providers.cached_to_result(cached, "tvdb", "tv") assert result.external_ids.imdb_id == "tt0149460" assert result.external_ids.tmdb_id == 615 @@ -171,7 +171,7 @@ def test_get_by_id_supplies_what_enrich_needs(monkeypatch: pytest.MonkeyPatch, k "remoteIds": [{"sourceName": "IMDB", "id": "tt0213338"}], } - monkeypatch.setattr(p, "_get", fake_get) + monkeypatch.setattr(p, "api_get", fake_get) result = p.get_by_id(73871, kind) assert seen == [path] assert (result.title, result.year, result.original_language) == ("Cowboy Bebop", 1998, "jpn") @@ -181,7 +181,7 @@ def test_get_by_id_supplies_what_enrich_needs(monkeypatch: pytest.MonkeyPatch, k def test_get_episodes_fails_closed_on_a_truncated_listing(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() pages: dict[int, object] = {0: {"episodes": [{"id": n} for n in range(500)]}, 1: None} - monkeypatch.setattr(p, "_get", lambda _path, params: pages[params["page"]]) + monkeypatch.setattr(p, "api_get", lambda _path, params: pages[params["page"]]) # page 1 fails mid-listing: a partial list would renumber wrongly assert p.get_episodes(73871, "official") == [] assert p._episodes == {} @@ -189,7 +189,7 @@ def test_get_episodes_fails_closed_on_a_truncated_listing(monkeypatch: pytest.Mo def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.MonkeyPatch) -> None: p = TVDBProvider() - monkeypatch.setattr(p, "_get", lambda _path, params: None) + monkeypatch.setattr(p, "api_get", lambda _path, params: None) assert p.get_episodes(73871, "official") == [] assert p._episodes == {} @@ -212,7 +212,7 @@ class _FakeProvider: return self.mapping -def _renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict): +def renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict): from types import SimpleNamespace from unshackle.commands.dl import dl @@ -229,7 +229,7 @@ def _renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict): return dl.apply_tvdb_order(stub, Series(episodes)), errors -def _ep(season: int, number: int, part=None): +def ep(season: int, number: int, part=None): from unshackle.core.titles.episode import Episode return Episode(id_=f"{season}x{number}.{part}", service=_Svc, title="Show", season=season, number=number, part=part) @@ -237,17 +237,17 @@ def _ep(season: int, number: int, part=None): def test_parts_of_one_episode_renumber_together(monkeypatch: pytest.MonkeyPatch) -> None: """Three parts share one (season, number) slot, so they must not read as a collision.""" - episodes = [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2)] + episodes = [ep(1, 1, 1), ep(1, 1, 2), ep(1, 1, 3), ep(1, 2)] mapping = {(1, 1): (2, 1, "Part-ful"), (1, 2): (2, 2, "Whole")} - titles, errors = _renumber(monkeypatch, episodes, mapping) + titles, errors = renumber(monkeypatch, episodes, mapping) assert errors == [] assert [(t.season, t.number, t.part) for t in titles] == [(2, 1, 1), (2, 1, 2), (2, 1, 3), (2, 2, None)] def test_a_real_collision_still_bails(monkeypatch: pytest.MonkeyPatch) -> None: """Dedupe must not disable the guard: two distinct episodes on one slot still refuses.""" - episodes = [_ep(1, 1), _ep(1, 2)] + episodes = [ep(1, 1), ep(1, 2)] mapping = {(1, 2): (1, 1, "Clash")} # (1, 1) is unmapped and keeps the slot (1, 2) moves onto - titles, errors = _renumber(monkeypatch, episodes, mapping) + titles, errors = renumber(monkeypatch, episodes, mapping) assert errors # refused, with the "two episodes each" error assert [(t.season, t.number) for t in titles] == [(1, 1), (1, 2)] # numbering untouched diff --git a/tests/downloaders/test_content_encoding.py b/tests/downloaders/test_content_encoding.py index fc1d97e9..e10da44d 100644 --- a/tests/downloaders/test_content_encoding.py +++ b/tests/downloaders/test_content_encoding.py @@ -5,7 +5,7 @@ import threading import pytest import requests as rq -from unshackle.core.downloaders.requests import _is_content_encoded, download +from unshackle.core.downloaders.requests import download, is_content_encoded @pytest.mark.parametrize( @@ -34,7 +34,7 @@ from unshackle.core.downloaders.requests import _is_content_encoded, download ], ) def test_is_content_encoded(value, expected): - assert _is_content_encoded(value) is expected + assert is_content_encoded(value) is expected PLAIN = b"WEBVTT\n\n" + b"".join(f"{i:05d} cue text\n".encode() for i in range(2000)) @@ -50,9 +50,9 @@ class _EncodingServer: self.sock.bind(("127.0.0.1", 0)) self.sock.listen(8) self.url = f"http://127.0.0.1:{self.sock.getsockname()[1]}/sub.vtt" - threading.Thread(target=self._serve, daemon=True).start() + threading.Thread(target=self.serve, daemon=True).start() - def _serve(self): + def serve(self): while True: try: conn, _ = self.sock.accept() diff --git a/tests/downloaders/test_failure_surfacing.py b/tests/downloaders/test_failure_surfacing.py index 2ac346e7..ccc1346e 100644 --- a/tests/downloaders/test_failure_surfacing.py +++ b/tests/downloaders/test_failure_surfacing.py @@ -6,7 +6,7 @@ import pytest import requests as rq from unshackle.core.constants import DOWNLOAD_CANCELLED -from unshackle.core.downloaders.requests import _has_range_header, download +from unshackle.core.downloaders.requests import download, has_range_header from unshackle.core.downloaders.requests import requests as requests_downloader # the package re-exports the `requests` function, shadowing the module of the same name @@ -26,9 +26,9 @@ class _Server: self.sock.listen(8) self.url = f"http://127.0.0.1:{self.sock.getsockname()[1]}/seg.mp4" self.requests = [] - threading.Thread(target=self._serve, daemon=True).start() + threading.Thread(target=self.serve, daemon=True).start() - def _serve(self): + def serve(self): while True: try: conn, _ = self.sock.accept() @@ -63,7 +63,7 @@ class _Server: @pytest.fixture(autouse=True) -def _clear_cancel(): +def clear_cancel(): # the batch failure path sets the process-global cancel; leaking it makes every later # test that waits on the event (the speed limiter) return instantly DOWNLOAD_CANCELLED.clear() @@ -161,4 +161,4 @@ def test_failed_segment_fails_the_batch(tmp_path, server): ], ) def test_has_range_header(item, expected): - assert _has_range_header(item) is expected + assert has_range_header(item) is expected diff --git a/tests/downloaders/test_speed_limit.py b/tests/downloaders/test_speed_limit.py index 538447ca..b0f766f9 100644 --- a/tests/downloaders/test_speed_limit.py +++ b/tests/downloaders/test_speed_limit.py @@ -99,11 +99,11 @@ def test_locked_limit_ignores_unlocked_calls(): set_speed_limit(5_000_000, lock=True) set_speed_limit(None) set_speed_limit(1_000_000) - limiter = requests_downloader._speed_limiter + limiter = requests_downloader.speed_limiter assert limiter is not None and limiter.rate == 5_000_000 finally: - requests_downloader._speed_limit_locked = False - requests_downloader._speed_limiter = None + requests_downloader.speed_limit_locked = False + requests_downloader.speed_limiter = None def test_bucket_wait_aborts_on_cancellation(): diff --git a/tests/lang_selection/test_overlapping_audio_dedup.py b/tests/lang_selection/test_overlapping_audio_dedup.py index 7de66f77..c962910d 100644 --- a/tests/lang_selection/test_overlapping_audio_dedup.py +++ b/tests/lang_selection/test_overlapping_audio_dedup.py @@ -101,11 +101,11 @@ REAL_WORLD_AUDIO_LANGS = [ ] -def _mk(*tags): +def mk(*tags): return [Audio("https://x", language=t, id_=t) for t in tags] -def _select(tags, pool, exact=True): +def select(tags, pool, exact=True): return [(t.id, str(t.language)) for t in Tracks.by_language(pool, tags, per_language=1, exact_match=exact)] @@ -124,56 +124,56 @@ def test_distinct_pairs_are_not_distance_zero(base, reg): @pytest.mark.parametrize("base,reg", COLLAPSING_PAIRS) def test_exact_lang_selects_distinct_collapsing_variants(base, reg): # Both tracks present + both tags requested: each tag must route to its own track. - assert _select([base, reg], _mk(base, reg)) == [(base, base), (reg, reg)] + assert select([base, reg], mk(base, reg)) == [(base, base), (reg, reg)] @pytest.mark.parametrize("base,reg", DISTINCT_PAIRS) def test_exact_lang_distinct_regionals_still_work(base, reg): - assert _select([base, reg], _mk(base, reg)) == [(base, base), (reg, reg)] + assert select([base, reg], mk(base, reg)) == [(base, base), (reg, reg)] def test_exact_lang_lenient_base_to_regional_fallback(): # Only the regional present; a base request must still find it when CLDR rates them equal. - assert _select(["en"], _mk("en-US")) == [("en-US", "en-US")] + assert select(["en"], mk("en-US")) == [("en-US", "en-US")] # Alias with no string-equal track must still resolve (zh -> cmn). - assert _select(["zh"], _mk("cmn")) == [("cmn", "cmn")] + assert select(["zh"], mk("cmn")) == [("cmn", "cmn")] def test_exact_lang_stays_strict_for_nonzero_distance(): # `es` does NOT accept `es-419` in exact mode (distance 5), but fuzzy mode does. - assert _select(["es"], _mk("es-419")) == [] - assert _select(["es"], _mk("es-419"), exact=False) == [("es-419", "es-419")] + assert select(["es"], mk("es-419")) == [] + assert select(["es"], mk("es-419"), exact=False) == [("es-419", "es-419")] @pytest.mark.parametrize("base,reg", COLLAPSING_PAIRS) def test_overlapping_tags_never_duplicate_in_fuzzy_mode(base, reg): # Non-exact still collapses base/regional, but must dedupe rather than return a track twice. - selected = Tracks.by_language(_mk(base, reg), [base, reg], per_language=1, exact_match=False) + selected = Tracks.by_language(mk(base, reg), [base, reg], per_language=1, exact_match=False) assert len({t.id for t in selected}) == len(selected) def test_exact_lang_three_way_base_and_two_regionals(): # fr (base, dist 0 to fr-FR) + fr-FR + fr-CA (dist 4): all three must resolve distinctly. - assert _select(["fr", "fr-FR", "fr-CA"], _mk("fr", "fr-FR", "fr-CA")) == [ + assert select(["fr", "fr-FR", "fr-CA"], mk("fr", "fr-FR", "fr-CA")) == [ ("fr", "fr"), ("fr-FR", "fr-FR"), ("fr-CA", "fr-CA"), ] # Base request with no bare-tag track falls back to its paradigm regional, without stealing # the sibling regional (fr -> fr-FR, not fr-CA). - assert _select(["fr", "fr-CA"], _mk("fr-FR", "fr-CA")) == [("fr-FR", "fr-FR"), ("fr-CA", "fr-CA")] + assert select(["fr", "fr-CA"], mk("fr-FR", "fr-CA")) == [("fr-FR", "fr-FR"), ("fr-CA", "fr-CA")] @pytest.mark.parametrize("tag", REAL_WORLD_AUDIO_LANGS) def test_full_language_set_each_tag_resolves_to_itself(tag): # Against a full real-world language pool, every exact request must return its own track, so # that no tag captures another's: ar-EG must resolve to ar-EG, never to ar. - pool = _mk(*REAL_WORLD_AUDIO_LANGS) - assert _select([tag], pool) == [(tag, tag)] + pool = mk(*REAL_WORLD_AUDIO_LANGS) + assert select([tag], pool) == [(tag, tag)] def test_full_language_set_selects_every_track_once(): - pool = _mk(*REAL_WORLD_AUDIO_LANGS) + pool = mk(*REAL_WORLD_AUDIO_LANGS) selected = Tracks.by_language(pool, REAL_WORLD_AUDIO_LANGS, per_language=1, exact_match=True) assert len(selected) == len(REAL_WORLD_AUDIO_LANGS) assert {t.id for t in selected} == set(REAL_WORLD_AUDIO_LANGS) diff --git a/tests/remote/conftest.py b/tests/remote/conftest.py index fc90669b..c4133748 100644 --- a/tests/remote/conftest.py +++ b/tests/remote/conftest.py @@ -65,13 +65,13 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item item.add_marker(skip_live) -def _free_port() -> int: +def free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] -def _wait_for_health(url: str, timeout: float = 60.0) -> bool: +def wait_for_health(url: str, timeout: float = 60.0) -> bool: import requests deadline = time.monotonic() + timeout @@ -87,11 +87,11 @@ def _wait_for_health(url: str, timeout: float = 60.0) -> bool: @pytest.fixture(autouse=True) -def _isolated_job_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def isolated_job_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Redirect job history writes to tmp_path so tests don't append to the server's api_history.jsonl.""" from unshackle.core.api import download_manager - monkeypatch.setattr(download_manager, "_history_path", lambda: tmp_path / "api_history.jsonl") + monkeypatch.setattr(download_manager, "history_path", lambda: tmp_path / "api_history.jsonl") @pytest.fixture(scope="session") @@ -120,19 +120,19 @@ def server_url(request: pytest.FixtureRequest): if mode == "never": if not explicit: pytest.fail("--spawn-serve=never requires --server-url") - if not _wait_for_health(explicit, timeout=10): + if not wait_for_health(explicit, timeout=10): pytest.fail(f"External serve not reachable at {explicit}") yield explicit return if mode == "auto" and explicit: - if not _wait_for_health(explicit, timeout=10): + if not wait_for_health(explicit, timeout=10): pytest.fail(f"External serve not reachable at {explicit}") yield explicit return # Spawn our own serve. - port = _free_port() + port = free_port() cmd = [ "uv", "run", @@ -153,7 +153,7 @@ def server_url(request: pytest.FixtureRequest): ) url = f"http://127.0.0.1:{port}" try: - if not _wait_for_health(url, timeout=60): + if not wait_for_health(url, timeout=60): proc.terminate() pytest.fail(f"Spawned serve at {url} did not become healthy within 60s") yield url diff --git a/tests/remote/e2e/conftest.py b/tests/remote/e2e/conftest.py index 222ae4fe..d676d921 100644 --- a/tests/remote/e2e/conftest.py +++ b/tests/remote/e2e/conftest.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest -def _load_scenarios(config: pytest.Config) -> dict: +def load_scenarios(config: pytest.Config) -> dict: cached = getattr(config, "_e2e_scenarios_cache", None) if cached is not None: return cached @@ -31,7 +31,7 @@ def _load_scenarios(config: pytest.Config) -> dict: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: if "service_case" in metafunc.fixturenames: - scenarios = _load_scenarios(metafunc.config) + scenarios = load_scenarios(metafunc.config) params = [pytest.param((tag, conf), id=tag) for tag, conf in (scenarios.get("services") or {}).items()] if not params: params = [ diff --git a/tests/remote/e2e/test_live_download.py b/tests/remote/e2e/test_live_download.py index 730d87d1..cb8dcc35 100644 --- a/tests/remote/e2e/test_live_download.py +++ b/tests/remote/e2e/test_live_download.py @@ -20,7 +20,7 @@ import pytest pytestmark = [pytest.mark.live, pytest.mark.slow] -def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): +def wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): deadline = time.monotonic() + timeout while time.monotonic() < deadline: r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30) @@ -38,14 +38,14 @@ def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0 return None -def _pick_target_title(titles, season: int = 1, episode: int = 1): +def pick_target_title(titles, season: int = 1, episode: int = 1): for t in titles: if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode: return t return titles[0] if titles else None -def _pick_track_at_height(video_tracks, target_height: int): +def pick_track_at_height(video_tracks, target_height: int): same_height = [v for v in video_tracks if v.get("height") == target_height and v.get("range") == "SDR"] return sorted(same_height, key=lambda v: v.get("bitrate") or 0)[0] if same_height else None @@ -77,10 +77,10 @@ def test_download_manifest_fetch(http_session, server_url: str, service_case) -> sid = r.json()["session_id"] try: - body = _wait_titles(http_session, server_url, sid) + body = wait_titles(http_session, server_url, sid) if not body: pytest.skip(f"{service}: titles timeout") - target = _pick_target_title( + target = pick_target_title( body.get("titles") or [], season=conf.get("target_season", 1), episode=conf.get("target_episode", 1), @@ -94,7 +94,7 @@ def test_download_manifest_fetch(http_session, server_url: str, service_case) -> timeout=240, ) assert tr.status_code == 200, tr.text - track = _pick_track_at_height(tr.json().get("video") or [], target_height) + track = pick_track_at_height(tr.json().get("video") or [], target_height) if not track: pytest.skip(f"{service}: no track at height={target_height}") diff --git a/tests/remote/e2e/test_live_license.py b/tests/remote/e2e/test_live_license.py index 5828ce88..f87e895f 100644 --- a/tests/remote/e2e/test_live_license.py +++ b/tests/remote/e2e/test_live_license.py @@ -20,7 +20,7 @@ import pytest pytestmark = [pytest.mark.live, pytest.mark.slow] -def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): +def wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): deadline = time.monotonic() + timeout while time.monotonic() < deadline: r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30) @@ -38,14 +38,14 @@ def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0 return None -def _pick_target_title(titles, season: int = 1, episode: int = 1): +def pick_target_title(titles, season: int = 1, episode: int = 1): for t in titles: if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode: return t return titles[0] if titles else None -def _pick_track_at_height(video_tracks, target_height: int): +def pick_track_at_height(video_tracks, target_height: int): """Prefer SDR + AVC at the requested height; smallest bitrate wins.""" same_height = [v for v in video_tracks if v.get("height") == target_height] preferred = [v for v in same_height if v.get("codec") == "AVC" and v.get("range") == "SDR"] @@ -80,10 +80,10 @@ def test_license_server_cdm(http_session, server_url: str, service_case) -> None sid = r.json()["session_id"] try: - body = _wait_titles(http_session, server_url, sid) + body = wait_titles(http_session, server_url, sid) if not body: pytest.skip(f"{service}: titles timeout") - target = _pick_target_title( + target = pick_target_title( body.get("titles") or [], season=conf.get("target_season", 1), episode=conf.get("target_episode", 1), @@ -97,7 +97,7 @@ def test_license_server_cdm(http_session, server_url: str, service_case) -> None timeout=240, ) assert tr.status_code == 200, tr.text - track = _pick_track_at_height(tr.json().get("video") or [], target_height) + track = pick_track_at_height(tr.json().get("video") or [], target_height) if not track: pytest.skip(f"{service}: no track at height={target_height}") diff --git a/tests/remote/e2e/test_live_quality.py b/tests/remote/e2e/test_live_quality.py index 327c5eae..52b59911 100644 --- a/tests/remote/e2e/test_live_quality.py +++ b/tests/remote/e2e/test_live_quality.py @@ -18,7 +18,7 @@ import pytest pytestmark = [pytest.mark.live, pytest.mark.slow] -def _create_session(http_session, server_url: str, service: str, title_id: str) -> str: +def create_session(http_session, server_url: str, service: str, title_id: str) -> str: payload = { "service": service, "title_id": title_id, @@ -33,7 +33,7 @@ def _create_session(http_session, server_url: str, service: str, title_id: str) return r.json()["session_id"] -def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): +def wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): deadline = time.monotonic() + timeout while time.monotonic() < deadline: r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30) @@ -51,7 +51,7 @@ def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 1 return 408, {"message": "auth timeout"} -def _pick_target_title(titles: list[dict[str, Any]], season: int = 1, episode: int = 1) -> Optional[dict[str, Any]]: +def pick_target_title(titles: list[dict[str, Any]], season: int = 1, episode: int = 1) -> Optional[dict[str, Any]]: """Pick the configured target episode; fall back to the first title.""" for t in titles: if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode: @@ -59,7 +59,7 @@ def _pick_target_title(titles: list[dict[str, Any]], season: int = 1, episode: i return titles[0] if titles else None -def _summarize(video_tracks: list[dict[str, Any]]) -> dict[str, Any]: +def summarize(video_tracks: list[dict[str, Any]]) -> dict[str, Any]: max_height = max((v.get("height") or 0 for v in video_tracks), default=0) max_width = max((v.get("width") or 0 for v in video_tracks), default=0) codecs = sorted({v.get("codec") for v in video_tracks if v.get("codec")}) @@ -76,25 +76,25 @@ def _summarize(video_tracks: list[dict[str, Any]]) -> dict[str, Any]: } -def _resolve_title_id(conf: dict) -> Optional[str]: +def resolve_title_id(conf: dict) -> Optional[str]: """Prefer series_url so S01E01 logic kicks in; fall back to movie title_url.""" return conf.get("series_url") or conf.get("title_url") def test_track_quality_meets_expected(http_session, server_url: str, service_case, capsys) -> None: service, conf = service_case - title_input = _resolve_title_id(conf) + title_input = resolve_title_id(conf) if not title_input: pytest.skip(f"{service}: no title_url/series_url in fixture") - sid = _create_session(http_session, server_url, service, title_input) + sid = create_session(http_session, server_url, service, title_input) try: - code, body = _wait_for_titles(http_session, server_url, sid) + code, body = wait_for_titles(http_session, server_url, sid) if code != 200: pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}") titles = body.get("titles") or [] - target = _pick_target_title(titles, season=conf.get("target_season", 1), episode=conf.get("target_episode", 1)) + target = pick_target_title(titles, season=conf.get("target_season", 1), episode=conf.get("target_episode", 1)) if not target: pytest.skip(f"{service}: no titles returned") @@ -114,7 +114,7 @@ def test_track_quality_meets_expected(http_session, server_url: str, service_cas tracks = r.json() video = tracks.get("video") or [] - summary = _summarize(video) + summary = summarize(video) # Print summary even when test passes — discovery aid. with capsys.disabled(): print(f"\n[{service}] target={kind} '{target.get('name')}' -> {summary}") diff --git a/tests/remote/e2e/test_live_session_lifecycle.py b/tests/remote/e2e/test_live_session_lifecycle.py index 7ae7e51f..6a361698 100644 --- a/tests/remote/e2e/test_live_session_lifecycle.py +++ b/tests/remote/e2e/test_live_session_lifecycle.py @@ -9,7 +9,7 @@ import pytest pytestmark = [pytest.mark.live, pytest.mark.slow] -def _create_session(http_session, server_url: str, service: str, conf: dict) -> str: +def create_session(http_session, server_url: str, service: str, conf: dict) -> str: payload = {"service": service, "title_id": conf["title_url"]} r = http_session.post(f"{server_url}/api/session/create", json=payload, timeout=120) if r.status_code >= 400: @@ -20,7 +20,7 @@ def _create_session(http_session, server_url: str, service: str, conf: dict) -> return sid -def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): +def wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0): """Poll /titles until auth completes. Returns (status_code, response_json). Server returns 400 + auth_status=authenticating while auth is in-flight, @@ -44,20 +44,20 @@ def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 1 return 408, {"message": "timeout waiting for auth"} -def _delete_session(http_session, server_url: str, sid: str) -> None: +def delete_session(http_session, server_url: str, sid: str) -> None: http_session.delete(f"{server_url}/api/session/{sid}", timeout=30) def test_session_create_then_delete(http_session, server_url: str, service_case) -> None: service, conf = service_case - sid = _create_session(http_session, server_url, service, conf) + sid = create_session(http_session, server_url, service, conf) try: r = http_session.get(f"{server_url}/api/session/{sid}", timeout=30) assert r.status_code == 200 info = r.json() assert info.get("session", {}).get("service_tag", service) == service or service in str(info) finally: - _delete_session(http_session, server_url, sid) + delete_session(http_session, server_url, sid) # After delete, info should 404 r2 = http_session.get(f"{server_url}/api/session/{sid}", timeout=30) @@ -66,9 +66,9 @@ def test_session_create_then_delete(http_session, server_url: str, service_case) def test_session_titles_returns_list(http_session, server_url: str, service_case) -> None: service, conf = service_case - sid = _create_session(http_session, server_url, service, conf) + sid = create_session(http_session, server_url, service, conf) try: - code, body = _wait_for_titles(http_session, server_url, sid) + code, body = wait_for_titles(http_session, server_url, sid) if code != 200: pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}") assert "titles" in body @@ -78,14 +78,14 @@ def test_session_titles_returns_list(http_session, server_url: str, service_case assert "id" in first assert "type" in first finally: - _delete_session(http_session, server_url, sid) + delete_session(http_session, server_url, sid) def test_session_tracks_for_first_title(http_session, server_url: str, service_case) -> None: service, conf = service_case - sid = _create_session(http_session, server_url, service, conf) + sid = create_session(http_session, server_url, service, conf) try: - code, body = _wait_for_titles(http_session, server_url, sid) + code, body = wait_for_titles(http_session, server_url, sid) if code != 200: pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}") titles = body.get("titles") or [] @@ -104,13 +104,13 @@ def test_session_tracks_for_first_title(http_session, server_url: str, service_c assert "video" in body or "audio" in body, body assert body.get("video") or body.get("audio"), body finally: - _delete_session(http_session, server_url, sid) + delete_session(http_session, server_url, sid) def test_session_delete_idempotent_returns_404_after(http_session, server_url: str, service_case) -> None: service, conf = service_case - sid = _create_session(http_session, server_url, service, conf) - _delete_session(http_session, server_url, sid) + sid = create_session(http_session, server_url, service, conf) + delete_session(http_session, server_url, sid) r = http_session.delete(f"{server_url}/api/session/{sid}", timeout=30) assert r.status_code in (404, 200) # tolerate both depending on server semantics diff --git a/tests/remote/unit/test_api_episode_part.py b/tests/remote/unit/test_api_episode_part.py index f1fb8bf9..fdcf21a5 100644 --- a/tests/remote/unit/test_api_episode_part.py +++ b/tests/remote/unit/test_api_episode_part.py @@ -26,7 +26,7 @@ class _FakeSvc: pass -def _ep(season: int = 1, number: int = 1, part: Optional[int] = None, **overrides: Any) -> Episode: +def ep(season: int = 1, number: int = 1, part: Optional[int] = None, **overrides: Any) -> Episode: base: Dict[str, Any] = dict( id_=f"ep-{season}-{number}-{part or 0}", service=_FakeSvc, @@ -58,7 +58,7 @@ class _FakeService: return _FakeTracks() -def _run_list_tracks(monkeypatch: pytest.MonkeyPatch, titles: List[Episode], **data: Any) -> Dict[str, Any]: +def run_list_tracks(monkeypatch: pytest.MonkeyPatch, titles: List[Episode], **data: Any) -> Dict[str, Any]: """Drive list_tracks_handler end to end with the service layer stubbed out.""" monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: "FAKE") monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService(titles)) @@ -71,7 +71,7 @@ def _run_list_tracks(monkeypatch: pytest.MonkeyPatch, titles: List[Episode], **d return json.loads(response.body) -def _selected(result: Dict[str, Any]) -> List[str]: +def selected(result: Dict[str, Any]) -> List[str]: """Selection-syntax keys of whatever the handler returned, single or multi.""" entries = result["episodes"] if "episodes" in result else [{"title": result["title"]}] keys = [] @@ -86,7 +86,7 @@ def _selected(result: Dict[str, Any]) -> List[str]: def test_partless_episode_json_has_no_part_key() -> None: - d = serialize_title(_ep(part=None)) + d = serialize_title(ep(part=None)) assert "part" not in d assert list(d.keys()) == [ "type", @@ -105,26 +105,26 @@ def test_partless_episode_json_has_no_part_key() -> None: def test_partful_episode_json_carries_part_last() -> None: - d = serialize_title(_ep(part=2)) + d = serialize_title(ep(part=2)) assert d["part"] == 2 # additive: `part` is appended, every pre-existing key keeps its position - assert list(d.keys())[:11] == list(serialize_title(_ep(part=None)).keys()) + assert list(d.keys())[:11] == list(serialize_title(ep(part=None)).keys()) assert list(d.keys())[11] == "part" def test_part_stays_out_of_the_name_field() -> None: - """`name` is a reconstruction input for _build_title, not a display string.""" - assert serialize_title(_ep(part=2))["name"] == "Pilot" - assert serialize_title(_ep(part=3, name=None))["name"] == "Episode 01" + """`name` is a reconstruction input for build_title, not a display string.""" + assert serialize_title(ep(part=2))["name"] == "Pilot" + assert serialize_title(ep(part=3, name=None))["name"] == "Episode 01" def test_remote_build_title_round_trip() -> None: - from unshackle.core.remote_service import _build_title + from unshackle.core.remote_service import build_title - rebuilt = _build_title(serialize_title(_ep(1, 4, 2)), "FAKE", "fallback-id") + rebuilt = build_title(serialize_title(ep(1, 4, 2)), "FAKE", "fallback-id") assert (rebuilt.season, rebuilt.number, rebuilt.part, rebuilt.name) == (1, 4, 2, "Pilot") - partless = _build_title(serialize_title(_ep(1, 4, None)), "FAKE", "fallback-id") + partless = build_title(serialize_title(ep(1, 4, None)), "FAKE", "fallback-id") assert partless.part is None @@ -143,32 +143,32 @@ def test_movie_json_unaffected() -> None: @pytest.fixture def split_titles() -> List[Episode]: """E1 split into three parts, E2 unsplit.""" - return [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2, None)] + return [ep(1, 1, 1), ep(1, 1, 2), ep(1, 1, 3), ep(1, 2, None)] def test_rest_bare_episode_key_selects_every_part(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01") - assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"] + result = run_list_tracks(monkeypatch, split_titles, wanted="S01E01") + assert selected(result) == ["1x1.1", "1x1.2", "1x1.3"] def test_rest_part_key_selects_exactly_one(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01.2") - assert _selected(result) == ["1x1.2"] + result = run_list_tracks(monkeypatch, split_titles, wanted="S01E01.2") + assert selected(result) == ["1x1.2"] def test_rest_negative_part_key_excludes_only_that_part(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, wanted=["S01", "-S01E01.2"]) - assert _selected(result) == ["1x1.1", "1x1.3", "1x2"] + result = run_list_tracks(monkeypatch, split_titles, wanted=["S01", "-S01E01.2"]) + assert selected(result) == ["1x1.1", "1x1.3", "1x2"] def test_rest_partless_episode_unchanged(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02") - assert _selected(result) == ["1x2"] + result = run_list_tracks(monkeypatch, split_titles, wanted="S01E02") + assert selected(result) == ["1x2"] assert "part" not in result["title"] def test_rest_part_of_unsplit_episode_selects_nothing(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02.1") + result = run_list_tracks(monkeypatch, split_titles, wanted="S01E02.1") assert result["status"] == 404 @@ -176,28 +176,28 @@ def test_rest_part_of_unsplit_episode_selects_nothing(monkeypatch, split_titles) def test_discrete_fields_without_part_select_every_part(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1) - assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"] + result = run_list_tracks(monkeypatch, split_titles, season=1, episode=1) + assert selected(result) == ["1x1.1", "1x1.2", "1x1.3"] def test_discrete_fields_with_part_select_one(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=3) - assert _selected(result) == ["1x1.3"] + result = run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=3) + assert selected(result) == ["1x1.3"] def test_discrete_part_accepts_string(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part="3") - assert _selected(result) == ["1x1.3"] + result = run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part="3") + assert selected(result) == ["1x1.3"] def test_discrete_no_match_error_reports_the_part_qualified_key(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=9) + result = run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=9) assert result["status"] == 404 assert result["details"]["wanted"] == "1x1.9" def test_discrete_partless_error_key_unchanged(monkeypatch, split_titles) -> None: - result = _run_list_tracks(monkeypatch, split_titles, season=9, episode=9) + result = run_list_tracks(monkeypatch, split_titles, season=9, episode=9) assert result["details"]["wanted"] == "9x9" @@ -211,15 +211,15 @@ def test_part_is_a_transport_key_not_a_service_option() -> None: def test_rest_multi_episode_order_is_stable_across_parts(monkeypatch) -> None: - titles = [_ep(1, 2, None), _ep(1, 1, 3), _ep(1, 1, 1), _ep(1, 1, 2)] - result = _run_list_tracks(monkeypatch, titles, wanted="S01") - assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3", "1x2"] + titles = [ep(1, 2, None), ep(1, 1, 3), ep(1, 1, 1), ep(1, 1, 2)] + result = run_list_tracks(monkeypatch, titles, wanted="S01") + assert selected(result) == ["1x1.1", "1x1.2", "1x1.3", "1x2"] # ---------- download_manager: already-internal wanted sniff ---------- -_SNIFF = r"^!?\d+x\d+(\.\d+)?$" +SNIFF = r"^!?\d+x\d+(\.\d+)?$" @pytest.mark.parametrize( @@ -237,7 +237,7 @@ _SNIFF = r"^!?\d+x\d+(\.\d+)?$" ], ) def test_download_manager_sniff_regex(wanted: List[str], internal: bool) -> None: - needs_conversion = any(not re.match(_SNIFF, w) for w in wanted) + needs_conversion = any(not re.match(SNIFF, w) for w in wanted) assert needs_conversion is (not internal) @@ -248,12 +248,12 @@ def test_sniff_regex_matches_the_one_in_download_manager() -> None: import unshackle.core.api.download_manager as dm source = Path(dm.__file__).read_text(encoding="utf8") - assert f'needs_conversion = any(not re.match(r"{_SNIFF}", w) for w in wanted_raw)' in source + assert f'needs_conversion = any(not re.match(r"{SNIFF}", w) for w in wanted_raw)' in source def test_perform_download_skips_reconversion_for_preparsed_part_keys(monkeypatch) -> None: """A pre-parsed list with part and negative keys must not reach parse_tokens.""" - from unshackle.core.api.download_manager import _perform_download + from unshackle.core.api.download_manager import perform_download from unshackle.core.utils.click_types import SeasonRange calls = [] @@ -262,14 +262,14 @@ def test_perform_download_skips_reconversion_for_preparsed_part_keys(monkeypatch params: Dict[str, Any] = {"wanted": ["1x1", "1x1.2", "!1x1.3"]} with pytest.raises(BaseException): # blows up later on the unknown service; the wanted block runs first - _perform_download("job-1", "NoSuchServiceXYZ", "t1", params) + perform_download("job-1", "NoSuchServiceXYZ", "t1", params) assert calls == [] assert params["wanted"] == ["1x1", "1x1.2", "!1x1.3"] def test_perform_download_still_converts_cli_style_tokens(monkeypatch) -> None: - from unshackle.core.api.download_manager import _perform_download + from unshackle.core.api.download_manager import perform_download from unshackle.core.utils.click_types import SeasonRange calls = [] @@ -277,7 +277,7 @@ def test_perform_download_still_converts_cli_style_tokens(monkeypatch) -> None: params: Dict[str, Any] = {"wanted": "S01E01"} with pytest.raises(BaseException): - _perform_download("job-2", "NoSuchServiceXYZ", "t1", params) + perform_download("job-2", "NoSuchServiceXYZ", "t1", params) assert calls == [("S01E01",)] assert params["wanted"] == ["1x1"] @@ -325,7 +325,7 @@ def test_rest_filter_agrees_with_cli_filter(monkeypatch, split_titles, wanted_to if t.matches_wanted(wanted) ] - result = _run_list_tracks(monkeypatch, split_titles, wanted=list(wanted_tokens)) - rest_selected = [] if result.get("status") == 404 else _selected(result) + result = run_list_tracks(monkeypatch, split_titles, wanted=list(wanted_tokens)) + rest_selected = [] if result.get("status") == 404 else selected(result) assert sorted(rest_selected) == sorted(cli_selected) diff --git a/tests/remote/unit/test_api_title_fields.py b/tests/remote/unit/test_api_title_fields.py index e62770f6..874eadac 100644 --- a/tests/remote/unit/test_api_title_fields.py +++ b/tests/remote/unit/test_api_title_fields.py @@ -1,7 +1,7 @@ """The title fields that cross the remote wire: air_date, absolute, anime and daily. Covers serialize_title's conditional keys, the service-class ANIME/DAILY stamp, and the -serialize_title / title_to_meta round trips through remote_service._build_title. +serialize_title / title_to_meta round trips through remote_service.build_title. """ from __future__ import annotations @@ -16,8 +16,8 @@ from langcodes import Language from unshackle.commands.dl import dl from unshackle.core.api import handlers -from unshackle.core.api.handlers import _stamp_service_flags, serialize_title -from unshackle.core.remote_service import _build_title +from unshackle.core.api.handlers import serialize_title, stamp_service_flags +from unshackle.core.remote_service import build_title from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie @@ -28,7 +28,7 @@ class _FakeSvc: pass -def _ep(**overrides: Any) -> Episode: +def ep(**overrides: Any) -> Episode: base: Dict[str, Any] = dict( id_="ep-0001", service=_FakeSvc, @@ -56,7 +56,7 @@ NEW_KEYS = ("air_date", "absolute", "daily", "anime") def test_a_plain_episode_gains_no_new_keys() -> None: - d = serialize_title(_ep()) + d = serialize_title(ep()) assert not [k for k in NEW_KEYS if k in d] @@ -66,20 +66,20 @@ def test_a_plain_movie_gains_no_new_keys() -> None: def test_air_date_is_serialized_as_an_iso_string() -> None: - assert serialize_title(_ep(air_date=date(2026, 8, 11)))["air_date"] == "2026-08-11" + assert serialize_title(ep(air_date=date(2026, 8, 11)))["air_date"] == "2026-08-11" def test_an_unparseable_air_date_is_serialized_as_text() -> None: - assert serialize_title(_ep(air_date="soon"))["air_date"] == "soon" + assert serialize_title(ep(air_date="soon"))["air_date"] == "soon" def test_absolute_is_serialized() -> None: - assert serialize_title(_ep(absolute=57))["absolute"] == 57 + assert serialize_title(ep(absolute=57))["absolute"] == 57 @pytest.mark.parametrize("value", [True, False]) def test_per_title_daily_and_anime_are_serialized_both_ways(value: bool) -> None: - episode = _ep() + episode = ep() episode.daily = value episode.anime = value d = serialize_title(episode) @@ -107,25 +107,25 @@ class _PlainSvc: def test_the_stamp_fills_both_flags_from_the_service_class() -> None: - d = _stamp_service_flags(serialize_title(_ep()), _AnimeDailySvc()) + d = stamp_service_flags(serialize_title(ep()), _AnimeDailySvc()) assert (d["anime"], d["daily"]) == (True, True) def test_the_stamp_never_overrides_a_per_title_false() -> None: - episode = _ep() + episode = ep() episode.anime = False episode.daily = False - d = _stamp_service_flags(serialize_title(episode), _AnimeDailySvc()) + d = stamp_service_flags(serialize_title(episode), _AnimeDailySvc()) assert (d["anime"], d["daily"]) == (False, False) def test_a_plain_service_stamps_nothing() -> None: - d = _stamp_service_flags(serialize_title(_ep()), _PlainSvc()) + d = stamp_service_flags(serialize_title(ep()), _PlainSvc()) assert not [k for k in NEW_KEYS if k in d] def test_a_movie_is_never_stamped_daily() -> None: - d = _stamp_service_flags(serialize_title(_movie()), _AnimeDailySvc()) + d = stamp_service_flags(serialize_title(_movie()), _AnimeDailySvc()) assert d["anime"] is True assert "daily" not in d @@ -143,55 +143,55 @@ class _FakeService: def test_list_titles_stamps_the_service_flags(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: "FAKE") - monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService([_ep()])) + monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService([ep()])) response = asyncio.run(handlers.list_titles_handler({"service": "FAKE", "title_id": "t1"})) title = json.loads(response.body)["titles"][0] assert (title["anime"], title["daily"]) == (True, True) -# ---------- round trips through _build_title ---------- +# ---------- round trips through build_title ---------- -def _round_trip(episode: Episode, stamp: Optional[Any] = None) -> Episode: +def round_trip(episode: Episode, stamp: Optional[Any] = None) -> Episode: info = serialize_title(episode) if stamp is not None: - info = _stamp_service_flags(info, stamp) - return _build_title(info, "FAKE", "fallback-id") + info = stamp_service_flags(info, stamp) + return build_title(info, "FAKE", "fallback-id") def test_serialize_round_trip_keeps_the_new_fields() -> None: - episode = _ep(air_date=date(2026, 8, 11), absolute=57) + episode = ep(air_date=date(2026, 8, 11), absolute=57) episode.daily = True episode.anime = True - rebuilt = _round_trip(episode) + rebuilt = round_trip(episode) assert rebuilt.air_date == date(2026, 8, 11) assert rebuilt.absolute == 57 assert (rebuilt.daily, rebuilt.anime) == (True, True) def test_serialize_round_trip_leaves_unset_flags_inheriting() -> None: - rebuilt = _round_trip(_ep()) + rebuilt = round_trip(ep()) assert rebuilt.air_date is None assert rebuilt.absolute is None assert (rebuilt.daily, rebuilt.anime) == (None, None) def test_the_stamped_service_flags_survive_the_round_trip() -> None: - rebuilt = _round_trip(_ep(), stamp=_AnimeDailySvc()) + rebuilt = round_trip(ep(), stamp=_AnimeDailySvc()) assert (rebuilt.daily, rebuilt.anime) == (True, True) def test_a_per_title_false_survives_the_round_trip() -> None: - episode = _ep() + episode = ep() episode.daily = False - rebuilt = _round_trip(episode) + rebuilt = round_trip(episode) assert rebuilt.daily is False def test_a_movie_round_trip_keeps_the_anime_flag() -> None: movie = _movie() movie.anime = True - rebuilt = _build_title(serialize_title(movie), "FAKE", "fallback-id") + rebuilt = build_title(serialize_title(movie), "FAKE", "fallback-id") assert isinstance(rebuilt, Movie) assert rebuilt.anime is True @@ -200,22 +200,22 @@ def test_a_movie_round_trip_keeps_the_anime_flag() -> None: def test_title_to_meta_omits_the_new_keys_for_a_plain_episode() -> None: - meta = dl.title_to_meta(_ep()) + meta = dl.title_to_meta(ep()) assert not [k for k in ("absolute", "daily", "anime") if k in meta] def test_title_to_meta_round_trip_keeps_the_new_fields() -> None: - episode = _ep(air_date=date(2026, 8, 11), absolute=57) + episode = ep(air_date=date(2026, 8, 11), absolute=57) episode.daily = True episode.anime = True - rebuilt = _build_title(dl.title_to_meta(episode), "FAKE", "fallback-id") + rebuilt = build_title(dl.title_to_meta(episode), "FAKE", "fallback-id") assert rebuilt.air_date == date(2026, 8, 11) assert rebuilt.absolute == 57 assert (rebuilt.daily, rebuilt.anime) == (True, True) def test_title_to_meta_round_trip_leaves_unset_flags_inheriting() -> None: - rebuilt = _build_title(dl.title_to_meta(_ep()), "FAKE", "fallback-id") + rebuilt = build_title(dl.title_to_meta(ep()), "FAKE", "fallback-id") assert rebuilt.absolute is None assert (rebuilt.daily, rebuilt.anime) == (None, None) @@ -223,5 +223,5 @@ def test_title_to_meta_round_trip_leaves_unset_flags_inheriting() -> None: def test_title_to_meta_round_trip_keeps_the_movie_anime_flag() -> None: movie = _movie() movie.anime = True - rebuilt = _build_title(dl.title_to_meta(movie), "FAKE", "fallback-id") + rebuilt = build_title(dl.title_to_meta(movie), "FAKE", "fallback-id") assert rebuilt.anime is True diff --git a/tests/remote/unit/test_compression.py b/tests/remote/unit/test_compression.py index a7837dca..9139f22d 100644 --- a/tests/remote/unit/test_compression.py +++ b/tests/remote/unit/test_compression.py @@ -19,7 +19,7 @@ class _FakeReq: self.headers = {"Accept-Encoding": accept_encoding} -def _run(coro): +def run(coro): return asyncio.run(coro) @@ -30,7 +30,7 @@ def test_skips_when_client_does_not_accept_gzip() -> None: return web.json_response({"data": "x" * 4096}) req = _FakeReq(accept_encoding="identity") - resp = _run(compression_middleware(req, handler)) + resp = run(compression_middleware(req, handler)) assert resp.headers.get("Content-Encoding") != "gzip" assert resp.body == body_json or len(resp.body) >= len(body_json) - 8 @@ -39,7 +39,7 @@ def test_skips_when_body_below_threshold() -> None: async def handler(req): # noqa: ARG001 return web.json_response({"hi": "x"}) - resp = _run(compression_middleware(_FakeReq(), handler)) + resp = run(compression_middleware(_FakeReq(), handler)) assert resp.headers.get("Content-Encoding") != "gzip" @@ -47,7 +47,7 @@ def test_skips_non_json_response() -> None: async def handler(req): # noqa: ARG001 return web.Response(body=b"x" * 4096, content_type="text/plain") - resp = _run(compression_middleware(_FakeReq(), handler)) + resp = run(compression_middleware(_FakeReq(), handler)) assert resp.headers.get("Content-Encoding") != "gzip" @@ -57,7 +57,7 @@ def test_compresses_large_json_when_accepted() -> None: async def handler(req): # noqa: ARG001 return web.json_response(big) - resp = _run(compression_middleware(_FakeReq(), handler)) + resp = run(compression_middleware(_FakeReq(), handler)) assert resp.headers.get("Content-Encoding") == "gzip" decompressed = gzip.decompress(resp.body) assert json.loads(decompressed) == big diff --git a/tests/remote/unit/test_download_gates_redaction.py b/tests/remote/unit/test_download_gates_redaction.py index 15658475..91668ab3 100644 --- a/tests/remote/unit/test_download_gates_redaction.py +++ b/tests/remote/unit/test_download_gates_redaction.py @@ -12,9 +12,9 @@ from unshackle.core.api import handlers from unshackle.core.api.download_manager import ( DownloadJob, JobStatus, - _redact_parameters, _redact_text, - _secret_values, + redact_parameters, + secret_values, ) from unshackle.core.api.errors import APIError, APIErrorCode @@ -34,7 +34,7 @@ def test_redact_parameters_masks_secrets_and_proxy_userinfo(): "proxy": "http://bob:secret@proxy.example:8080", "quality": "1080p", } - red = _redact_parameters(params) + red = redact_parameters(params) assert red["credential"] == "***" assert red["password"] == "***" assert red["token"] == "***" @@ -45,11 +45,11 @@ def test_redact_parameters_masks_secrets_and_proxy_userinfo(): def test_redact_parameters_masks_credentials_dict(): - assert _redact_parameters({"credentials": {"default": "u:p"}})["credentials"] == "***" + assert redact_parameters({"credentials": {"default": "u:p"}})["credentials"] == "***" def test_secret_values_includes_password_half_and_dict_values(): - secrets = _secret_values({"credential": "user:hunter2", "credentials": {"d": "alice:wonder"}}) + secrets = secret_values({"credential": "user:hunter2", "credentials": {"d": "alice:wonder"}}) assert "user:hunter2" in secrets # full credential assert "hunter2" in secrets # password half of user:pass assert "alice:wonder" in secrets # value from the credentials map @@ -98,10 +98,10 @@ def stub_handler(monkeypatch): forbidden request raises APIError *before* the try block and an allowed one is caught inside it.""" monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: tag) - def _boom(*_args, **_kwargs): + def boom(*_args, **_kwargs): raise _PastGate() - monkeypatch.setattr(handlers.Services, "load", _boom) + monkeypatch.setattr(handlers.Services, "load", boom) return monkeypatch diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index 7924d886..0fe340d2 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -5,8 +5,10 @@ from __future__ import annotations import pytest from langcodes import Language +from unshackle.core.api.errors import APIError, APIErrorCode from unshackle.core.api.handlers import ( sanitize_log, + search_handler, serialize_audio_track, serialize_drm, serialize_subtitle_track, @@ -27,7 +29,7 @@ class _FakeSvc: pass -def _video(**overrides) -> Video: +def video(**overrides) -> Video: base = dict( url="https://example.com/v.mpd", language=Language.get("en"), @@ -44,7 +46,7 @@ def _video(**overrides) -> Video: return Video(**base) -def _audio(**overrides) -> Audio: +def audio(**overrides) -> Audio: base = dict( url="https://example.com/a.mpd", language=Language.get("en"), @@ -60,7 +62,7 @@ def _audio(**overrides) -> Audio: return Audio(**base) -def _subtitle(**overrides) -> Subtitle: +def subtitle(**overrides) -> Subtitle: base = dict( url="https://example.com/s.vtt", language=Language.get("en"), @@ -142,7 +144,7 @@ def test_serialize_title_episode_unnamed_falls_back_to_number() -> None: def test_serialize_video_track_basic() -> None: - d = serialize_video_track(_video()) + d = serialize_video_track(video()) assert d["id"] == "video-001" assert d["codec"] == "AVC" assert d["bitrate"] == 5000 # kbps @@ -155,12 +157,12 @@ def test_serialize_video_track_basic() -> None: def test_serialize_video_track_include_url() -> None: - d = serialize_video_track(_video(), include_url=True) + d = serialize_video_track(video(), include_url=True) assert d["url"] == "https://example.com/v.mpd" def test_serialize_audio_track_basic() -> None: - d = serialize_audio_track(_audio()) + d = serialize_audio_track(audio()) assert d["id"] == "audio-001" assert d["codec"] == "AAC" assert d["bitrate"] == 128 @@ -169,7 +171,7 @@ def test_serialize_audio_track_basic() -> None: def test_serialize_subtitle_track_basic() -> None: - d = serialize_subtitle_track(_subtitle(forced=True)) + d = serialize_subtitle_track(subtitle(forced=True)) assert d["id"] == "sub-001" assert d["codec"] == "WebVTT" assert d["forced"] is True @@ -287,3 +289,20 @@ def test_validate_download_params_accepts_valid_values() -> None: ) is None ) + + +async def test_search_missing_service_raises_invalid_input() -> None: + # MISSING_SERVICE was never an APIErrorCode member, so this raised AttributeError + # and routes.py surfaced a 500 instead of the intended 400. + with pytest.raises(APIError) as exc: + await search_handler({"query": "dune"}, None) + assert exc.value.error_code == APIErrorCode.INVALID_INPUT + assert APIError.default_http_status(exc.value.error_code) == 400 + + +async def test_search_unknown_service_raises_invalid_service() -> None: + # get_tag echoes unknown tags back, so the old `not normalized_service` guard + # never fired and Services.get_path leaked a KeyError as a 500. + with pytest.raises(APIError) as exc: + await search_handler({"service": "NOPE_THIS_IS_NOT_REAL_", "query": "x"}, None) + assert exc.value.error_code == APIErrorCode.INVALID_SERVICE diff --git a/tests/remote/unit/test_progress_sink.py b/tests/remote/unit/test_progress_sink.py index 7a7e7313..2d1175c9 100644 --- a/tests/remote/unit/test_progress_sink.py +++ b/tests/remote/unit/test_progress_sink.py @@ -45,7 +45,7 @@ class Subtitle: self.bitrate = None -def _noop(**kwargs): +def noop(**kwargs): pass @@ -66,7 +66,7 @@ def test_weight_video_over_audio_over_subtitle(): def test_weighting_makes_video_dominate_progress(): updates: list[dict] = [] video, sub = Video(bitrate=4_000_000), Subtitle() - cbs = build_job_progress_callables([video, sub], [_noop, _noop], updates.append) + cbs = build_job_progress_callables([video, sub], [noop, noop], updates.append) # subtitle fully done, video untouched -> progress is tiny (subtitle barely weighted) cbs[1](downloaded="Downloaded") @@ -80,7 +80,7 @@ def test_weighting_makes_video_dominate_progress(): def test_active_tracks_labels_reported_and_cleared_on_done(): updates: list[dict] = [] - cbs = build_job_progress_callables([Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append) + cbs = build_job_progress_callables([Video(2160, "DV"), Audio("en-US", "2.0")], [noop, noop], updates.append) cbs[0](total=100, completed=10) # video downloading assert updates[-1]["active_tracks"] == ["video 2160p DV"] @@ -121,7 +121,7 @@ def test_aggregate_progress_is_monotonic_with_counts(): def test_all_tracks_done_reaches_download_ceiling(): # Downloads fill up to the ceiling; dl.result drives muxing the rest of the way to 100. updates: list[dict] = [] - cbs = build_job_progress_callables([Audio(bitrate=1000), Audio(bitrate=1000)], [_noop, _noop], updates.append) + cbs = build_job_progress_callables([Audio(bitrate=1000), Audio(bitrate=1000)], [noop, noop], updates.append) cbs[0](total=10, completed=10, downloaded="Downloaded") assert updates[-1]["progress"] < DOWNLOAD_PROGRESS_CEILING @@ -136,7 +136,7 @@ def test_finished_track_does_not_dip_when_callable_reused_for_decrypt(): """A track hits 100% (then decrypt reuses the callable with completed=0); the aggregate must hold, never dip - even before the terminal 'Downloaded'/'Decrypted' string arrives.""" updates: list[dict] = [] - cbs = build_job_progress_callables([Video(bitrate=1000), Video(bitrate=1000)], [_noop, _noop], updates.append) + cbs = build_job_progress_callables([Video(bitrate=1000), Video(bitrate=1000)], [noop, noop], updates.append) cbs[0](total=100, completed=100) # download hits 100% BEFORE any terminal string -> 50% cbs[0](total=200, completed=0) # decrypt phase resets counts, still no terminal string @@ -151,7 +151,7 @@ def test_finished_track_does_not_dip_when_callable_reused_for_decrypt(): def test_skipped_subtitle_counts_as_done(): updates: list[dict] = [] - cbs = build_job_progress_callables([Subtitle()], [_noop], updates.append) + cbs = build_job_progress_callables([Subtitle()], [noop], updates.append) cbs[0](downloaded="[yellow]SKIPPED") assert updates[-1]["completed_tracks"] == 1 assert updates[-1]["progress"] == pytest.approx(DOWNLOAD_PROGRESS_CEILING) diff --git a/tests/remote/unit/test_remote_resolve.py b/tests/remote/unit/test_remote_resolve.py index 5d264a32..9c48cc98 100644 --- a/tests/remote/unit/test_remote_resolve.py +++ b/tests/remote/unit/test_remote_resolve.py @@ -1,11 +1,11 @@ -"""Unit tests for resolve_server / _resolve_proxy in unshackle.core.remote_service.""" +"""Unit tests for resolve_server / resolve_proxy_arg in unshackle.core.remote_service.""" from __future__ import annotations import click import pytest -from unshackle.core.remote_service import _resolve_proxy, resolve_server +from unshackle.core.remote_service import resolve_proxy_arg, resolve_server pytestmark = pytest.mark.unit @@ -126,8 +126,8 @@ def test_resolve_server_multi_with_name(multi_remote_services) -> None: def test_resolve_proxy_none_returns_none() -> None: - assert _resolve_proxy(None) is None - assert _resolve_proxy("") is None + assert resolve_proxy_arg(None) is None + assert resolve_proxy_arg("") is None def test_resolve_proxy_passes_through_value(monkeypatch: pytest.MonkeyPatch) -> None: @@ -136,7 +136,7 @@ def test_resolve_proxy_passes_through_value(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(resolve_mod, "initialize_proxy_providers", lambda: []) monkeypatch.setattr(resolve_mod, "resolve_proxy", lambda arg, providers: f"http://proxy/{arg}") - assert _resolve_proxy("us") == "http://proxy/us" + assert resolve_proxy_arg("us") == "http://proxy/us" def test_resolve_proxy_value_error_becomes_click(monkeypatch: pytest.MonkeyPatch) -> None: @@ -150,5 +150,5 @@ def test_resolve_proxy_value_error_becomes_click(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr(resolve_mod, "resolve_proxy", boom) with pytest.raises(click.ClickException) as exc: - _resolve_proxy("xx") + resolve_proxy_arg("xx") assert "no such country" in str(exc.value.message) diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 81665c37..55cf5ab9 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -7,14 +7,14 @@ from enum import Enum import pytest from unshackle.core.remote_service import ( - _build_title, - _build_tracks, - _deserialize_audio, - _deserialize_subtitle, - _deserialize_video, - _enum_get, - _match_track, - _reconstruct_drm, + build_title, + build_tracks, + deserialize_audio, + deserialize_subtitle, + deserialize_video, + enum_get, + match_track, + reconstruct_drm, ) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie @@ -29,19 +29,19 @@ class _Color(Enum): def test_enum_get_known() -> None: - assert _enum_get(_Color, "RED") is _Color.RED + assert enum_get(_Color, "RED") is _Color.RED def test_enum_get_unknown_returns_default() -> None: - assert _enum_get(_Color, "PURPLE", default=_Color.BLUE) is _Color.BLUE + assert enum_get(_Color, "PURPLE", default=_Color.BLUE) is _Color.BLUE def test_enum_get_none_returns_default() -> None: - assert _enum_get(_Color, None, default=_Color.RED) is _Color.RED + assert enum_get(_Color, None, default=_Color.RED) is _Color.RED def test_deserialize_video_minimal() -> None: - v = _deserialize_video({"id": "video-1", "codec": "AVC", "width": 1920, "height": 1080, "bitrate": 5000}) + v = deserialize_video({"id": "video-1", "codec": "AVC", "width": 1920, "height": 1080, "bitrate": 5000}) assert isinstance(v, Video) assert v.id == "video-1" assert v.codec is Video.Codec.AVC @@ -52,12 +52,12 @@ def test_deserialize_video_minimal() -> None: def test_deserialize_video_unknown_codec_falls_back_to_none() -> None: - v = _deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0}) + v = deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0}) assert v.codec is None def test_deserialize_audio_atmos_flag_sets_joc() -> None: - a = _deserialize_audio({"id": "a1", "codec": "AAC", "atmos": True, "channels": 6, "bitrate": 256}) + a = deserialize_audio({"id": "a1", "codec": "AAC", "atmos": True, "channels": 6, "bitrate": 256}) assert isinstance(a, Audio) assert a.joc == 1 assert a.channels == 6 @@ -65,34 +65,34 @@ def test_deserialize_audio_atmos_flag_sets_joc() -> None: def test_deserialize_audio_no_atmos() -> None: - a = _deserialize_audio({"id": "a2", "codec": "AAC", "channels": 2}) + a = deserialize_audio({"id": "a2", "codec": "AAC", "channels": 2}) assert a.joc == 0 def test_deserialize_subtitle_forced_flag() -> None: - s = _deserialize_subtitle({"id": "s1", "codec": "WebVTT", "language": "en", "forced": True}) + s = deserialize_subtitle({"id": "s1", "codec": "WebVTT", "language": "en", "forced": True}) assert isinstance(s, Subtitle) assert s.forced is True assert s.sdh is False def test_deserialize_subtitle_sdh_flag() -> None: - s = _deserialize_subtitle({"id": "s2", "codec": "WebVTT", "language": "en", "sdh": True}) + s = deserialize_subtitle({"id": "s2", "codec": "WebVTT", "language": "en", "sdh": True}) assert s.sdh is True assert s.forced is False def test_reconstruct_drm_empty() -> None: - assert _reconstruct_drm(None) == [] - assert _reconstruct_drm([]) == [] + assert reconstruct_drm(None) == [] + assert reconstruct_drm([]) == [] def test_reconstruct_drm_skips_entries_without_pssh() -> None: - assert _reconstruct_drm([{"type": "widevine"}]) == [] + assert reconstruct_drm([{"type": "widevine"}]) == [] def test_reconstruct_drm_invalid_pssh_silently_dropped() -> None: - assert _reconstruct_drm([{"type": "widevine", "pssh": "not-real-pssh"}]) == [] + assert reconstruct_drm([{"type": "widevine", "pssh": "not-real-pssh"}]) == [] def test_build_tracks_aggregates() -> None: @@ -102,33 +102,33 @@ def test_build_tracks_aggregates() -> None: "subtitles": [{"id": "s", "codec": "WebVTT", "language": "en"}], "attachments": [], } - t = _build_tracks(data) + t = build_tracks(data) assert len(t.videos) == 1 assert len(t.audio) == 1 assert len(t.subtitles) == 1 def test_match_track_by_id() -> None: - a = _deserialize_video({"id": "v1", "codec": "AVC", "width": 1920, "height": 1080}) - b = _deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) - remote = _deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) - assert _match_track(remote, [a, b]) is b + a = deserialize_video({"id": "v1", "codec": "AVC", "width": 1920, "height": 1080}) + b = deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) + remote = deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) + assert match_track(remote, [a, b]) is b def test_match_track_by_attributes_when_id_missing() -> None: - local = _deserialize_video({"id": "X", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) - remote = _deserialize_video({"id": "Y", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) - assert _match_track(remote, [local]) is local + local = deserialize_video({"id": "X", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) + remote = deserialize_video({"id": "Y", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) + assert match_track(remote, [local]) is local def test_match_track_no_candidates_returns_none() -> None: - remote = _deserialize_video({"id": "X", "codec": "AVC", "width": 1, "height": 1}) - assert _match_track(remote, []) is None + remote = deserialize_video({"id": "X", "codec": "AVC", "width": 1, "height": 1}) + assert match_track(remote, []) is None def test_build_title_movie() -> None: info = {"type": "movie", "id": "movie-0001", "name": "Foo", "year": 2024, "language": "en"} - title = _build_title(info, "ATV", "fallback") + title = build_title(info, "ATV", "fallback") assert isinstance(title, Movie) assert title.id == "movie-0001" assert title.name == "Foo" @@ -145,7 +145,7 @@ def test_build_title_episode() -> None: "year": 2024, "language": "en", } - title = _build_title(info, "ATV", "fallback") + title = build_title(info, "ATV", "fallback") assert isinstance(title, Episode) assert title.season == 1 assert title.number == 2 @@ -153,5 +153,5 @@ def test_build_title_episode() -> None: def test_build_title_falls_back_to_id_when_missing() -> None: - title = _build_title({"type": "movie", "name": "x"}, "ATV", "fallback-id") + title = build_title({"type": "movie", "name": "x"}, "ATV", "fallback-id") assert title.id == "fallback-id" diff --git a/tests/remote/unit/test_routes.py b/tests/remote/unit/test_routes.py index 95e0e2bc..39498f97 100644 --- a/tests/remote/unit/test_routes.py +++ b/tests/remote/unit/test_routes.py @@ -21,20 +21,20 @@ pytestmark = pytest.mark.unit def make_app(): """Factory that builds an aiohttp app for tests.""" - def _factory(remote_only: bool = False, with_auth_middleware: bool = False): + def factory(remote_only: bool = False, with_auth_middleware: bool = False): middlewares = [cors_middleware, compression_middleware] if with_auth_middleware: - middlewares.insert(1, _no_key_required_auth()) + middlewares.insert(1, no_key_required_auth()) app = web.Application(middlewares=middlewares) app["config"] = {"users": {}} app["debug_api"] = False setup_routes(app, remote_only=remote_only) return app - return _factory + return factory -def _no_key_required_auth(): +def no_key_required_auth(): """Mirror serve.py's api_key_authentication middleware: required X-Secret-Key on every endpoint except /api/health.""" @@ -52,13 +52,13 @@ def _no_key_required_auth(): return mw -def _collect_paths(app: web.Application) -> list[tuple[str, str]]: +def collect_paths(app: web.Application) -> list[tuple[str, str]]: return sorted({(r.method, r.resource.canonical) for r in app.router.routes()}) def test_setup_routes_full_mode_wires_all_endpoints(make_app) -> None: app = make_app(remote_only=False) - paths = _collect_paths(app) + paths = collect_paths(app) expected = { ("GET", "/api/health"), ("GET", "/api/services"), @@ -84,7 +84,7 @@ def test_setup_routes_full_mode_wires_all_endpoints(make_app) -> None: def test_setup_routes_remote_only_excludes_list_and_download(make_app) -> None: app = make_app(remote_only=True) - paths = set(_collect_paths(app)) + paths = set(collect_paths(app)) assert ("POST", "/api/list-titles") not in paths assert ("POST", "/api/list-tracks") not in paths assert ("POST", "/api/download") not in paths @@ -121,7 +121,7 @@ async def test_api_handler_maps_apierror(aiohttp_client) -> None: client = await aiohttp_client(app) resp = await client.get("/boom") - assert resp.status == 404 # NOT_FOUND -> 404 per errors.APIError._default_http_status + assert resp.status == 404 # NOT_FOUND -> 404 per errors.APIError.default_http_status body = await resp.json() assert body["status"] == "error" assert body["error_code"] == "NOT_FOUND" @@ -142,10 +142,10 @@ async def test_cors_preflight_returns_headers(make_app, aiohttp_client) -> None: async def test_health_endpoint_responds_ok(make_app, aiohttp_client, monkeypatch: pytest.MonkeyPatch) -> None: from unshackle.core.api import routes as routes_mod - async def _no_update(_): + async def no_update(_): return None - monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", _no_update) + monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", no_update) app = make_app(remote_only=True) client = await aiohttp_client(app) resp = await client.get("/api/health") @@ -158,10 +158,10 @@ async def test_health_endpoint_responds_ok(make_app, aiohttp_client, monkeypatch async def test_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client, monkeypatch) -> None: from unshackle.core.api import routes as routes_mod - async def _no_update(_): + async def no_update(_): return None - monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", _no_update) + monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", no_update) app = make_app(remote_only=True, with_auth_middleware=True) client = await aiohttp_client(app) diff --git a/tests/remote/unit/test_server_cdm_gate.py b/tests/remote/unit/test_server_cdm_gate.py index 85f799b4..b2864297 100644 --- a/tests/remote/unit/test_server_cdm_gate.py +++ b/tests/remote/unit/test_server_cdm_gate.py @@ -28,13 +28,13 @@ def serve_users(monkeypatch): monkeypatch.setattr(handlers, "request_secret_key", lambda request: request.headers.get("X-Secret-Key")) -def _request(key): +def request(key): return SimpleNamespace(headers={"X-Secret-Key": key} if key else {}) @pytest.mark.parametrize(("key", "allowed"), ENTITLEMENTS) def test_server_cdm_allowed(serve_users, key, allowed): - assert handlers.server_cdm_allowed(_request(key)) is allowed + assert handlers.server_cdm_allowed(request(key)) is allowed def test_no_request_allows(serve_users): @@ -45,10 +45,10 @@ def test_no_request_allows(serve_users): def test_download_gate_follows_entitlement(serve_users, key, allowed): # A download job licenses with the server CDM, so submission obeys the same gate. if allowed: - handlers.enforce_download_gates({}, _request(key)) + handlers.enforce_download_gates({}, request(key)) else: with pytest.raises(APIError) as ei: - handlers.enforce_download_gates({}, _request(key)) + handlers.enforce_download_gates({}, request(key)) assert ei.value.error_code == APIErrorCode.FORBIDDEN diff --git a/tests/remote/unit/test_session_store.py b/tests/remote/unit/test_session_store.py index c05499f8..0ebff67c 100644 --- a/tests/remote/unit/test_session_store.py +++ b/tests/remote/unit/test_session_store.py @@ -69,7 +69,7 @@ async def test_cleanup_expired_drops_old_authenticated(store: SessionStore, monk from datetime import datetime, timedelta, timezone entry = await store.create("ATV", _FakeService()) - entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=store._ttl + 100) + entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=store.ttl + 100) removed = await store.cleanup_expired() assert removed == 1 assert store.session_count == 0 @@ -118,7 +118,7 @@ async def test_get_session_store_returns_singleton() -> None: async def test_max_sessions_evicts_oldest(store: SessionStore, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(type(store), "_max_sessions", property(lambda _: 2)) + monkeypatch.setattr(type(store), "max_sessions", property(lambda _: 2)) await store.create("A", _FakeService(), session_id="a") await asyncio.sleep(0.01) diff --git a/tests/tracks/test_encrypted_sample_entry.py b/tests/tracks/test_encrypted_sample_entry.py index 3874d2ce..9a9c70d2 100644 --- a/tests/tracks/test_encrypted_sample_entry.py +++ b/tests/tracks/test_encrypted_sample_entry.py @@ -14,73 +14,73 @@ from pathlib import Path from unshackle.core.tracks.track import has_encrypted_sample_entry -def _box(fmt: bytes, payload: bytes = b"") -> bytes: +def box(fmt: bytes, payload: bytes = b"") -> bytes: return struct.pack(">I", 8 + len(payload)) + fmt + payload -def _sample_entry(fmt: bytes, payload: bytes = b"") -> bytes: +def sample_entry(fmt: bytes, payload: bytes = b"") -> bytes: body = b"\x00" * 6 + struct.pack(">H", 1) + payload # reserved + data_reference_index return struct.pack(">I", 8 + len(body)) + fmt + body -def _stsd(entries: list[bytes]) -> bytes: +def stsd(entries: list[bytes]) -> bytes: body = b"\x00" * 4 + struct.pack(">I", len(entries)) + b"".join(entries) # version/flags + count - return _box(b"stsd", body) + return box(b"stsd", body) -def _moov(*children: bytes) -> bytes: - stbl = _box(b"stbl", b"".join(children)) - return _box(b"moov", _box(b"trak", _box(b"mdia", _box(b"minf", stbl)))) +def moov(*children: bytes) -> bytes: + stbl = box(b"stbl", b"".join(children)) + return box(b"moov", box(b"trak", box(b"mdia", box(b"minf", stbl)))) -FTYP = _box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2") +FTYP = box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2") # stray [size][encv] byte run as compressed A/V payload could randomly contain STRAY_ENCV = b"\xde\xad" * 4 + struct.pack(">I", 100) + b"encv" + b"\xbe\xef" * 8 -def _write(tmp_path: Path, name: str, *boxes: bytes) -> Path: +def write(tmp_path: Path, name: str, *boxes: bytes) -> Path: f = tmp_path / name f.write_bytes(b"".join(boxes)) return f def test_dual_stsd_encv_and_avc1_detected(tmp_path: Path) -> None: - sinf = _box(b"sinf", _box(b"frma", b"avc1")) - avc1 = _sample_entry(b"avc1", b"\x00" * 70) - encv = _sample_entry(b"encv", b"\x00" * 70 + sinf) - f = _write(tmp_path, "dual.mp4", FTYP, _moov(_stsd([avc1, encv])), _box(b"mdat", b"\x00" * 32)) + sinf = box(b"sinf", box(b"frma", b"avc1")) + avc1 = sample_entry(b"avc1", b"\x00" * 70) + encv = sample_entry(b"encv", b"\x00" * 70 + sinf) + f = write(tmp_path, "dual.mp4", FTYP, moov(stsd([avc1, encv])), box(b"mdat", b"\x00" * 32)) assert has_encrypted_sample_entry(f) is True def test_single_avc1_not_detected(tmp_path: Path) -> None: - avc1 = _sample_entry(b"avc1", b"\x00" * 70) - f = _write(tmp_path, "clear.mp4", FTYP, _moov(_stsd([avc1])), _box(b"mdat", b"\x00" * 32)) + avc1 = sample_entry(b"avc1", b"\x00" * 70) + f = write(tmp_path, "clear.mp4", FTYP, moov(stsd([avc1])), box(b"mdat", b"\x00" * 32)) assert has_encrypted_sample_entry(f) is False def test_stray_encv_in_mdat_not_detected(tmp_path: Path) -> None: # clean moov, mdat happens to contain a plausible [size][encv] run -> must NOT flag - avc1 = _sample_entry(b"avc1", b"\x00" * 70) - f = _write(tmp_path, "stray.mp4", FTYP, _moov(_stsd([avc1])), _box(b"mdat", STRAY_ENCV)) + avc1 = sample_entry(b"avc1", b"\x00" * 70) + f = write(tmp_path, "stray.mp4", FTYP, moov(stsd([avc1])), box(b"mdat", STRAY_ENCV)) assert has_encrypted_sample_entry(f) is False def test_leading_mdat_does_not_mask_real_encv(tmp_path: Path) -> None: # progressive layout (mdat before moov) with a stray run must not hide a genuine encv - enca = _sample_entry(b"enca", b"\x00" * 20 + _box(b"sinf", _box(b"frma", b"mp4a"))) - f = _write(tmp_path, "masked.mp4", FTYP, _box(b"mdat", STRAY_ENCV), _moov(_stsd([enca]))) + enca = sample_entry(b"enca", b"\x00" * 20 + box(b"sinf", box(b"frma", b"mp4a"))) + f = write(tmp_path, "masked.mp4", FTYP, box(b"mdat", STRAY_ENCV), moov(stsd([enca]))) assert has_encrypted_sample_entry(f) is True def test_text_containing_enca_in_moov_not_detected(tmp_path: Path) -> None: # "enca" inside a metadata string has no plausible box size before it -> must NOT flag - avc1 = _sample_entry(b"avc1", b"\x00" * 70) - udta = _box(b"udta", b"data is encapsulated here") - f = _write(tmp_path, "text.mp4", FTYP, _box(b"moov", _stsd([avc1]) + udta)) + avc1 = sample_entry(b"avc1", b"\x00" * 70) + udta = box(b"udta", b"data is encapsulated here") + f = write(tmp_path, "text.mp4", FTYP, box(b"moov", stsd([avc1]) + udta)) assert has_encrypted_sample_entry(f) is False def test_missing_or_bogus_file(tmp_path: Path) -> None: assert has_encrypted_sample_entry(tmp_path / "nope.mp4") is False - bogus = _write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4") + bogus = write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4") assert has_encrypted_sample_entry(bogus) is False diff --git a/tests/tracks/test_fragment_decrypted.py b/tests/tracks/test_fragment_decrypted.py index 6396b4fa..7dc5adda 100644 --- a/tests/tracks/test_fragment_decrypted.py +++ b/tests/tracks/test_fragment_decrypted.py @@ -19,54 +19,54 @@ from unshackle.core.manifests.ism_init import PIFF_SENC_UUID from unshackle.core.tracks.track import assert_fragments_decrypted -def _box(fmt: bytes, payload: bytes = b"") -> bytes: +def box(fmt: bytes, payload: bytes = b"") -> bytes: return struct.pack(">I", 8 + len(payload)) + fmt + payload -def _senc(sample_count: int = 4) -> bytes: - return _box(b"senc", b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) +def senc(sample_count: int = 4) -> bytes: + return box(b"senc", b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) -def _piff_senc(sample_count: int = 4) -> bytes: +def piff_senc(sample_count: int = 4) -> bytes: body = PIFF_SENC_UUID + b"\x00" * 4 + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count) - return _box(b"uuid", body) + return box(b"uuid", body) -def _moof(*children: bytes, sdi: int = 1) -> bytes: - tfhd = _box(b"tfhd", b"\x00\x00\x00\x02" + struct.pack(">II", 1, sdi)) # sample-description-index-present - return _box(b"moof", _box(b"mfhd", b"\x00" * 8) + _box(b"traf", tfhd + b"".join(children))) +def moof(*children: bytes, sdi: int = 1) -> bytes: + tfhd = box(b"tfhd", b"\x00\x00\x00\x02" + struct.pack(">II", 1, sdi)) # sample-description-index-present + return box(b"moof", box(b"mfhd", b"\x00" * 8) + box(b"traf", tfhd + b"".join(children))) -FTYP = _box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2") -MOOV = _box(b"moov", _box(b"mvex", _box(b"trex", b"\x00" * 24))) -MDAT = _box(b"mdat", b"\x00" * 64) +FTYP = box(b"ftyp", b"isom\x00\x00\x02\x00isomiso2") +MOOV = box(b"moov", box(b"mvex", box(b"trex", b"\x00" * 24))) +MDAT = box(b"mdat", b"\x00" * 64) # a compressed payload can contain anything, including plausible-looking box runs -STRAY = _box(b"mdat", struct.pack(">I", 64) + b"senc" + b"\xbe\xef" * 8 + PIFF_SENC_UUID) +STRAY = box(b"mdat", struct.pack(">I", 64) + b"senc" + b"\xbe\xef" * 8 + PIFF_SENC_UUID) -def _write(tmp_path: Path, name: str, *boxes: bytes) -> Path: +def write(tmp_path: Path, name: str, *boxes: bytes) -> Path: f = tmp_path / name f.write_bytes(b"".join(boxes)) return f def test_all_fragments_decrypted_passes(tmp_path: Path) -> None: - f = _write(tmp_path, "clean.mp4", FTYP, MOOV, _moof(), MDAT, _moof(sdi=2), MDAT) + f = write(tmp_path, "clean.mp4", FTYP, MOOV, moof(), MDAT, moof(sdi=2), MDAT) assert_fragments_decrypted(f) def test_surviving_senc_raises(tmp_path: Path) -> None: # the real bug: fragment 1 (index 1) decrypted, the rest (index 2) silently skipped - boxes = [FTYP, MOOV, _moof(), MDAT] + boxes = [FTYP, MOOV, moof(), MDAT] for _ in range(3): - boxes += [_moof(_senc(), sdi=2), MDAT] - f = _write(tmp_path, "skipped.mp4", *boxes) + boxes += [moof(senc(), sdi=2), MDAT] + f = write(tmp_path, "skipped.mp4", *boxes) with pytest.raises(ValueError, match=r"3/4 fragment"): assert_fragments_decrypted(f) def test_surviving_cenc_senc_raises(tmp_path: Path) -> None: - f = _write(tmp_path, "cenc.mp4", FTYP, MOOV, _moof(_senc()), MDAT) + f = write(tmp_path, "cenc.mp4", FTYP, MOOV, moof(senc()), MDAT) with pytest.raises(ValueError, match=r"1/1 fragment"): assert_fragments_decrypted(f) @@ -76,59 +76,59 @@ def test_leftover_piff_uuid_after_senc_removed_does_not_raise(tmp_path: Path) -> # consumed senc, detached senc/saiz/saio, and left the uuid on a fully decrypted fragment. boxes = [FTYP, MOOV] for _ in range(3): - boxes += [_moof(_piff_senc()), MDAT] - f = _write(tmp_path, "piff_leftover.mp4", *boxes) + boxes += [moof(piff_senc()), MDAT] + f = write(tmp_path, "piff_leftover.mp4", *boxes) assert_fragments_decrypted(f) def test_stray_senc_bytes_in_mdat_ignored(tmp_path: Path) -> None: - f = _write(tmp_path, "stray.mp4", FTYP, MOOV, _moof(), STRAY, _moof(sdi=2), STRAY) + f = write(tmp_path, "stray.mp4", FTYP, MOOV, moof(), STRAY, moof(sdi=2), STRAY) assert_fragments_decrypted(f) def test_senc_text_without_plausible_size_ignored(tmp_path: Path) -> None: # "senc" inside a free-box string has no valid box size before it - f = _write(tmp_path, "text.mp4", FTYP, MOOV, _moof(_box(b"free", b"presence sencoded here")), MDAT) + f = write(tmp_path, "text.mp4", FTYP, MOOV, moof(box(b"free", b"presence sencoded here")), MDAT) assert_fragments_decrypted(f) def test_senc_bytes_inside_trun_payload_ignored(tmp_path: Path) -> None: # a trun sample entry spelling "senc" after a size-looking duration; only a byte scan bites - trun = _box(b"trun", b"\x00\x00\x03\x01" + struct.pack(">I", 2) + struct.pack(">I", 512) + b"senc") - f = _write(tmp_path, "trun.mp4", FTYP, MOOV, _moof(trun), MDAT) + trun = box(b"trun", b"\x00\x00\x03\x01" + struct.pack(">I", 2) + struct.pack(">I", 512) + b"senc") + f = write(tmp_path, "trun.mp4", FTYP, MOOV, moof(trun), MDAT) assert_fragments_decrypted(f) def test_empty_senc_on_clear_fragment_ignored(tmp_path: Path) -> None: # sample_count 0 marks a fragment with no protected samples, so the guard ignores it - f = _write(tmp_path, "empty.mp4", FTYP, MOOV, _moof(_senc(sample_count=0)), MDAT) + f = write(tmp_path, "empty.mp4", FTYP, MOOV, moof(senc(sample_count=0)), MDAT) assert_fragments_decrypted(f) -def _senc_override(sample_count: int) -> bytes: +def senc_override(sample_count: int) -> bytes: # flags&1 => AlgorithmID(3) + IV_size(1) + KID(16) precede sample_count head = b"\x00\x00\x00\x01" + b"\x00\x00\x08" + b"\x08" + b"\xaa" * 16 - return _box(b"senc", head + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) + return box(b"senc", head + struct.pack(">I", sample_count) + b"\x00" * (8 * sample_count)) def test_override_flag_shifts_sample_count_offset(tmp_path: Path) -> None: - clear = _write(tmp_path, "ovr_clear.mp4", FTYP, MOOV, _moof(_senc_override(0)), MDAT) + clear = write(tmp_path, "ovr_clear.mp4", FTYP, MOOV, moof(senc_override(0)), MDAT) assert_fragments_decrypted(clear) - f = _write(tmp_path, "ovr.mp4", FTYP, MOOV, _moof(_senc_override(5)), MDAT) + f = write(tmp_path, "ovr.mp4", FTYP, MOOV, moof(senc_override(5)), MDAT) with pytest.raises(ValueError, match=r"1/1 fragment"): assert_fragments_decrypted(f) def test_senc_outside_traf_ignored(tmp_path: Path) -> None: # a decrypted moof whose only "senc" sits in a sibling box outside any traf - f = _write(tmp_path, "sibling.mp4", FTYP, MOOV, _box(b"moof", _box(b"mfhd", b"\x00" * 8) + _senc()), MDAT) + f = write(tmp_path, "sibling.mp4", FTYP, MOOV, box(b"moof", box(b"mfhd", b"\x00" * 8) + senc()), MDAT) assert_fragments_decrypted(f) def test_senc_nested_under_non_traf_ignored(tmp_path: Path) -> None: # senc two levels down under a non-traf container: only traf children count - meta = _box(b"meta", _senc()) - f = _write(tmp_path, "nested.mp4", FTYP, MOOV, _box(b"moof", _box(b"mfhd", b"\x00" * 8) + meta), MDAT) + meta = box(b"meta", senc()) + f = write(tmp_path, "nested.mp4", FTYP, MOOV, box(b"moof", box(b"mfhd", b"\x00" * 8) + meta), MDAT) assert_fragments_decrypted(f) @@ -136,17 +136,17 @@ def test_undersized_largesize_box_does_not_desync_walk(tmp_path: Path, caplog: p # size==1 reads a 16-byte header, so a largesize of 12 clears the 8-byte floor but # still stalls the cursor onto payload; only a header-aware floor catches it runt = struct.pack(">I", 1) + b"free" + struct.pack(">Q", 12) - f = _write(tmp_path, "runt.mp4", FTYP, MOOV, runt, _moof(_senc()), MDAT, _moof(_senc()), MDAT) + f = write(tmp_path, "runt.mp4", FTYP, MOOV, runt, moof(senc()), MDAT, moof(senc()), MDAT) with caplog.at_level("WARNING"): assert_fragments_decrypted(f) assert "malformed box size" in caplog.text def test_largesize_and_zero_size_boxes_walked(tmp_path: Path) -> None: - frag = _moof(_senc()) + frag = moof(senc()) large = struct.pack(">I", 1) + b"mdat" + struct.pack(">Q", 16 + 64) + b"\x00" * 64 trailing = struct.pack(">I", 0) + b"mdat" + b"\x00" * 64 # size 0 => runs to EOF - f = _write(tmp_path, "large.mp4", FTYP, MOOV, frag, large, trailing) + f = write(tmp_path, "large.mp4", FTYP, MOOV, frag, large, trailing) with pytest.raises(ValueError, match=r"1/1 fragment"): assert_fragments_decrypted(f) @@ -154,7 +154,7 @@ def test_largesize_and_zero_size_boxes_walked(tmp_path: Path) -> None: def test_malformed_box_size_warns_and_does_not_raise(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: # failing open is deliberate, but the abort must be logged so it cannot pass as clean overrun = struct.pack(">I", 1 << 30) + b"free" + b"\x00" * 8 - f = _write(tmp_path, "overrun.mp4", FTYP, MOOV, overrun, _moof(_senc()), MDAT) + f = write(tmp_path, "overrun.mp4", FTYP, MOOV, overrun, moof(senc()), MDAT) with caplog.at_level("WARNING"): assert_fragments_decrypted(f) assert "malformed box size" in caplog.text @@ -162,6 +162,6 @@ def test_malformed_box_size_warns_and_does_not_raise(tmp_path: Path, caplog: pyt def test_missing_or_bogus_file_is_noop(tmp_path: Path) -> None: assert_fragments_decrypted(tmp_path / "nope.mp4") - assert_fragments_decrypted(_write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4")) - truncated = _write(tmp_path, "cut.mp4", FTYP, MOOV, _moof(_senc())[:20]) + assert_fragments_decrypted(write(tmp_path, "bogus.bin", b"\x00\x01\x02\x03not-an-mp4")) + truncated = write(tmp_path, "cut.mp4", FTYP, MOOV, moof(senc())[:20]) assert_fragments_decrypted(truncated) diff --git a/tests/tracks/test_subtitle_convert.py b/tests/tracks/test_subtitle_convert.py index 1629d246..1b717e15 100644 --- a/tests/tracks/test_subtitle_convert.py +++ b/tests/tracks/test_subtitle_convert.py @@ -50,7 +50,7 @@ Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,World @pytest.fixture(autouse=True) -def _no_subtitleedit(monkeypatch): +def no_subtitleedit(monkeypatch): """Default every test to a SubtitleEdit-less environment; tests opt in when needed.""" monkeypatch.setattr(binaries, "SubtitleEdit", None) diff --git a/tests/tracks/test_subtitle_sdh_strip.py b/tests/tracks/test_subtitle_sdh_strip.py new file mode 100644 index 00000000..6a2b2ae2 --- /dev/null +++ b/tests/tracks/test_subtitle_sdh_strip.py @@ -0,0 +1,70 @@ +"""``strip_hearing_impaired`` fallback path (``tracks/subtitle.py``). + +Pins the filter-subs fallback's warn-and-continue branch: an invalid subtitle file must +log a warning and return, not crash (it previously raised AttributeError via a +nonexistent ``self.log``). +""" + +from __future__ import annotations + +import pytest + +from unshackle.core import binaries +from unshackle.core.config import config +from unshackle.core.tracks.subtitle import Subtitle + +SRT_SAMPLE = """1 +00:00:01,000 --> 00:00:02,000 +Hello +""" + + +@pytest.fixture() +def srt_track(tmp_path): + path = tmp_path / "sub.srt" + path.write_text(SRT_SAMPLE, encoding="utf-8") + sub = Subtitle(url="https://example.test/x", language="en", codec=Subtitle.Codec.SubRip) + sub.path = path + return sub + + +def test_invalid_subtitle_warns_and_continues(srt_track, monkeypatch, caplog): + import unshackle.core.tracks.subtitle as subtitle_mod + + monkeypatch.setattr(binaries, "SubtitleEdit", None) + monkeypatch.setattr(config, "subtitle", {"sdh_method": "auto"}, raising=False) + + def broken(*args, **kwargs): + raise RuntimeError("subby unavailable") + + monkeypatch.setattr(subtitle_mod, "CommonIssuesFixer", broken) + + def invalid(path): + raise OSError(f"{path} is not valid subtitle file") + + monkeypatch.setattr(subtitle_mod, "Subtitles", invalid) + + with caplog.at_level("WARNING", logger="Subtitle"): + srt_track.strip_hearing_impaired() + + assert any("Failed to strip SDH" in r.message for r in caplog.records) + + +def test_other_oserror_still_raises(srt_track, monkeypatch): + import unshackle.core.tracks.subtitle as subtitle_mod + + monkeypatch.setattr(binaries, "SubtitleEdit", None) + monkeypatch.setattr(config, "subtitle", {"sdh_method": "auto"}, raising=False) + + def broken(*args, **kwargs): + raise RuntimeError("subby unavailable") + + monkeypatch.setattr(subtitle_mod, "CommonIssuesFixer", broken) + + def denied(path): + raise PermissionError("permission denied") + + monkeypatch.setattr(subtitle_mod, "Subtitles", denied) + + with pytest.raises(PermissionError): + srt_track.strip_hearing_impaired() diff --git a/tests/tracks/test_subtitle_skip.py b/tests/tracks/test_subtitle_skip.py index 02c0c868..52080c5d 100644 --- a/tests/tracks/test_subtitle_skip.py +++ b/tests/tracks/test_subtitle_skip.py @@ -77,7 +77,7 @@ class Harness: @pytest.fixture(autouse=True) -def _clear_event(): +def clear_event(): DOWNLOAD_CANCELLED.clear() yield DOWNLOAD_CANCELLED.clear() diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 3dc1f851..8aab98f8 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -62,7 +62,7 @@ from unshackle.core.music import ( write_music_metadata, ) from unshackle.core.providers.anilist import parse_anilist_ref -from unshackle.core.providers.tvdb import SEASON_TYPES, _parse_int +from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service from unshackle.core.services import Services @@ -2063,10 +2063,10 @@ class dl: _progress.update(task_id=task_id, **kwargs) track_table = Table.grid() - track_table.add_row(music_renderer._song_line(song, titles)) + track_table.add_row(music_renderer.song_line(song, titles)) song_plan = music_song_plans.get(id(song)) if song_plan and song_plan.selected: - track_table.add_row(music_renderer._option_line(song_plan.selected), style="text2") + track_table.add_row(music_renderer.option_line(song_plan.selected), style="text2") else: track_table.add_row(str(track)[6:], style="text2") track_table.add_row(progress) @@ -3977,7 +3977,7 @@ class dl: aired_by_key: dict[tuple[int, int], str] = {} for episode in episodes: - season, number = _parse_int(episode.get("seasonNumber")), _parse_int(episode.get("number")) + season, number = parse_int(episode.get("seasonNumber")), parse_int(episode.get("number")) aired = episode.get("aired") if season is None or number is None or not aired: continue diff --git a/unshackle/commands/util.py b/unshackle/commands/util.py index d2322028..8365d16d 100644 --- a/unshackle/commands/util.py +++ b/unshackle/commands/util.py @@ -9,7 +9,7 @@ from unshackle.core import binaries from unshackle.core.constants import context_settings -def _natural_sort_key(path: Path) -> list: +def natural_sort_key(path: Path) -> list: """Sort key for natural sorting (S01E01 before S01E10).""" return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.name)] @@ -83,7 +83,7 @@ def crop(path: Path, aspect: str, letter: bool, offset: int, preview: bool) -> N raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.') if path.is_dir(): - paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=_natural_sort_key) + paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=natural_sort_key) else: paths = [path] for video_path in paths: @@ -186,7 +186,7 @@ def range_(path: Path, full: bool, preview: bool) -> None: raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.') if path.is_dir(): - paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=_natural_sort_key) + paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=natural_sort_key) else: paths = [path] for video_path in paths: @@ -272,7 +272,7 @@ def test(path: Path, map_: str) -> None: raise click.ClickException('FFmpeg executable "ffmpeg" not found but is required.') if path.is_dir(): - paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=_natural_sort_key) + paths = sorted(list(path.glob("*.mkv")) + list(path.glob("*.mp4")), key=natural_sort_key) else: paths = [path] for video_path in paths: diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index b7ff94b5..65e49af2 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -9,9 +9,9 @@ _patch_click_help() __version__ = "5.5.0" -_PKG = Path(__file__).parent.parent +PKG = Path(__file__).parent.parent # Framework code only. Services are user-swappable, so they are not part of the identity. -_CODE_DIRS = ("core", "commands", "utils", "vaults") +CODE_DIRS = ("core", "commands", "utils", "vaults") def _raise(error: OSError) -> None: @@ -20,9 +20,9 @@ def _raise(error: OSError) -> None: def code_files() -> list[str]: """Framework source paths relative to the package root, in a platform-stable order.""" - pkg = str(_PKG) + pkg = str(PKG) rels = ["__main__.py"] - for name in _CODE_DIRS: + for name in CODE_DIRS: for root, dirs, files in os.walk(os.path.join(pkg, name), onerror=_raise): dirs[:] = [d for d in dirs if d != "__pycache__"] rel = os.path.relpath(root, pkg).replace(os.sep, "/") @@ -41,7 +41,7 @@ def code_hash( (see tools/resolve_code_hash.py). Returns "" when the source cannot be read. """ if read is None: - pkg = str(_PKG) + pkg = str(PKG) def read(rel: str) -> bytes: with open(os.path.join(pkg, rel), "rb") as fh: diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index a582d12f..fa48bfc5 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -24,15 +24,15 @@ log = logging.getLogger("download_manager") # Job parameters may carry secrets (a raw "user:pass" credential, a proxy URL with embedded # userinfo). These must never leave the process via the API or logs, so they are masked # wherever parameters are serialized for a response. -_SENSITIVE_PARAM_KEYS = ("credential", "credentials", "password", "token", "api_key") +SENSITIVE_PARAM_KEYS = ("credential", "credentials", "password", "token", "api_key") -def _redact_parameters(parameters: Dict[str, Any]) -> Dict[str, Any]: +def redact_parameters(parameters: Dict[str, Any]) -> Dict[str, Any]: """Return a copy of job parameters with secrets masked, safe to serialize.""" if not isinstance(parameters, dict): return parameters redacted = dict(parameters) - for key in _SENSITIVE_PARAM_KEYS: + for key in SENSITIVE_PARAM_KEYS: if redacted.get(key): redacted[key] = REDACTED proxy = redacted.get("proxy") @@ -41,7 +41,7 @@ def _redact_parameters(parameters: Dict[str, Any]) -> Dict[str, Any]: return redacted -def _secret_values(parameters: Dict[str, Any]) -> List[str]: +def secret_values(parameters: Dict[str, Any]) -> List[str]: """Raw secret strings carried in job parameters, longest first, for scrubbing free text.""" if not isinstance(parameters, dict): return [] @@ -65,7 +65,7 @@ def _secret_values(parameters: Dict[str, Any]) -> List[str]: def _redact_text(text: Optional[str], parameters: Dict[str, Any]) -> Optional[str]: """Mask proxy userinfo and any known parameter secrets that leaked into a free-text field (error message / details / traceback / worker stderr) before it is returned via the API.""" - return redact_text(text, _secret_values(parameters)) + return redact_text(text, secret_values(parameters)) class JobStatus(Enum): @@ -154,10 +154,10 @@ class DownloadJob: if include_full_details: # Error/stderr/traceback are free text a service may have echoed a credential or proxy - # URL into, so scrub them with the same secrets that _redact_parameters masks. + # URL into, so scrub them with the same secrets that redact_parameters masks. result.update( { - "parameters": _redact_parameters(self.parameters), + "parameters": redact_parameters(self.parameters), "started_time": self.started_time.isoformat() if self.started_time else None, "completed_time": self.completed_time.isoformat() if self.completed_time else None, "output_files": self.output_files, @@ -172,14 +172,14 @@ class DownloadJob: return result -def _history_path() -> Path: +def history_path() -> Path: """Path of the persistent job history file (under the cache dir, kept out of the config/data tree).""" from unshackle.core.config import config return config.directories.cache / "api_history.jsonl" -def _history_limit() -> int: +def history_limit() -> int: """Max history entries to retain (serve.history_limit, default 100; <= 0 means unlimited).""" from unshackle.core.config import config @@ -203,14 +203,14 @@ def record_job_history(job: DownloadJob) -> None: "created_time": job.created_time.isoformat(), "completed_time": job.completed_time.isoformat() if job.completed_time else None, "output_files": job.output_files, - "parameters": _redact_parameters(job.parameters), + "parameters": redact_parameters(job.parameters), "error_message": _redact_text(job.error_message, job.parameters), } try: - path = _history_path() + path = history_path() path.parent.mkdir(parents=True, exist_ok=True) line = json.dumps(entry) - limit = _history_limit() + limit = history_limit() # Serialized on the manager's event loop, so read-append-trim doesn't race. if limit > 0 and path.exists(): existing = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] @@ -225,7 +225,7 @@ def record_job_history(job: DownloadJob) -> None: def read_job_history(limit: int = 100, service: Optional[str] = None) -> List[Dict[str, Any]]: """Read persisted job history, newest first; corrupt lines are skipped, missing file = empty.""" - path = _history_path() + path = history_path() if not path.exists(): return [] try: @@ -257,7 +257,7 @@ def delete_job_history(job_id: str, allowed: Optional[set] = None) -> bool: When `allowed` is given, an entry outside the caller's service allowlist is treated as absent (returns False) so it can't be deleted by a restricted key. """ - path = _history_path() + path = history_path() if not path.exists(): return False try: @@ -298,7 +298,7 @@ def to_enum(values: List[str], enum_cls: type[Enum]) -> List[Enum]: return [lookup[v.upper()] for v in values if v.upper() in lookup] -def _perform_download( +def perform_download( job_id: str, service: str, title_id: str, @@ -672,23 +672,23 @@ class DownloadQueueManager: if not queues: del self._subscribers[job_id] - def _publish(self, job: DownloadJob, event: str) -> None: + def publish(self, job: DownloadJob, event: str) -> None: """Fan an event carrying the job's full detail dict out to every listener.""" queues = self._subscribers.get(job.job_id) if not queues: return payload = {"event": event, "data": job.to_dict(include_full_details=True)} for queue in queues: - self._put(queue, payload) + self.put(queue, payload) - def _publish_terminal(self, job: DownloadJob) -> None: + def publish_terminal(self, job: DownloadJob) -> None: """Publish a job's final state, then close every listener's stream.""" - self._publish(job, job.status.value) + self.publish(job, job.status.value) for queue in self._subscribers.pop(job.job_id, []): - self._put(queue, None) + self.put(queue, None) @staticmethod - def _put(queue: asyncio.Queue, item: Optional[Dict[str, Any]]) -> None: + def put(queue: asyncio.Queue, item: Optional[Dict[str, Any]]) -> None: """Enqueue an item, discarding the oldest when a slow listener has filled the queue.""" try: queue.put_nowait(item) @@ -709,7 +709,7 @@ class DownloadQueueManager: job.cancel_event.set() # Signal cancellation job.completed_time = datetime.now() record_job_history(job) # queued jobs never reach a worker, so persist here - self._publish_terminal(job) + self.publish_terminal(job) log.info(f"Cancelled queued job {sanitize_log(job_id)}") return True elif job.status == JobStatus.DOWNLOADING: @@ -795,10 +795,10 @@ class DownloadQueueManager: # Start worker tasks for i in range(self.max_concurrent_downloads): - asyncio.create_task(self._download_worker(f"worker-{i}")) + asyncio.create_task(self.download_worker(f"worker-{i}")) # Start cleanup task - asyncio.create_task(self._cleanup_worker()) + asyncio.create_task(self.cleanup_worker()) log.info(f"Started {self.max_concurrent_downloads} download workers") @@ -809,7 +809,7 @@ class DownloadQueueManager: for queues in list(self._subscribers.values()): for queue in queues: - self._put(queue, None) + self.put(queue, None) self._subscribers.clear() # Cancel all active downloads @@ -846,7 +846,7 @@ class DownloadQueueManager: if self._active_downloads: await asyncio.gather(*self._active_downloads.values(), return_exceptions=True) - async def _download_worker(self, worker_name: str): + async def download_worker(self, worker_name: str): """Worker task that processes jobs from the queue.""" log.debug(f"Download worker {worker_name} started") @@ -868,12 +868,12 @@ class DownloadQueueManager: # Start processing the job job.status = JobStatus.DOWNLOADING job.started_time = datetime.now() - self._publish(job, "status") + self.publish(job, "status") log.info(f"Worker {worker_name} starting job {job.job_id}") # Create download task - download_task = asyncio.create_task(self._execute_download(job)) + download_task = asyncio.create_task(self.execute_download(job)) self._active_downloads[job.job_id] = download_task try: @@ -888,7 +888,7 @@ class DownloadQueueManager: finally: job.completed_time = datetime.now() record_job_history(job) - self._publish_terminal(job) + self.publish_terminal(job) if job.job_id in self._active_downloads: del self._active_downloads[job.job_id] @@ -897,12 +897,12 @@ class DownloadQueueManager: except Exception as e: log.error(f"Worker {worker_name} error: {e}") - async def _execute_download(self, job: DownloadJob): + async def execute_download(self, job: DownloadJob): """Execute the actual download for a job.""" log.info(f"Executing download for job {job.job_id}") try: - output_files = await self._run_download_async(job) + output_files = await self.run_download_async(job) job.status = JobStatus.COMPLETED job.output_files = output_files job.progress = 100.0 @@ -926,7 +926,7 @@ class DownloadQueueManager: log.error(f"Download failed for job {job.job_id}: {e}") raise - async def _run_download_async(self, job: DownloadJob) -> List[str]: + async def run_download_async(self, job: DownloadJob) -> List[str]: """Invoke a worker subprocess to execute the download.""" payload = { @@ -1011,7 +1011,7 @@ class DownloadQueueManager: log.info(f"Job {job.job_id} progress updated: {job.progress}%") if progress_data != last_published: last_published = progress_data - self._publish(job, "progress") + self.publish(job, "progress") except (FileNotFoundError, json.JSONDecodeError, ValueError) as e: log.debug(f"Could not read progress for job {job.job_id}: {e}") @@ -1083,7 +1083,7 @@ class DownloadQueueManager: except OSError: pass - async def _cleanup_worker(self): + async def cleanup_worker(self): """Worker that periodically cleans up old jobs.""" while not self._shutdown_event.is_set(): try: diff --git a/unshackle/core/api/download_worker.py b/unshackle/core/api/download_worker.py index 6be6df4e..45dc17fb 100644 --- a/unshackle/core/api/download_worker.py +++ b/unshackle/core/api/download_worker.py @@ -10,17 +10,17 @@ import traceback from pathlib import Path from typing import Any, Dict -from .download_manager import _perform_download +from .download_manager import perform_download log = logging.getLogger("download_worker") -def _read_payload(path: Path) -> Dict[str, Any]: +def read_payload(path: Path) -> Dict[str, Any]: with path.open("r", encoding="utf-8") as handle: return json.load(handle) -def _write_result(path: Path, payload: Dict[str, Any]) -> None: +def write_result(path: Path, payload: Dict[str, Any]) -> None: # Atomic replace: the parent polls this file while it is being rewritten. path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + ".tmp") @@ -44,7 +44,7 @@ def main(argv: list[str]) -> int: exit_code = 0 try: - payload = _read_payload(payload_path) + payload = read_payload(payload_path) job_id = payload["job_id"] service = payload["service"] title_id = payload["title_id"] @@ -61,12 +61,12 @@ def main(argv: list[str]) -> int: try: progress_state.update(progress_data) log.info(f"Writing progress update: {progress_data}") - _write_result(progress_path, progress_state) + write_result(progress_path, progress_state) log.info(f"Progress update written to {progress_path}") except Exception as e: log.error(f"Failed to write progress update: {e}") - output_files = _perform_download(job_id, service, title_id, params, progress_callback=progress_callback) + output_files = perform_download(job_id, service, title_id, params, progress_callback=progress_callback) result = {"status": "success", "output_files": output_files} @@ -96,7 +96,7 @@ def main(argv: list[str]) -> int: finally: try: - _write_result(result_path, result) + write_result(result_path, result) except Exception as exc: # noqa: BLE001 - last resort logging log.error(f"Failed to write worker result file: {exc}") diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 42f6c319..7fe2226d 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -73,10 +73,10 @@ class APIError(Exception): self.message = message self.details = details or {} self.retryable = retryable - self.http_status = http_status or self._default_http_status(error_code) + self.http_status = http_status or self.default_http_status(error_code) @staticmethod - def _default_http_status(error_code: APIErrorCode) -> int: + def default_http_status(error_code: APIErrorCode) -> int: """Map error codes to default HTTP status codes.""" status_map = { # 400 Bad Request diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 292bce0d..771c2bc6 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -108,7 +108,7 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] """Load a real CDM object for the given service. Services often touch ``ctx.obj.cdm.security_level`` / ``.device_type`` / ``.system_id`` - inside ``__init__``, so the lightweight ``_resolve_server_cdm`` stub is not enough + inside ``__init__``, so the lightweight ``resolve_server_cdm`` stub is not enough for list_titles / list_tracks / search. Mirrors ``dl.get_cdm`` selection logic but skips the quality-tier shortcuts (no track context yet) and falls back to the stub if no device is configured or loading fails. @@ -130,7 +130,7 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] cdm_name = cdm_name.get(profile) or cdm_name.get("default") or ci_get(app_config.cdm, "default") if not cdm_name or not isinstance(cdm_name, str): - return _resolve_server_cdm(service, profile, cdm_type) + return resolve_server_cdm(service, profile, cdm_type) try: return load_cdm(cdm_name, service_name=service) @@ -138,7 +138,7 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] log.warning( f"load_cdm({sanitize_log(cdm_name)!r}) failed for {sanitize_log(service)}: {exc}; using lightweight stub" ) - return _resolve_server_cdm(service, profile, cdm_type) + return resolve_server_cdm(service, profile, cdm_type) def load_service_yaml(normalized_service: str) -> dict: @@ -342,6 +342,8 @@ def owns_job(job: Any, request: Optional[web.Request] = None) -> bool: def validate_service(service_tag: str, request: Optional[web.Request] = None) -> Optional[str]: """Validate, normalize, and check allowlist for service tag.""" + if not isinstance(service_tag, str): + return None try: normalized = Services.get_tag(service_tag) service_path = Services.get_path(normalized) @@ -351,7 +353,7 @@ def validate_service(service_tag: str, request: Optional[web.Request] = None) -> if allowed is not None and normalized not in allowed: return None return normalized - except Exception: + except KeyError: return None @@ -366,7 +368,7 @@ def require_fields(data: Dict[str, Any], *names: str) -> None: ) -def _part_key_suffix(part: Optional[int]) -> str: +def part_key_suffix(part: Optional[int]) -> str: """`.2` selection-syntax suffix for a part-ful episode, empty otherwise.""" return f".{part}" if part is not None else "" @@ -385,7 +387,7 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: is_episode = isinstance(title, Episode) episode_part = title.part if isinstance(title, Episode) else None if is_episode: - # no part suffix here: remote_service._build_title rebuilds the Episode from this dict, so a + # no part suffix here: remote_service.build_title rebuilds the Episode from this dict, so a # suffixed name plus the structural `part` below would render the part twice in the filename name = title.name if title.name else f"Episode {title.number:02d}" else: @@ -424,7 +426,7 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: return result -def _stamp_service_flags(serialized: Dict[str, Any], service_instance: Any) -> Dict[str, Any]: +def stamp_service_flags(serialized: Dict[str, Any], service_instance: Any) -> Dict[str, Any]: """Fill anime/daily from the service class for titles that set neither. The client rebuilds titles from this JSON against a synthetic service class, so a @@ -438,7 +440,7 @@ def _stamp_service_flags(serialized: Dict[str, Any], service_instance: Any) -> D return serialized -def _extract_manifests(tracks) -> List[Dict[str, Any]]: +def extract_manifests(tracks) -> List[Dict[str, Any]]: """Extract manifest data from tracks for client-side re-parsing. Serializes DASH and ISM manifest XML as zlib-compressed base64 strings @@ -640,11 +642,13 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = query = data.get("query") if not service_tag: - raise APIError(APIErrorCode.MISSING_SERVICE, "Missing required 'service' field") + raise APIError(APIErrorCode.INVALID_INPUT, "Missing required 'service' field") if not query: raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field") - normalized_service = Services.get_tag(service_tag) + # get_tag echoes an unknown tag back, so it can never be falsy; validate_service + # is the check that actually resolves the service directory and the allowlist. + normalized_service = validate_service(service_tag, request) if not normalized_service: raise APIError( APIErrorCode.INVALID_SERVICE, @@ -652,14 +656,6 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = details={"service": service_tag}, ) - allowed = get_allowed_services(request) - if allowed is not None and normalized_service not in allowed: - raise APIError( - APIErrorCode.INVALID_SERVICE, - f"Service '{service_tag}' not found", - details={"service": service_tag}, - ) - profile = data.get("profile") proxy_param = data.get("proxy") no_proxy = data.get("no_proxy", False) @@ -742,9 +738,9 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques titles = service_instance.get_titles() if hasattr(titles, "__iter__") and not isinstance(titles, str): - title_list = [_stamp_service_flags(serialize_title(t), service_instance) for t in titles] + title_list = [stamp_service_flags(serialize_title(t), service_instance) for t in titles] else: - title_list = [_stamp_service_flags(serialize_title(titles), service_instance)] + title_list = [stamp_service_flags(serialize_title(titles), service_instance)] return web.json_response({"titles": title_list}) @@ -807,7 +803,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques details={"wanted": wanted_param, "service": normalized_service}, ) elif season is not None and episode is not None: - wanted = [f"{season}x{episode}{_part_key_suffix(part)}"] + wanted = [f"{season}x{episode}{part_key_suffix(part)}"] if wanted: # Filter titles based on wanted episodes, similar to how dl.py does it @@ -815,7 +811,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques log.debug(f"Filtering {len(titles_list)} titles with {len(wanted)} wanted episodes") for title in titles_list: if isinstance(title, Episode): - episode_key = f"{title.season}x{title.number}{_part_key_suffix(title.part)}" + episode_key = f"{title.season}x{title.number}{part_key_suffix(title.part)}" if title.matches_wanted(wanted): log.debug(f"Episode {episode_key} matches wanted list") matching_titles.append(title) @@ -864,12 +860,12 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques log.debug(f"Successfully got tracks for {title.season}x{title.number}") except SystemExit: # Service calls sys.exit() for unavailable episodes - catch and skip - failed_episodes.append(f"S{title.season}E{title.number:02d}{_part_key_suffix(title.part)}") + failed_episodes.append(f"S{title.season}E{title.number:02d}{part_key_suffix(title.part)}") log.debug(f"Episode {title.season}x{title.number} not available, skipping") continue except (Exception, SystemExit) as e: # Handle other errors gracefully - failed_episodes.append(f"S{title.season}E{title.number:02d}{_part_key_suffix(title.part)}") + failed_episodes.append(f"S{title.season}E{title.number:02d}{part_key_suffix(title.part)}") log.debug(f"Error getting tracks for {title.season}x{title.number}: {e}") continue @@ -1120,7 +1116,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] # Extract default values from the service's click command. # Skip None defaults here: this dict overlays into job params; injecting # None for keys like `profile` would clobber serve-config overrides. - # Missing required __init__ params are handled in download_manager._perform_download. + # Missing required __init__ params are handled in download_manager.perform_download. if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): for param in service_module.cli.params: if hasattr(param, "name") and param.default is not None and not isinstance(param.default, enum.Enum): @@ -1490,18 +1486,18 @@ async def prioritize_download_job_handler(job_id: str, request: Optional[web.Req # --------------------------------------------------------------------------- -_CONFIG_SECRET_KEY_RE = re.compile(r"secret|password|token|api_key|credential", re.IGNORECASE) +CONFIG_SECRET_KEY_RE = re.compile(r"secret|password|token|api_key|credential", re.IGNORECASE) -def _redact_config(value: Any) -> Any: +def redact_config(value: Any) -> Any: """Recursively mask secret-looking keys and URL userinfo; stringify paths.""" if isinstance(value, dict): return { - str(k): (REDACTED if v and _CONFIG_SECRET_KEY_RE.search(str(k)) else _redact_config(v)) + str(k): (REDACTED if v and CONFIG_SECRET_KEY_RE.search(str(k)) else redact_config(v)) for k, v in value.items() } if isinstance(value, (list, tuple)): - return [_redact_config(v) for v in value] + return [redact_config(v) for v in value] if isinstance(value, Path): return str(value) if isinstance(value, str) and "@" in value: @@ -1518,10 +1514,8 @@ async def profiles_handler(request: Optional[web.Request] = None) -> web.Respons # a plain (non-dict) credential is unnamed; that service is omitted entirely if not isinstance(creds, dict): continue - try: - tag = Services.get_tag(service) - except Exception: - tag = service + # get_tag returns its input unchanged when no service matches; str() covers non-str YAML keys + tag = Services.get_tag(str(service)) if allowed is not None and tag not in allowed: continue profiles[tag] = sorted(str(name) for name in creds) @@ -1548,14 +1542,14 @@ async def server_config_handler(request: Optional[web.Request] = None) -> web.Re service_tags = [t for t in service_tags if t in allowed] payload = { - "dl": _redact_config(config.dl), + "dl": redact_config(config.dl), "serve": { "max_concurrent_downloads": manager.max_concurrent_downloads, "job_retention_hours": manager.job_retention_hours, "history_limit": int(serve_cfg.get("history_limit", 100)), "services": serve_cfg.get("services") or None, "remote_only": bool(serve_cfg.get("remote_only", False)), - "cdm_overrides": _redact_config(serve_cfg.get("cdm_overrides")), + "cdm_overrides": redact_config(serve_cfg.get("cdm_overrides")), "allow_job_credentials": bool(serve_cfg.get("allow_job_credentials", False)), }, "directories": { @@ -1629,7 +1623,7 @@ async def delete_history_handler(job_id: str, request: Optional[web.Request] = N return handle_api_exception(e, context={"operation": "delete_history"}, debug_mode=debug_mode) -def _require_no_active_downloads(operation: str) -> None: +def require_no_active_downloads(operation: str) -> None: """Raise 409 CONFLICT if any job is currently downloading.""" from unshackle.core.api.download_manager import JobStatus, get_download_manager @@ -1647,7 +1641,7 @@ async def clear_cache_handler(request: Optional[web.Request] = None) -> web.Resp from unshackle.commands.env import clear_directory try: - _require_no_active_downloads("clear cache") + require_no_active_downloads("clear cache") _, freed_bytes = await asyncio.to_thread(clear_directory, config.directories.cache) return web.json_response({"cleared": True, "freed_bytes": freed_bytes}) @@ -1664,7 +1658,7 @@ async def clear_temp_handler(request: Optional[web.Request] = None) -> web.Respo from unshackle.commands.env import clear_directory try: - _require_no_active_downloads("clear temp") + require_no_active_downloads("clear temp") _, freed_bytes = await asyncio.to_thread(clear_directory, config.directories.temp) return web.json_response({"cleared": True, "freed_bytes": freed_bytes}) @@ -1701,10 +1695,10 @@ async def refresh_services_handler(request: Optional[web.Request] = None) -> web return handle_api_exception(e, context={"operation": "refresh_services"}, debug_mode=debug_mode) -_VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)*") +VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)*") -def _binary_version(path: Any) -> Optional[str]: +def binary_version(path: Any) -> Optional[str]: """Best-effort version probe of a binary; None when nothing parseable.""" import subprocess @@ -1715,7 +1709,7 @@ def _binary_version(path: Any) -> Optional[str]: ) except (OSError, subprocess.SubprocessError, ValueError): return None - match = _VERSION_RE.search(proc.stdout or "") or _VERSION_RE.search(proc.stderr or "") + match = VERSION_RE.search(proc.stdout or "") or VERSION_RE.search(proc.stderr or "") if match: return match.group(0) return None @@ -1725,7 +1719,7 @@ async def env_check_handler(request: Optional[web.Request] = None) -> web.Respon """Handle environment dependency check request.""" from unshackle.commands.env import get_dependencies - def _run_checks() -> List[Dict[str, Any]]: + def run_checks() -> List[Dict[str, Any]]: checks = [] for dep in get_dependencies(): binary = dep["binary"] @@ -1733,14 +1727,14 @@ async def env_check_handler(request: Optional[web.Request] = None) -> web.Respon { "name": dep["name"], "installed": binary is not None, - "version": _binary_version(binary) if binary else None, + "version": binary_version(binary) if binary else None, "required": dep["required"], } ) return checks try: - checks = await asyncio.to_thread(_run_checks) + checks = await asyncio.to_thread(run_checks) return web.json_response({"checks": checks}) except APIError: @@ -1777,7 +1771,7 @@ SESSION_TRANSPORT_KEYS = { } -def _create_service_instance( +def create_service_instance( normalized_service: str, title_id: str, data: Dict[str, Any], @@ -1903,7 +1897,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req ) try: - proxy_param, proxy_providers = _resolve_handler_proxy(data, normalized_service) + proxy_param, proxy_providers = resolve_handler_proxy(data, normalized_service) import hashlib import uuid as uuid_mod @@ -1916,7 +1910,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req api_key_hash = hashlib.pbkdf2_hmac("sha256", api_key.encode(), b"unshackle-session-ns", 100_000).hex()[:12] session_cache_tag = f"_sessions/{api_key_hash}/{session_id}/{normalized_service}" - service_instance, cookies, credential = _create_service_instance( + service_instance, cookies, credential = create_service_instance( normalized_service, title_id, data, @@ -1956,7 +1950,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req session.input_bridge = bridge session.auth_status = AuthStatus.AUTHENTICATING - async def _run_auth() -> None: + async def run_auth() -> None: try: await asyncio.to_thread(service_instance.authenticate, cookies, credential) session.auth_status = AuthStatus.AUTHENTICATED @@ -1967,7 +1961,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req session.auth_error = str(e) bridge.status = AuthStatus.FAILED - asyncio.create_task(_run_auth()) + asyncio.create_task(run_auth()) return web.json_response( { @@ -1996,8 +1990,8 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request] interactive auth flows (OTP, captcha) can complete before titles are fetched. """ - session = await _get_validated_session(session_id, request) - _require_authenticated(session) + session = await get_validated_session(session_id, request) + require_authenticated(session) try: service_instance = session.service_instance @@ -2014,7 +2008,7 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request] for t in titles_list: tid = str(t.id) if hasattr(t, "id") else str(id(t)) session.title_map[tid] = t - serialized_titles.append(_stamp_service_flags(serialize_title(t), service_instance)) + serialized_titles.append(stamp_service_flags(serialize_title(t), service_instance)) return web.json_response( { @@ -2042,8 +2036,8 @@ async def session_tracks_handler( This keeps auth separate from track fetching, allowing interactive auth flows (OTP, captcha) before any tracks are requested. """ - session = await _get_validated_session(session_id, request) - _require_authenticated(session) + session = await get_validated_session(session_id, request) + require_authenticated(session) title_id = data.get("title_id") if not title_id: @@ -2082,7 +2076,7 @@ async def session_tracks_handler( video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) - manifests = _extract_manifests(tracks) + manifests = extract_manifests(tracks) svc_session = session.service_instance.session session_headers = dict(svc_session.headers) if hasattr(svc_session, "headers") else {} @@ -2100,7 +2094,7 @@ async def session_tracks_handler( has_pr = bool(user_cfg.get("playready_devices")) service_tag = session.service_tag - config_cdm_type = _detect_cdm_type_for_service(service_tag, app_config) + config_cdm_type = detect_cdm_type_for_service(service_tag, app_config) track_has_wv = any( d.__class__.__name__ == "Widevine" for t in list(tracks.videos) + list(tracks.audio) if t.drm for d in t.drm @@ -2169,7 +2163,7 @@ async def session_segments_handler( Returns segment URLs, init data, DRM info, and any headers/cookies needed for CDN download. """ - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) track_ids = data.get("track_ids", []) if not track_ids: @@ -2240,13 +2234,13 @@ async def session_segments_handler( ) -def _cdm_type_stub(cdm_type: str) -> SimpleNamespace: +def cdm_type_stub(cdm_type: str) -> SimpleNamespace: """Lightweight CDM stand-in so ``is_playready_cdm()`` (reads ``.is_playready``) can detect the type without loading a full CDM.""" return SimpleNamespace(is_playready=cdm_type == "playready") -def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]) -> Optional[Any]: +def resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]) -> Optional[Any]: """Resolve CDM for the server context. Checks the server's own CDM config (``config.cdm[service]``) to @@ -2269,16 +2263,16 @@ def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None) if cdm_name and isinstance(cdm_name, str): - detected_type = _detect_cdm_type(cdm_name, app_config) + detected_type = detect_cdm_type(cdm_name, app_config) if detected_type: - return _cdm_type_stub(detected_type) + return cdm_type_stub(detected_type) if cdm_type: - return _cdm_type_stub(cdm_type) + return cdm_type_stub(cdm_type) return None -def _detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]: +def detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]: """Detect the CDM type configured for a service in config.cdm.""" cdm_name = ci_get(app_config.cdm, service) if not cdm_name: @@ -2289,11 +2283,11 @@ def _detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str] return "playready" if "playready" in lower_keys else "widevine" cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None) if cdm_name and isinstance(cdm_name, str): - return _detect_cdm_type(cdm_name, app_config) + return detect_cdm_type(cdm_name, app_config) return None -def _detect_cdm_type(cdm_name: str, app_config: Any) -> Optional[str]: +def detect_cdm_type(cdm_name: str, app_config: Any) -> Optional[str]: """Detect CDM type (playready/widevine) from config without loading it. Checks remote_cdm entries and local file extensions to determine the type. @@ -2316,7 +2310,7 @@ def _detect_cdm_type(cdm_name: str, app_config: Any) -> Optional[str]: return None -def _require_authenticated(session: Any) -> None: +def require_authenticated(session: Any) -> None: """Raise if the session has not finished authenticating.""" if session.auth_status == AuthStatus.FAILED: raise APIError( @@ -2337,7 +2331,7 @@ async def session_prompt_get_handler(session_id: str, request: Optional[web.Requ Returns the current auth status and any pending prompt that the remote client should display to the user. """ - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) if session.auth_status == AuthStatus.AUTHENTICATED: return web.json_response({"status": "authenticated"}) @@ -2362,7 +2356,7 @@ async def session_prompt_post_handler( The remote client calls this after collecting user input (OTP code, PIN, or device-code confirmation) to unblock the server auth thread. """ - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) response_text = data.get("response") if response_text is None: @@ -2376,7 +2370,7 @@ async def session_prompt_post_handler( return web.json_response({"status": "accepted"}) -async def _get_validated_session(session_id: str, request: Optional[web.Request]) -> Any: +async def get_validated_session(session_id: str, request: Optional[web.Request]) -> Any: """Fetch a session and verify the caller owns it. Ownership is bound to the authenticating X-Secret-Key rather than the source IP: @@ -2410,7 +2404,7 @@ async def _get_validated_session(session_id: str, request: Optional[web.Request] return session -def _resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tuple[Optional[str], list]: +def resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tuple[Optional[str], list]: """Resolve proxy and initialize providers from API request data. Handles explicit proxy param, provider:country format, and @@ -2440,7 +2434,8 @@ def _resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tup server_ip_info = get_ip_info(None, cached=True) server_region = server_ip_info.get("country", "").lower() if server_ip_info else None - except Exception: + except Exception as e: + log.debug(f"Server region lookup failed: {e!r}") server_region = None if server_region and server_region == client_region.lower(): @@ -2455,7 +2450,7 @@ def _resolve_handler_proxy(data: Dict[str, Any], normalized_service: str) -> tup return proxy_param, proxy_providers -def _find_title_for_track(track_id: str, session: Any) -> Any: +def find_title_for_track(track_id: str, session: Any) -> Any: """Find the title object that owns a given track.""" for t_id, tracks_dict in session.tracks_by_title.items(): if track_id in tracks_dict: @@ -2465,7 +2460,7 @@ def _find_title_for_track(track_id: str, session: Any) -> Any: return None -def _extract_pssh_from_track(track: Any, drm_type: str) -> Optional[str]: +def extract_pssh_from_track(track: Any, drm_type: str) -> Optional[str]: """Extract PSSH base64 string from a track's DRM objects.""" if not track.drm: return None @@ -2485,7 +2480,7 @@ def _extract_pssh_from_track(track: Any, drm_type: str) -> Optional[str]: return pssh_b64 -def _ensure_track_drm(track: Any) -> None: +def ensure_track_drm(track: Any) -> None: """Extract DRM from manifest data if track has none. Supports DASH (ContentProtection elements), HLS (EXT-X-KEY from @@ -2521,24 +2516,27 @@ def _ensure_track_drm(track: Any) -> None: if isinstance(drm, (Widevine, PlayReady)): track.drm = [drm] return - except Exception: + # EXT-X-KEY data is third-party input; skip keys we cannot turn into a DRM object + except Exception as e: + log.debug(f"Skipping HLS key {key}: {e!r}") continue - except Exception: - pass + except Exception as e: + log.debug(f"HLS DRM prefetch failed for {track.id}: {e!r}") # ISM: extract from ProtectionHeader elements if track.data.get("ism"): try: from unshackle.core.manifests import ISM as ISMManifest - stream_index = track.data["ism"].get("stream_index") - if stream_index is not None: - track.drm = ISMManifest.get_drm(stream_index) - except Exception: - pass + manifest = track.data["ism"].get("manifest") + if manifest is not None: + track.drm = ISMManifest.get_drm(manifest.xpath(".//ProtectionHeader")) + # a swallowed error here means the track silently gets no DRM, so it must be loud + except Exception as e: + log.warning(f"ISM DRM extraction failed for {track.id}: {e!r}") -def _resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") -> str: +def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") -> str: """Get the CDM device name, checking service-specific config.cdm first. Resolution order: @@ -2566,7 +2564,7 @@ def _resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "" return device_name -def _load_server_vaults(service_name: str) -> Any: +def load_server_vaults(service_name: str) -> Any: """Load server vaults from config.key_vaults.""" from unshackle.core.config import config as app_config from unshackle.core.services import Services @@ -2584,7 +2582,7 @@ def _load_server_vaults(service_name: str) -> Any: return vaults -def _check_vaults(kids: list, service_name: str) -> Optional[Dict[str, str]]: +def check_vaults(kids: list, service_name: str) -> Optional[Dict[str, str]]: """Check server vaults for existing keys matching all KIDs. Returns a KID:KEY dict if ALL KIDs are found, None otherwise. @@ -2592,7 +2590,7 @@ def _check_vaults(kids: list, service_name: str) -> Optional[Dict[str, str]]: from uuid import UUID try: - vaults = _load_server_vaults(service_name) + vaults = load_server_vaults(service_name) if not vaults.vaults: return None keys: Dict[str, str] = {} @@ -2606,17 +2604,18 @@ def _check_vaults(kids: list, service_name: str) -> Optional[Dict[str, str]]: if keys: log.info(f"Vault hit: {len(keys)} key(s) from server vaults, skipping CDM") return keys - except Exception: - pass + # vault lookup is a best-effort shortcut before the CDM; any failure falls through to licensing + except Exception as e: + log.debug(f"Server vault lookup failed: {e!r}") return None -def _cache_to_vaults(keys: Dict[str, str], service_name: str) -> None: +def cache_to_vaults(keys: Dict[str, str], service_name: str) -> None: """Cache newly obtained keys to server vaults.""" from uuid import UUID try: - vaults = _load_server_vaults(service_name) + vaults = load_server_vaults(service_name) if not vaults.vaults: return @@ -2628,7 +2627,7 @@ def _cache_to_vaults(keys: Dict[str, str], service_name: str) -> None: log.warning(f"Failed to cache keys to vaults: {e}") -def _handle_single_server_cdm( +def handle_single_server_cdm( service: Any, title: Any, track: Any, @@ -2642,10 +2641,10 @@ def _handle_single_server_cdm( from unshackle.core.cdm import load_cdm from unshackle.core.config import config as app_config - _ensure_track_drm(track) + ensure_track_drm(track) if not pssh_b64: - pssh_b64 = _extract_pssh_from_track(track, drm_type) + pssh_b64 = extract_pssh_from_track(track, drm_type) if not pssh_b64: raise APIError(APIErrorCode.INVALID_INPUT, "No PSSH available for server_cdm licensing") @@ -2662,9 +2661,9 @@ def _handle_single_server_cdm( # Gate on the caller's CDM device first so a caller with no device cannot # harvest server-side keys from the vault fallback below. - device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__) + device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__) - vault_keys = _check_vaults(pr_drm.kids, service.__class__.__name__) + vault_keys = check_vaults(pr_drm.kids, service.__class__.__name__) if vault_keys: return vault_keys @@ -2685,9 +2684,9 @@ def _handle_single_server_cdm( # Gate on the caller's CDM device first so a caller with no device cannot # harvest server-side keys from the vault fallback below. - device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__) + device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__) - vault_keys = _check_vaults(wv_drm.kids, service.__class__.__name__) + vault_keys = check_vaults(wv_drm.kids, service.__class__.__name__) if vault_keys: return vault_keys @@ -2709,11 +2708,11 @@ def _handle_single_server_cdm( if not keys: raise APIError(APIErrorCode.NO_CONTENT, "Server CDM returned no content keys") - _cache_to_vaults(keys, service.__class__.__name__) + cache_to_vaults(keys, service.__class__.__name__) return keys -def _handle_proxy_license( +def handle_proxy_license( service: Any, title: Any, track: Any, @@ -2758,7 +2757,7 @@ async def session_license_handler( """ import base64 - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) track_id = data.get("track_id") track_ids = data.get("track_ids") @@ -2782,7 +2781,7 @@ async def session_license_handler( has_pr_device = bool(user_config.get("playready_devices")) service_tag = session.service_tag - config_cdm_type = _detect_cdm_type_for_service(service_tag, app_config) + config_cdm_type = detect_cdm_type_for_service(service_tag, app_config) all_keys: Dict[str, Dict[str, str]] = {} seen_pssh: set[str] = set() @@ -2793,37 +2792,37 @@ async def session_license_handler( if not track: continue - _ensure_track_drm(track) + ensure_track_drm(track) if not track.drm: continue - title = _find_title_for_track(tid, session) + title = find_title_for_track(tid, session) track_drm_type = None pssh_str = None if config_cdm_type == "playready": - pssh_str = _extract_pssh_from_track(track, "playready") + pssh_str = extract_pssh_from_track(track, "playready") if pssh_str: track_drm_type = "playready" if not pssh_str: - pssh_str = _extract_pssh_from_track(track, "widevine") + pssh_str = extract_pssh_from_track(track, "widevine") if pssh_str: track_drm_type = "widevine" elif config_cdm_type == "widevine": - pssh_str = _extract_pssh_from_track(track, "widevine") + pssh_str = extract_pssh_from_track(track, "widevine") if pssh_str: track_drm_type = "widevine" if not pssh_str: - pssh_str = _extract_pssh_from_track(track, "playready") + pssh_str = extract_pssh_from_track(track, "playready") if pssh_str: track_drm_type = "playready" else: if has_wv_device: - pssh_str = _extract_pssh_from_track(track, "widevine") + pssh_str = extract_pssh_from_track(track, "widevine") if pssh_str: track_drm_type = "widevine" if not pssh_str and has_pr_device: - pssh_str = _extract_pssh_from_track(track, "playready") + pssh_str = extract_pssh_from_track(track, "playready") if pssh_str: track_drm_type = "playready" @@ -2839,7 +2838,7 @@ async def session_license_handler( seen_pssh.add(pssh_str) try: - keys = _handle_single_server_cdm(service, title, track, pssh_str, track_drm_type, request) + keys = handle_single_server_cdm(service, title, track, pssh_str, track_drm_type, request) if keys: all_keys[tid] = keys if track_drm_type: @@ -2866,7 +2865,7 @@ async def session_license_handler( ) try: - title = _find_title_for_track(track_id, session) + title = find_title_for_track(track_id, session) service = session.service_instance pssh_b64 = data.get("pssh") @@ -2892,11 +2891,11 @@ async def session_license_handler( track.drm.append(wv_drm) if mode == "server_cdm": - keys = _handle_single_server_cdm(service, title, track, pssh_b64, drm_type, request) + keys = handle_single_server_cdm(service, title, track, pssh_b64, drm_type, request) log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}") return web.json_response({"keys": keys}) - return _handle_proxy_license(service, title, track, challenge_b64, drm_type) + return handle_proxy_license(service, title, track, challenge_b64, drm_type) except APIError: raise @@ -2919,7 +2918,7 @@ async def session_license_handler( async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response: """Check session validity and get session info.""" - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) from unshackle.core.api.session_store import get_session_store @@ -2928,7 +2927,7 @@ async def session_info_handler(session_id: str, request: Optional[web.Request] = "session_id": session.session_id, "service": session.service_tag, "valid": True, - "expires_in": get_session_store()._ttl, + "expires_in": get_session_store().ttl, "track_count": len(session.tracks), "title_count": len(session.title_map), } @@ -2943,7 +2942,7 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request] from unshackle.core.api.session_store import get_session_store from unshackle.core.config import config as app_config - session = await _get_validated_session(session_id, request) + session = await get_validated_session(session_id, request) store = get_session_store() if session.input_bridge: @@ -2958,7 +2957,7 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request] if not f.stem.startswith("titles_"): try: cache_data[f.stem] = base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii") - except Exception: + except OSError: pass await store.delete(session_id) diff --git a/unshackle/core/api/session_store.py b/unshackle/core/api/session_store.py index 5d990b60..726cb0c7 100644 --- a/unshackle/core/api/session_store.py +++ b/unshackle/core/api/session_store.py @@ -57,12 +57,12 @@ class SessionStore: self._cleanup_task: Optional[asyncio.Task] = None @property - def _ttl(self) -> int: + def ttl(self) -> int: """Session TTL in seconds from config.""" return config.serve.get("session_ttl", 300) # 5 min default @property - def _max_sessions(self) -> int: + def max_sessions(self) -> int: """Max concurrent sessions from config.""" return config.serve.get("max_sessions", 100) @@ -74,9 +74,9 @@ class SessionStore: ) -> SessionEntry: """Create a new session with an authenticated service instance.""" async with self._lock: - if len(self._sessions) >= self._max_sessions: + if len(self._sessions) >= self.max_sessions: oldest_id = min(self._sessions, key=lambda k: self._sessions[k].last_accessed) - log.warning(f"Max sessions reached ({self._max_sessions}), evicting oldest: {oldest_id}") + log.warning(f"Max sessions reached ({self.max_sessions}), evicting oldest: {oldest_id}") del self._sessions[oldest_id] session_id = session_id or str(uuid.uuid4()) @@ -98,8 +98,8 @@ class SessionStore: if entry.auth_status not in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT): elapsed = (datetime.now(timezone.utc) - entry.last_accessed).total_seconds() - if elapsed > self._ttl: - log.info(f"Session {sanitize_log(session_id)} expired (elapsed={elapsed:.0f}s, ttl={self._ttl}s)") + if elapsed > self.ttl: + log.info(f"Session {sanitize_log(session_id)} expired (elapsed={elapsed:.0f}s, ttl={self.ttl}s)") del self._sessions[session_id] return None @@ -113,7 +113,7 @@ class SessionStore: if entry: if entry.input_bridge: entry.input_bridge.cancel() - self._cleanup_cache_dir(entry.cache_tag) + self.cleanup_cache_dir(entry.cache_tag) log.info(f"Deleted session {sanitize_log(session_id)}") return True return False @@ -128,13 +128,13 @@ class SessionStore: if entry.auth_status in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT): if elapsed > AUTH_INPUT_TIMEOUT: expired.append(sid) - elif elapsed > self._ttl: + elif elapsed > self.ttl: expired.append(sid) for sid in expired: entry = self._sessions.pop(sid) if entry.input_bridge: entry.input_bridge.cancel() - self._cleanup_cache_dir(entry.cache_tag) + self.cleanup_cache_dir(entry.cache_tag) if expired: log.info(f"Cleaned up {len(expired)} expired sessions") return len(expired) @@ -144,7 +144,7 @@ class SessionStore: if self._cleanup_task is not None: return - async def _loop() -> None: + async def loop() -> None: while True: await asyncio.sleep(60) # Check every minute try: @@ -152,7 +152,7 @@ class SessionStore: except Exception: log.exception("Error during session cleanup") - self._cleanup_task = asyncio.create_task(_loop()) + self._cleanup_task = asyncio.create_task(loop()) log.info("Session cleanup loop started") async def stop_cleanup_loop(self) -> None: @@ -172,7 +172,7 @@ class SessionStore: log.info(f"Cancelled bridges for {count} active session(s)") @staticmethod - def _cleanup_cache_dir(cache_tag: Optional[str]) -> None: + def cleanup_cache_dir(cache_tag: Optional[str]) -> None: """Remove session cache directory and empty parents.""" if not cache_tag: return @@ -190,7 +190,7 @@ class SessionStore: try: if parent.is_dir() and not any(parent.iterdir()): parent.rmdir() - except Exception: + except OSError: break @property @@ -200,12 +200,12 @@ class SessionStore: # Singleton instance -_session_store: Optional[SessionStore] = None +session_store: Optional[SessionStore] = None def get_session_store() -> SessionStore: """Get or create the global session store singleton.""" - global _session_store - if _session_store is None: - _session_store = SessionStore() - return _session_store + global session_store + if session_store is None: + session_store = SessionStore() + return session_store diff --git a/unshackle/core/cdm/custom_remote_cdm.py b/unshackle/core/cdm/custom_remote_cdm.py index 5e7562c7..161c2d97 100644 --- a/unshackle/core/cdm/custom_remote_cdm.py +++ b/unshackle/core/cdm/custom_remote_cdm.py @@ -158,7 +158,7 @@ class CustomRemoteCDM: # Get device type enum for compatibility if self.device_type_str: - self.device_type = self._get_device_type_enum(self.device_type_str) + self.device_type = self.get_device_type_enum(self.device_type_str) # Authentication configuration self.auth_config = auth or {"type": "header", "header_name": "Authorization", "key": ""} @@ -229,7 +229,7 @@ class CustomRemoteCDM: if custom_headers: self._http_session.headers.update(custom_headers) - def _get_device_type_enum(self, device_type: str): + def get_device_type_enum(self, device_type: str): """Convert device type string to enum for compatibility.""" device_type_upper = device_type.upper() if device_type_upper == "ANDROID": @@ -276,11 +276,11 @@ class CustomRemoteCDM: else: self._required_kids.append(str(kid).replace("-", "").lower()) - def _generate_session_id(self) -> bytes: + def generate_session_id(self) -> bytes: """Generate a unique session ID.""" return secrets.token_bytes(16) - def _get_init_data_from_pssh(self, pssh: Any) -> str: + def get_init_data_from_pssh(self, pssh: Any) -> str: """Extract init data from various PSSH formats.""" if self.is_playready and self._pssh_b64: return self._pssh_b64 @@ -292,7 +292,7 @@ class CustomRemoteCDM: try: base64.b64decode(dumps_result) return dumps_result - except Exception: + except ValueError: return base64.b64encode(dumps_result.encode("utf-8")).decode("utf-8") else: return base64.b64encode(dumps_result).decode("utf-8") @@ -314,7 +314,7 @@ class CustomRemoteCDM: else: raise ValueError(f"Unsupported PSSH type: {type(pssh)}") - def _get_nested_field(self, data: Dict[str, Any], field_path: str, default: Any = None) -> Any: + def get_nested_field(self, data: Dict[str, Any], field_path: str, default: Any = None) -> Any: """ Get a nested field from a dictionary using dot notation. @@ -327,8 +327,8 @@ class CustomRemoteCDM: Field value or default Examples: - _get_nested_field({"data": {"keys": [1,2,3]}}, "data.keys") -> [1,2,3] - _get_nested_field({"message": "success"}, "message") -> "success" + get_nested_field({"data": {"keys": [1,2,3]}}, "data.keys") -> [1,2,3] + get_nested_field({"message": "success"}, "message") -> "success" """ if not field_path: return default @@ -344,7 +344,7 @@ class CustomRemoteCDM: return current - def _apply_transform(self, value: Any, transform_type: str) -> Any: + def apply_transform(self, value: Any, transform_type: str) -> Any: """ Apply a transformation to a value. @@ -415,7 +415,7 @@ class CustomRemoteCDM: # Unknown transform type - return value unchanged return value - def _evaluate_condition(self, condition: str, context: Dict[str, Any]) -> bool: + def evaluate_condition(self, condition: str, context: Dict[str, Any]) -> bool: """ Evaluate a simple condition against a context. @@ -438,23 +438,23 @@ class CustomRemoteCDM: # Check for existence if " exists" in condition: field = condition.replace(" exists", "").strip() - return self._get_nested_field(context, field) is not None + return self.get_nested_field(context, field) is not None # Check for null comparisons if " == null" in condition: field = condition.replace(" == null", "").strip() - return self._get_nested_field(context, field) is None + return self.get_nested_field(context, field) is None if " != null" in condition: field = condition.replace(" != null", "").strip() - return self._get_nested_field(context, field) is not None + return self.get_nested_field(context, field) is not None # Check for equality if " == " in condition: parts = condition.split(" == ", 1) field = parts[0].strip() expected_value = parts[1].strip().strip("'\"") - actual_value = self._get_nested_field(context, field) + actual_value = self.get_nested_field(context, field) return str(actual_value) == expected_value # Check for inequality @@ -462,13 +462,13 @@ class CustomRemoteCDM: parts = condition.split(" != ", 1) field = parts[0].strip() expected_value = parts[1].strip().strip("'\"") - actual_value = self._get_nested_field(context, field) + actual_value = self.get_nested_field(context, field) return str(actual_value) != expected_value # Unknown condition format - return False return False - def _build_request_params( + def build_request_params( self, endpoint_name: str, base_params: Dict[str, Any], session: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ @@ -523,7 +523,7 @@ class CustomRemoteCDM: if session: context.update(session) - if self._evaluate_condition(condition, context): + if self.evaluate_condition(condition, context): params.update(condition_block.get("params", {})) # 4. Apply parameter transforms @@ -532,7 +532,7 @@ class CustomRemoteCDM: param_name = transform.get("param") transform_type = transform.get("type") if param_name in params: - params[param_name] = self._apply_transform(params[param_name], transform_type) + params[param_name] = self.apply_transform(params[param_name], transform_type) # 5. Handle nested parameter structure nested_params = mapping_config.get("nested_params", {}) @@ -552,7 +552,7 @@ class CustomRemoteCDM: return params - def _apply_authentication(self, session: Session) -> None: + def apply_authentication(self, session: Session) -> None: """ Apply authentication to the HTTP session based on auth configuration. @@ -587,7 +587,7 @@ class CustomRemoteCDM: session.auth = HTTPBasicAuth(username, password) - def _parse_response_data(self, endpoint_name: str, response_data: Dict[str, Any]) -> Dict[str, Any]: + def parse_response_data(self, endpoint_name: str, response_data: Dict[str, Any]) -> Dict[str, Any]: """ Parse response data based on response mapping configuration. @@ -609,7 +609,7 @@ class CustomRemoteCDM: parsed = {} for standard_name, field_path in fields_config.items(): - value = self._get_nested_field(response_data, field_path) + value = self.get_nested_field(response_data, field_path) if value is not None: parsed[standard_name] = value @@ -619,13 +619,13 @@ class CustomRemoteCDM: field_name = transform.get("field") transform_type = transform.get("type") if field_name in parsed: - parsed[field_name] = self._apply_transform(parsed[field_name], transform_type) + parsed[field_name] = self.apply_transform(parsed[field_name], transform_type) # Determine response type response_types = mapping_config.get("response_types", []) for response_type_config in response_types: condition = response_type_config.get("condition", "") - if self._evaluate_condition(condition, parsed): + if self.evaluate_condition(condition, parsed): parsed["_response_type"] = response_type_config.get("type") break @@ -633,7 +633,7 @@ class CustomRemoteCDM: success_conditions = mapping_config.get("success_conditions", []) is_success = True if success_conditions: - is_success = all(self._evaluate_condition(cond, parsed) for cond in success_conditions) + is_success = all(self.evaluate_condition(cond, parsed) for cond in success_conditions) parsed["_is_success"] = is_success # Extract error messages if not successful @@ -641,14 +641,14 @@ class CustomRemoteCDM: error_fields = mapping_config.get("error_fields", ["error", "message", "details"]) error_messages = [] for error_field in error_fields: - error_msg = self._get_nested_field(response_data, error_field) + error_msg = self.get_nested_field(response_data, error_field) if error_msg and error_msg not in error_messages: error_messages.append(str(error_msg)) parsed["_error_message"] = " - ".join(error_messages) if error_messages else "Unknown error" return parsed - def _parse_keys_from_response(self, endpoint_name: str, response_data: Dict[str, Any]) -> List[Dict[str, Any]]: + def parse_keys_from_response(self, endpoint_name: str, response_data: Dict[str, Any]) -> List[Dict[str, Any]]: """ Parse keys from response data using key field mapping. @@ -677,7 +677,7 @@ class CustomRemoteCDM: # Handle string format keys (e.g., "kid:key" format) elif isinstance(keys_data, str): - keys = self._apply_transform(keys_data, "parse_key_string") + keys = self.apply_transform(keys_data, "parse_key_string") return keys @@ -688,7 +688,7 @@ class CustomRemoteCDM: Returns: Session identifier as bytes """ - session_id = self._generate_session_id() + session_id = self.generate_session_id() self._sessions[session_id] = { "service_certificate": None, "keys": [], @@ -817,7 +817,7 @@ class CustomRemoteCDM: session = self._sessions[session_id] session["pssh"] = pssh_or_wrm - init_data = self._get_init_data_from_pssh(pssh_or_wrm) + init_data = self.get_init_data_from_pssh(pssh_or_wrm) # Check vaults for cached keys first if self.use_vaults and self._required_kids: @@ -858,10 +858,10 @@ class CustomRemoteCDM: base_params["service_certificate"] = base64.b64encode(session["service_certificate"]).decode("utf-8") # Transform parameters based on configuration - request_params = self._build_request_params("get_request", base_params, session) + request_params = self.build_request_params("get_request", base_params, session) # Apply authentication - self._apply_authentication(self._http_session) + self.apply_authentication(self._http_session) # Make API request endpoint_config = self.endpoints["get_request"] @@ -875,7 +875,7 @@ class CustomRemoteCDM: # Parse response response_data = response.json() - parsed_response = self._parse_response_data("get_request", response_data) + parsed_response = self.parse_response_data("get_request", response_data) # Check if request was successful if not parsed_response.get("_is_success", False): @@ -887,7 +887,7 @@ class CustomRemoteCDM: # Handle cached keys response if response_type == "cached_keys" or "cached_keys" in parsed_response: - cached_keys = self._parse_keys_from_response("get_request", parsed_response) + cached_keys = self.parse_keys_from_response("get_request", parsed_response) all_available_keys = list(cached_keys) if "vault_keys" in session: @@ -927,7 +927,7 @@ class CustomRemoteCDM: if isinstance(challenge, str): try: challenge = base64.b64decode(challenge) - except Exception: + except ValueError: challenge = challenge.encode("utf-8") session["challenge"] = challenge @@ -976,12 +976,12 @@ class CustomRemoteCDM: else: try: license_message = base64.b64decode(license_message) - except Exception: + except ValueError: license_message = license_message.encode("utf-8") # Build request parameters pssh = session["pssh"] - init_data = self._get_init_data_from_pssh(pssh) + init_data = self.get_init_data_from_pssh(pssh) license_request_b64 = base64.b64encode(session["challenge"]).decode("utf-8") license_response_b64 = base64.b64encode(license_message).decode("utf-8") @@ -994,10 +994,10 @@ class CustomRemoteCDM: } # Transform parameters based on configuration - request_params = self._build_request_params("decrypt_response", base_params, session) + request_params = self.build_request_params("decrypt_response", base_params, session) # Apply authentication - self._apply_authentication(self._http_session) + self.apply_authentication(self._http_session) # Make API request endpoint_config = self.endpoints["decrypt_response"] @@ -1011,7 +1011,7 @@ class CustomRemoteCDM: # Parse response response_data = response.json() - parsed_response = self._parse_response_data("decrypt_response", response_data) + parsed_response = self.parse_response_data("decrypt_response", response_data) # Check if request was successful if not parsed_response.get("_is_success", False): @@ -1019,7 +1019,7 @@ class CustomRemoteCDM: raise requests.RequestException(f"License decrypt error: {error_msg}") # Extract keys from response - license_keys = self._parse_keys_from_response("decrypt_response", parsed_response) + license_keys = self.parse_keys_from_response("decrypt_response", parsed_response) # Combine all keys (vault + cached + license) all_keys = [] diff --git a/unshackle/core/cdm/decrypt_labs_remote_cdm.py b/unshackle/core/cdm/decrypt_labs_remote_cdm.py index 39cf7d33..1f54fa1d 100644 --- a/unshackle/core/cdm/decrypt_labs_remote_cdm.py +++ b/unshackle/core/cdm/decrypt_labs_remote_cdm.py @@ -132,7 +132,7 @@ class DecryptLabsRemoteCDM: self._device_type_str = device_type if device_type: - self.device_type = self._get_device_type_enum(device_type) + self.device_type = self.get_device_type_enum(device_type) self._is_playready = (device_type and device_type.upper() == "PLAYREADY") or ( bool(device_name) and device_name.upper().startswith("SL") @@ -157,7 +157,7 @@ class DecryptLabsRemoteCDM: } ) - def _get_device_type_enum(self, device_type: str): + def get_device_type_enum(self, device_type: str): """Convert device type string to enum for compatibility.""" device_type_upper = device_type.upper() if device_type_upper == "ANDROID": @@ -204,11 +204,11 @@ class DecryptLabsRemoteCDM: else: self._required_kids.append(str(kid).replace("-", "").lower()) - def _generate_session_id(self) -> bytes: + def generate_session_id(self) -> bytes: """Generate a unique session ID.""" return secrets.token_bytes(16) - def _get_init_data_from_pssh(self, pssh: Any) -> str: + def get_init_data_from_pssh(self, pssh: Any) -> str: """Extract init data from various PSSH formats.""" if self.is_playready and self._pssh_b64: return self._pssh_b64 @@ -220,7 +220,7 @@ class DecryptLabsRemoteCDM: try: base64.b64decode(dumps_result) return dumps_result - except Exception: + except ValueError: return base64.b64encode(dumps_result.encode("utf-8")).decode("utf-8") else: return base64.b64encode(dumps_result).decode("utf-8") @@ -249,7 +249,7 @@ class DecryptLabsRemoteCDM: Returns: Session identifier as bytes """ - session_id = self._generate_session_id() + session_id = self.generate_session_id() self._sessions[session_id] = { "service_certificate": None, "keys": [], @@ -395,7 +395,7 @@ class DecryptLabsRemoteCDM: session = self._sessions[session_id] session["pssh"] = pssh_or_wrm - init_data = self._get_init_data_from_pssh(pssh_or_wrm) + init_data = self.get_init_data_from_pssh(pssh_or_wrm) already_tried_cache = session.get("tried_cache", False) if self.vaults and self._required_kids: @@ -470,7 +470,7 @@ class DecryptLabsRemoteCDM: for missing keys. """ cached_keys = data.get("cached_keys", []) - parsed_keys = self._parse_cached_keys(cached_keys) + parsed_keys = self.parse_cached_keys(cached_keys) all_available_keys = list(parsed_keys) if "vault_keys" in session: @@ -588,11 +588,11 @@ class DecryptLabsRemoteCDM: else: try: license_message = base64.b64decode(license_message) - except Exception: + except ValueError: license_message = license_message.encode("utf-8") pssh = session["pssh"] - init_data = self._get_init_data_from_pssh(pssh) + init_data = self.get_init_data_from_pssh(pssh) license_request_b64 = base64.b64encode(session["challenge"]).decode("utf-8") license_response_b64 = base64.b64encode(license_message).decode("utf-8") @@ -620,7 +620,7 @@ class DecryptLabsRemoteCDM: error_msg += f" - Details: {data['details']}" raise requests.RequestException(f"License decrypt error: {error_msg}") - license_keys = self._parse_keys_response(data) + license_keys = self.parse_keys_response(data) all_keys = [] @@ -705,7 +705,7 @@ class DecryptLabsRemoteCDM: return keys - def _parse_cached_keys(self, cached_keys_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def parse_cached_keys(self, cached_keys_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Parse cached keys from API response. Args: @@ -721,11 +721,12 @@ class DecryptLabsRemoteCDM: for key_data in cached_keys_data: if "kid" in key_data and "key" in key_data: keys.append({"kid": key_data["kid"], "key": key_data["key"], "type": "CONTENT"}) - except Exception: + # non-dict entries in the API payload raise TypeError on membership/index + except TypeError: pass return keys - def _parse_keys_response(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: + def parse_keys_response(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: """Parse keys from decrypt response.""" keys = [] diff --git a/unshackle/core/cdm/loader.py b/unshackle/core/cdm/loader.py index be66c428..cf97672d 100644 --- a/unshackle/core/cdm/loader.py +++ b/unshackle/core/cdm/loader.py @@ -13,7 +13,7 @@ from typing import Any, Optional log = logging.getLogger("cdm") -def _stamp_remote(cdm: Any, drm: str) -> Any: +def stamp_remote(cdm: Any, drm: str) -> Any: """Record a remote CDM's DRM type and mark it as remote. The loader knows the DRM type from config, so it sets it here as the value @@ -49,12 +49,12 @@ def load_cdm( cdm_api = next(iter(x.copy() for x in config.remote_cdm if x["name"] == cdm_name), None) if cdm_api: - return _load_remote_cdm(cdm_api, cdm_name, service_name, vaults) + return load_remote_cdm(cdm_api, cdm_name, service_name, vaults) - return _load_local_cdm(cdm_name) + return load_local_cdm(cdm_name) -def _load_remote_cdm( +def load_remote_cdm( cdm_api: dict, cdm_name: str, service_name: str, @@ -80,7 +80,7 @@ def _load_remote_cdm( ) cdm: Any = DecryptLabsRemoteCDM(service_name=service_name, vaults=vaults, **cdm_api) - return _stamp_remote(cdm, "playready" if cdm.is_playready else "widevine") + return stamp_remote(cdm, "playready" if cdm.is_playready else "widevine") if cdm_type == "custom_api": from unshackle.core.cdm.custom_remote_cdm import CustomRemoteCDM @@ -88,13 +88,13 @@ def _load_remote_cdm( del cdm_api["name"] del cdm_api["type"] cdm = CustomRemoteCDM(service_name=service_name, vaults=vaults, **cdm_api) - return _stamp_remote(cdm, "playready" if cdm.is_playready else "widevine") + return stamp_remote(cdm, "playready" if cdm.is_playready else "widevine") device_type = cdm_api.get("Device Type", cdm_api.get("device_type", "")) if str(device_type).upper() == "PLAYREADY": from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm - return _stamp_remote( + return stamp_remote( PlayReadyRemoteCdm( security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)), host=cdm_api.get("Host", cdm_api.get("host")), @@ -106,7 +106,7 @@ def _load_remote_cdm( from pywidevine.remotecdm import RemoteCdm - return _stamp_remote( + return stamp_remote( RemoteCdm( device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")), system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")), @@ -119,7 +119,7 @@ def _load_remote_cdm( ) -def _load_local_cdm(cdm_name: str) -> Any: +def load_local_cdm(cdm_name: str) -> Any: """Instantiate a local CDM from a .prd or .wvd file.""" from unshackle.core.config import config diff --git a/unshackle/core/cdm/monalisa/monalisa_cdm.py b/unshackle/core/cdm/monalisa/monalisa_cdm.py index ec9214e9..87842cfb 100644 --- a/unshackle/core/cdm/monalisa/monalisa_cdm.py +++ b/unshackle/core/cdm/monalisa/monalisa_cdm.py @@ -115,8 +115,8 @@ class MonaLisaCDM: memory_type = wasmtime.MemoryType(wasmtime.Limits(256, 256)) self.memory = wasmtime.Memory(self.store, memory_type) - self._write_i32(self.DYNAMICTOP_PTR, self.DYNAMIC_BASE) - imports = self._build_imports() + self.write_i32(self.DYNAMICTOP_PTR, self.DYNAMIC_BASE) + imports = self.build_imports() self.instance = wasmtime.Instance(self.store, self.module, imports) ex = self.instance.exports(self.store) @@ -140,7 +140,7 @@ class MonaLisaCDM: # Treat 0/negative/non-int-like values as allocation failure. try: ctx_int = int(ctx) - except Exception: + except (TypeError, ValueError): ctx_int = None if ctx_int is None or ctx_int <= 0: @@ -193,7 +193,7 @@ class MonaLisaCDM: else: license_b64 = license_data - ret = self._ccall( + ret = self.ccall( "monalisa_set_license", int, self.ctx, @@ -205,7 +205,7 @@ class MonaLisaCDM: if ret != 0: raise RuntimeError(f"License validation failed with code: {ret}") - key_bytes = self._extract_license_key_bytes() + key_bytes = self.extract_license_key_bytes() # Extract DCID from license to generate KID try: @@ -225,7 +225,7 @@ class MonaLisaCDM: # No DCID in the license: derive a deterministic per-license KID to avoid collisions. try: license_raw = base64.b64decode(license_b64) - except Exception: + except ValueError: license_raw = license_b64.encode("utf-8", errors="replace") license_hash = hashlib.sha256(license_raw).hexdigest() @@ -233,7 +233,7 @@ class MonaLisaCDM: return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"} - def _extract_license_key_bytes(self) -> bytes: + def extract_license_key_bytes(self) -> bytes: """Extract the 16-byte decryption key from WASM memory.""" data_ptr = self.memory.data_ptr(self.store) data_len = self.memory.data_len(self.store) @@ -247,7 +247,7 @@ class MonaLisaCDM: return bytes(mem_ptr.contents[start:end]) - def _ccall(self, func_name: str, return_type: type, *args): + def ccall(self, func_name: str, return_type: type, *args): """Call a WASM function with automatic string conversion.""" stack = 0 converted_args = [] @@ -259,7 +259,7 @@ class MonaLisaCDM: stack = self.exports["stackSave"](self.store) max_length = (len(arg) << 2) + 1 ptr = self.exports["stackAlloc"](self.store, max_length) - self._string_to_utf8(arg, ptr, max_length) + self.string_to_utf8(arg, ptr, max_length) converted_args.append(ptr) else: converted_args.append(arg) @@ -280,7 +280,7 @@ class MonaLisaCDM: return bool(result) return result - def _write_i32(self, addr: int, value: int) -> None: + def write_i32(self, addr: int, value: int) -> None: """Write a 32-bit integer to WASM memory.""" if addr % 4 != 0: raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)") @@ -293,7 +293,7 @@ class MonaLisaCDM: mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32)) mem_ptr[addr >> 2] = value - def _string_to_utf8(self, data: str, ptr: int, max_length: int) -> int: + def string_to_utf8(self, data: str, ptr: int, max_length: int) -> int: """Convert string to UTF-8 and write to WASM memory.""" encoded = data.encode("utf-8") write_length = min(len(encoded), max_length - 1) @@ -306,7 +306,7 @@ class MonaLisaCDM: mem_ptr[ptr + write_length] = 0 return write_length - def _write_ascii_to_memory(self, string: str, buffer: int, dont_add_null: int = 0) -> None: + def write_ascii_to_memory(self, string: str, buffer: int, dont_add_null: int = 0) -> None: """Write ASCII string to WASM memory.""" mem_data = self.memory.data_ptr(self.store) mem_ptr = ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte)) @@ -318,7 +318,7 @@ class MonaLisaCDM: if dont_add_null == 0: mem_ptr[buffer + len(encoded)] = 0 - def _build_imports(self): + def build_imports(self): """Build the WASM import stubs required by the MonaLisa module.""" def sys_fcntl64(a, b, c): @@ -381,15 +381,15 @@ class MonaLisaCDM: buf_size = 0 for index, string in enumerate(self.ENV_STRINGS): ptr = environ_buf + buf_size - self._write_i32(environ_ptr + index * 4, ptr) - self._write_ascii_to_memory(string, ptr) + self.write_i32(environ_ptr + index * 4, ptr) + self.write_ascii_to_memory(string, ptr) buf_size += len(string) + 1 return 0 def environ_sizes_get(penviron_count, penviron_buf_size): - self._write_i32(penviron_count, len(self.ENV_STRINGS)) + self.write_i32(penviron_count, len(self.ENV_STRINGS)) buf_size = sum(len(s) + 1 for s in self.ENV_STRINGS) - self._write_i32(penviron_buf_size, buf_size) + self.write_i32(penviron_buf_size, buf_size) return 0 i32 = wasmtime.ValType.i32() diff --git a/unshackle/core/commands.py b/unshackle/core/commands.py index 5d90c13a..459f6143 100644 --- a/unshackle/core/commands.py +++ b/unshackle/core/commands.py @@ -9,7 +9,7 @@ from unshackle.core.utilities import import_module_by_path log = logging.getLogger("commands") -_COMMANDS = sorted( +COMMANDS = sorted( (path for path in config.directories.commands.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem ) @@ -49,7 +49,7 @@ def load_commands(paths: list[Path]) -> tuple[dict[str, object], list[str]]: return modules, errors -_MODULES, LOAD_ERRORS = load_commands(_COMMANDS) +MODULES, LOAD_ERRORS = load_commands(COMMANDS) def check_load_errors() -> None: @@ -65,12 +65,12 @@ class Commands(click.Group): def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of command names from the command filenames.""" check_load_errors() - return [x.stem.replace("_", "-") for x in _COMMANDS] + return [x.stem.replace("_", "-") for x in COMMANDS] def get_command(self, ctx: click.Context, name: str) -> Optional[click.Command]: """Load the command code and return the main click command function.""" check_load_errors() - module = _MODULES.get(name) or _MODULES.get(name.replace("-", "_")) + module = MODULES.get(name) or MODULES.get(name.replace("-", "_")) if not module: raise click.ClickException(f"Unable to find command by the name '{name}'") diff --git a/unshackle/core/config.py b/unshackle/core/config.py index d1fcce5c..3a6cea21 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -151,7 +151,7 @@ class Config: ) if self.output_template: - self._validate_output_templates() + self.validate_output_templates() self.unicode_filenames: bool = kwargs.get("unicode_filenames", False) @@ -163,7 +163,7 @@ class Config: self.debug_keys: bool = kwargs.get("debug_keys", False) self.debug_requests: bool = kwargs.get("debug_requests", False) - def _validate_output_templates(self) -> None: + def validate_output_templates(self) -> None: """Validate output template configurations and warn about potential issues.""" if not self.output_template: return diff --git a/unshackle/core/console.py b/unshackle/core/console.py index 1015b389..9ba1ae8c 100644 --- a/unshackle/core/console.py +++ b/unshackle/core/console.py @@ -361,7 +361,7 @@ class SyncLive(Live): class GradientPulseBarColumn(BarColumn): - _LUT_SIZE = 32 + LUT_SIZE = 32 def __init__(self, *args: Any, pulse_period: float = 40.0, pulse_speed: float = 15.0, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -371,7 +371,7 @@ class GradientPulseBarColumn(BarColumn): hi = Color.parse(primary_scheme["pink"]).triplet assert lo and hi self._lut = [ - Style(color=Color.from_triplet(blend_rgb(lo, hi, i / (self._LUT_SIZE - 1)))) for i in range(self._LUT_SIZE) + Style(color=Color.from_triplet(blend_rgb(lo, hi, i / (self.LUT_SIZE - 1)))) for i in range(self.LUT_SIZE) ] def render(self, task: Task) -> RenderableType: # type: ignore[override] diff --git a/unshackle/core/downloaders/requests.py b/unshackle/core/downloaders/requests.py index 495fb1fd..38d91c3c 100644 --- a/unshackle/core/downloaders/requests.py +++ b/unshackle/core/downloaders/requests.py @@ -84,8 +84,8 @@ class TokenBucket: DOWNLOAD_CANCELLED.wait(wait) -_speed_limiter: Optional[TokenBucket] = None -_speed_limit_locked = False +speed_limiter: Optional[TokenBucket] = None +speed_limit_locked = False SPEED_UNITS = { "": 1, @@ -135,33 +135,33 @@ def set_speed_limit(bytes_per_sec: Optional[float], lock: bool = False) -> None: A locked limit (serve's global_speed_limit) wins: later unlocked calls, like the per-job one in dl.result(), become no-ops for the process. """ - global _speed_limiter, _speed_limit_locked - if _speed_limit_locked and not lock: + global speed_limiter, speed_limit_locked + if speed_limit_locked and not lock: return - _speed_limit_locked = lock - _speed_limiter = TokenBucket(bytes_per_sec) if bytes_per_sec else None + speed_limit_locked = lock + speed_limiter = TokenBucket(bytes_per_sec) if bytes_per_sec else None -def _adaptive_chunk_size(content_length: int) -> int: +def adaptive_chunk_size(content_length: int) -> int: """Pick chunk size based on content length. Benchmarked sweet spot: 512KB-4MB.""" if content_length <= 0: return DEFAULT_CHUNK return min(MAX_CHUNK, max(MIN_CHUNK, content_length // 4)) -def _is_requests_session(session: Any) -> bool: +def is_requests_session(session: Any) -> bool: """Check if the session is a standard requests.Session (supports resp.raw).""" return isinstance(session, Session) -def _is_rnet_session(session: Any) -> bool: +def is_rnet_session(session: Any) -> bool: """Check if the session is an RnetSession (uses resp.stream()).""" from unshackle.core.session import RnetSession return isinstance(session, RnetSession) -def _is_content_encoded(value: Optional[str]) -> bool: +def is_content_encoded(value: Optional[str]) -> bool: """ Check a Content-Encoding value for any coding other than absent or `identity`. @@ -173,7 +173,7 @@ def _is_content_encoded(value: Optional[str]) -> bool: return any(coding.strip() not in ("", "identity") for coding in (value or "").lower().split(",")) -def _has_range_header(item: dict[str, Any]) -> bool: +def has_range_header(item: dict[str, Any]) -> bool: """ Whether the caller asked for a byte-range slice of a parent resource (DASH SegmentBase, HLS EXT-X-BYTERANGE). @@ -184,10 +184,10 @@ def _has_range_header(item: dict[str, Any]) -> bool: return any(str(k).lower() == "range" for k in (item.get("headers") or {})) -def _probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]: +def probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]: headers = {**(kwargs.get("headers") or {}), "Range": "bytes=0-0"} rest = {k: v for k, v in kwargs.items() if k != "headers"} - if _is_rnet_session(session): + if is_rnet_session(session): rest.setdefault("read_timeout", READ_TIMEOUT) else: rest.setdefault("timeout", (CONNECT_TIMEOUT, READ_TIMEOUT)) @@ -198,7 +198,7 @@ def _probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]: try: if resp.status_code != 206: return 0, False - if _is_content_encoded(resp.headers.get("Content-Encoding") or resp.headers.get("content-encoding")): + if is_content_encoded(resp.headers.get("Content-Encoding") or resp.headers.get("content-encoding")): return 0, False content_range = resp.headers.get("Content-Range") or resp.headers.get("content-range") or "" total = content_range.rsplit("/", 1)[-1].strip() @@ -210,7 +210,7 @@ def _probe_ranged(url: str, session: Any, **kwargs: Any) -> tuple[int, bool]: pass -def _dispatch_parts( +def dispatch_parts( url: str, save_path: Path, session: Any, @@ -239,7 +239,7 @@ def _dispatch_parts( # poison the plain-download fallback and silently stand down sibling tracks abort = threading.Event() - def _worker(start: int, end: int) -> None: + def worker(start: int, end: int) -> None: for ev in download( url=url, save_path=save_path, @@ -252,7 +252,7 @@ def _dispatch_parts( events.put(ev) pool = ThreadPoolExecutor(max_workers=len(parts)) - futures = [pool.submit(_worker, s, e) for s, e in parts] + futures = [pool.submit(worker, s, e) for s, e in parts] pending = set(futures) yield {"total": total_size} @@ -387,8 +387,8 @@ def download( resume_offset = tmp_file.stat().st_size _time = time.time - use_raw = _is_requests_session(session) - item_range = _has_range_header(kwargs) + use_raw = is_requests_session(session) + item_range = has_range_header(kwargs) attempts = 1 written = 0 @@ -402,7 +402,7 @@ def download( last_speed_refresh = _time() try: - use_rnet = _is_rnet_session(session) + use_rnet = is_rnet_session(session) request_kwargs = dict(kwargs) if use_rnet: @@ -442,7 +442,7 @@ def download( if use_rnet: content_length = stream.content_length or 0 else: - content_encoded = _is_content_encoded(stream.headers.get("Content-Encoding")) + content_encoded = is_content_encoded(stream.headers.get("Content-Encoding")) try: content_length = int(stream.headers.get("Content-Length", "0")) if content_encoded: @@ -459,8 +459,8 @@ def download( resume_offset = 0 continue - limiter = _speed_limiter - chunk_size = _adaptive_chunk_size(content_length) + limiter = speed_limiter + chunk_size = adaptive_chunk_size(content_length) if limiter: chunk_size = min(chunk_size, max(8192, int(limiter.rate / 4))) total_size = (resume_offset + content_length) if resumed and content_length > 0 else content_length @@ -483,7 +483,7 @@ def download( if part_mode: f.seek(part_offset + written) - _write = f.write + write = f.write if use_rnet: chunks = stream.stream() @@ -511,7 +511,7 @@ def download( return if limiter is not None: limiter.consume(len(chunk)) - _write(chunk) + write(chunk) download_size = len(chunk) written += download_size @@ -701,11 +701,11 @@ def requests( url_item = urls[0] try: ranged_used = False - if max_workers > 1 and _speed_limiter is None and not _has_range_header(url_item): - total_size, supports_ranges = _probe_ranged(url_item["url"], session) + if max_workers > 1 and speed_limiter is None and not has_range_header(url_item): + total_size, supports_ranges = probe_ranged(url_item["url"], session) if supports_ranges and total_size >= RANGE_PARALLEL_MIN_SIZE: try: - yield from _dispatch_parts( + yield from dispatch_parts( session=session, total_size=total_size, max_workers=max_workers, @@ -770,7 +770,7 @@ def requests( seg_durations: list[float] = [] hedged: set[int] = set() - def _download_worker(index: int, url_item: dict[str, Any], hedge: bool = False) -> None: + def download_worker(index: int, url_item: dict[str, Any], hedge: bool = False) -> None: with seg_lock: seg_active[index] = seg_active.get(index, 0) + 1 left = False @@ -824,7 +824,7 @@ def requests( with seg_lock: seg_active[index] -= 1 - futures = [pool.submit(_download_worker, i, url) for i, url in enumerate(urls)] + futures = [pool.submit(download_worker, i, url) for i, url in enumerate(urls)] pending = set(futures) pending_advance = 0 @@ -867,7 +867,7 @@ def requests( if ( len(pending) < max_workers and seg_durations - and _speed_limiter is None + and speed_limiter is None and now - last_hedge_check > 0.5 and not DOWNLOAD_CANCELLED.is_set() ): @@ -885,7 +885,7 @@ def requests( ] for i in stuck[: max_workers - len(pending)]: hedged.add(i) - pending.add(pool.submit(_download_worker, i, urls[i], True)) + pending.add(pool.submit(download_worker, i, urls[i], True)) # all segments claimed; superseded losers exit via their claimed() check if len(seg_done) == len(urls): diff --git a/unshackle/core/drm/clearkey.py b/unshackle/core/drm/clearkey.py index a1732769..0fca7110 100644 --- a/unshackle/core/drm/clearkey.py +++ b/unshackle/core/drm/clearkey.py @@ -50,7 +50,7 @@ class ClearKey: self.iv: bytes = iv self._warned_clear: bool = False - def _warn_clear(self, reason: str) -> None: + def warn_clear(self, reason: str) -> None: """Warn once per track that a segment was passed through undecrypted.""" if not self._warned_clear: log.warning("ClearKey: manifest signaled AES-128 encryption but %s; passing segment through.", reason) @@ -71,13 +71,13 @@ class ClearKey: and len(data) >= TS_PACKET_SIZE * TS_SYNC_CHECKS and all(data[i * TS_PACKET_SIZE] == SYNC_BYTE for i in range(TS_SYNC_CHECKS)) ): - self._warn_clear("the segment is clear MPEG-TS") + self.warn_clear("the segment is clear MPEG-TS") return # Valid AES-128-CBC ciphertext is always a 16-byte multiple; anything else is not # ciphertext (truncated/clear/SAMPLE-AES). Decrypting would raise, so pass it through. if len(data) % AES.block_size != 0: - self._warn_clear(f"the segment length ({len(data)} bytes) is not a multiple of {AES.block_size}") + self.warn_clear(f"the segment length ({len(data)} bytes) is not a multiple of {AES.block_size}") return log_event( diff --git a/unshackle/core/drm/monalisa.py b/unshackle/core/drm/monalisa.py index 284a19bf..fa4c2182 100644 --- a/unshackle/core/drm/monalisa.py +++ b/unshackle/core/drm/monalisa.py @@ -84,9 +84,9 @@ class MonaLisa: self.data: dict = kwargs or {} # Extract keys immediately - self._extract_keys() + self.extract_keys() - def _extract_keys(self) -> None: + def extract_keys(self) -> None: """Extract keys from the ticket using the MonaLisa CDM.""" # Import here to avoid circular import from unshackle.core.cdm.monalisa import MonaLisaCDM @@ -183,7 +183,7 @@ class MonaLisa: # Validate it looks like a content ID if content_id.startswith("H5DCID-"): return content_id - except Exception: + except (ValueError, TypeError): pass return None diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index 58685361..18434016 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -42,7 +42,7 @@ class PlayReady: raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}") if pssh_b64: - kids = self._extract_kids_from_pssh_b64(pssh_b64) + kids = self.extract_kids_from_pssh_b64(pssh_b64) else: kids = [] @@ -55,7 +55,7 @@ class PlayReady: kids.append(signed_key_id.value) else: kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value))) - except Exception: + except (ValueError, TypeError): continue if kid: @@ -79,7 +79,7 @@ class PlayReady: if pssh_b64: self.data.setdefault("pssh_b64", pssh_b64) - def _extract_kids_from_pssh_b64(self, pssh_b64: str) -> list[UUID]: + def extract_kids_from_pssh_b64(self, pssh_b64: str) -> list[UUID]: """Extract all KIDs from base64-encoded PSSH data.""" try: # PSSH XML comes from third-party manifests; defusedxml guards against entity expansion @@ -118,7 +118,7 @@ class PlayReady: kid_bytes = base64.b64decode(value + "==") kid_uuid = UUID(bytes_le=kid_bytes) kids.append(kid_uuid) - except Exception: + except ValueError: pass # v4.2/v4.3: DATA/PROTECTINFO/KIDS/KID @@ -131,7 +131,7 @@ class PlayReady: kid_uuid = UUID(bytes_le=kid_bytes) if kid_uuid not in kids: kids.append(kid_uuid) - except Exception: + except ValueError: pass # v4.1: DATA/PROTECTINFO/KID @@ -144,7 +144,7 @@ class PlayReady: kid_uuid = UUID(bytes_le=kid_bytes) if kid_uuid not in kids: kids.append(kid_uuid) - except Exception: + except ValueError: pass # v4.0: DATA/KID @@ -156,12 +156,14 @@ class PlayReady: kid_uuid = UUID(bytes_le=kid_bytes) if kid_uuid not in kids: kids.append(kid_uuid) - except Exception: + except ValueError: pass return kids - except Exception: + # covers bad base64 (binascii.Error), defusedxml rejections (both ValueError) and XML + # ParseError (SyntaxError) from arbitrary third-party PSSH data + except (ValueError, SyntaxError): pass return [] @@ -265,7 +267,7 @@ class PlayReady: def kids(self) -> list[UUID]: return self._kids - def _extract_keys_from_cdm(self, cdm: PlayReadyCdm, session_id: bytes) -> dict: + def extract_keys_from_cdm(self, cdm: PlayReadyCdm, session_id: bytes) -> dict: """Extract keys from CDM session with cross-library compatibility. Args: @@ -330,7 +332,7 @@ class PlayReady: if "" not in license_str: try: license_str = base64.b64decode(license_str + "===").decode() - except Exception: + except ValueError: pass cdm.parse_license(session_id, license_str) @@ -344,12 +346,12 @@ class PlayReady: except PlayReady.Exceptions.DeviceRevoked: raise except Exception as e: - revoked = self._detect_revocation(license_str) or self._detect_revocation(str(e)) + revoked = self.detect_revocation(license_str) or self.detect_revocation(str(e)) if revoked: raise PlayReady.Exceptions.DeviceRevoked(revoked) from e raise - keys = self._extract_keys_from_cdm(cdm, session_id) + keys = self.extract_keys_from_cdm(cdm, session_id) self.content_keys.update(keys) if keys: @@ -368,7 +370,7 @@ class PlayReady: raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") @staticmethod - def _detect_revocation(text: str) -> Optional[str]: + def detect_revocation(text: str) -> Optional[str]: """Return the decoded error string if a revocation HRESULT is present, else None. Reads the code from the raw SOAP body (0x8004C065) @@ -415,9 +417,9 @@ class PlayReady: decrypt_start = time.monotonic() if decrypter == "mp4decrypt": - self._decrypt_with_mp4decrypt(path) + self.decrypt_with_mp4decrypt(path) else: - self._decrypt_with_shaka_packager(path) + self.decrypt_with_shaka_packager(path) log_event( "drm_decrypt_complete", @@ -430,7 +432,7 @@ class PlayReady: output_size=path.stat().st_size if path.exists() else 0, ) - def _decrypt_with_mp4decrypt(self, path: Path) -> None: + def decrypt_with_mp4decrypt(self, path: Path) -> None: """Decrypt using mp4decrypt""" if not binaries.Mp4decrypt: raise EnvironmentError("mp4decrypt executable not found but is required.") @@ -483,7 +485,7 @@ class PlayReady: path.unlink() shutil.move(output_path, path) - def _decrypt_with_shaka_packager(self, path: Path) -> None: + def decrypt_with_shaka_packager(self, path: Path) -> None: """Decrypt using Shaka Packager (original method)""" if not binaries.ShakaPackager: raise EnvironmentError("Shaka Packager executable not found but is required.") diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py index 2b036235..d44f4e49 100644 --- a/unshackle/core/drm/widevine.py +++ b/unshackle/core/drm/widevine.py @@ -331,9 +331,9 @@ class Widevine: decrypt_start = time.monotonic() if decrypter == "mp4decrypt": - self._decrypt_with_mp4decrypt(path) + self.decrypt_with_mp4decrypt(path) else: - self._decrypt_with_shaka_packager(path) + self.decrypt_with_shaka_packager(path) log_event( "drm_decrypt_complete", @@ -346,7 +346,7 @@ class Widevine: output_size=path.stat().st_size if path.exists() else 0, ) - def _decrypt_with_mp4decrypt(self, path: Path) -> None: + def decrypt_with_mp4decrypt(self, path: Path) -> None: """Decrypt using mp4decrypt""" if not binaries.Mp4decrypt: raise EnvironmentError("mp4decrypt executable not found but is required.") @@ -399,7 +399,7 @@ class Widevine: path.unlink() shutil.move(output_path, path) - def _decrypt_with_shaka_packager(self, path: Path) -> None: + def decrypt_with_shaka_packager(self, path: Path) -> None: """Decrypt using Shaka Packager (original method)""" if not binaries.ShakaPackager: raise EnvironmentError("Shaka Packager executable not found but is required.") diff --git a/unshackle/core/import_service.py b/unshackle/core/import_service.py index 3dedd1f8..adf26a18 100644 --- a/unshackle/core/import_service.py +++ b/unshackle/core/import_service.py @@ -16,7 +16,7 @@ from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential from unshackle.core.drm import drm_from_dict from unshackle.core.manifests import DASH, HLS, ISM -from unshackle.core.remote_service import RemoteService, _build_title, _match_track, _resolve_proxy +from unshackle.core.remote_service import RemoteService, build_title, match_track, resolve_proxy_arg from unshackle.core.titles import Episode, Movies, Series, Title_T, Titles_T, remap_titles from unshackle.core.tracks import Audio, Chapter, Chapters, Tracks, Video from unshackle.core.tracks.attachment import Attachment @@ -28,7 +28,7 @@ PARSERS = {"DASH": DASH, "HLS": HLS, "ISM": ISM} MANIFEST_DATA_KEYS = {"DASH": "dash", "ISM": "ism"} -def _fallback_language(language: Any) -> Optional[Any]: +def fallback_language(language: Any) -> Optional[Any]: """The title language if the parsers would accept it, else None so they raise. DASH drops an und/invalid fallback but ISM does not, and a truthy 'und' there silently @@ -40,7 +40,7 @@ def _fallback_language(language: Any) -> Optional[Any]: return language -def _resolve_import_manifest_data( +def resolve_import_manifest_data( tracks: Tracks, manifest_type: Optional[str], *, @@ -68,7 +68,7 @@ def _resolve_import_manifest_data( if not pending: return - fallback_lang = _fallback_language(language) + fallback_lang = fallback_language(language) for url in {str(track.url) for track in pending if track.url}: try: manifest = parser.from_url(url=url, session=session) @@ -90,7 +90,7 @@ def _resolve_import_manifest_data( for track in pending: if track.data.get(data_key) or str(track.url) != url: continue - matched = _match_track(track, local_tracks) + matched = match_track(track, local_tracks) if matched and matched.data.get(data_key): track.data.update(matched.data) @@ -160,7 +160,7 @@ class ImportService: return session try: - proxy = _resolve_proxy(proxy_query) + proxy = resolve_proxy_arg(proxy_query) except Exception as e: if explicit: raise click.ClickException(f"Failed to resolve proxy '{proxy_query}': {e}") @@ -184,7 +184,7 @@ class ImportService: if self.titles is not None: return self.titles titles_list = [ - _build_title(entry.get("meta", {}), self.service_tag, fallback_id=title_id) + build_title(entry.get("meta", {}), self.service_tag, fallback_id=title_id) for title_id, entry in self.titles_data.items() ] self.titles = ( @@ -231,7 +231,7 @@ class ImportService: f"The manifest URL may have expired since export. ({e})" ) try: - parsed = manifest.to_tracks(language=_fallback_language(title.language)) + parsed = manifest.to_tracks(language=fallback_language(title.language)) except ValueError as e: if "Language information could not be derived" in str(e): raise click.ClickException( @@ -275,7 +275,7 @@ class ImportService: track.drm = drm tracks.add(track, warn_only=True) - _resolve_import_manifest_data( + resolve_import_manifest_data( tracks, manifest_type, session=self.session, @@ -328,7 +328,7 @@ class ImportService: if drm_obj is None and keys: drm_type = (drm_dicts[0].get("system", "Widevine").lower()) if drm_dicts else "widevine" - drm_obj = RemoteService._create_drm_stub(drm_type, list(keys.keys())) + drm_obj = RemoteService.create_drm_stub(drm_type, list(keys.keys())) if drm_obj is None: return None @@ -354,7 +354,7 @@ class ImportService: for track in title.tracks: if not isinstance(track, (Video, Audio)) or not self.track_is_encrypted(track): continue - drm_obj = track.drm[0] if track.drm else RemoteService._create_drm_stub(system, kid_hexes) + drm_obj = track.drm[0] if track.drm else RemoteService.create_drm_stub(system, kid_hexes) for kid, key in pool.items(): drm_obj.content_keys[kid] = key track.drm = [drm_obj] diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 9610b9c9..d95d3991 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -115,7 +115,7 @@ class DASH: if period_id := period.get("id"): filtered_period_ids.append(period_id) continue - if not DASH._is_content_period(period, []): + if not DASH.is_content_period(period, []): if period_id := period.get("id"): filtered_period_ids.append(period_id) continue @@ -126,8 +126,8 @@ class DASH: continue for rep in adaptation_set.findall("Representation"): - get = partial(self._get, adaptation_set=adaptation_set, representation=rep) - findall = partial(self._findall, adaptation_set=adaptation_set, representation=rep, both=True) + get = partial(self.get_attr, adaptation_set=adaptation_set, representation=rep) + findall = partial(self.find_elements, adaptation_set=adaptation_set, representation=rep, both=True) segment_base = rep.find("SegmentBase") codecs = get("codecs") @@ -343,7 +343,7 @@ class DASH: init_data: Optional[bytes] = None track_kid: Optional[UUID] = None - content_periods = [p for p in all_periods if DASH._is_content_period(p, filtered_period_ids)] + content_periods = [p for p in all_periods if DASH.is_content_period(p, filtered_period_ids)] period_count = len(content_periods) if period_count > 1: @@ -365,7 +365,7 @@ class DASH: log.warning(f"Representation '{rep_id}' not found in period '{period_id}', skipping") continue - p_init, p_segments, p_timescale, p_durations, p_kid = DASH._get_period_segments( + p_init, p_segments, p_timescale, p_durations, p_kid = DASH.get_period_segments( period=content_period, adaptation_set=matched_as, representation=matched_rep, @@ -583,7 +583,7 @@ class DASH: progress(downloaded="Downloaded") @staticmethod - def _is_content_period(period: Element, filtered_period_ids: list[str]) -> bool: + def is_content_period(period: Element, filtered_period_ids: list[str]) -> bool: """Check if a period is a valid content period (not an ad, not filtered, not trick mode).""" period_id = period.get("id") if period_id and period_id in filtered_period_ids: @@ -597,7 +597,7 @@ class DASH: return True @staticmethod - def _merge_segment_templates(adaptation_set: Element, representation: Element) -> Optional[Element]: + def merge_segment_templates(adaptation_set: Element, representation: Element) -> Optional[Element]: """ Build the effective SegmentTemplate for a Representation by cascading the AdaptationSet > Representation levels (ISO/IEC 23009-1 5.3.9.1). @@ -623,7 +623,7 @@ class DASH: return merged @staticmethod - def _get_period_segments( + def get_period_segments( period: Element, adaptation_set: Element, representation: Element, @@ -656,7 +656,7 @@ class DASH: period_duration = period.get("duration") or manifest.get("mediaPresentationDuration") init_data: Optional[bytes] = None - segment_template = DASH._merge_segment_templates(adaptation_set, representation) + segment_template = DASH.merge_segment_templates(adaptation_set, representation) segment_list = representation.find("SegmentList") if segment_list is None: @@ -811,8 +811,12 @@ class DASH: return init_data, segments, segment_timescale, segment_durations, track_kid + # Deprecated 5.5.0 shims for service repos still on the old underscored names; drop once they migrate. + _is_content_period = is_content_period + _get_period_segments = get_period_segments + @staticmethod - def _get(item: str, adaptation_set: Element, representation: Optional[Element] = None) -> Optional[Any]: + def get_attr(item: str, adaptation_set: Element, representation: Optional[Element] = None) -> Optional[Any]: """Helper to get a requested item from the Representation, otherwise from the AdaptationSet.""" adaptation_set_item = adaptation_set.get(item) if representation is None: @@ -825,7 +829,7 @@ class DASH: return adaptation_set_item @staticmethod - def _findall( + def find_elements( item: str, adaptation_set: Element, representation: Optional[Element] = None, both: bool = False ) -> list[Any]: """ @@ -1018,7 +1022,7 @@ class DASH: return next( ( int(x.get("value")) - for x in DASH._findall("SupplementalProperty", adaptation_set, representation, both=True) + for x in DASH.find_elements("SupplementalProperty", adaptation_set, representation, both=True) if x.get("schemeIdUri") == "tag:dolby.com,2018:dash:EC3_ExtensionComplexityIndex:2018" ), None, @@ -1080,7 +1084,7 @@ class DASH: if kid_b64: try: kid = UUID(bytes=base64.b64decode(kid_b64)) - except Exception: + except ValueError: kid = None drm.append(PlayReady(pssh=pr_pssh, kid=kid, pssh_b64=pr_pssh_b64)) @@ -1096,7 +1100,7 @@ class DASH: except ValueError: try: kid = UUID(bytes=base64.b64decode(kid_attr)) - except Exception: + except ValueError: kid = None if not kid: @@ -1151,7 +1155,7 @@ class DASH: @staticmethod @lru_cache(maxsize=None) - def _field_format_pattern(field: str) -> re.Pattern: + def field_format_pattern(field: str) -> re.Pattern: # printf-style `$Field%fmt$` matcher, compiled once per field name return re.compile(rf"\${re.escape(field)}%([a-z0-9]+)\$", flags=re.I) @@ -1161,7 +1165,7 @@ class DASH: return url for field, value in kwargs.items(): url = url.replace(f"${field}$", str(value)) - m = DASH._field_format_pattern(field).search(url) + m = DASH.field_format_pattern(field).search(url) if m: url = url.replace(m.group(), f"{value:{m.group(1)}}") return url diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index c857707d..6a13daa7 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -311,15 +311,16 @@ class HLS: if has_resolution and has_codec: continue try: - probe = HLS._probe_ts_info(video.url, self.session) + probe = HLS.probe_ts_info(video.url, self.session) if probe: width, height, codec = probe if not has_resolution: video.width, video.height = width, height if not has_codec: video.codec = codec - except Exception: - pass + # best-effort probe over network + ffprobe; the track stays usable without it + except Exception as e: + logging.getLogger("HLS").debug(f"TS probe failed for {video.url}: {e!r}") if self.url: tracks.manifest_url = self.url @@ -342,7 +343,7 @@ class HLS: return tracks @staticmethod - def _probe_ts_info( + def probe_ts_info( variant_url: str, session: Optional[Union[Session, RnetSession]] = None ) -> Optional[tuple[int, int, Video.Codec]]: """Probe the first TS segment of a variant playlist to extract resolution and codec.""" @@ -360,10 +361,10 @@ class HLS: res = session.get(seg_uri, headers={"Range": "bytes=0-8191"}) data = res.content - return HLS._parse_ts_video_info(data) + return HLS.parse_ts_video_info(data) @staticmethod - def _parse_ts_video_info(data: bytes) -> Optional[tuple[int, int, Video.Codec]]: + def parse_ts_video_info(data: bytes) -> Optional[tuple[int, int, Video.Codec]]: """Parse H.264/H.265 NAL units from TS segment data to extract resolution and codec.""" class _BitReader: @@ -1144,12 +1145,12 @@ class HLS: try: decoded = base64.b64decode(value).decode() - except Exception: + except ValueError: decoded = value try: items = json.loads(decoded) - except Exception: + except ValueError: continue for item in items if isinstance(items, list) else []: @@ -1220,8 +1221,9 @@ class HLS: map_res = session.get(url=map_uri, headers=headers) if map_res.ok: return track.get_key_id(map_res.content) - except Exception: - pass + # best-effort key-id probe over network + init parse; caller falls back without it + except Exception as e: + logging.getLogger("HLS").debug(f"Init map key-id probe failed for {map_uri}: {e!r}") return None @staticmethod diff --git a/unshackle/core/manifests/ism.py b/unshackle/core/manifests/ism.py index 65296012..b058d953 100644 --- a/unshackle/core/manifests/ism.py +++ b/unshackle/core/manifests/ism.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import hashlib import html +import logging import re import shutil import struct @@ -78,24 +79,28 @@ class ISM: return cls(load_xml(text), url) @staticmethod - def _get_drm(headers: list[Element]) -> list[DRM_T]: + def get_drm(headers: list[Element]) -> list[DRM_T]: drm: list[DRM_T] = [] for header in headers: - system_id = (header.get("SystemID") or header.get("SystemId") or "").lower() + # MS-SSTR manifests often write the GUID in registry format ({...}). + system_id = (header.get("SystemID") or header.get("SystemId") or "").strip().strip("{}").lower() data = "".join(header.itertext()).strip() if not data: continue if system_id == "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": try: pssh = PSSH(base64.b64decode(data)) - except Exception: + # a dropped header can leave the track with no DRM, so never skip silently + except Exception as e: + logging.getLogger("ISM").warning(f"Skipping unparseable Widevine ProtectionHeader: {e!r}") continue kid = next(iter(pssh.key_ids), None) drm.append(Widevine(pssh=pssh, kid=kid)) elif system_id == "9a04f079-9840-4286-ab92-e65be0885f95": try: pr_pssh = PR_PSSH(data) - except Exception: + except Exception as e: + logging.getLogger("ISM").warning(f"Skipping unparseable PlayReady ProtectionHeader: {e!r}") continue drm.append(PlayReady(pssh=pr_pssh, pssh_b64=data)) return drm @@ -122,7 +127,7 @@ class ISM: return ISM.get_video_range_and_fps(fourcc, codec_private_data)[0] @staticmethod - def _init_segment( + def init_segment( track: AnyTrack, session_drm: Optional[DRM_T], first_segment: Optional[bytes] = None ) -> Optional[bytes]: # Smooth fragments are moof+mdat only; rebuild the ftyp+moov init box from @@ -225,7 +230,7 @@ class ISM: tracks = Tracks() base_url = self.url duration = int(self.manifest.get("Duration") or 0) - drm = self._get_drm(self.manifest.xpath(".//ProtectionHeader")) + drm = self.get_drm(self.manifest.xpath(".//ProtectionHeader")) for stream_index in self.manifest.findall("StreamIndex"): content_type = stream_index.get("Type") @@ -272,7 +277,8 @@ class ISM: if not duration_frag: try: next_time = int(fragments[idx + 1].get("t")) - except (IndexError, AttributeError): + except (IndexError, TypeError): + # no next fragment, or the next omits t (get returns None) next_time = duration # floor division: float times would corrupt segment URLs; # any drift is reset by the next fragment's explicit t. @@ -520,7 +526,7 @@ class ISM: progress(downloaded="Merging", completed=0, total=len(segments_to_merge)) with open(save_path, "wb") as f: first_segment = segments_to_merge[0].read_bytes() if segments_to_merge else None - init_segment = ISM._init_segment(track, session_drm, first_segment) + init_segment = ISM.init_segment(track, session_drm, first_segment) if init_segment: f.write(init_segment) iv_size = (read_per_sample_iv_size(first_segment) if session_drm and first_segment else None) or 8 diff --git a/unshackle/core/music/integrity.py b/unshackle/core/music/integrity.py index c1706406..d38d3421 100644 --- a/unshackle/core/music/integrity.py +++ b/unshackle/core/music/integrity.py @@ -56,7 +56,7 @@ def verify_music_audio( if not audio_track: raise MusicAudioIntegrityError(f"MediaInfo could not find an audio stream in: {path.name}") - duration = _duration_seconds(getattr(audio_track, "duration", None)) + duration = duration_seconds(getattr(audio_track, "duration", None)) expected_duration = _expected_duration(song, track) warnings: list[str] = [] if expected_duration and duration: @@ -69,15 +69,15 @@ def verify_music_audio( result = MusicAudioIntegrityResult( path=path, size=size, - codec=_first_text( + codec=first_text( getattr(audio_track, "format", None), getattr(audio_track, "commercial_name", None), getattr(audio_track, "codec_id", None), ), duration=duration, - bit_depth=_first_int(getattr(audio_track, "bit_depth", None)), - sample_rate=_first_int(getattr(audio_track, "sampling_rate", None)), - channels=_first_float( + bit_depth=first_int(getattr(audio_track, "bit_depth", None)), + sample_rate=first_int(getattr(audio_track, "sampling_rate", None)), + channels=first_float( getattr(audio_track, "channel_s", None), getattr(audio_track, "channel_s_original", None), getattr(track, "channels", None), @@ -115,13 +115,13 @@ def _expected_duration(song: Any, track: Any) -> Optional[float]: data = getattr(song, "data", None) if isinstance(data, dict): metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - value = _first_float(data.get("duration"), metadata.get("duration")) + value = first_float(data.get("duration"), metadata.get("duration")) if value: return value track_data = getattr(track, "data", None) if isinstance(track_data, dict): metadata = track_data.get("metadata") if isinstance(track_data.get("metadata"), dict) else {} - value = _first_float(track_data.get("duration"), metadata.get("duration")) + value = first_float(track_data.get("duration"), metadata.get("duration")) if value: return value return None @@ -140,7 +140,7 @@ def _expected_size(track: Any) -> Optional[int]: data.get("metadata") if isinstance(data.get("metadata"), dict) else {}, ] for source in sources: - value = _first_int( + value = first_int( source.get("content_length"), source.get("contentLength"), source.get("file_size"), @@ -152,8 +152,8 @@ def _expected_size(track: Any) -> Optional[int]: return None -def _duration_seconds(value: Any) -> Optional[float]: - number = _first_float(value) +def duration_seconds(value: Any) -> Optional[float]: + number = first_float(value) if number is None: return None if number > 1000: @@ -161,7 +161,7 @@ def _duration_seconds(value: Any) -> Optional[float]: return number -def _first_text(*values: Any) -> str: +def first_text(*values: Any) -> str: for value in values: text = str(value or "").strip() if text: @@ -169,7 +169,7 @@ def _first_text(*values: Any) -> str: return "" -def _first_float(*values: Any) -> Optional[float]: +def first_float(*values: Any) -> Optional[float]: for value in values: if value in (None, "", [], {}): continue @@ -181,8 +181,8 @@ def _first_float(*values: Any) -> Optional[float]: return None -def _first_int(*values: Any) -> Optional[int]: - value = _first_float(*values) +def first_int(*values: Any) -> Optional[int]: + value = first_float(*values) return int(value) if value is not None else None diff --git a/unshackle/core/music/planner.py b/unshackle/core/music/planner.py index 2ac0bcde..0ae26320 100644 --- a/unshackle/core/music/planner.py +++ b/unshackle/core/music/planner.py @@ -25,7 +25,7 @@ class MusicPlanner: or (first_song.album_artist or first_song.artist if first_song else ""), album_artist=(first_song.album_artist if first_song else "") or "", year=getattr(music, "year", None) or (first_song.year if first_song else None), - released=self._first_text( + released=self.first_text( getattr(music, "released", None), getattr(music, "release_date", None), first_data.get("release_date"), @@ -37,15 +37,15 @@ class MusicPlanner: label=(first_song.label if first_song else "") or "", artwork_url=getattr(music, "artwork_url", None), total_tracks=getattr(music, "total_tracks", None) or len(music), - total_discs=getattr(music, "total_discs", None) or self._max_value(music, "disc"), - total_duration=getattr(music, "total_duration", None) or self._sum_duration(music), - quality_requested=self._quality_requested(music), + total_discs=getattr(music, "total_discs", None) or self.max_value(music, "disc"), + total_duration=getattr(music, "total_duration", None) or self.sum_duration(music), + quality_requested=self.quality_requested(music), ) selected_options: list[MusicTrackOption] = [] discs: dict[int, list[MusicSongPlan]] = defaultdict(list) for song in music: - options = self._get_options(song) + options = self.get_options(song) selected = options[0] if options else None if selected: selected_options.append(selected) @@ -60,33 +60,33 @@ class MusicPlanner: for disc_number in sorted(discs): plan.discs.append(MusicDiscPlan(disc_number=disc_number, songs=discs[disc_number])) - quality = self._quality_summary_from_options(selected_options) + quality = self.quality_summary_from_options(selected_options) if quality: plan.quality_requested = quality return plan - def _get_options(self, song: Song) -> list[MusicTrackOption]: + def get_options(self, song: Song) -> list[MusicTrackOption]: provider = getattr(self.service, "get_music_track_options", None) if callable(provider): options = provider(song) if options: return options - option = self._option_from_song(song) + option = self.option_from_song(song) return [option] if option else [] @staticmethod - def _option_from_song(song: Song) -> MusicTrackOption | None: + def option_from_song(song: Song) -> MusicTrackOption | None: data = song.data if isinstance(song.data, dict) else {} metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - quality = MusicPlanner._first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) - duration = MusicPlanner._first_number(data.get("duration"), metadata.get("duration")) + quality = MusicPlanner.first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) + duration = MusicPlanner.first_number(data.get("duration"), metadata.get("duration")) if not quality and duration is None: return None - codec = MusicPlanner._codec_from_quality(quality) - bit_depth, sample_rate = MusicPlanner._quality_numbers(quality) - bitrate = MusicPlanner._bitrate_from_quality(quality) + codec = MusicPlanner.codec_from_quality(quality) + bit_depth, sample_rate = MusicPlanner.quality_numbers(quality) + bitrate = MusicPlanner.bitrate_from_quality(quality) hires = bool((bit_depth and bit_depth > 16) or (sample_rate and sample_rate > 48000)) lossless = codec in {"FLAC", "ALAC", "WAV", "AIFF"} or "lossless" in quality.lower() atmos = "atmos" in quality.lower() or bool(data.get("atmos") or metadata.get("atmos")) @@ -97,7 +97,7 @@ class MusicPlanner: bit_depth=bit_depth, sample_rate=sample_rate, bitrate=bitrate, - channels=MusicPlanner._first_number(data.get("channels"), metadata.get("channels")), + channels=MusicPlanner.first_number(data.get("channels"), metadata.get("channels")), lossless=lossless, hires=hires, atmos=atmos, @@ -106,7 +106,7 @@ class MusicPlanner: quality_label=quality, ) - def _quality_requested(self, music: Music) -> str: + def quality_requested(self, music: Music) -> str: service_quality = getattr(self.service, "quality", None) if service_quality: quality_map = { @@ -121,10 +121,10 @@ class MusicPlanner: if not first_song: return "" data = first_song.data if isinstance(first_song.data, dict) else {} - return self._first_text(data.get("quality")) + return self.first_text(data.get("quality")) @staticmethod - def _first_text(*values: Any) -> str: + def first_text(*values: Any) -> str: for value in values: if value in (None, ""): continue @@ -134,7 +134,7 @@ class MusicPlanner: return "" @staticmethod - def _first_number(*values: Any) -> float | None: + def first_number(*values: Any) -> float | None: for value in values: if value in (None, ""): continue @@ -145,7 +145,7 @@ class MusicPlanner: return None @staticmethod - def _codec_from_quality(value: str) -> str: + def codec_from_quality(value: str) -> str: lowered = value.lower() for codec in ("flac", "alac", "aac", "mp3", "wav", "aiff"): if codec in lowered: @@ -153,7 +153,7 @@ class MusicPlanner: return "" @staticmethod - def _quality_numbers(value: str) -> tuple[int | None, int | None]: + def quality_numbers(value: str) -> tuple[int | None, int | None]: bit_depth = None sample_rate = None bit_match = re.search(r"(?P\d+)\s*[- ]?bit", value.lower()) @@ -165,14 +165,14 @@ class MusicPlanner: return bit_depth, sample_rate @staticmethod - def _bitrate_from_quality(value: str) -> int | None: + def bitrate_from_quality(value: str) -> int | None: match = re.search(r"(?P\d+)\s*kb/s", value.lower()) if not match: return None return int(match.group("rate")) * 1000 @staticmethod - def _quality_summary_from_options(options: list[MusicTrackOption]) -> str: + def quality_summary_from_options(options: list[MusicTrackOption]) -> str: if not options: return "" @@ -194,19 +194,19 @@ class MusicPlanner: return "AAC" if "MP3" in codecs: return "MP3" - return MusicPlanner._first_text(*labels) + return MusicPlanner.first_text(*labels) @staticmethod - def _sum_duration(music: Music) -> int | None: + def sum_duration(music: Music) -> int | None: total = 0 for song in music: data = song.data if isinstance(song.data, dict) else {} - value = MusicPlanner._first_number(data.get("duration")) + value = MusicPlanner.first_number(data.get("duration")) total += int(value or 0) return total or None @staticmethod - def _max_value(music: Music, attr: str) -> int | None: + def max_value(music: Music, attr: str) -> int | None: values = [getattr(song, attr, 0) or 0 for song in music] return max(values, default=0) or None diff --git a/unshackle/core/music/renderer.py b/unshackle/core/music/renderer.py index 5e9c5d4c..af9d9faf 100644 --- a/unshackle/core/music/renderer.py +++ b/unshackle/core/music/renderer.py @@ -47,9 +47,9 @@ class MusicRenderer: kind = self.display_kind(music.kind) explicit = any(bool(getattr(song, "explicit", None)) for song in music) total_tracks = getattr(music, "total_tracks", None) or len(music) - total_discs = getattr(music, "total_discs", None) or self._max_value(music, "disc") - released = self._format_release_date( - self._first_value( + total_discs = getattr(music, "total_discs", None) or self.max_value(music, "disc") + released = self.format_release_date( + self.first_value( getattr(music, "released", None), getattr(music, "release_date", None), data.get("release_date"), @@ -58,23 +58,23 @@ class MusicRenderer: metadata.get("released_at"), ) ) - length = self._format_total_duration(getattr(music, "total_duration", None) or self._sum_duration(music)) - quality = self._quality_summary( - self._first_text( + length = self.format_total_duration(getattr(music, "total_duration", None) or self.sum_duration(music)) + quality = self.quality_summary( + self.first_text( getattr(music, "quality", None), data.get("quality"), metadata.get("quality"), metadata.get("quality_label"), ), - lossless=self._as_bool(self._first_value(data.get("lossless"), metadata.get("lossless"))), - hires=self._as_bool(self._first_value(data.get("hires"), metadata.get("hires"))), + lossless=self.as_bool(self.first_value(data.get("lossless"), metadata.get("lossless"))), + hires=self.as_bool(self.first_value(data.get("hires"), metadata.get("hires"))), ) - grid = self._metadata_grid() + grid = self.metadata_grid() grid.add_row(Text("Title", style="bright_black"), Text(str(title))) grid.add_row(Text("Artist", style="bright_black"), Text(str(artist))) - grid.add_row(Text("Type", style="bright_black"), self._kind_text(kind, explicit=explicit)) + grid.add_row(Text("Type", style="bright_black"), self.kind_text(kind, explicit=explicit)) if released: grid.add_row(Text("Released", style="bright_black"), Text(released)) if year: @@ -103,8 +103,8 @@ class MusicRenderer: visible_songs = visible_songs[: self.COMPACT_TRACK_LIMIT] for song in visible_songs: - node = tree.add(self._song_line(song, music), guide_style="bright_black") - option = self._option_from_song(song) + node = tree.add(self.song_line(song, music), guide_style="bright_black") + option = self.option_from_song(song) if option: node.add(option, guide_style="bright_black") @@ -124,16 +124,16 @@ class MusicRenderer: for disc in plan.discs for song_plan in disc.songs ) - released = self._format_release_date(getattr(plan, "released", None) or getattr(plan, "release_date", None)) - length = self._format_total_duration(plan.total_duration) - quality = self._quality_summary(plan.quality_requested) + released = self.format_release_date(getattr(plan, "released", None) or getattr(plan, "release_date", None)) + length = self.format_total_duration(plan.total_duration) + quality = self.quality_summary(plan.quality_requested) - grid = self._metadata_grid() + grid = self.metadata_grid() if title: grid.add_row(Text("Title", style="bright_black"), Text(str(title))) if artist: grid.add_row(Text("Artist", style="bright_black"), Text(str(artist))) - grid.add_row(Text("Type", style="bright_black"), self._kind_text(kind, explicit=explicit)) + grid.add_row(Text("Type", style="bright_black"), self.kind_text(kind, explicit=explicit)) if released: grid.add_row(Text("Released", style="bright_black"), Text(released)) if plan.year: @@ -163,12 +163,12 @@ class MusicRenderer: visible_songs = visible_songs[: self.COMPACT_TRACK_LIMIT] for song_plan in visible_songs: - node = tree.add(self._song_line(song_plan.song, plan), guide_style="bright_black") + node = tree.add(self.song_line(song_plan.song, plan), guide_style="bright_black") if song_plan.skip_reason: node.add(f"[yellow]Skipped:[/] {escape(song_plan.skip_reason)}", guide_style="bright_black") continue for option in song_plan.options: - node.add(self._option_line(option), guide_style="bright_black") + node.add(self.option_line(option), guide_style="bright_black") hidden = total - len(visible_songs) if hidden > 0: @@ -178,7 +178,7 @@ class MusicRenderer: return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") @staticmethod - def _metadata_grid() -> Table: + def metadata_grid() -> Table: grid = Table.grid(padding=(0, 2)) grid.add_column(style="orchid1", no_wrap=True) grid.add_column() @@ -205,7 +205,7 @@ class MusicRenderer: return labels[key] return text.replace("_", " ").replace("-", " ").title() - def _song_line(self, song: Song, music: Music | MusicDownloadPlan) -> Text: + def song_line(self, song: Song, music: Music | MusicDownloadPlan) -> Text: number = f"{song.disc}.{song.track:02}" if song.disc > 1 else f"{song.track:02}" line = Text() line.append(number, style="repr.number") @@ -217,13 +217,13 @@ class MusicRenderer: line.append(f" - {song.artist}", style="bright_black") return line - def _option_from_song(self, song: Song) -> Text | str: + def option_from_song(self, song: Song) -> Text | str: data = song.data if isinstance(song.data, dict) else {} metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - quality = self._first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) - duration = self._format_duration(self._first_value(data.get("duration"), metadata.get("duration"))) - reason = self._first_text( + quality = self.first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) + duration = self.format_duration(self.first_value(data.get("duration"), metadata.get("duration"))) + reason = self.first_text( data.get("unavailable_reason"), data.get("skip_reason"), metadata.get("unavailable_reason"), @@ -235,9 +235,9 @@ class MusicRenderer: badges = [] if song.explicit: badges.append(("E", "bold bright_red")) - if self._as_bool(self._first_value(data.get("atmos"), metadata.get("atmos"))): + if self.as_bool(self.first_value(data.get("atmos"), metadata.get("atmos"))): badges.append(("Atmos", "magenta")) - if self._is_hires_quality(quality): + if self.is_hires_quality(quality): badges.append(("Hi-Res", "gold1")) details = [] @@ -247,43 +247,43 @@ class MusicRenderer: details.append(duration) if not details and not badges: return "" - return self._format_option_text(details, badges) + return self.format_option_text(details, badges) - def _option_line(self, option: MusicTrackOption) -> Text: + def option_line(self, option: MusicTrackOption) -> Text: parts = [] codec = str(option.codec or "").strip() if codec: parts.append(f"[{codec}]") if option.quality_label: - parts.extend(self._split_quality_label(option.quality_label, codec)) + parts.extend(self.split_quality_label(option.quality_label, codec)) elif option.bit_depth and option.sample_rate: - parts.append(f"{option.bit_depth}-bit/{self._format_sample_rate(option.sample_rate)}") + parts.append(f"{option.bit_depth}-bit/{self.format_sample_rate(option.sample_rate)}") elif option.bitrate: parts.append(f"{int(option.bitrate / 1000)} kb/s") if option.channels: - parts.append(self._format_channels(option.channels)) + parts.append(self.format_channels(option.channels)) if option.duration: - parts.append(self._format_duration(option.duration)) + parts.append(self.format_duration(option.duration)) badges = [] if option.explicit: badges.append(("E", "bold bright_red")) if option.atmos: badges.append(("Atmos", "magenta")) - if self._is_cd_option(option): + if self.is_cd_option(option): badges.append(("CD", "yellow1")) if option.hires: badges.append(("Hi-Res", "gold1")) - return self._format_option_text(parts, badges) + return self.format_option_text(parts, badges) @staticmethod - def _kind_text(kind: str, *, explicit: bool = False) -> Text: + def kind_text(kind: str, *, explicit: bool = False) -> Text: text = Text(str(kind)) if explicit: text.append(" Explicit", style="bold red") return text @staticmethod - def _format_option_text(parts: list[str], badges: list[tuple[str, str]]) -> Text: + def format_option_text(parts: list[str], badges: list[tuple[str, str]]) -> Text: text = Text(style="text2") first = True for part in parts: @@ -301,13 +301,13 @@ class MusicRenderer: return text @staticmethod - def _format_option_parts(parts: list[str]) -> str: + def format_option_parts(parts: list[str]) -> str: if not parts: return "" return " | ".join(escape(part) for part in parts if part) @staticmethod - def _split_quality_label(value: str, codec: str = "") -> list[str]: + def split_quality_label(value: str, codec: str = "") -> list[str]: text = str(value or "").strip() if not text: return [] @@ -316,7 +316,7 @@ class MusicRenderer: return [text] if text else [] @staticmethod - def _is_cd_option(option: MusicTrackOption) -> bool: + def is_cd_option(option: MusicTrackOption) -> bool: codec = str(option.codec or "").upper() if codec not in {"FLAC", "ALAC", "WAV", "AIFF"}: return False @@ -325,7 +325,7 @@ class MusicRenderer: return "16-bit/44.1" in str(option.quality_label or "").lower() @staticmethod - def _format_sample_rate(value: Any) -> str: + def format_sample_rate(value: Any) -> str: try: sample_rate = float(value) except (TypeError, ValueError): @@ -337,7 +337,7 @@ class MusicRenderer: return f"{sample_rate:g} kHz" @staticmethod - def _format_channels(value: Any) -> str: + def format_channels(value: Any) -> str: try: channels = float(value) except (TypeError, ValueError): @@ -351,7 +351,7 @@ class MusicRenderer: return f"{channels:g}" @staticmethod - def _first_text(*values: Any) -> str: + def first_text(*values: Any) -> str: for value in values: if value is None: continue @@ -361,7 +361,7 @@ class MusicRenderer: if nested: return str(nested).strip() elif isinstance(value, (list, tuple)): - text = MusicRenderer._first_text(*value) + text = MusicRenderer.first_text(*value) if text: return text else: @@ -371,14 +371,14 @@ class MusicRenderer: return "" @staticmethod - def _first_value(*values: Any) -> Any: + def first_value(*values: Any) -> Any: for value in values: if value is not None and value != "": return value return None @staticmethod - def _as_bool(value: Any) -> bool: + def as_bool(value: Any) -> bool: if isinstance(value, bool): return value if isinstance(value, str): @@ -386,7 +386,7 @@ class MusicRenderer: return bool(value) @staticmethod - def _format_duration(value: Any) -> str: + def format_duration(value: Any) -> str: if value in (None, ""): return "" try: @@ -400,7 +400,7 @@ class MusicRenderer: return f"{minutes}:{seconds:02}" @staticmethod - def _format_total_duration(value: Any) -> str: + def format_total_duration(value: Any) -> str: if value in (None, ""): return "" try: @@ -416,7 +416,7 @@ class MusicRenderer: return f"{minutes}m {seconds:02}s" @staticmethod - def _format_release_date(value: Any) -> str: + def format_release_date(value: Any) -> str: if value in (None, ""): return "" text = str(value).strip() @@ -450,7 +450,7 @@ class MusicRenderer: return f"{month_names[month - 1]} {day}, {year}" @classmethod - def _quality_summary(cls, value: Any, *, lossless: bool = False, hires: bool = False) -> str: + def quality_summary(cls, value: Any, *, lossless: bool = False, hires: bool = False) -> str: text = str(value or "").strip() lowered = text.lower() if not text: @@ -462,7 +462,7 @@ class MusicRenderer: if "atmos" in lowered: return "Dolby Atmos" if any(codec in lowered for codec in ("flac", "alac", "wav", "aiff")): - return "Hi-Res Lossless" if cls._is_hires_quality(text) else "Lossless" + return "Hi-Res Lossless" if cls.is_hires_quality(text) else "Lossless" if "lossless" in lowered: return "Hi-Res Lossless" if "hi-res" in lowered or hires else "Lossless" if "aac" in lowered: @@ -472,7 +472,7 @@ class MusicRenderer: return text @staticmethod - def _is_hires_quality(value: str) -> bool: + def is_hires_quality(value: str) -> bool: lowered = value.lower() bit_depth = None sample_rate = None @@ -488,12 +488,12 @@ class MusicRenderer: return bool((bit_depth and bit_depth > 16) or (sample_rate and sample_rate > 48)) @staticmethod - def _sum_duration(music: Music) -> int: + def sum_duration(music: Music) -> int: total = 0 for song in music: data = song.data if isinstance(song.data, dict) else {} metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - value = MusicRenderer._first_value(data.get("duration"), metadata.get("duration")) + value = MusicRenderer.first_value(data.get("duration"), metadata.get("duration")) try: total += int(float(value or 0)) except (TypeError, ValueError): @@ -501,7 +501,7 @@ class MusicRenderer: return total @staticmethod - def _max_value(music: Music, attr: str) -> int: + def max_value(music: Music, attr: str) -> int: values = [getattr(song, attr, 0) or 0 for song in music] return max(values, default=0) diff --git a/unshackle/core/music/tagger.py b/unshackle/core/music/tagger.py index f756f15b..84b1e9e4 100644 --- a/unshackle/core/music/tagger.py +++ b/unshackle/core/music/tagger.py @@ -74,49 +74,49 @@ def write_music_metadata(path: Path, song: Any, *, session: Any = None, source_m if extension in {".m4a", ".mp4"} and MP4 is None: return MusicMetadataResult(skipped=True, reason="install mutagen to write MP4/M4A tags") - tags = _build_tag_values(song, source_md5=source_md5) - artwork_url = _first_text(getattr(song, "artwork_url", None), _metadata(song).get("artwork_url")) - cover_data, mime_type = _download_cover(session, artwork_url) + tags = build_tag_values(song, source_md5=source_md5) + artwork_url = first_text(getattr(song, "artwork_url", None), _metadata(song).get("artwork_url")) + cover_data, mime_type = download_cover(session, artwork_url) if extension == ".flac": - _write_flac_tags(path, tags, cover_data, mime_type) + write_flac_tags(path, tags, cover_data, mime_type) elif extension == ".mp3": - _write_mp3_tags(path, tags, cover_data, mime_type) + write_mp3_tags(path, tags, cover_data, mime_type) else: - _write_mp4_tags(path, tags, cover_data, mime_type) + write_mp4_tags(path, tags, cover_data, mime_type) return MusicMetadataResult(written=True, artwork_embedded=bool(cover_data)) -def _build_tag_values(song: Any, *, source_md5: str = "") -> dict[str, str]: +def build_tag_values(song: Any, *, source_md5: str = "") -> dict[str, str]: metadata = _metadata(song) - track_number = _string_tag(getattr(song, "track", None) or metadata.get("track_number")) - track_total = _string_tag(getattr(song, "total_tracks", None) or metadata.get("total_tracks")) - disc_number = _string_tag(getattr(song, "disc", None) or metadata.get("disc_number")) - disc_total = _string_tag(getattr(song, "total_discs", None) or metadata.get("total_discs")) - explicit = _as_bool(getattr(song, "explicit", None), metadata.get("explicit"), metadata.get("parental_warning")) + track_number = string_tag(getattr(song, "track", None) or metadata.get("track_number")) + track_total = string_tag(getattr(song, "total_tracks", None) or metadata.get("total_tracks")) + disc_number = string_tag(getattr(song, "disc", None) or metadata.get("disc_number")) + disc_total = string_tag(getattr(song, "total_discs", None) or metadata.get("total_discs")) + explicit = as_bool(getattr(song, "explicit", None), metadata.get("explicit"), metadata.get("parental_warning")) tags = { - "TITLE": _first_text(getattr(song, "name", None), metadata.get("title")), - "ARTIST": _first_text(getattr(song, "artist", None), metadata.get("artist"), metadata.get("performer")), - "ALBUM": _first_text(getattr(song, "album", None), metadata.get("album")), - "ALBUMARTIST": _first_text(getattr(song, "album_artist", None), metadata.get("album_artist")), + "TITLE": first_text(getattr(song, "name", None), metadata.get("title")), + "ARTIST": first_text(getattr(song, "artist", None), metadata.get("artist"), metadata.get("performer")), + "ALBUM": first_text(getattr(song, "album", None), metadata.get("album")), + "ALBUMARTIST": first_text(getattr(song, "album_artist", None), metadata.get("album_artist")), "TRACKNUMBER": f"{track_number}/{track_total}" if track_number and track_total else track_number, "TRACKTOTAL": track_total, "DISCNUMBER": f"{disc_number}/{disc_total}" if disc_number and disc_total else disc_number, "DISCTOTAL": disc_total, - "DATE": _string_tag(metadata.get("release_date") or metadata.get("year") or getattr(song, "year", None)), - "RELEASEDATE": _string_tag(metadata.get("release_date")), - "GENRE": _first_text(getattr(song, "genre", None), metadata.get("genre")), - "COMPOSER": _first_text(metadata.get("composer")), - "PERFORMER": _first_text(metadata.get("performer")), - "ISRC": _string_tag(getattr(song, "isrc", None) or metadata.get("isrc")), - "BARCODE": _string_tag(getattr(song, "upc", None) or metadata.get("upc") or metadata.get("barcode")), - "UPC": _string_tag(getattr(song, "upc", None) or metadata.get("upc") or metadata.get("barcode")), - "COPYRIGHT": _string_tag(getattr(song, "copyright", None) or metadata.get("copyright")), - "LABEL": _first_text(getattr(song, "label", None), metadata.get("label")), - "COMMENT": _first_text(metadata.get("comment"), metadata.get("quality")), - "SOURCE": _first_text(metadata.get("source"), metadata.get("service")), + "DATE": string_tag(metadata.get("release_date") or metadata.get("year") or getattr(song, "year", None)), + "RELEASEDATE": string_tag(metadata.get("release_date")), + "GENRE": first_text(getattr(song, "genre", None), metadata.get("genre")), + "COMPOSER": first_text(metadata.get("composer")), + "PERFORMER": first_text(metadata.get("performer")), + "ISRC": string_tag(getattr(song, "isrc", None) or metadata.get("isrc")), + "BARCODE": string_tag(getattr(song, "upc", None) or metadata.get("upc") or metadata.get("barcode")), + "UPC": string_tag(getattr(song, "upc", None) or metadata.get("upc") or metadata.get("barcode")), + "COPYRIGHT": string_tag(getattr(song, "copyright", None) or metadata.get("copyright")), + "LABEL": first_text(getattr(song, "label", None), metadata.get("label")), + "COMMENT": first_text(metadata.get("comment"), metadata.get("quality")), + "SOURCE": first_text(metadata.get("source"), metadata.get("service")), "ENCODEDBY": "Unshackle", "UNSHACKLE_SOURCE_MD5": source_md5, } @@ -126,22 +126,22 @@ def _build_tag_values(song: Any, *, source_md5: str = "") -> dict[str, str]: if config.tag and config.tag_group_name: tags["GROUP"] = config.tag - service = _first_text(metadata.get("service"), metadata.get("source")) + service = first_text(metadata.get("service"), metadata.get("source")) if service: prefix = service.upper().replace(" ", "_").replace("-", "_") if metadata.get("track_id"): - tags[f"{prefix}_TRACK_ID"] = _string_tag(metadata.get("track_id")) + tags[f"{prefix}_TRACK_ID"] = string_tag(metadata.get("track_id")) if metadata.get("album_id"): - tags[f"{prefix}_ALBUM_ID"] = _string_tag(metadata.get("album_id")) + tags[f"{prefix}_ALBUM_ID"] = string_tag(metadata.get("album_id")) if metadata.get("track_url"): - tags[f"{prefix}_TRACK_URL"] = _string_tag(metadata.get("track_url")) + tags[f"{prefix}_TRACK_URL"] = string_tag(metadata.get("track_url")) if metadata.get("album_url"): - tags[f"{prefix}_ALBUM_URL"] = _string_tag(metadata.get("album_url")) + tags[f"{prefix}_ALBUM_URL"] = string_tag(metadata.get("album_url")) return {key: value for key, value in tags.items() if value} -def _write_flac_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: +def write_flac_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: audio = FLAC(path) for key, value in tags.items(): audio[key] = [value] @@ -163,7 +163,7 @@ def _write_flac_tags(path: Path, tags: dict[str, str], cover_data: Optional[byte audio.save() -def _write_mp3_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: +def write_mp3_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: try: audio = MP3(path) except ID3NoHeaderError: @@ -203,7 +203,7 @@ def _write_mp3_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes audio.save() -def _write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: +def write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: audio = MP4(path) if tags.get("TITLE"): audio["\xa9nam"] = [tags["TITLE"]] @@ -224,10 +224,10 @@ def _write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes if tags.get("COPYRIGHT"): audio["cprt"] = [tags["COPYRIGHT"]] - track_number, track_total = _split_number_pair(tags.get("TRACKNUMBER", "")) + track_number, track_total = split_number_pair(tags.get("TRACKNUMBER", "")) if track_number: audio["trkn"] = [(track_number, track_total or 0)] - disc_number, disc_total = _split_number_pair(tags.get("DISCNUMBER", "")) + disc_number, disc_total = split_number_pair(tags.get("DISCNUMBER", "")) if disc_number: audio["disk"] = [(disc_number, disc_total or 0)] @@ -254,7 +254,7 @@ def _write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes audio.save() -def _download_cover(session: Any, artwork_url: str) -> tuple[Optional[bytes], str]: +def download_cover(session: Any, artwork_url: str) -> tuple[Optional[bytes], str]: if not session or not artwork_url: return None, "" try: @@ -264,7 +264,7 @@ def _download_cover(session: Any, artwork_url: str) -> tuple[Optional[bytes], st if not data: return None, "" content_type = str(response.headers.get("Content-Type") or "").split(";")[0].strip().lower() - return data, _mime_from_image(data, content_type or "image/jpeg") + return data, mime_from_image(data, content_type or "image/jpeg") except Exception as error: log.debug("Music cover download failed for %s: %s", artwork_url, error) return None, "" @@ -275,14 +275,14 @@ def _metadata(song: Any) -> dict[str, Any]: return data if isinstance(data, dict) else {} -def _first_text(*values: Any) -> str: +def first_text(*values: Any) -> str: for value in values: if isinstance(value, dict): - text = _first_text(value.get("name"), value.get("title"), value.get("display_name"), value.get("value")) + text = first_text(value.get("name"), value.get("title"), value.get("display_name"), value.get("value")) if text: return text elif isinstance(value, list): - parts = [_first_text(item) for item in value] + parts = [first_text(item) for item in value] text = ", ".join(part for part in parts if part) if text: return text @@ -293,7 +293,7 @@ def _first_text(*values: Any) -> str: return "" -def _string_tag(value: Any) -> str: +def string_tag(value: Any) -> str: if value in (None, "", [], {}): return "" if isinstance(value, bool): @@ -301,7 +301,7 @@ def _string_tag(value: Any) -> str: return str(value).strip() -def _as_bool(*values: Any) -> bool: +def as_bool(*values: Any) -> bool: for value in values: if value in (None, "", [], {}): continue @@ -317,7 +317,7 @@ def _as_bool(*values: Any) -> bool: return False -def _mime_from_image(data: bytes, fallback: str = "image/jpeg") -> str: +def mime_from_image(data: bytes, fallback: str = "image/jpeg") -> str: if data.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" if data.startswith(b"\xff\xd8\xff"): @@ -327,7 +327,7 @@ def _mime_from_image(data: bytes, fallback: str = "image/jpeg") -> str: return fallback -def _split_number_pair(value: str) -> tuple[int, int]: +def split_number_pair(value: str) -> tuple[int, int]: if not value: return 0, 0 first, _, second = str(value).partition("/") diff --git a/unshackle/core/providers/__init__.py b/unshackle/core/providers/__init__.py index 19ad1dcc..9c4160cf 100644 --- a/unshackle/core/providers/__init__.py +++ b/unshackle/core/providers/__init__.py @@ -95,7 +95,7 @@ def search_metadata( continue cached = title_cacher.get_cached_provider(p.NAME, cache_title_id, kind, cache_region, cache_account_hash) if cached: - result = _cached_to_result(cached, p.NAME, kind) + result = cached_to_result(cached, p.NAME, kind) if result and result.title and fuzzy_match(result.title, title): log.debug("Using cached %s data for %r", p.NAME, title) return result @@ -120,7 +120,7 @@ def search_metadata( if result.external_ids.tmdb_id or result.external_ids.tvdb_id: cache_data = { **result.raw, - "_enriched_ids": _external_ids_to_dict(result.external_ids), + "_enriched_ids": external_ids_to_dict(result.external_ids), } title_cacher.cache_provider( p.NAME, cache_title_id, cache_data, kind, cache_region, cache_account_hash @@ -165,7 +165,7 @@ def get_title_by_id( title_cacher.cache_provider( "tmdb", cache_title_id, - {"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)}, + {"detail": result.raw, "external_ids": external_ids_to_dict(ext_ids)}, kind, cache_region, cache_account_hash, @@ -210,7 +210,7 @@ def get_year_by_id( title_cacher.cache_provider( "tmdb", cache_title_id, - {"detail": result.raw, "external_ids": _external_ids_to_dict(ext_ids)}, + {"detail": result.raw, "external_ids": external_ids_to_dict(ext_ids)}, kind, cache_region, cache_account_hash, @@ -282,7 +282,7 @@ def fetch_external_ids( title_cacher.cache_provider( "tmdb", cache_title_id, - {"detail": detail, "external_ids": _external_ids_to_dict(ext)}, + {"detail": detail, "external_ids": external_ids_to_dict(ext)}, kind, cache_region, cache_account_hash, @@ -378,13 +378,13 @@ def resolve_by_ids( # trust ranking for cross-validating enrichments; `metadata_providers` filters this # set but sets search order only, not the ranking -_ENRICHMENT_AUTHORITY: tuple[str, ...] = ("tmdb", "simkl", "tvdb") +ENRICHMENT_AUTHORITY: tuple[str, ...] = ("tmdb", "simkl", "tvdb") -def _enrichment_providers(kind: Optional[str] = None) -> list[str]: +def enrichment_providers(kind: Optional[str] = None) -> list[str]: """Names of configured providers that can resolve an IMDB ID, most trusted first.""" configured = {cls.NAME for cls in provider_order(kind) if hasattr(cls, "find_by_imdb_id")} - return [name for name in _ENRICHMENT_AUTHORITY if name in configured] + return [name for name in ENRICHMENT_AUTHORITY if name in configured] def enrich_ids(result: MetadataResult) -> None: @@ -403,7 +403,7 @@ def enrich_ids(result: MetadataResult) -> None: kind = result.kind or "movie" # Step 1: Collect enrichment results from all available providers - authority = {name: i for i, name in enumerate(_enrichment_providers(kind))} + authority = {name: i for i, name in enumerate(enrichment_providers(kind))} enrichments: list[tuple[str, ExternalIds]] = [] for provider_name in authority: p = get_provider(provider_name) @@ -421,7 +421,7 @@ def enrich_ids(result: MetadataResult) -> None: return # Step 2: Cross-validate using tmdb_id as anchor — drop providers that disagree - validated = _validate_enrichments(enrichments, authority) + validated = validate_enrichments(enrichments, authority) # Step 3: Merge validated data (fill gaps only) for _provider_name, ext in validated: @@ -432,7 +432,7 @@ def enrich_ids(result: MetadataResult) -> None: ids.tvdb_id = ext.tvdb_id -def _validate_enrichments( +def validate_enrichments( enrichments: list[tuple[str, ExternalIds]], authority: dict[str, int], ) -> list[tuple[str, ExternalIds]]: @@ -484,7 +484,7 @@ def _validate_enrichments( return validated -def _external_ids_to_dict(ext: ExternalIds) -> dict: +def external_ids_to_dict(ext: ExternalIds) -> dict: """Convert ExternalIds to a dict for caching.""" result: dict = {} if ext.imdb_id: @@ -500,7 +500,7 @@ def _external_ids_to_dict(ext: ExternalIds) -> dict: return result -def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[MetadataResult]: +def cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[MetadataResult]: """Convert a cached provider dict back to a MetadataResult.""" if provider_name == "tmdb": detail = cached.get("detail", {}) @@ -566,10 +566,10 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M raw=cached, ) elif provider_name == "tvdb": - from unshackle.core.providers.tvdb import _ids_from_remote, _parse_int + from unshackle.core.providers.tvdb import ids_from_remote, parse_int - tvdb_id = _parse_int(cached.get("tvdb_id") or cached.get("id")) - ext = _ids_from_remote(cached.get("remote_ids") or cached.get("remoteIds"), tvdb_id) + tvdb_id = parse_int(cached.get("tvdb_id") or cached.get("id")) + ext = ids_from_remote(cached.get("remote_ids") or cached.get("remoteIds"), tvdb_id) # restore IDs that enrichment filled in beyond the raw remote_ids enriched = cached.get("_enriched_ids", {}) ext.imdb_id = ext.imdb_id or enriched.get("imdb_id") @@ -578,14 +578,14 @@ def _cached_to_result(cached: dict, provider_name: str, kind: str) -> Optional[M ext.tmdb_kind = kind return MetadataResult( title=cached.get("name"), - year=_parse_int(cached.get("year")), + year=parse_int(cached.get("year")), kind=kind, external_ids=ext, source="tvdb", raw=cached, ) elif provider_name == "anilist": - return AniListProvider()._to_result(cached) + return AniListProvider().to_result(cached) elif provider_name == "imdb": from unshackle.core.providers.imdb import primary_language diff --git a/unshackle/core/providers/_base.py b/unshackle/core/providers/_base.py index 38770347..d2a29689 100644 --- a/unshackle/core/providers/_base.py +++ b/unshackle/core/providers/_base.py @@ -86,15 +86,15 @@ class MetadataProvider(metaclass=ABCMeta): """Fetch external IDs for a title by this provider's native ID.""" -def _clean(s: str) -> str: +def clean(s: str) -> str: return STRIP_RE.sub("", s).lower() -def _strip_year(s: str) -> str: +def strip_year(s: str) -> str: return YEAR_RE.sub("", s).strip() def fuzzy_match(a: str, b: str, threshold: float = 0.8) -> bool: """Return True if ``a`` and ``b`` are a close match.""" - ratio = SequenceMatcher(None, _clean(a), _clean(b)).ratio() + ratio = SequenceMatcher(None, clean(a), clean(b)).ratio() return ratio >= threshold diff --git a/unshackle/core/providers/anilist.py b/unshackle/core/providers/anilist.py index aed3c680..8b7d3f13 100644 --- a/unshackle/core/providers/anilist.py +++ b/unshackle/core/providers/anilist.py @@ -5,7 +5,7 @@ from typing import Optional, Union import requests -from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean +from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, clean GRAPHQL_URL = "https://graphql.anilist.co" @@ -55,7 +55,7 @@ class AniListProvider(MetadataProvider): def is_available(self) -> bool: return True - def _graphql(self, query: str, variables: dict) -> Optional[dict]: + def graphql(self, query: str, variables: dict) -> Optional[dict]: try: r = self.session.post(GRAPHQL_URL, json={"query": query, "variables": variables}, timeout=30) body = r.json() @@ -69,7 +69,7 @@ class AniListProvider(MetadataProvider): return None return body.get("data") - def _title_variant(self, titles: dict) -> Optional[str]: + def title_variant(self, titles: dict) -> Optional[str]: from unshackle.core.config import config configured = (config.anilist_title_language or "english").lower() @@ -79,8 +79,8 @@ class AniListProvider(MetadataProvider): return str(value) return None - def _to_result(self, node: dict) -> Optional[MetadataResult]: - title = self._title_variant(node.get("title") or {}) + def to_result(self, node: dict) -> Optional[MetadataResult]: + title = self.title_variant(node.get("title") or {}) if not title: return None return MetadataResult( @@ -96,7 +96,7 @@ class AniListProvider(MetadataProvider): def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: self.log.debug("Searching AniList for %r (%s, %s)", title, kind, year) - data = self._graphql(SEARCH_QUERY, {"search": title}) + data = self.graphql(SEARCH_QUERY, {"search": title}) nodes = ((data or {}).get("Page") or {}).get("media") or [] if not nodes: self.log.debug("AniList returned no results for %r", title) @@ -110,8 +110,8 @@ class AniListProvider(MetadataProvider): dated = [n for n in nodes if (n.get("startDate") or {}).get("year") == year] nodes = dated or nodes - best = max(nodes, key=lambda n: _match_ratio(n, title)) - result = self._to_result(best) + best = max(nodes, key=lambda n: match_ratio(n, title)) + result = self.to_result(best) if result: self.log.debug("AniList -> %s (ID %s)", result.title, result.external_ids.anilist_id) return result @@ -126,14 +126,14 @@ class AniListProvider(MetadataProvider): namespace, number = ref self.log.debug("Fetching AniList media %s:%d", namespace, number) if namespace == "mal": - data = self._graphql(MAL_QUERY, {"idMal": number}) + data = self.graphql(MAL_QUERY, {"idMal": number}) else: - data = self._graphql(MEDIA_QUERY, {"id": number}) + data = self.graphql(MEDIA_QUERY, {"id": number}) node = (data or {}).get("Media") if not node: return None - return self._to_result(node) + return self.to_result(node) def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: """Return external IDs. AniList knows no western IDs, so only its own.""" @@ -147,6 +147,6 @@ class AniListProvider(MetadataProvider): return result.external_ids if result else ExternalIds() -def _match_ratio(node: dict, title: str) -> float: +def match_ratio(node: dict, title: str) -> float: names = (node.get("title") or {}).values() - return max((SequenceMatcher(None, _clean(title), _clean(n)).ratio() for n in names if n), default=0.0) + return max((SequenceMatcher(None, clean(title), clean(n)).ratio() for n in names if n), default=0.0) diff --git a/unshackle/core/providers/imdb.py b/unshackle/core/providers/imdb.py index b8fde1e5..5b42f440 100644 --- a/unshackle/core/providers/imdb.py +++ b/unshackle/core/providers/imdb.py @@ -7,7 +7,7 @@ from typing import Optional, Union import requests -from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, fuzzy_match +from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, clean, fuzzy_match GRAPHQL_URL = "https://caching.graphql.imdb.com/" @@ -41,7 +41,7 @@ def primary_language(data: dict) -> Optional[str]: return langs[0].get("id") if langs else None -def _title_to_result(node: dict, kind: str) -> Optional[MetadataResult]: +def title_to_result(node: dict, kind: str) -> Optional[MetadataResult]: title = (node.get("titleText") or {}).get("text") or (node.get("originalTitleText") or {}).get("text") if not title: return None @@ -66,7 +66,7 @@ class IMDBProvider(MetadataProvider): def is_available(self) -> bool: return True - def _graphql(self, operation: str, query: str) -> Optional[dict]: + def graphql(self, operation: str, query: str) -> Optional[dict]: """Run a persisted query, registering it with one POST the first time it is seen.""" extensions = {"persistedQuery": {"version": 1, "sha256Hash": hashlib.sha256(query.encode()).hexdigest()}} # IMDb's GraphQL gateway decodes "+" literally, so these JSON params must carry no spaces @@ -79,7 +79,7 @@ class IMDBProvider(MetadataProvider): try: r = self.session.get(GRAPHQL_URL, params=params, headers=GRAPHQL_HEADERS, timeout=30) body = r.json() - if _persisted_query_missing(body): + if persisted_query_missing(body): self.log.debug("Registering persisted query %s", operation) payload = {"operationName": operation, "variables": {}, "query": query, "extensions": extensions} r = self.session.post(GRAPHQL_URL, json=payload, headers=GRAPHQL_HEADERS, timeout=30) @@ -110,15 +110,15 @@ class IMDBProvider(MetadataProvider): "{ edges { node { title { %s } } } } }" % (", ".join(constraints), TITLE_FIELDS) ) - data = self._graphql("UnshackleTitleSearch", query) + data = self.graphql("UnshackleTitleSearch", query) edges = ((data or {}).get("advancedTitleSearch") or {}).get("edges") or [] nodes = [edge["node"]["title"] for edge in edges if (edge.get("node") or {}).get("title")] if not nodes: self.log.debug("IMDb returned no results for %r", title) return None - best = max(nodes, key=lambda n: _match_ratio(n, title)) - result = _title_to_result(best, kind) + best = max(nodes, key=lambda n: match_ratio(n, title)) + result = title_to_result(best, kind) if not result or not result.title or not fuzzy_match(result.title, title): self.log.debug("IMDb title mismatch: searched %r, got %r", title, result.title if result else None) return None @@ -132,23 +132,23 @@ class IMDBProvider(MetadataProvider): self.log.debug("Fetching IMDb title %s", imdb_id) query = 'query UnshackleTitle { title(id: "%s") { %s } }' % (escape_graphql(imdb_id), TITLE_FIELDS) - data = self._graphql("UnshackleTitle", query) + data = self.graphql("UnshackleTitle", query) node = (data or {}).get("title") if not node: return None - return _title_to_result(node, kind) + return title_to_result(node, kind) def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: """Return external IDs. For IMDB, the provider_id IS the IMDB ID.""" return ExternalIds(imdb_id=str(provider_id)) -def _match_ratio(node: dict, title: str) -> float: +def match_ratio(node: dict, title: str) -> float: names = [(node.get("titleText") or {}).get("text"), (node.get("originalTitleText") or {}).get("text")] - return max((SequenceMatcher(None, _clean(title), _clean(n)).ratio() for n in names if n), default=0.0) + return max((SequenceMatcher(None, clean(title), clean(n)).ratio() for n in names if n), default=0.0) -def _persisted_query_missing(body: dict) -> bool: +def persisted_query_missing(body: dict) -> bool: for error in body.get("errors") or []: code = (error.get("extensions") or {}).get("code") if code == "PERSISTED_QUERY_NOT_FOUND" or error.get("message") == "PersistedQueryNotFound": diff --git a/unshackle/core/providers/omdb.py b/unshackle/core/providers/omdb.py index 7d8a1b47..083bfe93 100644 --- a/unshackle/core/providers/omdb.py +++ b/unshackle/core/providers/omdb.py @@ -7,7 +7,7 @@ import requests from langcodes import Language from unshackle.core.config import config -from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, fuzzy_match +from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, clean, fuzzy_match # Mapping from our kind ("movie"/"tv") to OMDb title types KIND_TO_TYPE: dict[str, str] = { @@ -27,7 +27,7 @@ def primary_language(data: dict) -> Optional[str]: return None -def _parse_year(value: Optional[str]) -> Optional[int]: +def parse_year(value: Optional[str]) -> Optional[int]: # OMDb years look like "2017", "2008–2013" or "2023–" if value and len(value) >= 4 and value[:4].isdigit(): return int(value[:4]) @@ -46,12 +46,12 @@ class OMDBProvider(MetadataProvider): return bool(config.omdb_api_key) @property - def _api_key(self) -> str: + def api_key(self) -> str: return config.omdb_api_key - def _get(self, params: dict[str, str]) -> Optional[dict]: + def api_get(self, params: dict[str, str]) -> Optional[dict]: try: - r = self.session.get(self.BASE_URL, params={"apikey": self._api_key, **params}, timeout=30) + r = self.session.get(self.BASE_URL, params={"apikey": self.api_key, **params}, timeout=30) r.raise_for_status() data = r.json() except (requests.RequestException, ValueError) as exc: @@ -72,11 +72,11 @@ class OMDBProvider(MetadataProvider): if year is not None: params["y"] = str(year) - data = self._get(params) + data = self.api_get(params) if not data and year is not None: # OMDb's year filter is exact; retry without it for off-by-one metadata del params["y"] - data = self._get(params) + data = self.api_get(params) if not data: return None @@ -88,9 +88,9 @@ class OMDBProvider(MetadataProvider): name = candidate.get("Title") or "" if not name: continue - ratio = SequenceMatcher(None, _clean(title), _clean(name)).ratio() + ratio = SequenceMatcher(None, clean(title), clean(name)).ratio() if ratio > best_ratio: - candidate_year = _parse_year(candidate.get("Year")) + candidate_year = parse_year(candidate.get("Year")) if year and candidate_year and abs(year - candidate_year) > 1: continue best_ratio = ratio @@ -109,11 +109,11 @@ class OMDBProvider(MetadataProvider): self.log.debug("OMDb -> %s (ID %s)", result_title, imdb_id) # Fetch full detail so raw carries ratings, genre, rating cert, etc. - detail = self._get({"i": imdb_id}) if imdb_id else None + detail = self.api_get({"i": imdb_id}) if imdb_id else None return MetadataResult( title=result_title, - year=_parse_year(best_match.get("Year")), + year=parse_year(best_match.get("Year")), kind=kind, external_ids=ExternalIds(imdb_id=imdb_id), original_language=primary_language(detail or best_match), @@ -126,13 +126,13 @@ class OMDBProvider(MetadataProvider): imdb_id = str(provider_id) self.log.debug("Fetching OMDb title %s", imdb_id) - data = self._get({"i": imdb_id}) + data = self.api_get({"i": imdb_id}) if not data: return None return MetadataResult( title=data.get("Title"), - year=_parse_year(data.get("Year")), + year=parse_year(data.get("Year")), kind=kind, external_ids=ExternalIds(imdb_id=data.get("imdbID")), original_language=primary_language(data), diff --git a/unshackle/core/providers/simkl.py b/unshackle/core/providers/simkl.py index 00bad342..c61c174f 100644 --- a/unshackle/core/providers/simkl.py +++ b/unshackle/core/providers/simkl.py @@ -47,7 +47,7 @@ class SimklProvider(MetadataProvider): self.log.debug("Simkl returned list (no matches) for %r", filename) return None - return self._parse_response(data, title, year, kind) + return self.parse_response(data, title, year, kind) def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: return None # SIMKL has no direct ID lookup used here @@ -115,7 +115,7 @@ class SimklProvider(MetadataProvider): tvdb_id=tvdb_id, ) - def _parse_response( + def parse_response( self, data: dict, search_title: str, search_year: Optional[int], kind: str ) -> Optional[MetadataResult]: """Parse a SIMKL response into a MetadataResult.""" diff --git a/unshackle/core/providers/tmdb.py b/unshackle/core/providers/tmdb.py index 1360b693..60014161 100644 --- a/unshackle/core/providers/tmdb.py +++ b/unshackle/core/providers/tmdb.py @@ -6,7 +6,7 @@ from typing import Optional, Union import requests from unshackle.core.config import config -from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, _strip_year +from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, clean, strip_year class TMDBProvider(MetadataProvider): @@ -21,14 +21,14 @@ class TMDBProvider(MetadataProvider): return bool(config.tmdb_api_key) @property - def _api_key(self) -> str: + def api_key(self) -> str: return config.tmdb_api_key def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: - search_title = _strip_year(title) + search_title = strip_year(title) self.log.debug("Searching TMDB for %r (%s, %s)", search_title, kind, year) - params: dict[str, str | int] = {"api_key": self._api_key, "query": search_title} + params: dict[str, str | int] = {"api_key": self.api_key, "query": search_title} if year is not None: params["year" if kind == "movie" else "first_air_date_year"] = year @@ -56,7 +56,7 @@ class TMDBProvider(MetadataProvider): candidates = [c for c in candidates if c] for candidate in candidates: - ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio() + ratio = SequenceMatcher(None, clean(search_title), clean(candidate)).ratio() if ratio > best_ratio: best_ratio = ratio best_id = result.get("id") @@ -73,8 +73,8 @@ class TMDBProvider(MetadataProvider): return None # Fetch full detail for caching - detail = self._fetch_detail(best_id, kind) - ext_raw = self._fetch_external_ids_raw(best_id, kind) + detail = self.fetch_detail(best_id, kind) + ext_raw = self.fetch_external_ids_raw(best_id, kind) date = (detail or {}).get("release_date") or (detail or {}).get("first_air_date") result_year = int(date[:4]) if date and len(date) >= 4 and date[:4].isdigit() else None @@ -97,7 +97,7 @@ class TMDBProvider(MetadataProvider): ) def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: - detail = self._fetch_detail(int(provider_id), kind) + detail = self.fetch_detail(int(provider_id), kind) if not detail: return None @@ -116,7 +116,7 @@ class TMDBProvider(MetadataProvider): ) def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: - raw = self._fetch_external_ids_raw(int(provider_id), kind) + raw = self.fetch_external_ids_raw(int(provider_id), kind) if not raw: return ExternalIds(tmdb_id=int(provider_id), tmdb_kind=kind) return ExternalIds( @@ -132,7 +132,7 @@ class TMDBProvider(MetadataProvider): try: r = self.session.get( f"{self.BASE_URL}/find/{imdb_id}", - params={"api_key": self._api_key, "external_source": "imdb_id"}, + params={"api_key": self.api_key, "external_source": "imdb_id"}, timeout=30, ) r.raise_for_status() @@ -166,7 +166,7 @@ class TMDBProvider(MetadataProvider): self.log.debug("TMDB find -> ID %s (%s) for IMDB %s", tmdb_id, kind, imdb_id) # Now fetch the full external IDs from TMDB to get TVDB etc. - ext_raw = self._fetch_external_ids_raw(tmdb_id, kind) + ext_raw = self.fetch_external_ids_raw(tmdb_id, kind) return ExternalIds( imdb_id=imdb_id, @@ -175,11 +175,11 @@ class TMDBProvider(MetadataProvider): tvdb_id=ext_raw.get("tvdb_id") if ext_raw else None, ) - def _fetch_detail(self, tmdb_id: int, kind: str) -> Optional[dict]: + def fetch_detail(self, tmdb_id: int, kind: str) -> Optional[dict]: try: r = self.session.get( f"{self.BASE_URL}/{kind}/{tmdb_id}", - params={"api_key": self._api_key}, + params={"api_key": self.api_key}, timeout=30, ) r.raise_for_status() @@ -188,11 +188,11 @@ class TMDBProvider(MetadataProvider): self.log.debug("Failed to fetch TMDB detail: %s", exc) return None - def _fetch_external_ids_raw(self, tmdb_id: int, kind: str) -> Optional[dict]: + def fetch_external_ids_raw(self, tmdb_id: int, kind: str) -> Optional[dict]: try: r = self.session.get( f"{self.BASE_URL}/{kind}/{tmdb_id}/external_ids", - params={"api_key": self._api_key}, + params={"api_key": self.api_key}, timeout=30, ) r.raise_for_status() diff --git a/unshackle/core/providers/tvdb.py b/unshackle/core/providers/tvdb.py index aecdaf94..592eb0a9 100644 --- a/unshackle/core/providers/tvdb.py +++ b/unshackle/core/providers/tvdb.py @@ -7,7 +7,7 @@ from typing import Any, Optional, Union import requests from unshackle.core.config import config -from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, _clean, _strip_year +from unshackle.core.providers._base import ExternalIds, MetadataProvider, MetadataResult, clean, strip_year def primary_language(data: dict) -> Optional[str]: @@ -26,14 +26,14 @@ AIRED_ORDER = "official" _token: Optional[str] = None -def _parse_int(value: Optional[str]) -> Optional[int]: +def parse_int(value: Optional[str]) -> Optional[int]: try: return int(value) # type: ignore[arg-type] except (TypeError, ValueError): return None -def _ids_from_remote(remote_ids: Optional[list], tvdb_id: Optional[int]) -> ExternalIds: +def ids_from_remote(remote_ids: Optional[list], tvdb_id: Optional[int]) -> ExternalIds: """Build ExternalIds from a TVDB remote_ids/remoteIds list.""" ext = ExternalIds(tvdb_id=tvdb_id) for entry in remote_ids or []: @@ -42,11 +42,11 @@ def _ids_from_remote(remote_ids: Optional[list], tvdb_id: Optional[int]) -> Exte if source == "imdb": ext.imdb_id = value elif source.startswith("themoviedb"): - ext.tmdb_id = _parse_int(value) + ext.tmdb_id = parse_int(value) return ext -def _pick_match( +def pick_match( results: list[dict], search_title: str, year: Optional[int] ) -> tuple[Optional[dict], Optional[str], float]: """Best title match among results, ignoring any whose year is more than a year out.""" @@ -54,13 +54,13 @@ def _pick_match( best_title: Optional[str] = None best_ratio = 0.0 for result in results: - result_year = _parse_int(result.get("year")) + result_year = parse_int(result.get("year")) if year and result_year and abs(result_year - year) > 1: continue for candidate in [result.get("name"), *(result.get("aliases") or [])]: if not candidate: continue - ratio = SequenceMatcher(None, _clean(search_title), _clean(candidate)).ratio() + ratio = SequenceMatcher(None, clean(search_title), clean(candidate)).ratio() if ratio > best_ratio: best, best_title, best_ratio = result, candidate, ratio return best, best_title, best_ratio @@ -81,7 +81,7 @@ class TVDBProvider(MetadataProvider): def is_available(self) -> bool: return bool(config.tvdb_api_key) - def _login(self) -> Optional[str]: + def login(self) -> Optional[str]: global _token payload: dict[str, str] = {"apikey": config.tvdb_api_key} if config.tvdb_pin: @@ -95,10 +95,10 @@ class TVDBProvider(MetadataProvider): _token = None return _token - def _get(self, path: str, params: Optional[dict] = None) -> Any: + def api_get(self, path: str, params: Optional[dict] = None) -> Any: """The `data` payload of a TVDB response: a dict for entities, a list for searches.""" global _token - token = _token or self._login() + token = _token or self.login() if not token: return None for _ in range(2): @@ -111,7 +111,7 @@ class TVDBProvider(MetadataProvider): ) if r.status_code == 401: _token = None - token = self._login() + token = self.login() if not token: return None continue @@ -123,7 +123,7 @@ class TVDBProvider(MetadataProvider): return None def search(self, title: str, year: Optional[int], kind: str) -> Optional[MetadataResult]: - search_title = _strip_year(title) + search_title = strip_year(title) self.log.debug("Searching TVDB for %r (%s, %s)", search_title, kind, year) params: dict[str, str | int] = {"query": search_title, "limit": 10} @@ -133,23 +133,23 @@ class TVDBProvider(MetadataProvider): if year is not None: params["year"] = year - results = self._get("/search", params) + results = self.api_get("/search", params) if not results and year is not None: del params["year"] - results = self._get("/search", params) + results = self.api_get("/search", params) if not results: self.log.debug("No TVDB results for %r", search_title) return None # year first, so an identically named remake cannot win on title alone - best, best_title, best_ratio = _pick_match(results, search_title, year) + best, best_title, best_ratio = pick_match(results, search_title, year) if best is None: - best, best_title, best_ratio = _pick_match(results, search_title, None) + best, best_title, best_ratio = pick_match(results, search_title, None) if best is None: best, best_title = results[0], results[0].get("name") - tvdb_id = _parse_int(best.get("tvdb_id")) - ext = _ids_from_remote(best.get("remote_ids"), tvdb_id) + tvdb_id = parse_int(best.get("tvdb_id")) + ext = ids_from_remote(best.get("remote_ids"), tvdb_id) if ext.tmdb_id: ext.tmdb_kind = kind @@ -157,7 +157,7 @@ class TVDBProvider(MetadataProvider): return MetadataResult( title=best_title or best.get("name"), - year=_parse_int(best.get("year")), + year=parse_int(best.get("year")), kind=kind, external_ids=ext, original_language=primary_language(best), @@ -165,23 +165,23 @@ class TVDBProvider(MetadataProvider): raw=best, ) - def _fetch_extended(self, tvdb_id: int, kind: str) -> Optional[dict]: + def fetch_extended(self, tvdb_id: int, kind: str) -> Optional[dict]: path = KIND_TO_PATH.get(kind, "series") - return self._get(f"/{path}/{tvdb_id}/extended", {"short": "true"}) + return self.api_get(f"/{path}/{tvdb_id}/extended", {"short": "true"}) def get_by_id(self, provider_id: Union[int, str], kind: str) -> Optional[MetadataResult]: tvdb_id = int(provider_id) - detail = self._fetch_extended(tvdb_id, kind) + detail = self.fetch_extended(tvdb_id, kind) if not detail: return None - ext = _ids_from_remote(detail.get("remoteIds"), tvdb_id) + ext = ids_from_remote(detail.get("remoteIds"), tvdb_id) if ext.tmdb_id: ext.tmdb_kind = kind return MetadataResult( title=detail.get("name"), - year=_parse_int(detail.get("year")), + year=parse_int(detail.get("year")), kind=kind, external_ids=ext, original_language=primary_language(detail), @@ -191,10 +191,10 @@ class TVDBProvider(MetadataProvider): def get_external_ids(self, provider_id: Union[int, str], kind: str) -> ExternalIds: tvdb_id = int(provider_id) - detail = self._fetch_extended(tvdb_id, kind) + detail = self.fetch_extended(tvdb_id, kind) if not detail: return ExternalIds(tvdb_id=tvdb_id) - ext = _ids_from_remote(detail.get("remoteIds"), tvdb_id) + ext = ids_from_remote(detail.get("remoteIds"), tvdb_id) if ext.tmdb_id: ext.tmdb_kind = kind return ext @@ -207,7 +207,7 @@ class TVDBProvider(MetadataProvider): episodes: list[dict] = [] for page in range(20): # ponytail: hard page cap, 500 eps/page covers any real series - data = self._get(f"/series/{tvdb_id}/episodes/{order}", {"page": page}) + data = self.api_get(f"/series/{tvdb_id}/episodes/{order}", {"page": page}) if data is None and page: # a partial listing would renumber episodes wrongly self.log.warning("TVDB %s order listing for series %s failed at page %d", order, tvdb_id, page) @@ -232,7 +232,7 @@ class TVDBProvider(MetadataProvider): episodes = self.get_episodes(tvdb_id, order) if not episodes: continue - slots = {(_parse_int(e.get("seasonNumber")), _parse_int(e.get("number"))) for e in episodes} + slots = {(parse_int(e.get("seasonNumber")), parse_int(e.get("number"))) for e in episodes} score = sum(1 for key in keys if key in slots) self.log.debug("TVDB order %s matches %d/%d of the service's episodes", order, score, len(keys)) if score > best_score: @@ -264,8 +264,8 @@ class TVDBProvider(MetadataProvider): match = by_id.get(ep.get("id")) if not match: continue - src_season, src_number = _parse_int(ep.get("seasonNumber")), _parse_int(ep.get("number")) - dst_season, dst_number = _parse_int(match.get("seasonNumber")), _parse_int(match.get("number")) + src_season, src_number = parse_int(ep.get("seasonNumber")), parse_int(ep.get("number")) + dst_season, dst_number = parse_int(match.get("seasonNumber")), parse_int(match.get("number")) if src_season is None or src_number is None or dst_season is None or dst_number is None: continue mapping[(src_season, src_number)] = (dst_season, dst_number, match.get("name")) @@ -275,7 +275,7 @@ class TVDBProvider(MetadataProvider): def find_by_imdb_id(self, imdb_id: str, kind: str) -> Optional[ExternalIds]: """Resolve a TVDB ID from an IMDb ID via /search/remoteid.""" - results = self._get(f"/search/remoteid/{imdb_id}") + results = self.api_get(f"/search/remoteid/{imdb_id}") if not results: self.log.debug("No TVDB results for IMDB ID %s", imdb_id) return None @@ -290,6 +290,6 @@ class TVDBProvider(MetadataProvider): if not entity: return None - tvdb_id = _parse_int(entity.get("id")) + tvdb_id = parse_int(entity.get("id")) self.log.debug("TVDB find -> TVDB %s for IMDB %s", tvdb_id, imdb_id) return ExternalIds(imdb_id=imdb_id, tvdb_id=tvdb_id) diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py index 68ea4a2e..59972186 100644 --- a/unshackle/core/proxies/gluetun.py +++ b/unshackle/core/proxies/gluetun.py @@ -17,17 +17,17 @@ from unshackle.core.utilities import get_country_code, get_country_name, get_deb from unshackle.core.utils.ip_info import get_ip_info # Global registry for cleanup on exit -_gluetun_instances: list["Gluetun"] = [] -_cleanup_lock = threading.Lock() -_cleanup_registered = False +gluetun_instances: list["Gluetun"] = [] +cleanup_lock = threading.Lock() +cleanup_registered = False -def _cleanup_all_gluetun_containers(): +def cleanup_all_gluetun_containers(): """Cleanup all Gluetun containers on exit.""" # Get instances without holding the lock during cleanup - with _cleanup_lock: - instances = list(_gluetun_instances) - _gluetun_instances.clear() + with cleanup_lock: + instances = list(gluetun_instances) + gluetun_instances.clear() # Cleanup each instance (no lock held, so no deadlock possible) for instance in instances: @@ -37,15 +37,15 @@ def _cleanup_all_gluetun_containers(): pass -def _register_cleanup(): +def register_cleanup(): """Register cleanup handlers (only once).""" - global _cleanup_registered - with _cleanup_lock: - if not _cleanup_registered: + global cleanup_registered + with cleanup_lock: + if not cleanup_registered: # Only use atexit for cleanup - don't override signal handlers # This allows Ctrl+C to work normally while still cleaning up on exit - atexit.register(_cleanup_all_gluetun_containers) - _cleanup_registered = True + atexit.register(cleanup_all_gluetun_containers) + cleanup_registered = True class Gluetun(Proxy): @@ -215,12 +215,12 @@ class Gluetun(Proxy): # Validate provider configurations for provider_name, config in self.providers.items(): - self._validate_provider_config(provider_name, config) + self.validate_provider_config(provider_name, config) # Register this instance for cleanup on exit - _register_cleanup() - with _cleanup_lock: - _gluetun_instances.append(self) + register_cleanup() + with cleanup_lock: + gluetun_instances.append(self) # Log initialization log_event( @@ -271,7 +271,7 @@ class Gluetun(Proxy): # This handles multiple concurrent Unshackle sessions if query_key in self.active_containers: container = self.active_containers[query_key] - if self._is_container_running(container["container_name"]): + if self.is_container_running(container["container_name"]): log_event( "gluetun_container_reuse", level="DEBUG", @@ -284,11 +284,11 @@ class Gluetun(Proxy): ) # Re-verify if needed if self.verify_ip: - self._verify_container(query_key) - return self._build_proxy_uri(container["port"]) + self.verify_container(query_key) + return self.build_proxy_uri(container["port"]) else: # Not in memory, but might exist in Docker (from another session) - existing_info = self._get_existing_container_info(container_name) + existing_info = self.get_existing_container_info(container_name) if existing_info: # Container exists in Docker, reuse it self.active_containers[query_key] = existing_info @@ -304,8 +304,8 @@ class Gluetun(Proxy): ) # Re-verify if needed if self.verify_ip: - self._verify_container(query_key) - return self._build_proxy_uri(existing_info["port"]) + self.verify_container(query_key) + return self.build_proxy_uri(existing_info["port"]) # Get provider configuration provider_config = self.providers[provider_name] @@ -329,7 +329,7 @@ class Gluetun(Proxy): server_num = specific_server_match.group(2) # Build hostname based on provider - hostname = self._build_server_hostname(provider_name, country_code, server_num) + hostname = self.build_server_hostname(provider_name, country_code, server_num) country = country_code # Set country for verification # If not explicitly mapped and not a specific server, try to use query as country code @@ -349,14 +349,14 @@ class Gluetun(Proxy): ) # Remove any stopped container with the same name - self._remove_stopped_container(container_name) + self.remove_stopped_container(container_name) # Find available port - port = self._get_available_port() + port = self.get_available_port() # Create container (name already set above) try: - self._create_container( + self.create_container( container_name=container_name, port=port, provider_name=provider_name, @@ -378,9 +378,9 @@ class Gluetun(Proxy): } # Wait for container to be ready (60s timeout for VPN connection) - if not self._wait_for_container(container_name, timeout=60): + if not self.wait_for_container(container_name, timeout=60): # Get container logs for better error message - logs = self._get_container_logs(container_name, tail=30) + logs = self.get_container_logs(container_name, tail=30) error_msg = f"Gluetun container '{container_name}' failed to start" if hasattr(self, "_last_wait_error") and self._last_wait_error: error_msg += f": {self._last_wait_error}" @@ -392,13 +392,13 @@ class Gluetun(Proxy): # Verify IP and region if enabled if self.verify_ip: - self._verify_container(query_key) + self.verify_container(query_key) - return self._build_proxy_uri(port) + return self.build_proxy_uri(port) except Exception as e: # Cleanup on failure - self._remove_container(container_name) + self.remove_container(container_name) if query_key in self.active_containers: del self.active_containers[query_key] raise RuntimeError(f"Failed to create Gluetun container: {e}") @@ -420,7 +420,7 @@ class Gluetun(Proxy): for query_key, container_info in list(self.active_containers.items()): container_name = container_info["container_name"] - self._remove_container(container_name) + self.remove_container(container_name) log_event( "gluetun_container_removed", @@ -474,7 +474,7 @@ class Gluetun(Proxy): "org": container.get("ip_org"), } - def _validate_provider_config(self, provider_name: str, config: dict): + def validate_provider_config(self, provider_name: str, config: dict): """Validate a provider's configuration.""" vpn_type = config.get("vpn_type", "wireguard").lower() credentials = config.get("credentials", {}) @@ -517,16 +517,16 @@ class Gluetun(Proxy): f"Provider '{provider_name}': OpenVPN requires 'username' and 'password' in credentials" ) - def _get_available_port(self) -> int: + def get_available_port(self) -> int: """Find an available port starting from base_port (thread-safe).""" with self._port_lock: used_ports = {info["port"] for info in self.active_containers.values()} port = self.base_port - while port in used_ports or self._is_port_in_use(port): + while port in used_ports or self.is_port_in_use(port): port += 1 return port - def _is_port_in_use(self, port: int) -> bool: + def is_port_in_use(self, port: int) -> bool: """Check if a port is in use on the system or by any Docker container.""" import socket @@ -554,7 +554,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError): return False - def _build_server_hostname(self, provider_name: str, country_code: str, server_num: str) -> str: + def build_server_hostname(self, provider_name: str, country_code: str, server_num: str) -> str: """ Build a server hostname for specific server selection. @@ -585,7 +585,7 @@ class Gluetun(Proxy): # Generic format: country_code + server_num return f"{country_lower}{server_num}" - def _ensure_image_available(self, image: str = "qmcgaw/gluetun:latest") -> bool: + def ensure_image_available(self, image: str = "qmcgaw/gluetun:latest") -> bool: """ Ensure the Gluetun Docker image is available locally. @@ -637,7 +637,7 @@ class Gluetun(Proxy): except subprocess.TimeoutExpired: raise RuntimeError(f"Timed out pulling Docker image '{image}'") - def _create_container( + def create_container( self, container_name: str, port: int, @@ -666,7 +666,7 @@ class Gluetun(Proxy): # Ensure the Gluetun image is available (pulls if needed) gluetun_image = "qmcgaw/gluetun:latest" - if not self._ensure_image_available(gluetun_image): + if not self.ensure_image_available(gluetun_image): log_event( "gluetun_image_pull_failed", level="ERROR", @@ -880,7 +880,7 @@ class Gluetun(Proxy): except Exception: pass - def _is_container_running(self, container_name: str) -> bool: + def is_container_running(self, container_name: str) -> bool: """Check if a Docker container is running.""" try: result = subprocess.run( @@ -906,7 +906,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError): return False - def _get_existing_container_info(self, container_name: str) -> Optional[dict]: + def get_existing_container_info(self, container_name: str) -> Optional[dict]: """ Check if a container exists in Docker and get its info. @@ -921,7 +921,7 @@ class Gluetun(Proxy): """ try: # Check if container is running - if not self._is_container_running(container_name): + if not self.is_container_running(container_name): return None # Get container port mapping @@ -964,7 +964,7 @@ class Gluetun(Proxy): if specific_server_match: country_code = specific_server_match.group(1).upper() server_num = specific_server_match.group(2) - hostname = self._build_server_hostname(provider_name, country_code, server_num) + hostname = self.build_server_hostname(provider_name, country_code, server_num) country = country_code # Otherwise check config @@ -989,7 +989,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return None - def _wait_for_container(self, container_name: str, timeout: int = 60) -> bool: + def wait_for_container(self, container_name: str, timeout: int = 60) -> bool: """ Wait for Gluetun container to be ready by checking logs for proxy readiness. @@ -1015,9 +1015,9 @@ class Gluetun(Proxy): while time.time() - start_time < timeout: try: # First check if container is still running - if not self._is_container_running(container_name): + if not self.is_container_running(container_name): # Container may have exited - check if it crashed - exit_info = self._get_container_exit_info(container_name) + exit_info = self.get_container_exit_info(container_name) if exit_info: last_error = f"Container exited with code {exit_info.get('exit_code', 'unknown')}" time.sleep(1) @@ -1108,7 +1108,7 @@ class Gluetun(Proxy): ) return False - def _get_container_exit_info(self, container_name: str) -> Optional[dict]: + def get_container_exit_info(self, container_name: str) -> Optional[dict]: """Get exit information for a stopped container.""" try: result = subprocess.run( @@ -1129,7 +1129,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return None - def _get_container_logs(self, container_name: str, tail: int = 50) -> str: + def get_container_logs(self, container_name: str, tail: int = 50) -> str: """Get recent logs from a container for error reporting.""" try: result = subprocess.run( @@ -1144,7 +1144,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError): return "" - def _verify_container(self, query_key: str, max_retries: int = 3): + def verify_container(self, query_key: str, max_retries: int = 3): """ Verify container's VPN IP and region using ipinfo.io lookup. @@ -1165,7 +1165,7 @@ class Gluetun(Proxy): return container = self.active_containers[query_key] - proxy_url = self._build_proxy_uri(container["port"]) + proxy_url = self.build_proxy_uri(container["port"]) expected_country = container.get("country", "").upper() log_event( @@ -1299,7 +1299,7 @@ class Gluetun(Proxy): f"after {max_retries} attempts. Last error: {last_error}" ) - def _remove_stopped_container(self, container_name: str) -> bool: + def remove_stopped_container(self, container_name: str) -> bool: """ Remove a stopped container with the given name if it exists. @@ -1349,7 +1349,7 @@ class Gluetun(Proxy): except (subprocess.TimeoutExpired, FileNotFoundError): return False - def _remove_container(self, container_name: str): + def remove_container(self, container_name: str): """Stop and remove a Docker container.""" try: if self.auto_cleanup: @@ -1386,7 +1386,7 @@ class Gluetun(Proxy): except subprocess.TimeoutExpired: pass - def _build_proxy_uri(self, port: int) -> str: + def build_proxy_uri(self, port: int) -> str: """Build HTTP proxy URI.""" if self.auth_user and self.auth_password: return f"http://{self.auth_user}:{self.auth_password}@localhost:{port}" diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 38a2566d..450b2ac2 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -67,7 +67,7 @@ class RemoteClient: self._session.headers[self.auth_headers[self._auth_header_index]] = self.api_key return self._session - def _next_auth_header(self) -> bool: + def next_auth_header(self) -> bool: """Move the api key onto the next candidate header. False once they are exhausted.""" if not self.api_key or self._auth_header_index + 1 >= len(self.auth_headers): return False @@ -77,7 +77,7 @@ class RemoteClient: session.headers[self.auth_headers[self._auth_header_index]] = self.api_key return True - def _request(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def request(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: url = f"{self.server_url}{endpoint}" while True: try: @@ -90,7 +90,7 @@ class RemoteClient: log.error(f"Request to remote server timed out: {endpoint}") raise SystemExit(1) # servers differ on which header carries the key, so retry the rest before giving up - if resp.status_code == 401 and self._next_auth_header(): + if resp.status_code == 401 and self.next_auth_header(): continue break result = resp.json() @@ -102,16 +102,16 @@ class RemoteClient: return result def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: - return self._request("post", endpoint, data) + return self.request("post", endpoint, data) def get(self, endpoint: str) -> Dict[str, Any]: - return self._request("get", endpoint) + return self.request("get", endpoint) def delete(self, endpoint: str) -> Dict[str, Any]: - return self._request("delete", endpoint) + return self.request("delete", endpoint) -def _enum_get(enum_cls: type[Enum], name: Optional[str], default: Any = None) -> Any: +def enum_get(enum_cls: type[Enum], name: Optional[str], default: Any = None) -> Any: """Safely get an enum value by name.""" if not name: return default @@ -121,13 +121,13 @@ def _enum_get(enum_cls: type[Enum], name: Optional[str], default: Any = None) -> return default -def _deserialize_video(data: Dict[str, Any]) -> Video: +def deserialize_video(data: Dict[str, Any]) -> Video: v = Video( url=data.get("url") or "https://placeholder", language=Language.get(data.get("language") or "und"), - descriptor=_enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), - codec=_enum_get(Video.Codec, data.get("codec")), - range_=_enum_get(Video.Range, data.get("range"), Video.Range.SDR), + descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + codec=enum_get(Video.Codec, data.get("codec")), + range_=enum_get(Video.Range, data.get("range"), Video.Range.SDR), bitrate=data["bitrate"] * 1000 if data.get("bitrate") else 0, width=data.get("width") or 0, height=data.get("height") or 0, @@ -137,12 +137,12 @@ def _deserialize_video(data: Dict[str, Any]) -> Video: return v -def _deserialize_audio(data: Dict[str, Any]) -> Audio: +def deserialize_audio(data: Dict[str, Any]) -> Audio: a = Audio( url=data.get("url") or "https://placeholder", language=Language.get(data.get("language") or "und"), - descriptor=_enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), - codec=_enum_get(Audio.Codec, data.get("codec")), + descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + codec=enum_get(Audio.Codec, data.get("codec")), bitrate=data["bitrate"] * 1000 if data.get("bitrate") else 0, channels=data.get("channels"), joc=1 if data.get("atmos") else 0, @@ -152,12 +152,12 @@ def _deserialize_audio(data: Dict[str, Any]) -> Audio: return a -def _deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: +def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: return Subtitle( url=data.get("url") or "https://placeholder", language=Language.get(data.get("language") or "und"), - descriptor=_enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), - codec=_enum_get(Subtitle.Codec, data.get("codec")), + descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + codec=enum_get(Subtitle.Codec, data.get("codec")), cc=data.get("cc", False), sdh=data.get("sdh", False), forced=data.get("forced", False), @@ -165,7 +165,7 @@ def _deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: ) -def _reconstruct_drm(drm_list: Optional[list]) -> list: +def reconstruct_drm(drm_list: Optional[list]) -> list: """Reconstruct DRM objects from serialized API data.""" if not drm_list: return [] @@ -192,22 +192,24 @@ def _reconstruct_drm(drm_list: Optional[list]) -> list: pr_pssh = PlayReadyPSSH(b64.b64decode(pssh_str)) result.append(PlayReady(pssh=pr_pssh, pssh_b64=pssh_str)) - except Exception: + # a dropped entry can leave the track with no DRM, so never skip silently + except Exception as e: + log.warning(f"Skipping unparseable {drm_type} DRM entry from server: {e!r}") continue return result -def _build_tracks(data: Dict[str, Any]) -> Tracks: +def build_tracks(data: Dict[str, Any]) -> Tracks: tracks = Tracks() - tracks.videos = [_deserialize_video(v) for v in data.get("video", [])] - tracks.audio = [_deserialize_audio(a) for a in data.get("audio", [])] - tracks.subtitles = [_deserialize_subtitle(s) for s in data.get("subtitles", [])] + tracks.videos = [deserialize_video(v) for v in data.get("video", [])] + tracks.audio = [deserialize_audio(a) for a in data.get("audio", [])] + tracks.subtitles = [deserialize_subtitle(s) for s in data.get("subtitles", [])] for track_data, track_obj in [ *zip(data.get("video", []), tracks.videos), *zip(data.get("audio", []), tracks.audio), ]: - drm_objs = _reconstruct_drm(track_data.get("drm")) + drm_objs = reconstruct_drm(track_data.get("drm")) if drm_objs: track_obj.drm = drm_objs tracks.attachments = [ @@ -217,7 +219,7 @@ def _build_tracks(data: Dict[str, Any]) -> Tracks: return tracks -def _resolve_manifest_data(tracks: Tracks, manifests: list) -> None: +def resolve_manifest_data(tracks: Tracks, manifests: list) -> None: """Re-parse serialized manifests and populate track.data for downloading. The server serializes DASH and ISM manifest XML as zlib-compressed base64. @@ -269,7 +271,7 @@ def _resolve_manifest_data(tracks: Tracks, manifests: list) -> None: for remote_track in all_tracks: if remote_track.data.get(m_type): continue - matched = _match_track(remote_track, local_all) + matched = match_track(remote_track, local_all) if matched and matched.data.get(m_type): remote_track.data.update(matched.data) remote_track.descriptor = matched.descriptor @@ -280,7 +282,7 @@ def _resolve_manifest_data(tracks: Tracks, manifests: list) -> None: log_m.warning("Failed to re-parse %s manifest from %s: %s", m_type, m_url, e) -def _match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: +def match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: """Match a remote track to a locally-parsed track by ID or attributes.""" remote_id = str(remote_track.id) for lt in local_tracks: @@ -304,7 +306,7 @@ def _match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: return None -def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Union[Episode, Movie]: +def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Union[Episode, Movie]: svc_class = type(service_tag, (), {}) lang = Language.get(info["language"]) if info.get("language") else None title: Union[Episode, Movie] @@ -338,7 +340,7 @@ def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Un return title -def _resolve_auth_headers(svc: dict, server_name: str) -> list[str]: +def resolve_auth_headers(svc: dict, server_name: str) -> list[str]: """Configured header names first, then the defaults as fallbacks.""" auth_headers = svc.get("auth_headers") if auth_headers is None: @@ -380,7 +382,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: raise click.ClickException(f"Remote service '{server_name}' not found. Available: {available}") services = svc.get("services", {}) services["_server_cdm"] = svc.get("server_cdm", False) - services["_auth_headers"] = _resolve_auth_headers(svc, server_name) + services["_auth_headers"] = resolve_auth_headers(svc, server_name) return svc["url"], svc.get("api_key", ""), services if len(remote_services) == 1: @@ -388,14 +390,14 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: log.info(f"Using remote service: {name}") services = svc.get("services", {}) services["_server_cdm"] = svc.get("server_cdm", False) - services["_auth_headers"] = _resolve_auth_headers(svc, name) + services["_auth_headers"] = resolve_auth_headers(svc, name) 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}") -def _load_credentials_for_transport(service_tag: str, profile: Optional[str]) -> Optional[Dict[str, str]]: +def load_credentials_for_transport(service_tag: str, profile: Optional[str]) -> Optional[Dict[str, str]]: from unshackle.commands.dl import dl credential = dl.get_credentials(service_tag, profile) @@ -407,7 +409,7 @@ def _load_credentials_for_transport(service_tag: str, profile: Optional[str]) -> return None -def _load_cookies_for_transport(service_tag: str, profile: Optional[str]) -> Optional[str]: +def load_cookies_for_transport(service_tag: str, profile: Optional[str]) -> Optional[str]: import zlib from unshackle.commands.dl import dl @@ -418,7 +420,7 @@ def _load_cookies_for_transport(service_tag: str, profile: Optional[str]) -> Opt return None -def _resolve_proxy(proxy_arg: Optional[str]) -> Optional[str]: +def resolve_proxy_arg(proxy_arg: Optional[str]) -> Optional[str]: if not proxy_arg: return None @@ -486,9 +488,9 @@ class RemoteService: svc_config = services_config.get(service_tag, {}) self._server_cdm = services_config.get("_server_cdm", False) - self._apply_service_config(svc_config) + self.apply_service_config(svc_config) - def _apply_service_config(self, svc_config: dict) -> None: + def apply_service_config(self, svc_config: dict) -> None: if not svc_config: return config_maps = { @@ -532,16 +534,16 @@ class RemoteService: create_data: Dict[str, Any] = {"service": self.service_tag, "title_id": self.title_id} - credentials = _load_credentials_for_transport(self.service_tag, profile) + credentials = load_credentials_for_transport(self.service_tag, profile) if credentials: create_data["credentials"] = credentials - cookies_text = _load_cookies_for_transport(self.service_tag, profile) + cookies_text = load_cookies_for_transport(self.service_tag, profile) if cookies_text: create_data["cookies"] = cookies_text if not no_proxy and proxy: - resolved_proxy = _resolve_proxy(proxy) + resolved_proxy = resolve_proxy_arg(proxy) if resolved_proxy: create_data["proxy"] = resolved_proxy @@ -582,7 +584,7 @@ class RemoteService: create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine" - cache_data = self._load_cache_files() + cache_data = self.load_cache_files() if cache_data: create_data["cache"] = cache_data @@ -591,9 +593,9 @@ class RemoteService: status = result.get("status", "authenticated") if status == "authenticating": - self._poll_auth_completion() + self.poll_auth_completion() - def _poll_auth_completion(self, poll_interval: float = 2.0, timeout: float = 600.0) -> None: + def poll_auth_completion(self, poll_interval: float = 2.0, timeout: float = 600.0) -> None: """Poll the server until authentication completes, handling interactive prompts. When the server needs user input (OTP, device code, PIN), it returns @@ -636,7 +638,7 @@ class RemoteService: if self._titles is not None: return self._titles result = self.client.get(f"/api/session/{self._session_id}/titles") - titles_list = [_build_title(t, self.service_tag, self.title_id) for t in result.get("titles", [])] + titles_list = [build_title(t, self.service_tag, self.title_id) for t in result.get("titles", [])] self._titles = ( Series(titles_list) if titles_list and isinstance(titles_list[0], Episode) else Movies(titles_list) ) @@ -656,7 +658,7 @@ class RemoteService: if title_id in self._tracks_by_title: return self._tracks_by_title[title_id] result = self.client.post(f"/api/session/{self._session_id}/tracks", {"title_id": title_id}) - tracks = _build_tracks(result) + tracks = build_tracks(result) for k, v in result.get("session_headers", {}).items(): if k.lower() not in ("host", "content-length", "content-type"): @@ -664,7 +666,7 @@ class RemoteService: for k, v in result.get("session_cookies", {}).items(): self._session.cookies.set(k, v) - _resolve_manifest_data(tracks, result.get("manifests", [])) + resolve_manifest_data(tracks, result.get("manifests", [])) if self._server_cdm and not result.get("server_cdm", True): self._server_cdm = False @@ -717,7 +719,7 @@ class RemoteService: continue kid_list = list(track_keys.keys()) - drm_obj = self._create_drm_stub(server_drm_type, kid_list) + drm_obj = self.create_drm_stub(server_drm_type, kid_list) for kid_hex, key_hex in track_keys.items(): drm_obj.content_keys[UUID(hex=kid_hex)] = key_hex track.drm = [drm_obj] @@ -731,7 +733,7 @@ class RemoteService: self.log.warning("Failed to resolve server CDM keys: %s", e) @staticmethod - def _create_drm_stub(drm_type: str, kid_hexes: list[str]) -> Any: + def create_drm_stub(drm_type: str, kid_hexes: list[str]) -> Any: """Create a DRM object stub matching the type the server actually used. For server_cdm mode, this is only used for display — keys are already @@ -783,12 +785,12 @@ class RemoteService: return Chapters([Chapter(ch["timestamp"], ch.get("name")) for ch in raw]) def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - return self._proxy_license(challenge, track, "widevine") + return self.proxy_license(challenge, track, "widevine") def get_playready_license( self, *, challenge: bytes, title: Title_T, track: AnyTrack ) -> Optional[Union[bytes, str]]: - return self._proxy_license(challenge, track, "playready") + return self.proxy_license(challenge, track, "playready") def get_clearkey_license( self, *, challenge: bytes, title: Title_T, track: AnyTrack @@ -813,10 +815,12 @@ class RemoteService: }, ) return base64.b64decode(resp["license"]) - except Exception: + # a missing certificate is legal (services may not use one); log and continue without + except Exception as e: + self.log.debug(f"Service certificate fetch failed: {e!r}") return None - def _proxy_license(self, challenge: Union[bytes, str], track: AnyTrack, drm_type: str) -> bytes: + def proxy_license(self, challenge: Union[bytes, str], track: AnyTrack, drm_type: str) -> bytes: if isinstance(challenge, str): challenge = challenge.encode("utf-8") @@ -886,12 +890,12 @@ class RemoteService: if self._session_id: try: result = self.client.delete(f"/api/session/{self._session_id}") - self._save_returned_cache(result.get("cache", {})) + self.save_returned_cache(result.get("cache", {})) except Exception as e: self.log.warning(f"Failed to clean up remote session: {e}") self._session_id = None - def _save_returned_cache(self, cache_data: Dict[str, str]) -> None: + def save_returned_cache(self, cache_data: Dict[str, str]) -> None: """Save cache files returned by the server to the local cache directory. The server returns updated cache files (e.g. refreshed tokens) on @@ -920,7 +924,7 @@ class RemoteService: self.log.info(f"Saved {len(cache_data)} cache file(s) from server") - def _load_cache_files(self) -> Dict[str, str]: + def load_cache_files(self) -> Dict[str, str]: import zlib cache_dir = config.directories.cache / self.service_tag diff --git a/unshackle/core/service.py b/unshackle/core/service.py index c3b6f0a5..76fe3b43 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -80,7 +80,7 @@ def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]: netloc = f"REDACTED@{netloc.split('@', 1)[1]}" return urlunparse(parsed._replace(netloc=netloc)) - except Exception: + except ValueError: if "@" in uri: return f"REDACTED@{uri.split('@', 1)[1]}" return uri @@ -239,7 +239,7 @@ class Service(metaclass=ABCMeta): self.log.debug(f"Failed to get cached IP info: {e}") self.current_region = None - def _get_tracks_for_variants( + def get_tracks_for_variants( self, title: Title_T, fetch_fn: Callable[..., Tracks], @@ -311,6 +311,9 @@ class Service(metaclass=ABCMeta): return all_tracks + # Deprecated 5.5.0 shim for service repos still on the old underscored name; drop once they migrate. + _get_tracks_for_variants = get_tracks_for_variants + # Optional Abstract functions # The following functions may be implemented by the Service. # Otherwise, the base service code (if any) of the function will be executed on call. diff --git a/unshackle/core/service_repo.py b/unshackle/core/service_repo.py index 92649bfb..1d708b05 100644 --- a/unshackle/core/service_repo.py +++ b/unshackle/core/service_repo.py @@ -54,10 +54,10 @@ def parse_spec(spec: str) -> tuple[str, Optional[str], str]: url, branch = spec.rsplit("@", 1) if SHORTHAND_RE.match(url) and not url.startswith(("http", "ssh", "git@")): url = f"https://github.com/{url}" - return url, branch, _slug(url) + return url, branch, slug(url) -def _slug(url: str) -> str: +def slug(url: str) -> str: """Filesystem-safe clone dir name from a repo URL, host included so same owner/repo on different hosts don't collide, e.g. github.com__owner__repo.""" cleaned = re.sub(r"^[a-z]+://", "", url).split("@")[-1].replace(":", "/") @@ -91,15 +91,15 @@ def resolve_service_repo(spec: str, *, ttl: int = DEFAULT_TTL, force: bool = Fal stamp = stamp_for(dest) if not dest.exists(): - return _clone(url, branch, dest, stamp) + return clone(url, branch, dest, stamp) if force: - _force_sync(dest, stamp) - elif _is_stale(stamp, ttl): - _pull(dest, stamp) + force_sync(dest, stamp) + elif is_stale(stamp, ttl): + pull(dest, stamp) return dest # ponytail: git IS the cache layer — no bespoke fetch/integrity code -def _clone(url: str, branch: Optional[str], dest: Path, stamp: Path) -> Optional[Path]: +def clone(url: str, branch: Optional[str], dest: Path, stamp: Path) -> Optional[Path]: if not binaries.Git: log.error("git not found on PATH — cannot fetch service repo %s", url) return None @@ -111,26 +111,26 @@ def _clone(url: str, branch: Optional[str], dest: Path, stamp: Path) -> Optional try: subprocess.run(args, check=True, capture_output=True) # nosec B603 except subprocess.CalledProcessError as e: - log.error("failed to clone service repo %s: %s", url, _stderr(e)) + log.error("failed to clone service repo %s: %s", url, stderr_text(e)) shutil.rmtree(dest, ignore_errors=True) # don't leave a half-cloned dir that sticks on retries return None - _write_stamp(stamp) + write_stamp(stamp) log.info("cloned service repo %s", url) return dest -def _pull(dest: Path, stamp: Path) -> None: +def pull(dest: Path, stamp: Path) -> None: if not binaries.Git: - _write_stamp(stamp) + write_stamp(stamp) return # never clobber local edits — refuse to refresh a dirty clone and let the caller exit cleanly - if _is_dirty(dest): + if is_dirty(dest): raise DirtyServiceRepo(dest) try: subprocess.run([str(binaries.Git), "-C", str(dest), "pull", "--ff-only"], check=True, capture_output=True) # nosec B603 except subprocess.CalledProcessError as e: - log.warning("could not update service repo at %s, using existing copy: %s", dest, _stderr(e)) - _write_stamp(stamp) # stamp regardless so a flaky remote can't trigger a fetch every run (force overrides) + log.warning("could not update service repo at %s, using existing copy: %s", dest, stderr_text(e)) + write_stamp(stamp) # stamp regardless so a flaky remote can't trigger a fetch every run (force overrides) def refresh_repo(spec: str) -> tuple[Optional[Path], list[str]]: @@ -143,23 +143,23 @@ def refresh_repo(spec: str) -> tuple[Optional[Path], list[str]]: dest = repos_base() / dest_name stamp = stamp_for(dest) if not dest.exists(): - return _clone(url, branch, dest, stamp), ["cloned (new)"] + return clone(url, branch, dest, stamp), ["cloned (new)"] if not binaries.Git: return dest, [] - old = _head(dest) - discarded = _local_dirty_services(dest) # uncommitted edits the force reset is about to wipe - _force_sync(dest, stamp) + old = head(dest) + discarded = local_dirty_services(dest) # uncommitted edits the force reset is about to wipe + force_sync(dest, stamp) lines = [f"! {tag} (local changes discarded)" for tag in discarded] - lines += _changed_services(dest, old, _head(dest)) # upstream changes pulled in + lines += changed_services(dest, old, head(dest)) # upstream changes pulled in return dest, lines -def _head(dest: Path) -> Optional[str]: +def head(dest: Path) -> Optional[str]: r = subprocess.run([str(binaries.Git), "-C", str(dest), "rev-parse", "HEAD"], capture_output=True) # nosec B603 return r.stdout.decode(errors="ignore").strip() if r.returncode == 0 else None -def _local_dirty_services(dest: Path) -> list[str]: +def local_dirty_services(dest: Path) -> list[str]: """Top-level service dirs with uncommitted edits to tracked files (lost on a force reset).""" r = subprocess.run([str(binaries.Git), "-C", str(dest), "diff", "--name-only", "HEAD"], capture_output=True) # nosec B603 if r.returncode != 0: @@ -167,7 +167,7 @@ def _local_dirty_services(dest: Path) -> list[str]: return sorted({line.split("/")[0] for line in r.stdout.decode(errors="ignore").splitlines() if line}) -def _changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> list[str]: +def changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> list[str]: """Summarise a HEAD old->new diff grouped by top-level service dir, plus a line shortstat.""" if not old or not new or old == new: return [] @@ -192,21 +192,21 @@ def _changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> lis return lines -def _force_sync(dest: Path, stamp: Path) -> None: +def force_sync(dest: Path, stamp: Path) -> None: """Hard-reset the clone to its upstream, discarding local changes (manual refresh only).""" if not binaries.Git: - _write_stamp(stamp) + write_stamp(stamp) return git = str(binaries.Git) try: subprocess.run([git, "-C", str(dest), "fetch", "--depth", "1", "origin"], check=True, capture_output=True) # nosec B603 subprocess.run([git, "-C", str(dest), "reset", "--hard", "@{u}"], check=True, capture_output=True) # nosec B603 except subprocess.CalledProcessError as e: - log.warning("could not force-refresh service repo at %s, using existing copy: %s", dest, _stderr(e)) - _write_stamp(stamp) + log.warning("could not force-refresh service repo at %s, using existing copy: %s", dest, stderr_text(e)) + write_stamp(stamp) -def _is_dirty(dest: Path) -> bool: +def is_dirty(dest: Path) -> bool: """True if the clone has uncommitted edits to tracked files or local commits not on its upstream. Untracked files are intentionally ignored: a `git pull --ff-only` only touches tracked files, so @@ -230,14 +230,14 @@ def _is_dirty(dest: Path) -> bool: return False -def _is_stale(stamp: Path, ttl: int) -> bool: +def is_stale(stamp: Path, ttl: int) -> bool: try: return (time.time() - float(stamp.read_text().strip())) >= ttl except (OSError, ValueError): return True -def _write_stamp(stamp: Path) -> None: +def write_stamp(stamp: Path) -> None: try: stamp.parent.mkdir(parents=True, exist_ok=True) stamp.write_text(str(time.time())) @@ -245,5 +245,5 @@ def _write_stamp(stamp: Path) -> None: pass -def _stderr(e: subprocess.CalledProcessError) -> str: +def stderr_text(e: subprocess.CalledProcessError) -> str: return (e.stderr or b"").decode(errors="ignore").strip() diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 51e44b2b..97195aa3 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -15,14 +15,14 @@ from unshackle.core.utils.redact import redact_path log = logging.getLogger("services") -_raw = config.directories.services -if not isinstance(_raw, list): - _raw = [_raw] +raw = config.directories.services +if not isinstance(raw, list): + raw = [raw] # resolve in config order - priority IS list order: the first source to define a tag is the source, # later sources (local or repo) with the same tag are shadowed. List local last to make it a fallback. -_service_dirs: list[Path] = [] +service_dirs: list[Path] = [] DIRTY_REPOS: list[str] = [] -for _entry in _raw: +for _entry in raw: if isinstance(_entry, str) and is_repo_spec(_entry): try: _resolved = resolve_service_repo(_entry) @@ -31,23 +31,21 @@ for _entry in _raw: DIRTY_REPOS.append(redact_path(str(e.path))) _resolved = None if _resolved: - _service_dirs.append(_resolved) + service_dirs.append(_resolved) else: - _service_dirs.append(Path(_entry)) + service_dirs.append(Path(_entry)) # dedupe by tag honoring list order (first wins); track shadows for the load summary -_seen: dict[str, Path] = {} +seen: dict[str, Path] = {} SHADOWED: list[str] = [] -for _dir in _service_dirs: +for _dir in service_dirs: for _path in _dir.glob("*/__init__.py"): tag = _path.parent.stem - if tag in _seen: - SHADOWED.append( - f"{tag}: using {redact_path(str(_seen[tag]))}, ignoring duplicate {redact_path(str(_path))}" - ) + if tag in seen: + SHADOWED.append(f"{tag}: using {redact_path(str(seen[tag]))}, ignoring duplicate {redact_path(str(_path))}") else: - _seen[tag] = _path -_SERVICES = sorted(_seen.values(), key=lambda x: x.parent.stem) + seen[tag] = _path +SERVICES = sorted(seen.values(), key=lambda x: x.parent.stem) def load_service(path: Path) -> object: @@ -87,12 +85,12 @@ def load_services(paths: list[Path]) -> tuple[dict[str, object], list[str]]: return modules, errors -_MODULES, LOAD_ERRORS = load_services(_SERVICES) +MODULES, LOAD_ERRORS = load_services(SERVICES) -_ALIASES = {tag: getattr(module, "ALIASES", ()) for tag, module in _MODULES.items()} +ALIASES = {tag: getattr(module, "ALIASES", ()) for tag, module in MODULES.items()} -_SUMMARY_LOGGED = False +SUMMARY_LOGGED = False def check_load_errors() -> None: @@ -110,10 +108,10 @@ def check_load_errors() -> None: "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 - if not _SUMMARY_LOGGED: - _SUMMARY_LOGGED = True - summary = f"Loaded {len(_MODULES)} services" + global SUMMARY_LOGGED + if not SUMMARY_LOGGED: + SUMMARY_LOGGED = True + summary = f"Loaded {len(MODULES)} services" if SHADOWED: summary += f" ({len(SHADOWED)} duplicate(s) ignored)" log.info(summary) @@ -127,7 +125,7 @@ def check_load_errors() -> None: class Services(click.Group): """Lazy-loaded command group of project services.""" - _remote_services_cache: list[dict] | None = None + remote_services_cache: list[dict] | None = None # Click-specific methods @@ -156,7 +154,7 @@ class Services(click.Group): """ remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote")) if remote: - remote_services = Services._fetch_remote_services(ctx) + remote_services = Services.fetch_remote_services(ctx) if remote_services is not None: return [s["tag"] for s in remote_services] tags = Services.get_tags() @@ -175,11 +173,11 @@ class Services(click.Group): import_file = ctx.params.get("import_file") or (ctx.parent and ctx.parent.params.get("import_file")) if import_file: - return Services._make_import_command(tag, ctx) + return Services.make_import_command(tag, ctx) remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote")) if remote: - return Services._make_remote_command(tag, ctx) + return Services.make_remote_command(tag, ctx) try: service = Services.load(tag) @@ -196,13 +194,13 @@ class Services(click.Group): cli.name = tag doc = service.__doc__ if doc and doc.strip() and cli.help in (None, "", doc): - cli.help = Services._docstring_help(doc) + cli.help = Services.docstring_help(doc) return cli raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.") @staticmethod - def _docstring_help(doc: str) -> str: + def docstring_help(doc: str) -> str: """Format a service docstring for Click help, one \\b per paragraph so Click keeps the layout. The first paragraph stays unprefixed: it is the summary line, and a \\b there @@ -215,10 +213,10 @@ class Services(click.Group): return "\n\n".join([first] + [f"\b\n{p}" for p in rest]) @staticmethod - def _fetch_remote_services(ctx: click.Context) -> list[dict] | None: + def fetch_remote_services(ctx: click.Context) -> list[dict] | None: """Fetch the service list from the remote server (cached per process).""" - if Services._remote_services_cache is not None: - return Services._remote_services_cache + if Services.remote_services_cache is not None: + return Services.remote_services_cache try: from unshackle.core.remote_service import RemoteClient, resolve_server @@ -226,21 +224,21 @@ class Services(click.Group): server_url, api_key, services_config = resolve_server(server_name) client = RemoteClient(server_url, api_key, services_config.get("_auth_headers")) result = client.get("/api/services") - Services._remote_services_cache = result.get("services", []) - return Services._remote_services_cache + Services.remote_services_cache = result.get("services", []) + return Services.remote_services_cache except click.ClickException: raise except Exception: return None @staticmethod - def _make_remote_command(tag: str, ctx: click.Context) -> click.Command: + def make_remote_command(tag: str, ctx: click.Context) -> click.Command: """Create a Click command for a remote service with server-provided options.""" - svc_info = Services._fetch_remote_service_info(tag, ctx) + svc_info = Services.fetch_remote_service_info(tag, ctx) short_help = svc_info.get("url") if svc_info else None help_text = svc_info.get("help") if svc_info else None if help_text: - help_text = Services._docstring_help(help_text) + help_text = Services.docstring_help(help_text) cli_params = svc_info.get("cli_params") if svc_info else None @click.command(name=tag, short_help=short_help, help=help_text) @@ -272,7 +270,7 @@ class Services(click.Group): return remote_cli @staticmethod - def _make_import_command(tag: str, ctx: click.Context) -> click.Command: + def make_import_command(tag: str, ctx: click.Context) -> click.Command: """Create a synthetic command that yields an ImportService from an export JSON. Mirrors how remote services are wired so dl.py's result() runs unchanged. @@ -290,16 +288,16 @@ class Services(click.Group): return import_cli @staticmethod - def _fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None: + def fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None: """Fetch service info for a specific service from the remote server.""" try: - services = Services._fetch_remote_services(ctx) + services = Services.fetch_remote_services(ctx) if services: for svc in services: if svc.get("tag") == tag: return svc - except Exception: - pass + except Exception as e: + log.debug(f"Remote service info lookup failed for {tag}: {e!r}") return None # Methods intended to be used anywhere @@ -307,14 +305,14 @@ class Services(click.Group): @staticmethod def get_tags() -> list[str]: """Returns a list of service tags from all available Services.""" - return [x.parent.stem for x in _SERVICES] + return [x.parent.stem for x in SERVICES] @staticmethod def get_path(name: str) -> Path: """Get the directory path of a command.""" tag = Services.get_tag(name) - for service in _SERVICES: + for service in SERVICES: if service.parent.stem == tag: return service.parent raise KeyError(f"There is no Service added by the Tag '{name}'") @@ -329,9 +327,9 @@ class Services(click.Group): original_value = value value = value.lower() - for path in _SERVICES: + for path in SERVICES: tag = path.parent.stem - if value in (tag.lower(), *_ALIASES.get(tag, [])): + if value in (tag.lower(), *ALIASES.get(tag, [])): return tag return original_value @@ -339,7 +337,7 @@ class Services(click.Group): @staticmethod def load(tag: str) -> Service: """Load a Service module by Service tag.""" - module = _MODULES.get(tag) + module = MODULES.get(tag) if module: return module diff --git a/unshackle/core/session.py b/unshackle/core/session.py index 66e0a7d0..1bfd7301 100644 --- a/unshackle/core/session.py +++ b/unshackle/core/session.py @@ -26,7 +26,7 @@ from unshackle.core.config import config DEFAULT_IMPERSONATE = rnet.Impersonate.Chrome131 -def _resolve_impersonate(browser: str) -> rnet.Impersonate: +def resolve_impersonate(browser: str) -> rnet.Impersonate: """Resolve a browser string to an rnet.Impersonate preset. Accepts exact rnet preset names (e.g. "Chrome131", "OkHttp4_12", "Edge101"). @@ -43,7 +43,7 @@ def _resolve_impersonate(browser: str) -> rnet.Impersonate: # Map string method names to rnet.Method enum -_METHOD_MAP: dict[str, rnet.Method] = { +METHOD_MAP: dict[str, rnet.Method] = { "GET": rnet.Method.GET, "POST": rnet.Method.POST, "PUT": rnet.Method.PUT, @@ -66,12 +66,12 @@ class RnetResponseHeaders(MutableMapping): def __init__(self, header_map: Any) -> None: self._map = header_map - def _decode(self, val: Any) -> str: + def decode(self, val: Any) -> str: return val.decode("utf-8", errors="replace") if isinstance(val, (bytes, bytearray)) else str(val) def __getitem__(self, key: str) -> str: val = self._map[key] - return self._decode(val) + return self.decode(val) def __setitem__(self, key: str, value: str) -> None: raise TypeError("Response headers are read-only") @@ -87,7 +87,7 @@ class RnetResponseHeaders(MutableMapping): def __iter__(self) -> Iterator[str]: seen: set[str] = set() for k, _ in self._map.items(): - dk = self._decode(k) + dk = self.decode(k) if dk not in seen: seen.add(dk) yield dk @@ -99,10 +99,10 @@ class RnetResponseHeaders(MutableMapping): val = self._map.get(key) if val is None: return default - return self._decode(val) + return self.decode(val) def items(self) -> list[tuple[str, str]]: - return [(self._decode(k), self._decode(v)) for k, v in self._map.items()] + return [(self.decode(k), self.decode(v)) for k, v in self._map.items()] # --------------------------------------------------------------------------- @@ -237,14 +237,14 @@ class RnetSessionHeaders(CaseInsensitiveDict): self._client = client super().__init__() - def _sync(self) -> None: + def sync(self) -> None: """Push current headers to the rnet client.""" if self._client is not None and hasattr(self, "_store") and self._store: self._client.update(headers={k: v for k, v in self.items()}) def __setitem__(self, key: str, value: str) -> None: super().__setitem__(key, value) - self._sync() + self.sync() def update(self, __m: Any = None, **kwargs: Any) -> None: if __m: @@ -256,7 +256,7 @@ class RnetSessionHeaders(CaseInsensitiveDict): super().__setitem__(k, v) for k, v in kwargs.items(): super().__setitem__(k, v) - self._sync() + self.sync() def pop(self, key: str, *args: Any) -> Any: result = super().pop(key, *args) @@ -282,7 +282,7 @@ class RnetCookieAdapter(MutableMapping): self._flat: dict[str, str] = {} self._original_cookies: list[Any] = [] - def _set_cookie_on_client(self, url: str, name: str, value: str) -> None: + def set_cookie_on_client(self, url: str, name: str, value: str) -> None: """Set a cookie on the rnet client, or buffer locally if the client is not yet created.""" if self._client is not None: try: @@ -290,7 +290,7 @@ class RnetCookieAdapter(MutableMapping): except Exception: pass - def _flush_to_client(self) -> None: + def flush_to_client(self) -> None: """Push all buffered cookies to the rnet client once it is created.""" if self._client is None: return @@ -325,20 +325,20 @@ class RnetCookieAdapter(MutableMapping): self._cookies.setdefault(domain, {})[name] = value self._original_cookies.append(cookie) url = f"https://{domain.lstrip('.')}" if domain else "https://localhost" - self._set_cookie_on_client(url, name, value) + self.set_cookie_on_client(url, name, value) elif isinstance(other, dict): for name, value in other.items(): self._flat[name] = value - self._set_cookie_on_client("https://localhost", name, str(value)) + self.set_cookie_on_client("https://localhost", name, str(value)) self._flat.update(other) elif hasattr(other, "items"): for name, value in other.items(): self._flat[name] = str(value) - self._set_cookie_on_client("https://localhost", name, str(value)) + self.set_cookie_on_client("https://localhost", name, str(value)) for name, value in kwargs.items(): self._flat[name] = value - self._set_cookie_on_client("https://localhost", name, value) + self.set_cookie_on_client("https://localhost", name, value) def get( self, name: str, default: Optional[str] = None, domain: Optional[str] = None, path: Optional[str] = None @@ -351,7 +351,7 @@ class RnetCookieAdapter(MutableMapping): self._flat[name] = value self._cookies.setdefault(domain, {})[name] = value url = f"https://{domain.lstrip('.')}" - self._set_cookie_on_client(url, name, value) + self.set_cookie_on_client(url, name, value) def __getitem__(self, name: str) -> str: return self._flat[name] @@ -437,7 +437,7 @@ class RnetProxyDict(dict): super().__init__() self._session = session - def _sync(self) -> None: + def sync(self) -> None: proxy = self.get("all") or self.get("https") or self.get("http") proxies = [rnet.Proxy.all(proxy)] if proxy else [] self._session._client_kwargs["proxies"] = proxies or None @@ -446,11 +446,11 @@ class RnetProxyDict(dict): def update(self, __m: Any = None, **kwargs: Any) -> None: super().update(__m or {}, **kwargs) - self._sync() + self.sync() def __setitem__(self, key: str, value: str) -> None: super().__setitem__(key, value) - self._sync() + self.sync() # --------------------------------------------------------------------------- @@ -545,17 +545,17 @@ class RnetSession: if "proxies" in session_kwargs: self.proxies.update(session_kwargs.pop("proxies")) - def _ensure_client(self) -> rnet.BlockingClient: + def ensure_client(self) -> rnet.BlockingClient: """Lazily create the rnet client on first use, flushing any buffered state.""" if self._client is None: self._client = rnet.BlockingClient(**self._client_kwargs) self.headers._client = self._client - self.headers._sync() + self.headers.sync() self.cookies._client = self._client - self.cookies._flush_to_client() + self.cookies.flush_to_client() return self._client - def _build_url(self, url: str, params: Optional[Any] = None) -> str: + def build_url(self, url: str, params: Optional[Any] = None) -> str: """Encode params into the URL (rnet ignores the params kwarg). Accepts the same shapes as requests: a mapping, a sequence of pairs, or a @@ -594,11 +594,11 @@ class RnetSession: return min(sleep_time, self.max_backoff) def request(self, method: str, url: str, **kwargs: Any) -> RnetResponse: - client = self._ensure_client() + client = self.ensure_client() method_upper = method.upper() if isinstance(method, str) else str(method).upper() # Build URL with params - url = self._build_url(url, kwargs.pop("params", None)) + url = self.build_url(url, kwargs.pop("params", None)) # Default allow_redirects=True kwargs.setdefault("allow_redirects", True) @@ -621,7 +621,7 @@ class RnetSession: kwargs["body"] = data # Resolve method enum - rnet_method = _METHOD_MAP.get(method_upper) + rnet_method = METHOD_MAP.get(method_upper) if rnet_method is None: raise ValueError(f"Unsupported HTTP method: {method}") @@ -770,7 +770,7 @@ def session( if browser is None: browser = config.network.get("browser", "Chrome131") - impersonate = _resolve_impersonate(browser) + impersonate = resolve_impersonate(browser) session_kwargs: dict[str, Any] = {"impersonate": impersonate} # optional rnet client knobs, see docs/NETWORK_CONFIG.md diff --git a/unshackle/core/title_cacher.py b/unshackle/core/title_cacher.py index da386c3f..3726518b 100644 --- a/unshackle/core/title_cacher.py +++ b/unshackle/core/title_cacher.py @@ -28,7 +28,7 @@ class TitleCacher: self.stats = {"hits": 0, "misses": 0, "fallbacks": 0} self.no_cache = False - def _generate_cache_key( + def generate_cache_key( self, title_id: str, region: Optional[str] = None, account_hash: Optional[str] = None ) -> str: """ @@ -92,7 +92,7 @@ class TitleCacher: return fetch_function() # Generate cache key - cache_key = self._generate_cache_key(title_id, region, account_hash) + cache_key = self.generate_cache_key(title_id, region, account_hash) # If reset_cache flag is set, clear the cache entry if reset_cache: @@ -193,7 +193,7 @@ class TitleCacher: if not config.title_cache_enabled or self.no_cache: return None - cache_key = self._generate_cache_key(title_id, region, account_hash) + cache_key = self.generate_cache_key(title_id, region, account_hash) cache = self.cacher.get(cache_key, version=1) if not cache or not cache.data: @@ -246,7 +246,7 @@ class TitleCacher: if not config.title_cache_enabled or self.no_cache: return - cache_key = self._generate_cache_key(title_id, region, account_hash) + cache_key = self.generate_cache_key(title_id, region, account_hash) cache = self.cacher.get(cache_key, version=1) if not cache or not cache.data: diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 2a133f60..fc3bec1d 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -136,18 +136,18 @@ class Episode(Title): return False return any(k in wanted for k in keys) - def _part_suffix(self) -> str: + def part_suffix(self) -> str: """``.Part.2`` / `` Part 2`` in the series template's own separator style.""" if self.part is None: return "" sep = config.get_template_separator("series") if config.output_template.get("series") else "." return f"{sep}Part{sep}{self.part}" - def _build_template_context( + def build_template_context( self, media_info: MediaInfo, show_service: bool = True, include_part: bool = True ) -> dict: """Build template context dictionary from MediaInfo.""" - context = self._build_base_template_context(media_info, show_service) + context = self.build_base_template_context(media_info, show_service) context["title"] = self.title.replace("$", "S") context["year"] = self.year or "" context["season"] = f"S{self.season:02}" @@ -159,7 +159,7 @@ class Episode(Title): context["absolute"] = f"{self.absolute:03}" if self.absolute is not None else "" if self.air_date: # daily/sports: air date replaces SxxExx - disp = self._air_date_display() + disp = self.air_date_display() context["season"] = disp context["episode"] = "" context["season_episode"] = disp @@ -168,25 +168,25 @@ class Episode(Title): if include_part and self.part is not None: # folded into the identity tokens, not a token of its own: a standalone {part} is # absent from every shipped template, so two parts would render the same filename - suffix = self._part_suffix() + suffix = self.part_suffix() context["episode"] = f"{context['episode']}{suffix}" context["season_episode"] = f"{context['season_episode']}{suffix}" return context - def _air_date_display(self) -> str: + def air_date_display(self) -> str: """Render air_date using the series template's own separator (dots or spaces).""" if isinstance(self.air_date, date): sep = config.get_template_separator("series") if config.output_template.get("series") else "." return f"{self.air_date.year:04}{sep}{self.air_date.month:02}{sep}{self.air_date.day:02}" return str(self.air_date) - def _folder_season(self) -> str: + def folder_season(self) -> str: """Season folder label: air year for dated content, else SxxExx-style season.""" if isinstance(self.air_date, date): return f"{self.air_date.year:04}" return f"S{self.season:02}" - def _part_label(self) -> str: + def part_label(self) -> str: """``.2`` selection-syntax suffix for console output, empty when part-less.""" return f".{self.part}" if self.part is not None else "" @@ -197,8 +197,8 @@ class Episode(Title): return "{title}{year} {date}{part} {name}".format( title=self.title, year=f" {self.year}" if self.year else "", - date=self._air_date_display(), - part=self._part_label(), + date=self.air_date_display(), + part=self.part_label(), name=self.name or "", ).strip() # the console shows the -w selection syntax (S01E01.2), not the filename form @@ -207,7 +207,7 @@ class Episode(Title): year=f" {self.year}" if self.year else "", season=self.season, number=self.number, - part=self._part_label(), + part=self.part_label(), name=self.name or "", ).strip() @@ -216,8 +216,8 @@ class Episode(Title): template = config.get_folder_template("series") if template: # all parts of an episode land in the same season folder - context = self._build_template_context(media_info, show_service, include_part=False) - context["season"] = self._folder_season() + context = self.build_template_context(media_info, show_service, include_part=False) + context["season"] = self.folder_season() context["year"] = self.year or "" # folders keep the year spacer = detect_spacer(template) # one style for the whole path segments = [ @@ -240,8 +240,8 @@ class Episode(Title): derived_template = re.sub(r"^[\.\s]+|[\.\s]+$", "", derived_template) formatter = TemplateFormatter(derived_template) - context = self._build_template_context(media_info, show_service, include_part=False) - context["season"] = self._folder_season() + context = self.build_template_context(media_info, show_service, include_part=False) + context["season"] = self.folder_season() context["year"] = self.year or "" # folders keep the year folder_name = formatter.format(context) @@ -253,11 +253,11 @@ class Episode(Title): name = f"{self.title}" if self.year: name += f" {self.year}" - name += f" {self._folder_season()}" + name += f" {self.folder_season()}" return sanitize_filename(name, " ") formatter = TemplateFormatter(config.output_template["series"]) - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) return formatter.format(context) @@ -289,10 +289,10 @@ class Series(SortedKeyList, ABC): for episode in self: if episode.season == season: label = ( - episode._air_date_display() + episode.air_date_display() if episode.air_date else str(episode.number).zfill(len(str(episodes))) - ) + episode._part_label() + ) + episode.part_label() if episode.name: season_tree.add(f"[bold]{label}.[/] [bright_black]{episode.name}") elif episode.air_date: diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index f02a6223..6d6508c5 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -46,9 +46,9 @@ class Movie(Title): self.year = year self.description = description - def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build template context dictionary from MediaInfo.""" - context = self._build_base_template_context(media_info, show_service) + context = self.build_base_template_context(media_info, show_service) context["title"] = self.name.replace("$", "S") context["year"] = self.year or "" return context @@ -62,7 +62,7 @@ class Movie(Title): if folder: template = config.get_folder_template("movies") if template: - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) spacer = detect_spacer(template) # one style for the whole path segments = [ TemplateFormatter(seg, spacer).format(context) @@ -76,7 +76,7 @@ class Movie(Title): return sanitize_filename(name, " ") formatter = TemplateFormatter(config.output_template["movies"]) - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) return formatter.format(context) diff --git a/unshackle/core/titles/music.py b/unshackle/core/titles/music.py index 41aba6ff..a6b0d1b1 100644 --- a/unshackle/core/titles/music.py +++ b/unshackle/core/titles/music.py @@ -144,9 +144,9 @@ class Song(Title): artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name ).strip() - def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build template context dictionary from MediaInfo.""" - context = self._build_base_template_context(media_info, show_service) + context = self.build_base_template_context(media_info, show_service) context["title"] = self.name.replace("$", "S") context["year"] = self.year or "" context["track_number"] = f"{self.track:02}" @@ -170,7 +170,7 @@ class Song(Title): # legacy "songs" folder template, then to "{artist} - {album} ({year})". template = config.get_folder_template("albums") or config.get_folder_template("songs") if template: - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) spacer = detect_spacer(template) # one style for the whole path segments = [ TemplateFormatter(seg, spacer).format(context) @@ -185,7 +185,7 @@ class Song(Title): template = config.output_template.get("songs") or "{track_number}. {title}" formatter = TemplateFormatter(template) - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) return formatter.format(context) diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index af114c93..8a7766b9 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -82,9 +82,9 @@ class Song(Title): artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name ).strip() - def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build template context dictionary from MediaInfo.""" - context = self._build_base_template_context(media_info, show_service) + context = self.build_base_template_context(media_info, show_service) context["title"] = self.name.replace("$", "S") context["year"] = self.year or "" context["track_number"] = f"{self.track:02}" @@ -97,7 +97,7 @@ class Song(Title): if folder: template = config.get_folder_template("songs") if template: - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) spacer = detect_spacer(template) # one style for the whole path segments = [ TemplateFormatter(seg, spacer).format(context) @@ -111,7 +111,7 @@ class Song(Title): return sanitize_filename(name, " ") formatter = TemplateFormatter(config.output_template["songs"]) - context = self._build_template_context(media_info, show_service) + context = self.build_template_context(media_info, show_service) return formatter.format(context) diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index bab31320..fe351806 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -55,7 +55,7 @@ class Title: def __eq__(self, other: Title) -> bool: return self.id == other.id - def _build_base_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def build_base_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build base template context dictionary from MediaInfo. Extracts video, audio, HDR, HFR, and multi-language information shared @@ -132,7 +132,8 @@ class Title: track_height = primary_video_track.height if abs(resolution - track_height) <= 10 or track_height in (2160, 1440, 1080, 720, 480): resolution = track_height - except Exception: + # garbage DAR strings or a missing height fall back to the plain min(width, height) + except (ValueError, TypeError, ZeroDivisionError): pass scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p" diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 1f95d403..538ade44 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -435,7 +435,7 @@ class Hybrid: max_fall = int(float(stripped.split("MaxFALL:")[1].split("nits")[0].strip().rstrip(","))) if any(v is None for v in (max_cll, max_fall, max_mdl, min_mdl)): - base_max_mdl, base_min_mdl, base_cll, base_fall = self._probe_hdr_metadata() + base_max_mdl, base_min_mdl, base_cll, base_fall = self.probe_hdr_metadata() if max_cll is None: max_cll = base_cll if max_fall is None: @@ -536,7 +536,7 @@ class Hybrid: success=True, ) - def extract_hdr10plus(self, _video): + def extract_hdr10plus(self, video): """Extract HDR10+ metadata from the video stream""" if os.path.isfile(config.directories.temp / self.hdr10plus_file): return @@ -584,7 +584,7 @@ class Hybrid: success=True, ) - def _probe_hdr_metadata(self): + def probe_hdr_metadata(self): """Extract mastering display and content light level metadata from the HDR10 stream via ffprobe. Returns (max_mdl, min_mdl, max_cll, max_fall) in dovi_tool level6 units: @@ -652,7 +652,7 @@ class Hybrid: with console.status("Converting HDR10+ metadata to Dolby Vision...", spinner="dots"): # Extract actual HDR metadata from the source stream - max_mdl, min_mdl, max_cll, max_fall = self._probe_hdr_metadata() + max_mdl, min_mdl, max_cll, max_fall = self.probe_hdr_metadata() max_mdl, min_mdl, max_cll, max_fall = self.sanitize_l6(max_mdl, min_mdl, max_cll, max_fall) # First create the extra metadata JSON for dovi_tool diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 33e14236..fba371b4 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -97,10 +97,10 @@ class Subtitle(Track): raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec") # WebVTT sanitization patterns (compiled once for performance) - _CUE_ID_PATTERN = re.compile(r"^[A-Za-z]+\d+$") - _TIMING_START_PATTERN = re.compile(r"^\d+:\d+[:\.]") - _TIMING_LINE_PATTERN = re.compile(r"^((?:\d+:)?\d+:\d+[.,]\d+)\s*-->\s*((?:\d+:)?\d+:\d+[.,]\d+)(.*)$") - _LINE_POS_PATTERN = re.compile(r"line:(\d+(?:\.\d+)?%?)") + CUE_ID_PATTERN = re.compile(r"^[A-Za-z]+\d+$") + TIMING_START_PATTERN = re.compile(r"^\d+:\d+[:\.]") + TIMING_LINE_PATTERN = re.compile(r"^((?:\d+:)?\d+:\d+[.,]\d+)\s*-->\s*((?:\d+:)?\d+:\d+[.,]\d+)(.*)$") + LINE_POS_PATTERN = re.compile(r"line:(\d+(?:\.\d+)?%?)") def __init__( self, @@ -347,12 +347,12 @@ class Subtitle(Track): for i, line in enumerate(lines): line = line.strip() - if Subtitle._CUE_ID_PATTERN.match(line): + if Subtitle.CUE_ID_PATTERN.match(line): # Look ahead to see if next non-empty line is a timing line j = i + 1 while j < len(lines) and not lines[j].strip(): j += 1 - if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + if j < len(lines) and ("-->" in lines[j] or Subtitle.TIMING_START_PATTERN.match(lines[j].strip())): return True return False @@ -383,12 +383,12 @@ class Subtitle(Track): line = lines[i].strip() # Check if this line is a cue identifier followed by a timing line - if Subtitle._CUE_ID_PATTERN.match(line): + if Subtitle.CUE_ID_PATTERN.match(line): # Look ahead to see if next non-empty line is a timing line j = i + 1 while j < len(lines) and not lines[j].strip(): j += 1 - if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + if j < len(lines) and ("-->" in lines[j] or Subtitle.TIMING_START_PATTERN.match(lines[j].strip())): # This is a cue identifier, skip it i += 1 continue @@ -399,7 +399,7 @@ class Subtitle(Track): return "\n".join(sanitized_lines) @staticmethod - def _parse_vtt_time(t: str) -> int: + def parse_vtt_time(t: str) -> int: """Parse WebVTT timestamp to milliseconds. Returns 0 for malformed input.""" try: t = t.replace(",", ".") @@ -435,10 +435,10 @@ class Subtitle(Track): """ timings = [] for line in text.split("\n"): - match = Subtitle._TIMING_LINE_PATTERN.match(line) + match = Subtitle.TIMING_LINE_PATTERN.match(line) if match: start_str, end_str = match.group(1), match.group(2) - timings.append((Subtitle._parse_vtt_time(start_str), Subtitle._parse_vtt_time(end_str))) + timings.append((Subtitle.parse_vtt_time(start_str), Subtitle.parse_vtt_time(end_str))) # Check for overlapping cues (within 50ms start, same end) for i in range(len(timings) - 1): @@ -485,11 +485,11 @@ class Subtitle(Track): i += 1 continue - match = Subtitle._TIMING_LINE_PATTERN.match(line) + match = Subtitle.TIMING_LINE_PATTERN.match(line) if match: start_str, end_str, settings = match.groups() line_pos = 100.0 # Default to bottom - line_match = Subtitle._LINE_POS_PATTERN.search(settings) + line_match = Subtitle.LINE_POS_PATTERN.search(settings) if line_match: pos_str = line_match.group(1).rstrip("%") line_pos = float(pos_str) @@ -502,8 +502,8 @@ class Subtitle(Track): cues.append( { - "start_ms": Subtitle._parse_vtt_time(start_str), - "end_ms": Subtitle._parse_vtt_time(end_str), + "start_ms": Subtitle.parse_vtt_time(start_str), + "end_ms": Subtitle.parse_vtt_time(end_str), "start_str": start_str, "end_str": end_str, "line_pos": line_pos, @@ -1041,8 +1041,8 @@ class Subtitle(Track): if status is True: stripped.save(self.path) return - except Exception: - pass # Fall through to other methods + except Exception as e: # Fall through to other methods + logging.getLogger("Subtitle").debug(f"subby SDH strip failed, falling back: {e!r}") conversion_method = config.subtitle.get("conversion_method", "auto") use_subtitleedit = sdh_method == "subtitleedit" or ( @@ -1093,8 +1093,9 @@ class Subtitle(Track): sub.save() except (IOError, OSError) as e: if "is not valid subtitle file" in str(e): - self.log.warning(f"Failed to strip SDH from {self.path.name}: {e}") - self.log.warning("Continuing without SDH stripping for this subtitle") + 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") else: raise diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 26c10329..2d04d051 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -58,7 +58,7 @@ def direct_session(session: Union[Session, "RnetSession"]) -> Session: return new -def _read_top_level_box(path: Path, box_type: bytes) -> Optional[bytes]: +def read_top_level_box(path: Path, box_type: bytes) -> Optional[bytes]: # seek through top-level box headers; only the wanted box's bytes are read into RAM file_size = path.stat().st_size with path.open("rb") as f: @@ -94,8 +94,8 @@ def has_encrypted_sample_entry(path: Path) -> bool: scoping avoids chance byte collisions in mdat. Any read error -> False. """ try: - moov = _read_top_level_box(path, b"moov") - except Exception: + moov = read_top_level_box(path, b"moov") + except OSError: return False if not moov: return False @@ -110,7 +110,7 @@ def has_encrypted_sample_entry(path: Path) -> bool: return False -def _senc_protects_samples(buf: bytes, body: int, end: int) -> bool: +def senc_protects_samples(buf: bytes, body: int, end: int) -> bool: """True if a senc/PIFF-uuid box describes protected samples. An empty senc (sample_count 0) sits on a genuinely clear fragment, so treating it as @@ -124,7 +124,7 @@ def _senc_protects_samples(buf: bytes, body: int, end: int) -> bool: return int.from_bytes(buf[pos : pos + 4], "big") > 0 -def _moof_still_encrypted(moof: bytes) -> bool: +def moof_still_encrypted(moof: bytes) -> bool: # senc only: a decrypter detaches just the atom it consumed, so a PIFF uuid can # outlive a good decrypt. ISM arrives as senc via piff_senc_to_cenc. # structural walk: a 4CC byte scan collides with trun payload @@ -135,7 +135,7 @@ def _moof_still_encrypted(moof: bytes) -> bool: if box_type != b"traf": continue for child, _usertype, child_body, child_end in iter_boxes(moof, traf_body, traf_end): - if child == b"senc" and _senc_protects_samples(moof, child_body, child_end): + if child == b"senc" and senc_protects_samples(moof, child_body, child_end): return True return False @@ -194,7 +194,7 @@ def assert_fragments_decrypted(path: Path) -> None: if header[4:8] == b"moof": total += 1 f.seek(start) - if _moof_still_encrypted(f.read(size)): + if moof_still_encrypted(f.read(size)): surviving += 1 if first_offset is None: first_offset = start @@ -918,7 +918,7 @@ class Track: original_path = self.path output_path = original_path.with_stem(f"{original_path.stem}_repack") - def _ffmpeg(extra_args: list[str] = None, bsf: Optional[str] = None): + def ffmpeg(extra_args: list[str] = None, bsf: Optional[str] = None): args = [ binaries.FFMPEG, "-nostdin", @@ -975,18 +975,18 @@ class Track: bsf_applied = False try: - _ffmpeg(bsf=bsf_v) + ffmpeg(bsf=bsf_v) bsf_applied = bsf_v is not None except subprocess.CalledProcessError as e: if b"Malformed AAC bitstream detected" in e.stderr: # e.g., TruTV's dodgy encodes - _ffmpeg(["-y", "-bsf:a", "aac_adtstoasc"], bsf=bsf_v) + ffmpeg(["-y", "-bsf:a", "aac_adtstoasc"], bsf=bsf_v) bsf_applied = bsf_v is not None elif bsf_v is not None: # Repack is mandatory, the VUI bitstream filter is best-effort: retry # without it so the remux still succeeds; caller falls back to normalize_vui. output_path.unlink(missing_ok=True) - _ffmpeg() + ffmpeg() else: raise diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 6d2aecd9..0c297d97 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -178,15 +178,15 @@ class Video(Track): # The codebase is currently agnostic to either, so a manual conversion to 6 is done. transfer = 6 - def _safe(enum_cls, value): + def safe(enum_cls, value): try: return enum_cls(value) except ValueError: return enum_cls(2) # Unspecified for unknown/private-use codes - primaries = _safe(Primaries, primaries) - transfer = _safe(Transfer, transfer) - matrix = _safe(Matrix, matrix) + primaries = safe(Primaries, primaries) + transfer = safe(Transfer, transfer) + matrix = safe(Matrix, matrix) # primaries and matrix does not strictly correlate to a range @@ -540,7 +540,7 @@ class Video(Track): out_path = Path(out_path) - def _run_ccextractor() -> bool: + def run_ccextractor() -> bool: cc_start = time.monotonic() try: subprocess.run( @@ -575,9 +575,9 @@ class Video(Track): # Try on the original file first (preserves container-level CC data like c608 boxes), # then fall back to repacked file (ccextractor can fail on some container formats). - if not _run_ccextractor(): + if not run_ccextractor(): self.repackage() - _run_ccextractor() + run_ccextractor() if out_path.exists(): cc_track = Subtitle( diff --git a/unshackle/core/update_checker.py b/unshackle/core/update_checker.py index 6d919083..d18a50ea 100644 --- a/unshackle/core/update_checker.py +++ b/unshackle/core/update_checker.py @@ -67,7 +67,7 @@ class UpdateChecker: return version_string.lstrip("v") @staticmethod - def _is_valid_version(version: str) -> bool: + def is_valid_version(version: str) -> bool: """ Validate version string format. @@ -93,7 +93,7 @@ class UpdateChecker: return False @classmethod - def _fetch_latest_version(cls) -> Optional[str]: + def fetch_latest_version(cls) -> Optional[str]: """ Fetch the latest version from GitHub API. @@ -109,13 +109,13 @@ class UpdateChecker: data = response.json() latest_version = cls.parse_version(data.get("tag_name", "")) - return latest_version if cls._is_valid_version(latest_version) else None + return latest_version if cls.is_valid_version(latest_version) else None except Exception: return None @classmethod - def _should_check_for_updates(cls, check_interval: int = DEFAULT_CHECK_INTERVAL) -> bool: + def should_check_for_updates(cls, check_interval: int = DEFAULT_CHECK_INTERVAL) -> bool: """ Check if enough time has passed since the last update check. @@ -136,7 +136,7 @@ class UpdateChecker: return (current_time - last_check) >= check_interval @classmethod - def _update_cache(cls, latest_version: Optional[str] = None, current_version: Optional[str] = None) -> None: + def update_cache(cls, latest_version: Optional[str] = None, current_version: Optional[str] = None) -> None: """ Update the cache file with the current timestamp and version info. @@ -158,11 +158,12 @@ class UpdateChecker: with open(cache_file, "w") as f: json.dump(cache_data, f, indent=2) - except (OSError, json.JSONEncodeError): + # json.dump signals encode failures as TypeError/ValueError, not a json-specific class + except (OSError, TypeError, ValueError): pass @staticmethod - def _compare_versions(current: str, latest: str) -> bool: + def compare_versions(current: str, latest: str) -> bool: """ Simple semantic version comparison. @@ -173,7 +174,7 @@ class UpdateChecker: Returns: True if latest > current, False otherwise """ - if not UpdateChecker._is_valid_version(current) or not UpdateChecker._is_valid_version(latest): + if not UpdateChecker.is_valid_version(current) or not UpdateChecker.is_valid_version(latest): return False try: @@ -205,13 +206,13 @@ class UpdateChecker: Returns: The latest version string if an update is available, None otherwise """ - if not cls._is_valid_version(current_version): + if not cls.is_valid_version(current_version): return None try: - latest_version = await asyncio.to_thread(cls._fetch_latest_version) + latest_version = await asyncio.to_thread(cls.fetch_latest_version) - if latest_version and cls._compare_versions(current_version, latest_version): + if latest_version and cls.compare_versions(current_version, latest_version): return latest_version except Exception: @@ -220,7 +221,7 @@ class UpdateChecker: return None @classmethod - def _get_cached_update_info(cls, current_version: str) -> Optional[str]: + def get_cached_update_info(cls, current_version: str) -> Optional[str]: """ Check if there's a cached update available for the current version. @@ -239,7 +240,7 @@ class UpdateChecker: cached_latest = cache_data.get("latest_version") if cached_current == current_version and cached_latest: - if cls._compare_versions(current_version, cached_latest): + if cls.compare_versions(current_version, cached_latest): return cached_latest return None @@ -256,7 +257,7 @@ class UpdateChecker: Returns: The latest version string if an update is available, None otherwise """ - if not cls._is_valid_version(current_version): + if not cls.is_valid_version(current_version): return None if check_interval is None: @@ -264,12 +265,12 @@ class UpdateChecker: check_interval = config.update_check_interval * 60 * 60 - if not cls._should_check_for_updates(check_interval): - return cls._get_cached_update_info(current_version) + if not cls.should_check_for_updates(check_interval): + return cls.get_cached_update_info(current_version) - latest_version = cls._fetch_latest_version() - cls._update_cache(latest_version, current_version) - if latest_version and cls._compare_versions(current_version, latest_version): + latest_version = cls.fetch_latest_version() + cls.update_cache(latest_version, current_version) + if latest_version and cls.compare_versions(current_version, latest_version): return latest_version return None diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index e28ac03b..0870b8a7 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -523,12 +523,12 @@ def try_ensure_utf8(data: bytes) -> bytes: if data[:2] == b"\x1f\x8b": try: data = gzip.decompress(data) - except Exception: + except (OSError, EOFError, zlib.error): pass elif data[:1] == b"\x78" and len(data) > 1 and data[1:2] in (b"\x01", b"\x5e", b"\x9c", b"\xda"): try: data = zlib.decompress(data) - except Exception: + except zlib.error: pass try: @@ -1165,12 +1165,12 @@ class DebugLogger: # Global debug logger instance -_debug_logger: Optional[DebugLogger] = None +debug_logger: Optional[DebugLogger] = None def get_debug_logger() -> Optional[DebugLogger]: """Get the global debug logger instance.""" - return _debug_logger + return debug_logger def log_event(operation: str, *, level: str = "DEBUG", message: str = "", **kwargs: Any) -> None: @@ -1180,7 +1180,7 @@ def log_event(operation: str, *, level: str = "DEBUG", message: str = "", **kwar ``if dl := get_debug_logger(): dl.log(...)`` guard boilerplate. To add logging to a new feature, call ``log_event("my_feature_event", message="...", context={...})``. """ - dl = _debug_logger + dl = debug_logger if dl: dl.log(level=level, operation=operation, message=message, **kwargs) @@ -1198,7 +1198,7 @@ def timed_operation(operation: str, *, level: str = "DEBUG", message: str = "", with timed_operation("mux", context={"output": str(path)}): run_mkvmerge(...) """ - dl = _debug_logger + dl = debug_logger if not dl: yield return @@ -1235,18 +1235,18 @@ def init_debug_logger(log_path: Optional[Path] = None, enabled: bool = False, lo enabled: Whether debug logging is enabled log_keys: Whether to log decryption keys (for debugging key issues) """ - global _debug_logger - if _debug_logger: - _debug_logger.close() - _debug_logger = DebugLogger(log_path=log_path, enabled=enabled, log_keys=log_keys) + global debug_logger + if debug_logger: + debug_logger.close() + debug_logger = DebugLogger(log_path=log_path, enabled=enabled, log_keys=log_keys) def close_debug_logger(): """Close the global debug logger.""" - global _debug_logger - if _debug_logger: - _debug_logger.close() - _debug_logger = None + global debug_logger + if debug_logger: + debug_logger.close() + debug_logger = None __all__ = ( diff --git a/unshackle/core/utils/bitrate.py b/unshackle/core/utils/bitrate.py index 17f5b441..013cfe89 100644 --- a/unshackle/core/utils/bitrate.py +++ b/unshackle/core/utils/bitrate.py @@ -261,7 +261,7 @@ def uniform_segments( """ Build Segments giving each an equal share of the total duration. - Used for DASH: ``DASH._get_period_segments`` returns timeline *start times* + Used for DASH: ``DASH.get_period_segments`` returns timeline *start times* rather than per-segment durations, so they cannot be trusted. Segment lengths are near-uniform in practice, so the track duration (from ``mediaPresentationDuration``) split evenly is both correct and timeline-safe. @@ -285,7 +285,7 @@ def extract_dash(track: "Track", session: Union[Session, RnetSession]) -> list[S stored_period = data.get("period") track_url = track.url if isinstance(track.url, str) else track.url[0] - content_periods = [p for p in manifest.findall("Period") if DASH._is_content_period(p, filtered_period_ids)] + content_periods = [p for p in manifest.findall("Period") if DASH.is_content_period(p, filtered_period_ids)] raw_segments: list[tuple[str, Optional[str]]] = [] for period in content_periods: @@ -297,7 +297,7 @@ def extract_dash(track: "Track", session: Union[Session, RnetSession]) -> list[S if matched_rep is None or matched_as is None: continue - _, period_segments, _, _, _ = DASH._get_period_segments( + _, period_segments, _, _, _ = DASH.get_period_segments( period=period, adaptation_set=matched_as, representation=matched_rep, diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index e02f88ca..2d846ddf 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -181,7 +181,7 @@ class SeasonRange(click.ParamType): DATE_TOKEN = re.compile(r"^(?P\d{4}-\d{2}-\d{2})(:(?P\d{4}-\d{2}-\d{2}))?$") - def _parse_date_token(self, token: str, match: re.Match) -> list[str]: + def parse_date_token(self, token: str, match: re.Match) -> list[str]: """Expand an ISO date token or ':'-separated date range into ISO day keys.""" try: left = date.fromisoformat(match.group("left")) @@ -237,7 +237,7 @@ class SeasonRange(click.ParamType): # dates carry their own '-' separators, so they must be read before the range split date_match = self.DATE_TOKEN.match(token) if date_match: - (computed if not exclude else exclusions).extend(self._parse_date_token(token, date_match)) + (computed if not exclude else exclusions).extend(self.parse_date_token(token, date_match)) continue parsed = [ re.match(r"^S(?P\d+)(E(?P\d+)(\.(?P\d+))?)?$", x, re.IGNORECASE) diff --git a/unshackle/core/utils/dovi.py b/unshackle/core/utils/dovi.py index 805120f7..d9f31738 100644 --- a/unshackle/core/utils/dovi.py +++ b/unshackle/core/utils/dovi.py @@ -21,7 +21,7 @@ from unshackle.core import binaries from unshackle.core.utils.subprocess import log_tool_run, run_step -def _require_dovi_tool() -> str: +def require_dovi_tool() -> str: if not binaries.DoviTool: raise EnvironmentError("dovi_tool executable was not found but is required.") return str(binaries.DoviTool) @@ -36,7 +36,7 @@ def extract_rpu( label: str = "dovi_tool extract-rpu", ) -> bytes: """Extract DV RPU NALs from a raw HEVC stream. `mode=None` skips the -m flag (untouched).""" - tool = _require_dovi_tool() + tool = require_dovi_tool() args: list = [tool] if mode is not None: args += ["-m", str(mode)] @@ -53,7 +53,7 @@ def inject_rpu( label: str = "dovi_tool inject-rpu", ) -> bytes: """Inject a DV RPU back into a raw HEVC stream, producing DV-signaled output.""" - tool = _require_dovi_tool() + tool = require_dovi_tool() return run_step( [tool, "inject-rpu", "-i", source, "--rpu-in", rpu, "-o", output], status=status, @@ -71,7 +71,7 @@ def editor( label: str = "dovi_tool editor", ) -> bytes: """Apply a JSON edit spec to an RPU file.""" - tool = _require_dovi_tool() + tool = require_dovi_tool() return run_step( [tool, "editor", "-i", source, "-j", json_spec, "-o", output], status=status, @@ -82,7 +82,7 @@ def editor( def info_summary(rpu: Path) -> str: """Return the textual summary (`dovi_tool info -i ... -s`) for an RPU file.""" - tool = _require_dovi_tool() + tool = require_dovi_tool() info_start = time.monotonic() p = subprocess.run( [tool, "info", "-i", str(rpu), "-s"], capture_output=True, text=True, encoding="utf-8", errors="replace" @@ -107,7 +107,7 @@ def generate_from_hdr10plus( label: str = "dovi_tool generate", ) -> bytes: """Build a DV RPU from extracted HDR10+ metadata + an extra JSON descriptor.""" - tool = _require_dovi_tool() + tool = require_dovi_tool() return run_step( [tool, "generate", "-j", extra_json, "--hdr10plus-json", hdr10plus_json, "-o", output], status=status, diff --git a/unshackle/core/utils/language_tags.py b/unshackle/core/utils/language_tags.py index abb62ce8..a8b0ccd1 100644 --- a/unshackle/core/utils/language_tags.py +++ b/unshackle/core/utils/language_tags.py @@ -36,14 +36,14 @@ def evaluate_language_tag( log.warning("Language tag rule missing 'tag' field, skipping: %s", rule) continue - if _rule_matches(rule, audio_languages, subtitle_languages): + if rule_matches(rule, audio_languages, subtitle_languages): log.debug("Language tag rule matched: %s -> %s", rule, tag) return str(tag) return "" -def _rule_matches( +def rule_matches( rule: dict[str, Any], audio_languages: Sequence[Language], subtitle_languages: Sequence[Language], diff --git a/unshackle/core/utils/redact.py b/unshackle/core/utils/redact.py index e4c1f5b8..3350e1bb 100644 --- a/unshackle/core/utils/redact.py +++ b/unshackle/core/utils/redact.py @@ -41,7 +41,7 @@ def safe_display_url(url: str) -> str: return urlunsplit((parts.scheme, netloc, parts.path, "", "")) -def _path_bases() -> list[tuple[str, str]]: +def path_bases() -> list[tuple[str, str]]: """Local base directories to strip from logged paths, longest-first. Anchors everything left of the unshackle install/root (and the venv / home dir) so an @@ -52,14 +52,14 @@ def _path_bases() -> list[tuple[str, str]]: try: # redact.py lives at /unshackle/core/utils/redact.py -> parents[3] == project/install root candidates.append((str(Path(__file__).resolve().parents[3]), "")) - except Exception: + except (OSError, IndexError): pass for raw in (getattr(sys, "prefix", ""), getattr(sys, "base_prefix", "")): if raw: candidates.append((str(Path(raw)), "")) try: candidates.append((str(Path.home()), "~")) - except Exception: + except (OSError, RuntimeError): pass bases: list[tuple[str, str]] = [] @@ -74,10 +74,10 @@ def _path_bases() -> list[tuple[str, str]]: return bases -_PATH_BASES = _path_bases() +PATH_BASES = path_bases() -def _path_redaction_enabled() -> bool: +def path_redaction_enabled() -> bool: """Path-prefix redaction is on unless ``config.redact_paths`` is explicitly False.""" try: from unshackle.core.config import config # lazy: config doesn't import redact, so no cycle @@ -93,9 +93,9 @@ def redact_path(text: Optional[str]) -> Optional[str]: Idempotent and cheap; only touches strings that actually contain a known base dir, so URLs and relative paths pass through unchanged. Disabled by ``config.redact_paths: false``. """ - if not isinstance(text, str) or not text or not _path_redaction_enabled(): + if not isinstance(text, str) or not text or not path_redaction_enabled(): return text - for base, token in _PATH_BASES: + for base, token in PATH_BASES: if base in text: text = text.replace(base, token) return text @@ -104,16 +104,16 @@ def redact_path(text: Optional[str]) -> Optional[str]: # any http(s) URL embedded in free text (content/manifest/segment/api locations) URL_RE = re.compile(r"https?://[^\s\"'<>\\]+", re.IGNORECASE) # a plausible file extension to preserve (e.g. .mpd, .m3u8, .mp4, .m4s, .vtt) -_EXT_RE = re.compile(r"^\.[A-Za-z0-9]{1,5}$") +EXT_RE = re.compile(r"^\.[A-Za-z0-9]{1,5}$") -def _collapse_url(match: "re.Match[str]") -> str: +def collapse_url(match: "re.Match[str]") -> str: url = match.group(0) try: suffix = Path(urlsplit(url).path).suffix except Exception: suffix = "" - if not _EXT_RE.match(suffix): + if not EXT_RE.match(suffix): suffix = "" return f"redacted{suffix}" @@ -127,7 +127,7 @@ def redact_url(text: Optional[str]) -> Optional[str]: """ if not isinstance(text, str) or not text: return text - return URL_RE.sub(_collapse_url, text) + return URL_RE.sub(collapse_url, text) def redact_all(text: Optional[str]) -> Optional[str]: diff --git a/unshackle/core/utils/tag_rules.py b/unshackle/core/utils/tag_rules.py index 7e902b1f..53636087 100644 --- a/unshackle/core/utils/tag_rules.py +++ b/unshackle/core/utils/tag_rules.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Optional log = logging.getLogger(__name__) -_OPS: dict[str, Callable[[Any, Any], bool]] = { +OPS: dict[str, Callable[[Any, Any], bool]] = { ">=": operator.ge, "<=": operator.le, "!=": operator.ne, @@ -19,8 +19,8 @@ _OPS: dict[str, Callable[[Any, Any], bool]] = { "<": operator.lt, "=": operator.eq, } -_ORDERING = (">=", "<=", ">", "<") -_NUMBER = re.compile(r"\d+(?:\.\d+)?") +ORDERING = (">=", "<=", ">", "<") +NUMBER = re.compile(r"\d+(?:\.\d+)?") def evaluate_tag_rules(rules: Any, context: dict[str, Any]) -> Optional[str]: @@ -56,7 +56,7 @@ def evaluate_tag_rules(rules: Any, context: dict[str, Any]) -> Optional[str]: if not when or not tag: log.warning("Tag rule needs both 'when' conditions and a 'tag', skipping: %s", rule) continue - if isinstance(tag, bool) or any(_has_bool(value) for value in when.values()): + if isinstance(tag, bool) or any(has_bool(value) for value in when.values()): log.warning("Tag rule has a YAML boolean, quote the value to use it as text, skipping: %s", rule) continue if not isinstance(tag, (str, int, float)): @@ -68,35 +68,35 @@ def evaluate_tag_rules(rules: Any, context: dict[str, Any]) -> Optional[str]: log.warning("Tag rule has unknown 'when' key(s) %s, skipping: %s", ", ".join(sorted(unknown)), rule) continue - if all(_matches(expected, context[key]) for key, expected in when.items()): + if all(matches(expected, context[key]) for key, expected in when.items()): log.debug("Tag rule matched: %s -> %s", rule, tag) return str(tag) return None -def _has_bool(expected: Any) -> bool: +def has_bool(expected: Any) -> bool: """Check for a YAML boolean, which is an unquoted word the user meant as text.""" values = expected if isinstance(expected, list) else [expected] return any(isinstance(x, bool) for x in values) -def _matches(expected: Any, actual: Any) -> bool: +def matches(expected: Any, actual: Any) -> bool: """Check one condition, where a list matches on any entry.""" values = expected if isinstance(expected, list) else [expected] - return any(_matches_one(x, actual) for x in values) + return any(matches_one(x, actual) for x in values) -def _matches_one(expected: Any, actual: Any) -> bool: +def matches_one(expected: Any, actual: Any) -> bool: """Check a single value, which may carry a leading comparison operator.""" text = "" if expected is None else str(expected) - for op in sorted(_OPS, key=len, reverse=True): + for op in sorted(OPS, key=len, reverse=True): if text.startswith(op): - return _compare(op, text[len(op) :].strip(), actual) + return compare(op, text[len(op) :].strip(), actual) return str(actual or "").lower() == text.lower() -def _compare(op: str, operand: str, actual: Any) -> bool: +def compare(op: str, operand: str, actual: Any) -> bool: """Compare against an operand, numerically when the operand is a number.""" if not operand: log.warning("Tag rule condition '%s' has no value to compare against, condition failed", op) @@ -105,12 +105,12 @@ def _compare(op: str, operand: str, actual: Any) -> bool: try: wanted = float(operand) except ValueError: - if op in _ORDERING: + if op in ORDERING: log.warning("Tag rule condition '%s%s' needs a numeric operand, condition failed", op, operand) return False - return _OPS[op](str(actual or "").lower(), operand.lower()) + return OPS[op](str(actual or "").lower(), operand.lower()) - found = _NUMBER.search(str(actual or "")) + found = NUMBER.search(str(actual or "")) if not found: return op == "!=" - return _OPS[op](float(found.group()), wanted) + return OPS[op](float(found.group()), wanted) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index db2cd1db..79b206a1 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -64,7 +64,7 @@ def apply_tags(path: Path, tags: dict[str, str]) -> None: tmp_path.unlink(missing_ok=True) -def _build_tags_from_ids(ids: ExternalIds, kind: str) -> dict[str, str]: +def build_tags_from_ids(ids: ExternalIds, kind: str) -> dict[str, str]: """Build standard MKV tags from external IDs.""" tags: dict[str, str] = {} if ids.imdb_id: @@ -129,7 +129,7 @@ def tag_file( ) if result and result.external_ids: - standard_tags = _build_tags_from_ids(result.external_ids, kind) + standard_tags = build_tags_from_ids(result.external_ids, kind) except Exception as e: log.warning("Metadata lookup failed, applying custom tags only: %s", e) diff --git a/unshackle/core/utils/template_formatter.py b/unshackle/core/utils/template_formatter.py index 47c777a6..a7c9be34 100644 --- a/unshackle/core/utils/template_formatter.py +++ b/unshackle/core/utils/template_formatter.py @@ -38,9 +38,9 @@ class TemplateFormatter: """ self.template = template self.spacer = spacer or detect_spacer(template) - self.variables = self._extract_variables() + self.variables = self.extract_variables() - def _extract_variables(self) -> list[str]: + def extract_variables(self) -> list[str]: """Extract all variables from the template.""" pattern = r"\{([^}]+)\}" matches = re.findall(pattern, self.template) @@ -82,7 +82,7 @@ class TemplateFormatter: # Remove the placeholder and consume the adjacent separator on one side # e.g. "{disc?}-{track}" → "{track}" when disc is empty # e.g. "{title}.{edition?}.{quality}" → "{title}.{quality}" when edition is empty - def _remove_conditional(m: re.Match) -> str: + def remove_conditional(m: re.Match) -> str: s = m.group(0) has_left = s[0] in ".- " has_right = s[-1] in ".- " @@ -94,7 +94,7 @@ class TemplateFormatter: result = re.sub( rf"[\.\s\-]?{re.escape(placeholder)}[\.\s\-]?", - _remove_conditional, + remove_conditional, result, count=1, ) diff --git a/unshackle/core/utils/webvtt.py b/unshackle/core/utils/webvtt.py index 11b3f67b..5c4196b5 100644 --- a/unshackle/core/utils/webvtt.py +++ b/unshackle/core/utils/webvtt.py @@ -2,10 +2,10 @@ import re from typing import Optional # Timing line: optional hours, verbatim settings tail. -_TIMING = re.compile(r"^((?:\d+:)?\d{2}:\d{2}\.\d{3})[ \t]+-->[ \t]+((?:\d+:)?\d{2}:\d{2}\.\d{3})(.*)$") +TIMING = re.compile(r"^((?:\d+:)?\d{2}:\d{2}\.\d{3})[ \t]+-->[ \t]+((?:\d+:)?\d{2}:\d{2}\.\d{3})(.*)$") -def _timestamp_ms(ts: str) -> int: +def timestamp_ms(ts: str) -> int: hms, ms = ts.rsplit(".", 1) parts = [int(p) for p in hms.split(":")] while len(parts) < 3: @@ -52,7 +52,7 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] if header not in headers: # segments repeat the same block headers.append(header) continue - timing = _TIMING.match(lines[timing_i].strip()) + timing = TIMING.match(lines[timing_i].strip()) if not timing: continue start, end, settings = timing.groups() @@ -61,13 +61,13 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] continue normalized = "\n".join(line.strip() for line in payload) - if prev is not None and _timestamp_ms(start) - prev_end_ms <= 1 and normalized == prev[4]: + if prev is not None and timestamp_ms(start) - prev_end_ms <= 1 and normalized == prev[4]: prev[1] = end # splice: extend the kept cue, drop the duplicate - prev_end_ms = _timestamp_ms(end) + prev_end_ms = timestamp_ms(end) continue prev = [start, end, settings, "\n".join(payload), normalized] - prev_end_ms = _timestamp_ms(end) + prev_end_ms = timestamp_ms(end) cues.append(prev) blocks = headers + [f"{c[0]} --> {c[1]}{c[2]}\n{c[3]}" for c in cues] diff --git a/unshackle/core/vaults.py b/unshackle/core/vaults.py index 538eec3e..936bd29a 100644 --- a/unshackle/core/vaults.py +++ b/unshackle/core/vaults.py @@ -10,11 +10,11 @@ from unshackle.core.vault import Vault log = logging.getLogger(__name__) -_VAULTS = sorted( +VAULTS = sorted( (path for path in config.directories.vaults.glob("*.py") if path.stem.lower() != "__init__"), key=lambda x: x.stem ) -_MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in _VAULTS} +MODULES = {path.stem: getattr(import_module_by_path(path), path.stem) for path in VAULTS} class Vaults: @@ -32,7 +32,7 @@ class Vaults: def load(self, type_: str, **kwargs: Any) -> bool: """Load a Vault into the vaults list. Returns True if successful, False otherwise.""" - module = _MODULES.get(type_) + module = MODULES.get(type_) if not module: raise ValueError(f"Unable to find vault command by the name '{type_}'.") # only pass the global default to vaults that declare a timeout param (per-vault config still wins) @@ -42,12 +42,14 @@ class Vaults: vault = module(**kwargs) self.vaults.append(vault) return True - except Exception: + # one bad vault config must not stop the others from loading + except Exception as e: + log.debug(f"Vault '{type_}' failed to load: {e!r}") return False def load_critical(self, type_: str, **kwargs: Any) -> None: """Load a critical Vault that must succeed or raise an exception.""" - module = _MODULES.get(type_) + module = MODULES.get(type_) if not module: raise ValueError(f"Unable to find vault command by the name '{type_}'.") vault = module(**kwargs) diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index e9f0463f..e283c5e4 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -55,7 +55,7 @@ class EXAMPLE(Service): search SearchResult generator get_titles Movies / Series / Album (music) + data passthrough get_tracks DASH variant fan-out (default) + HLS/ISM alternates - _fetch_dash_manifest range override, HDR10+ flip, DV-composite, Atmos, + fetch_dash_manifest range override, HDR10+ flip, DV-composite, Atmos, descriptive audio, channel fixups, cover-art attachment, VUI normalize, bitrate-window awareness get_chapters Chapters() with named + unnamed markers @@ -298,14 +298,14 @@ class EXAMPLE(Service): return Series(episodes) # DEFAULT (shown live): this service needs a SEPARATE manifest per codec/range, - # so we fan out with the base helper `_get_tracks_for_variants`. It walks every + # so we fan out with the base helper `get_tracks_for_variants`. It walks every # codec x range in the TrackRequest, handles HYBRID (HDR10 + DV merge), and - # when --best-available is set - skips combos the service can't deliver. def get_tracks(self, title: Title_T) -> Tracks: - def _fetch_variant(title: Title_T, codec: Optional[Video.Codec], range_: Video.Range) -> Tracks: + def fetch_variant(title: Title_T, codec: Optional[Video.Codec], range_: Video.Range) -> Tracks: vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264" self.log.info(f" + Fetching {vcodec_str} {range_.name} manifest") - tracks = self._fetch_dash_manifest(title, vcodec=vcodec_str, range_=range_) + tracks = self.fetch_dash_manifest(title, vcodec=vcodec_str, range_=range_) # Guard: if we asked for HDR/DV but the manifest came back SDR-only, raise # so the helper can fall back (best-available) or fail loudly otherwise. @@ -314,7 +314,7 @@ class EXAMPLE(Service): raise ValueError(f"{range_.name} requested but unavailable") return tracks - return self._get_tracks_for_variants(title, _fetch_variant) + return self.get_tracks_for_variants(title, fetch_variant) # ── ALTERNATE A - HLS (one master playlist returns every codec/range) ─────── # When a service exposes a single master playlist, you do NOT need the variant @@ -332,7 +332,7 @@ class EXAMPLE(Service): # from unshackle.core.manifests import ISM # return ISM.from_url(url=ism_url, session=self.session).to_tracks(title.language) - def _fetch_dash_manifest( + def fetch_dash_manifest( self, title: Title_T, vcodec: str = "H264", range_: Video.Range = Video.Range.SDR ) -> Tracks: video_format = self.VIDEO_RANGE_MAP.get(range_, "sdr") diff --git a/unshackle/utils/base58.py b/unshackle/utils/base58.py index eae39fb0..e63f665b 100644 --- a/unshackle/utils/base58.py +++ b/unshackle/utils/base58.py @@ -64,7 +64,7 @@ def b58encode(v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET) -> bytes @lru_cache() -def _get_base58_decode_map(alphabet: bytes, autofix: bool) -> Mapping[int, int]: +def get_base58_decode_map(alphabet: bytes, autofix: bool) -> Mapping[int, int]: invmap = {char: index for index, char in enumerate(alphabet)} if autofix: @@ -86,7 +86,7 @@ def b58decode_int(v: Union[str, bytes], alphabet: bytes = BITCOIN_ALPHABET, *, a v = v.rstrip() v = scrub_input(v) - map = _get_base58_decode_map(alphabet, autofix=autofix) + map = get_base58_decode_map(alphabet, autofix=autofix) decimal = 0 base = len(alphabet) diff --git a/unshackle/utils/base62.py b/unshackle/utils/base62.py index 433b9c14..e74ddf3a 100644 --- a/unshackle/utils/base62.py +++ b/unshackle/utils/base62.py @@ -38,7 +38,7 @@ def encodebytes(barray, charset=CHARSET_DEFAULT): :rtype: str """ - _check_type(barray, bytes) + check_type(barray, bytes) # Count the number of leading zeros. leading_zeros_count = 0 @@ -68,11 +68,11 @@ def decode(encoded, charset=CHARSET_DEFAULT): :type encoded: str :rtype: int """ - _check_type(encoded, str) + check_type(encoded, str) length, i, v = len(encoded), 0, 0 for x in encoded: - v += _value(x, charset=charset) * (BASE ** (length - (i + 1))) + v += value(x, charset=charset) * (BASE ** (length - (i + 1))) i += 1 return v @@ -88,7 +88,7 @@ def decodebytes(encoded, charset=CHARSET_DEFAULT): leading_null_bytes = b"" while encoded.startswith("0") and len(encoded) >= 2: - leading_null_bytes += b"\x00" * _value(encoded[1], charset) + leading_null_bytes += b"\x00" * value(encoded[1], charset) encoded = encoded[2:] decoded = decode(encoded, charset=charset) @@ -101,7 +101,7 @@ def decodebytes(encoded, charset=CHARSET_DEFAULT): return leading_null_bytes + bytes(buf) -def _value(ch, charset): +def value(ch, charset): """Decodes an individual digit of a base62 encoded string.""" try: @@ -110,7 +110,7 @@ def _value(ch, charset): raise ValueError("base62: Invalid character (%s)" % ch) -def _check_type(value, expected_type): +def check_type(value, expected_type): """Checks if the input is in an appropriate type.""" if not isinstance(value, expected_type): diff --git a/unshackle/vaults/MySQL.py b/unshackle/vaults/MySQL.py index 8f6ef185..7dca4749 100644 --- a/unshackle/vaults/MySQL.py +++ b/unshackle/vaults/MySQL.py @@ -253,10 +253,10 @@ class ConnectionFactory: self._con = con self._store = threading.local() - def _create_connection(self) -> pymysql.Connection: + def create_connection(self) -> pymysql.Connection: return pymysql.connect(**self._con) def get(self) -> pymysql.Connection: if not hasattr(self._store, "conn"): - self._store.conn = self._create_connection() + self._store.conn = self.create_connection() return self._store.conn diff --git a/unshackle/vaults/SQLite.py b/unshackle/vaults/SQLite.py index 25362962..0407bcc8 100644 --- a/unshackle/vaults/SQLite.py +++ b/unshackle/vaults/SQLite.py @@ -207,7 +207,7 @@ class ConnectionFactory: self._path = path self._store = threading.local() - def _create_connection(self) -> Connection: + def create_connection(self) -> Connection: conn = sqlite3.connect(self._path, timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") @@ -216,5 +216,5 @@ class ConnectionFactory: def get(self) -> Connection: if not hasattr(self._store, "conn"): - self._store.conn = self._create_connection() + self._store.conn = self.create_connection() return self._store.conn