From e31e60a418e7fe535f9d0e4c4115d271ecb4ef4b Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 15 Jul 2026 14:49:27 -0600 Subject: [PATCH] style: run ruff format over the codebase --- tests/core/test_cdm_detect.py | 11 ++- tests/core/test_clearkey_cenc.py | 1 + tests/core/test_debug_logging.py | 9 +- tests/core/test_ism_init.py | 24 +++-- tests/core/test_ism_range.py | 4 +- tests/core/test_music_display.py | 30 +++++-- tests/core/test_music_extract.py | 14 ++- tests/core/test_service_dl_overrides.py | 4 +- tests/core/test_service_repo.py | 4 +- .../test_overlapping_audio_dedup.py | 72 +++++++++++++-- .../unit/test_download_gates_redaction.py | 13 +-- tests/remote/unit/test_errors.py | 9 +- tests/remote/unit/test_handlers_serialize.py | 13 ++- tests/remote/unit/test_progress_sink.py | 12 +-- .../unit/test_remote_service_helpers.py | 12 ++- tests/tracks/test_subtitle_skip.py | 49 +++++++--- tests/tracks/test_webvtt_merge.py | 6 +- unshackle/commands/dl.py | 40 +++++++-- unshackle/core/api/download_manager.py | 4 +- unshackle/core/api/download_worker.py | 4 +- unshackle/core/api/handlers.py | 14 +-- unshackle/core/api/routes.py | 41 ++++++--- unshackle/core/api/session_store.py | 4 +- unshackle/core/drm/clearkey_cenc.py | 9 +- unshackle/core/drm/playready.py | 9 +- unshackle/core/manifests/ism.py | 8 +- unshackle/core/manifests/ism_init.py | 89 ++++++++++++++----- unshackle/core/music/__init__.py | 13 ++- unshackle/core/music/integrity.py | 4 +- unshackle/core/music/planner.py | 12 ++- unshackle/core/music/renderer.py | 4 +- unshackle/core/music/tagger.py | 19 +++- unshackle/core/proxies/hola.py | 6 +- unshackle/core/service_repo.py | 14 ++- unshackle/core/services.py | 4 +- unshackle/core/tracks/hybrid.py | 2 +- unshackle/core/utilities.py | 4 +- .../core/utils/firefox_cookie_extractor.py | 75 ++++++++++------ unshackle/core/utils/tags.py | 12 ++- 39 files changed, 492 insertions(+), 186 deletions(-) diff --git a/tests/core/test_cdm_detect.py b/tests/core/test_cdm_detect.py index c414b2f5..612f2a1c 100644 --- a/tests/core/test_cdm_detect.py +++ b/tests/core/test_cdm_detect.py @@ -15,8 +15,15 @@ from types import SimpleNamespace import pytest from unshackle.core.cdm import loader as loader_mod -from unshackle.core.cdm.detect import (cdm_location, is_local_cdm, is_playready_cdm, is_remote_cdm, - is_remote_playready_cdm, is_remote_widevine_cdm, is_widevine_cdm) +from unshackle.core.cdm.detect import ( + cdm_location, + is_local_cdm, + is_playready_cdm, + is_remote_cdm, + is_remote_playready_cdm, + is_remote_widevine_cdm, + is_widevine_cdm, +) from unshackle.core.cdm.loader import _stamp_remote pytestmark = pytest.mark.unit diff --git a/tests/core/test_clearkey_cenc.py b/tests/core/test_clearkey_cenc.py index 6fbb3198..e19b59f2 100644 --- a/tests/core/test_clearkey_cenc.py +++ b/tests/core/test_clearkey_cenc.py @@ -16,6 +16,7 @@ from typing import Any, Optional from uuid import UUID import pytest + # lxml.etree: XML parser used to build ContentProtection fixtures for DASH.get_drm from lxml import etree diff --git a/tests/core/test_debug_logging.py b/tests/core/test_debug_logging.py index b52bfa5f..7041f160 100644 --- a/tests/core/test_debug_logging.py +++ b/tests/core/test_debug_logging.py @@ -9,8 +9,13 @@ from pathlib import Path import pytest -from unshackle.core.utilities import (close_debug_logger, get_debug_logger, init_debug_logger, log_event, - timed_operation) +from unshackle.core.utilities import ( + close_debug_logger, + get_debug_logger, + init_debug_logger, + log_event, + timed_operation, +) from unshackle.core.utils.redact import redact_all, redact_path, redact_text, redact_url, safe_display_url from unshackle.core.utils.subprocess import log_tool_run diff --git a/tests/core/test_ism_init.py b/tests/core/test_ism_init.py index c016d01a..363bb031 100644 --- a/tests/core/test_ism_init.py +++ b/tests/core/test_ism_init.py @@ -12,12 +12,24 @@ import struct import pytest -from unshackle.core.manifests.ism_init import (NAL_START_CODE, PIFF_SENC_UUID, box, build_avcc, build_dec3, - build_hvcc, build_init_segment, full_box, - parse_codec_private_data_colour, parse_codec_private_data_vui, - parse_hevc_sps_format, read_per_sample_iv_size, read_track_id, - remove_emulation_prevention, split_nal_units, - synthesize_aac_codec_private_data) +from unshackle.core.manifests.ism_init import ( + NAL_START_CODE, + PIFF_SENC_UUID, + box, + build_avcc, + build_dec3, + build_hvcc, + build_init_segment, + full_box, + parse_codec_private_data_colour, + parse_codec_private_data_vui, + parse_hevc_sps_format, + read_per_sample_iv_size, + read_track_id, + remove_emulation_prevention, + split_nal_units, + synthesize_aac_codec_private_data, +) # Real CodecPrivateData taken from a Smooth Streaming manifest. VIDEO_HEVC_CPD = ( diff --git a/tests/core/test_ism_range.py b/tests/core/test_ism_range.py index 1cb88eb9..59728409 100644 --- a/tests/core/test_ism_range.py +++ b/tests/core/test_ism_range.py @@ -4,11 +4,11 @@ undecidable from the manifest; the post-mux bitstream probe names it.""" from __future__ import annotations +from tests.core.test_ism_init import VIDEO_HEVC10_CPD, VIDEO_HEVC_DV_CPD, VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD from unshackle.core.manifests import ISM from unshackle.core.tracks import Video -from tests.core.test_ism_init import (VIDEO_HEVC10_CPD, VIDEO_HEVC_DV_CPD, VIDEO_HEVC_HLG_CPD, # isort: skip - VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD) +from tests.core.test_ism_init import VIDEO_HEVC_HLG_CPD # isort: skip def manifest_xml(cpd: str, fourcc: str = "HVC1") -> str: diff --git a/tests/core/test_music_display.py b/tests/core/test_music_display.py index ba5c98b7..0992086d 100644 --- a/tests/core/test_music_display.py +++ b/tests/core/test_music_display.py @@ -12,8 +12,14 @@ from typing import Any, Optional from rich.console import Console, RenderableType from rich.panel import Panel -from unshackle.core.music.display import (MusicHeaderInfo, TrackRow, format_track_detail_quality, render_album_header, - render_artwork_preview, render_track_panel) +from unshackle.core.music.display import ( + MusicHeaderInfo, + TrackRow, + format_track_detail_quality, + render_album_header, + render_artwork_preview, + render_track_panel, +) from unshackle.core.titles.music import Song @@ -97,8 +103,20 @@ def test_format_track_detail_quality_passthrough() -> None: def test_render_track_panel_returns_panel() -> None: rows = [ - TrackRow(song=make_song(track=1, name="First"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00", cd=True), - TrackRow(song=make_song(track=2, name="Second"), quality_label="OGG 320", layout="Stereo", duration_str="4:00", hires=True), + TrackRow( + song=make_song(track=1, name="First"), + quality_label="FLAC 16-bit/44.1kHz", + layout="Stereo", + duration_str="3:00", + cd=True, + ), + TrackRow( + song=make_song(track=2, name="Second"), + quality_label="OGG 320", + layout="Stereo", + duration_str="4:00", + hires=True, + ), ] panel = render_track_panel(rows, total=len(rows)) assert isinstance(panel, Panel) @@ -112,7 +130,9 @@ def test_render_track_panel_returns_panel() -> None: def test_render_track_panel_singular_label() -> None: - rows = [TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00")] + rows = [ + TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00") + ] text = render_to_text(render_track_panel(rows, total=1)) # The count node reads "1 Track" (singular); the panel title is always # "Available Tracks", so we only check the count node phrasing here. diff --git a/tests/core/test_music_extract.py b/tests/core/test_music_extract.py index 2702781b..3b7ca080 100644 --- a/tests/core/test_music_extract.py +++ b/tests/core/test_music_extract.py @@ -6,9 +6,17 @@ from typing import Any, Optional import pytest -from unshackle.core.music.extract import (build_music_from_songs, classify_release_kind, dedupe_track_options, - duration_seconds, first_number, first_text, format_duration, format_names, - year_from_value) +from unshackle.core.music.extract import ( + build_music_from_songs, + classify_release_kind, + dedupe_track_options, + duration_seconds, + first_number, + first_text, + format_duration, + format_names, + year_from_value, +) from unshackle.core.music.models import MusicTrackOption from unshackle.core.titles.music import Music, Song diff --git a/tests/core/test_service_dl_overrides.py b/tests/core/test_service_dl_overrides.py index 704f1f64..25321aa5 100644 --- a/tests/core/test_service_dl_overrides.py +++ b/tests/core/test_service_dl_overrides.py @@ -115,9 +115,7 @@ def hand_built_ctx(body: dict[str, Any]) -> click.Context: ctx = click.Context(parsed.command) ctx.params = {"repack": body.get("repack", False)} for name in ctx.params: - ctx.set_parameter_source( - name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT - ) + ctx.set_parameter_source(name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT) return ctx diff --git a/tests/core/test_service_repo.py b/tests/core/test_service_repo.py index 17b12782..05ee84f1 100644 --- a/tests/core/test_service_repo.py +++ b/tests/core/test_service_repo.py @@ -240,9 +240,7 @@ def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path): 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") - ) + 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") == [] diff --git a/tests/lang_selection/test_overlapping_audio_dedup.py b/tests/lang_selection/test_overlapping_audio_dedup.py index d2341e70..7de66f77 100644 --- a/tests/lang_selection/test_overlapping_audio_dedup.py +++ b/tests/lang_selection/test_overlapping_audio_dedup.py @@ -24,24 +24,80 @@ from unshackle.core.utilities import is_exact_match # Base language vs regional variant that CLDR rates at distance 0 (paradigm locales). These are # the pairs that collapse under naive exact matching. COLLAPSING_PAIRS = [ - ("ar", "ar-EG"), ("en", "en-US"), ("pt", "pt-BR"), ("fr", "fr-FR"), - ("es", "es-ES"), ("de", "de-DE"), ("it", "it-IT"), + ("ar", "ar-EG"), + ("en", "en-US"), + ("pt", "pt-BR"), + ("fr", "fr-FR"), + ("es", "es-ES"), + ("de", "de-DE"), + ("it", "it-IT"), ] # Pairs CLDR rates at distance >0, which exact matching already separates. Never collapsed. # Includes two-regional splits (no bare base) as services more commonly ship them. DISTINCT_PAIRS = [ - ("es", "es-419"), ("fr", "fr-CA"), ("fr-FR", "fr-CA"), ("pt-PT", "pt-BR"), ("es-ES", "es-419"), - ("en-US", "en-GB"), ("de-DE", "de-AT"), ("it-IT", "it-CH"), ("es-419", "es-AR"), ("no", "nn"), + ("es", "es-419"), + ("fr", "fr-CA"), + ("fr-FR", "fr-CA"), + ("pt-PT", "pt-BR"), + ("es-ES", "es-419"), + ("en-US", "en-GB"), + ("de-DE", "de-AT"), + ("it-IT", "it-CH"), + ("es-419", "es-AR"), + ("no", "nn"), ] # A full real-world audio language set for one title, the kind of mix a streaming manifest ships, # which must resolve 1:1 with no collision or loss. Tags are drawn from published streaming # language references (Warner Bros. Discovery / Netflix) and include regional and script variants. REAL_WORLD_AUDIO_LANGS = [ - "ar", "ar-EG", "ar-SA", "bn-IN", "cmn-TW", "cs", "da", "de", "de-AT", "de-CH", "el", "en", - "en-GB", "en-AU", "es-419", "es-AR", "es-ES", "fi", "fr-CA", "fr-FR", "he", "hi", "hu", "id", - "it", "it-CH", "ja", "ko", "nl", "no", "pl", "pt-BR", "pt-PT", "ro", "ru", "sk", "sr", - "sr-Latn", "sv", "ta", "th", "tr", "uk", "vi", "yue", "zh-Hans", "zh-Hant", + "ar", + "ar-EG", + "ar-SA", + "bn-IN", + "cmn-TW", + "cs", + "da", + "de", + "de-AT", + "de-CH", + "el", + "en", + "en-GB", + "en-AU", + "es-419", + "es-AR", + "es-ES", + "fi", + "fr-CA", + "fr-FR", + "he", + "hi", + "hu", + "id", + "it", + "it-CH", + "ja", + "ko", + "nl", + "no", + "pl", + "pt-BR", + "pt-PT", + "ro", + "ru", + "sk", + "sr", + "sr-Latn", + "sv", + "ta", + "th", + "tr", + "uk", + "vi", + "yue", + "zh-Hans", + "zh-Hant", ] diff --git a/tests/remote/unit/test_download_gates_redaction.py b/tests/remote/unit/test_download_gates_redaction.py index 3d361389..15658475 100644 --- a/tests/remote/unit/test_download_gates_redaction.py +++ b/tests/remote/unit/test_download_gates_redaction.py @@ -9,8 +9,13 @@ import pytest from aiohttp import web from unshackle.core.api import handlers -from unshackle.core.api.download_manager import (DownloadJob, JobStatus, _redact_parameters, _redact_text, - _secret_values) +from unshackle.core.api.download_manager import ( + DownloadJob, + JobStatus, + _redact_parameters, + _redact_text, + _secret_values, +) from unshackle.core.api.errors import APIError, APIErrorCode pytestmark = pytest.mark.unit @@ -116,9 +121,7 @@ async def test_cdm_override_allowed_when_enabled(stub_handler): async def test_cdm_override_allowlist_permits_only_named_device(stub_handler): stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]}) - assert isinstance( - await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response - ) + assert isinstance(await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response) with pytest.raises(APIError) as ei: await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "other"}) assert ei.value.error_code == APIErrorCode.FORBIDDEN diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 51f37534..e0de7dfc 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -6,8 +6,13 @@ import json import pytest -from unshackle.core.api.errors import (APIError, APIErrorCode, build_error_response, categorize_exception, - handle_api_exception) +from unshackle.core.api.errors import ( + APIError, + APIErrorCode, + build_error_response, + categorize_exception, + handle_api_exception, +) pytestmark = pytest.mark.unit diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index 473b0fe9..7924d886 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -5,9 +5,16 @@ from __future__ import annotations import pytest from langcodes import Language -from unshackle.core.api.handlers import (sanitize_log, serialize_audio_track, serialize_drm, serialize_subtitle_track, - serialize_title, serialize_video_track, validate_download_parameters, - validate_service) +from unshackle.core.api.handlers import ( + sanitize_log, + serialize_audio_track, + serialize_drm, + serialize_subtitle_track, + serialize_title, + serialize_video_track, + validate_download_parameters, + validate_service, +) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie from unshackle.core.tracks import Audio, Subtitle, Video diff --git a/tests/remote/unit/test_progress_sink.py b/tests/remote/unit/test_progress_sink.py index 934832e3..7a7e7313 100644 --- a/tests/remote/unit/test_progress_sink.py +++ b/tests/remote/unit/test_progress_sink.py @@ -9,8 +9,12 @@ from __future__ import annotations import pytest -from unshackle.core.api.progress import (DOWNLOAD_PROGRESS_CEILING, build_job_progress_callables, - track_progress_label, track_progress_weight) +from unshackle.core.api.progress import ( + DOWNLOAD_PROGRESS_CEILING, + build_job_progress_callables, + track_progress_label, + track_progress_weight, +) pytestmark = pytest.mark.unit @@ -76,9 +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"] diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 032759f5..81665c37 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -6,8 +6,16 @@ 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) +from unshackle.core.remote_service import ( + _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 from unshackle.core.tracks import Audio, Subtitle, Video diff --git a/tests/tracks/test_subtitle_skip.py b/tests/tracks/test_subtitle_skip.py index daf4d408..02c0c868 100644 --- a/tests/tracks/test_subtitle_skip.py +++ b/tests/tracks/test_subtitle_skip.py @@ -90,8 +90,11 @@ def test_failed_subtitle_does_not_truncate_video_or_audio(): h = Harness(fail_ids={"s-he"}) download_tracks_in_passes( - [video, audio, sub], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [video, audio, sub], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert set(h.completed) == {"v", "a"} # both fatal tracks fully downloaded @@ -105,8 +108,11 @@ def test_good_subtitle_kept_bad_subtitle_skipped(): h = Harness(fail_ids={"s-fr"}) download_tracks_in_passes( - [video, good, bad], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [video, good, bad], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert "s-en" in h.completed # the available subtitle still downloaded @@ -121,8 +127,11 @@ def test_subtitle_failure_stays_fatal_without_flag(): with pytest.raises(RuntimeError): download_tracks_in_passes( - [video, sub], 4, h.run_one, - skip_subtitle_errors=False, on_subtitle_skipped=h.on_subtitle_skipped, + [video, sub], + 4, + h.run_one, + skip_subtitle_errors=False, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert not DOWNLOAD_CANCELLED.is_set() # the finally clears the event even on the fatal path @@ -135,8 +144,11 @@ def test_cancel_event_is_reset_between_titles(): h = Harness(fail_ids=set()) download_tracks_in_passes( - [video, audio], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [video, audio], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert set(h.completed) == {"v", "a"} @@ -150,8 +162,11 @@ def test_cancel_event_cleared_after_failed_final_subtitle(): h = Harness(fail_ids={"s-he"}) download_tracks_in_passes( - [video, sub], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [video, sub], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert not DOWNLOAD_CANCELLED.is_set() # the helper clears it on exit for any later code @@ -164,8 +179,11 @@ def test_all_subtitles_skipped_video_audio_kept(): h = Harness(fail_ids={"s-en", "s-he"}) download_tracks_in_passes( - [video, audio, s1, s2], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [video, audio, s1, s2], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert set(h.completed) == {"v", "a"} # both fatal tracks survived @@ -179,8 +197,11 @@ def test_duplicate_language_subtitles_distinguished_by_id(): h = Harness(fail_ids={"en-forced", "en-sdh"}) download_tracks_in_passes( - [make_video(), forced, sdh], 4, h.run_one, - skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, + [make_video(), forced, sdh], + 4, + h.run_one, + skip_subtitle_errors=True, + on_subtitle_skipped=h.on_subtitle_skipped, ) assert [t.id for t in h.skipped] == ["en-forced", "en-sdh"] # same language, distinct ids diff --git a/tests/tracks/test_webvtt_merge.py b/tests/tracks/test_webvtt_merge.py index d59852f8..07258e97 100644 --- a/tests/tracks/test_webvtt_merge.py +++ b/tests/tracks/test_webvtt_merge.py @@ -103,11 +103,7 @@ def test_merge_dedupes_repeated_style_blocks_before_cues(): def test_merge_skips_malformed_timing_line(): - vtt = ( - "WEBVTT\n\n" - "garbage --> not-a-time\nBroken\n\n" - "00:00:01.000 --> 00:00:02.000\nGood\n" - ) + vtt = "WEBVTT\n\ngarbage --> not-a-time\nBroken\n\n00:00:01.000 --> 00:00:02.000\nGood\n" out = merge_segmented_webvtt(vtt) assert "Good" in out assert "Broken" not in out diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index b1e4f530..88c49e7e 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -47,8 +47,16 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.music import (MusicAudioIntegrityError, MusicMetadataResult, MusicPlanner, MusicRenderer, - file_md5, verify_music_audio, write_music_manifest, write_music_metadata) +from unshackle.core.music import ( + MusicAudioIntegrityError, + MusicMetadataResult, + MusicPlanner, + MusicRenderer, + file_md5, + verify_music_audio, + write_music_manifest, + write_music_metadata, +) 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 @@ -59,14 +67,30 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.dv_fixup import apply_dv_fixup from unshackle.core.tracks.hybrid import Hybrid -from unshackle.core.utilities import (find_font_with_fallbacks, find_missing_langs, get_debug_logger, - get_system_fonts, init_debug_logger, is_close_match, log_event, - suggest_font_packages, time_elapsed_since) +from unshackle.core.utilities import ( + find_font_with_fallbacks, + find_missing_langs, + get_debug_logger, + get_system_fonts, + init_debug_logger, + is_close_match, + log_event, + suggest_font_packages, + time_elapsed_since, +) from unshackle.core.utils import tags from unshackle.core.utils.bitrate import apply_real_bitrates -from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, - SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice, - SubtitleCodecChoice) +from unshackle.core.utils.click_types import ( + AUDIO_CODEC_LIST, + LANGUAGE_RANGE, + QUALITY_LIST, + SEASON_RANGE, + SLOW_DELAY_RANGE, + ContextData, + MultipleChoice, + MultipleVideoCodecChoice, + SubtitleCodecChoice, +) from unshackle.core.utils.collections import ci_get, merge_dict from unshackle.core.utils.selector import select_multiple from unshackle.core.utils.subprocess import ffprobe diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index a670ab81..3e7d3bdf 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -620,9 +620,7 @@ class DownloadQueueManager: f"Initialized download queue manager: max_concurrent={max_concurrent_downloads}, retention_hours={job_retention_hours}" ) - def create_job( - self, service: str, title_id: str, owner_key: Optional[str] = None, **parameters - ) -> DownloadJob: + def create_job(self, service: str, title_id: str, owner_key: Optional[str] = None, **parameters) -> DownloadJob: """Create a new download job and add it to the queue.""" job_id = str(uuid.uuid4()) job = DownloadJob( diff --git a/unshackle/core/api/download_worker.py b/unshackle/core/api/download_worker.py index 148483f5..81cf0072 100644 --- a/unshackle/core/api/download_worker.py +++ b/unshackle/core/api/download_worker.py @@ -63,9 +63,7 @@ def main(argv: list[str]) -> int: 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} diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index cc02194b..a7268cea 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -929,9 +929,7 @@ def enforce_download_gates(params: Dict[str, Any]) -> None: requested_cdm = params.get("cdm") if requested_cdm: allowed = (config.serve or {}).get("cdm_overrides") - permitted = allowed is True or ( - isinstance(allowed, (list, tuple, set)) and requested_cdm in allowed - ) + permitted = allowed is True or (isinstance(allowed, (list, tuple, set)) and requested_cdm in allowed) if not permitted: raise APIError( APIErrorCode.FORBIDDEN, @@ -1005,9 +1003,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] **service_specific_defaults, **filtered_params, } - job = manager.create_job( - normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults - ) + job = manager.create_job(normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults) return web.json_response( {"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202 @@ -1225,7 +1221,11 @@ async def retry_download_job_handler(job_id: str, request: Optional[web.Request] new_job = manager.create_job(job.service, job.title_id, owner_key=caller_key(request), **job.parameters) return web.json_response( - {"job_id": new_job.job_id, "status": new_job.status.value, "created_time": new_job.created_time.isoformat()}, + { + "job_id": new_job.job_id, + "status": new_job.status.value, + "created_time": new_job.created_time.isoformat(), + }, status=202, ) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 693b2a1f..44956915 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -10,17 +10,36 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings from unshackle.core import __version__ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception -from unshackle.core.api.handlers import (cancel_download_job_handler, clear_cache_handler, - clear_finished_download_jobs_handler, clear_temp_handler, - delete_history_handler, download_handler, download_history_handler, - env_check_handler, get_allowed_services, get_download_job_handler, - list_download_jobs_handler, list_titles_handler, list_tracks_handler, - prioritize_download_job_handler, profiles_handler, refresh_services_handler, - retry_download_job_handler, search_handler, server_config_handler, - session_create_handler, session_delete_handler, session_info_handler, - session_license_handler, session_prompt_get_handler, - session_prompt_post_handler, session_segments_handler, - session_titles_handler, session_tracks_handler) +from unshackle.core.api.handlers import ( + cancel_download_job_handler, + clear_cache_handler, + clear_finished_download_jobs_handler, + clear_temp_handler, + delete_history_handler, + download_handler, + download_history_handler, + env_check_handler, + get_allowed_services, + get_download_job_handler, + list_download_jobs_handler, + list_titles_handler, + list_tracks_handler, + prioritize_download_job_handler, + profiles_handler, + refresh_services_handler, + retry_download_job_handler, + search_handler, + server_config_handler, + session_create_handler, + session_delete_handler, + session_info_handler, + session_license_handler, + session_prompt_get_handler, + session_prompt_post_handler, + session_segments_handler, + session_titles_handler, + session_tracks_handler, +) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker diff --git a/unshackle/core/api/session_store.py b/unshackle/core/api/session_store.py index 71a657e4..5d990b60 100644 --- a/unshackle/core/api/session_store.py +++ b/unshackle/core/api/session_store.py @@ -99,9 +99,7 @@ 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)" - ) + log.info(f"Session {sanitize_log(session_id)} expired (elapsed={elapsed:.0f}s, ttl={self._ttl}s)") del self._sessions[session_id] return None diff --git a/unshackle/core/drm/clearkey_cenc.py b/unshackle/core/drm/clearkey_cenc.py index 2fe557f7..5e72376d 100644 --- a/unshackle/core/drm/clearkey_cenc.py +++ b/unshackle/core/drm/clearkey_cenc.py @@ -224,8 +224,13 @@ class ClearKeyCENC: try: subprocess.run( - cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - encoding="utf-8", errors="replace", + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", ) except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index 1ab06313..6fdd46d9 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -439,8 +439,13 @@ class PlayReady: try: subprocess.run( - cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - encoding="utf-8", errors="replace", + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", ) except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" diff --git a/unshackle/core/manifests/ism.py b/unshackle/core/manifests/ism.py index 8c84bd67..23854c12 100644 --- a/unshackle/core/manifests/ism.py +++ b/unshackle/core/manifests/ism.py @@ -19,8 +19,12 @@ from requests import Session from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from unshackle.core.drm import DRM_T, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.manifests.ism_init import (build_init_segment, parse_codec_private_data_vui, - read_per_sample_iv_size, read_track_id) +from unshackle.core.manifests.ism_init import ( + build_init_segment, + parse_codec_private_data_vui, + read_per_sample_iv_size, + read_track_id, +) from unshackle.core.session import RnetSession from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Track, Tracks, Video from unshackle.core.utilities import log_event, try_ensure_utf8 diff --git a/unshackle/core/manifests/ism_init.py b/unshackle/core/manifests/ism_init.py index d3421da1..fff76966 100644 --- a/unshackle/core/manifests/ism_init.py +++ b/unshackle/core/manifests/ism_init.py @@ -29,9 +29,13 @@ s32 = struct.Struct(">i") # 3x3 transformation matrix (identity), as stored in tkhd/mvhd. UNITY_MATRIX = ( - s32.pack(0x10000) + s32.pack(0) * 3 - + s32.pack(0) + s32.pack(0x10000) + s32.pack(0) * 2 - + s32.pack(0) * 2 + s32.pack(0x40000000) + s32.pack(0x10000) + + s32.pack(0) * 3 + + s32.pack(0) + + s32.pack(0x10000) + + s32.pack(0) * 2 + + s32.pack(0) * 2 + + s32.pack(0x40000000) ) TRACK_ENABLED = 0x1 @@ -635,27 +639,54 @@ def build_init_segment( # --- mvhd --- mvhd = full_box( - b"mvhd", 1, 0, - u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(timescale) + u64.pack(duration) - + s1616.pack(1) + s88.pack(1) + u16.pack(0) + u32.pack(0) * 2 - + UNITY_MATRIX + u32.pack(0) * 6 + u32.pack(0xFFFFFFFF), + b"mvhd", + 1, + 0, + u64.pack(EPOCH) + + u64.pack(EPOCH) + + u32.pack(timescale) + + u64.pack(duration) + + s1616.pack(1) + + s88.pack(1) + + u16.pack(0) + + u32.pack(0) * 2 + + UNITY_MATRIX + + u32.pack(0) * 6 + + u32.pack(0xFFFFFFFF), ) # --- tkhd --- tkhd = full_box( - b"tkhd", 1, TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW, - u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(track_id) + u32.pack(0) - + u64.pack(duration) + u32.pack(0) * 2 + s16.pack(0) + s16.pack(0) - + s88.pack(1 if stream_type == "audio" else 0) + u16.pack(0) + UNITY_MATRIX - + u1616.pack(width) + u1616.pack(height), + b"tkhd", + 1, + TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW, + u64.pack(EPOCH) + + u64.pack(EPOCH) + + u32.pack(track_id) + + u32.pack(0) + + u64.pack(duration) + + u32.pack(0) * 2 + + s16.pack(0) + + s16.pack(0) + + s88.pack(1 if stream_type == "audio" else 0) + + u16.pack(0) + + UNITY_MATRIX + + u1616.pack(width) + + u1616.pack(height), ) # --- mdhd + hdlr --- packed_lang = ((ord(lang[0]) - 0x60) << 10) | ((ord(lang[1]) - 0x60) << 5) | (ord(lang[2]) - 0x60) mdhd = full_box( - b"mdhd", 1, 0, - u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(timescale) + u64.pack(duration) - + u16.pack(packed_lang) + u16.pack(0), + b"mdhd", + 1, + 0, + u64.pack(EPOCH) + + u64.pack(EPOCH) + + u32.pack(timescale) + + u64.pack(duration) + + u16.pack(packed_lang) + + u16.pack(0), ) if stream_type == "audio": hdlr = full_box(b"hdlr", 0, 0, u32.pack(0) + b"soun" + u32.pack(0) * 3 + b"SoundHandler\0") @@ -675,10 +706,18 @@ def build_init_segment( sample_entry_payload = u8.pack(0) * 6 + u16.pack(1) # reserved + data reference index if stream_type == "video": sample_entry_payload += ( - u16.pack(0) + u16.pack(0) + u32.pack(0) * 3 - + u16.pack(width) + u16.pack(height) - + u1616.pack(0x48) + u1616.pack(0x48) + u32.pack(0) + u16.pack(1) - + u8.pack(0) * 32 + u16.pack(0x18) + s16.pack(-1) + u16.pack(0) + + u16.pack(0) + + u32.pack(0) * 3 + + u16.pack(width) + + u16.pack(height) + + u1616.pack(0x48) + + u1616.pack(0x48) + + u32.pack(0) + + u16.pack(1) + + u8.pack(0) * 32 + + u16.pack(0x18) + + s16.pack(-1) ) if fourcc in ("H264", "AVC1", "DAVC"): config_box = build_avcc(cpd, nal_length_size) @@ -702,8 +741,12 @@ def build_init_segment( # samplerate is 16.16 fixed-point; rates above 65535 Hz are written as 0 # (decoders read the real rate from the codec config), matching ffmpeg. sample_entry_payload += ( - u32.pack(0) * 2 + u16.pack(channels) + u16.pack(bits_per_sample) - + u16.pack(0) + u16.pack(0) + u32.pack((sampling_rate if sampling_rate <= 0xFFFF else 0) << 16) + u32.pack(0) * 2 + + u16.pack(channels) + + u16.pack(bits_per_sample) + + u16.pack(0) + + u16.pack(0) + + u32.pack((sampling_rate if sampling_rate <= 0xFFFF else 0) << 16) ) if fourcc in ("AACL", "AACH", "AAC"): if not cpd: @@ -749,7 +792,9 @@ def build_init_segment( # --- mvex (mehd + trex) signals a fragmented file --- mehd = full_box(b"mehd", 1, 0, u64.pack(duration)) trex = full_box( - b"trex", 0, 0, + b"trex", + 0, + 0, u32.pack(track_id) + u32.pack(1) + u32.pack(0) + u32.pack(0) + u32.pack(0), ) mvex = box(b"mvex", mehd + trex) diff --git a/unshackle/core/music/__init__.py b/unshackle/core/music/__init__.py index df6779e1..87189990 100644 --- a/unshackle/core/music/__init__.py +++ b/unshackle/core/music/__init__.py @@ -1,6 +1,15 @@ from .display import MusicHeaderInfo, TrackRow, render_album_header, render_artwork_preview, render_track_panel -from .extract import (build_music_from_songs, classify_release_kind, dedupe_track_options, duration_seconds, - first_number, first_text, format_duration, format_names, year_from_value) +from .extract import ( + build_music_from_songs, + classify_release_kind, + dedupe_track_options, + duration_seconds, + first_number, + first_text, + format_duration, + format_names, + year_from_value, +) from .hasher import file_md5 from .integrity import MusicAudioIntegrityError, MusicAudioIntegrityResult, verify_music_audio from .manifest import write_music_manifest diff --git a/unshackle/core/music/integrity.py b/unshackle/core/music/integrity.py index 8ade62fd..c1706406 100644 --- a/unshackle/core/music/integrity.py +++ b/unshackle/core/music/integrity.py @@ -40,7 +40,9 @@ class MusicAudioIntegrityResult: } -def verify_music_audio(path: Path, *, song: Any = None, track: Any = None, media_info: Optional[MediaInfo] = None) -> MusicAudioIntegrityResult: +def verify_music_audio( + path: Path, *, song: Any = None, track: Any = None, media_info: Optional[MediaInfo] = None +) -> MusicAudioIntegrityResult: """Verify a downloaded music audio file after it is playable/decrypted.""" path = Path(path) if not path.exists(): diff --git a/unshackle/core/music/planner.py b/unshackle/core/music/planner.py index 9f6797f1..2ac0bcde 100644 --- a/unshackle/core/music/planner.py +++ b/unshackle/core/music/planner.py @@ -21,7 +21,8 @@ class MusicPlanner: plan = MusicDownloadPlan( kind=getattr(music, "kind", "music"), title=getattr(music, "title", None) or (first_song.album if first_song else ""), - artist=getattr(music, "artist", None) or (first_song.album_artist or first_song.artist if first_song else ""), + artist=getattr(music, "artist", None) + 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( @@ -180,9 +181,14 @@ class MusicPlanner: if any(option.atmos for option in options): return "Dolby Atmos" - if any(option.hires and (option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"}) for option in options): + if any( + option.hires and (option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"}) + for option in options + ): return "Hi-Res Lossless" - if any(option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"} for option in options): + if any( + option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"} for option in options + ): return "Lossless" if "AAC" in codecs: return "AAC" diff --git a/unshackle/core/music/renderer.py b/unshackle/core/music/renderer.py index 6bfaf44c..65674aa0 100644 --- a/unshackle/core/music/renderer.py +++ b/unshackle/core/music/renderer.py @@ -57,9 +57,7 @@ class MusicRenderer: metadata.get("released_at"), ) ) - length = self._format_total_duration( - getattr(music, "total_duration", None) or self._sum_duration(music) - ) + 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), diff --git a/unshackle/core/music/tagger.py b/unshackle/core/music/tagger.py index 42172361..f756f15b 100644 --- a/unshackle/core/music/tagger.py +++ b/unshackle/core/music/tagger.py @@ -15,8 +15,23 @@ except ImportError: # pragma: no cover - optional artwork enhancement try: from mutagen.flac import FLAC, Picture - from mutagen.id3 import (APIC, COMM, TALB, TCOM, TCON, TCOP, TDRC, TIT2, TPE1, TPE2, TPOS, TRCK, TSRC, TXXX, - ID3NoHeaderError) + from mutagen.id3 import ( + APIC, + COMM, + TALB, + TCOM, + TCON, + TCOP, + TDRC, + TIT2, + TPE1, + TPE2, + TPOS, + TRCK, + TSRC, + TXXX, + ID3NoHeaderError, + ) from mutagen.mp3 import MP3 from mutagen.mp4 import MP4, MP4Cover except ImportError: # pragma: no cover - optional tagging dependency diff --git a/unshackle/core/proxies/hola.py b/unshackle/core/proxies/hola.py index 1189b49e..292871cd 100644 --- a/unshackle/core/proxies/hola.py +++ b/unshackle/core/proxies/hola.py @@ -33,9 +33,9 @@ class Hola(Proxy): """ query = query.lower() - p = subprocess.check_output( - [self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT - ).decode("utf-8", errors="replace") + p = subprocess.check_output([self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT).decode( + "utf-8", errors="replace" + ) if "Transaction error: temporary ban detected." in p: raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.") diff --git a/unshackle/core/service_repo.py b/unshackle/core/service_repo.py index dcd96d0f..92649bfb 100644 --- a/unshackle/core/service_repo.py +++ b/unshackle/core/service_repo.py @@ -20,6 +20,7 @@ class DirtyServiceRepo(Exception): self.path = path super().__init__(str(path)) + DEFAULT_TTL = 24 * 60 * 60 # refresh at most once a day (mirrors UpdateChecker) STAMP_SUFFIX = ".fetch" # last-fetch unix time, kept beside (not inside) the clone @@ -28,6 +29,7 @@ def stamp_for(dest: Path) -> Path: """Fetch-timestamp file path, kept OUTSIDE the clone so it never shows up in `git status`.""" return dest.parent / f"{dest.name}{STAMP_SUFFIX}" + # owner/repo shorthand: exactly two path-like segments, no scheme SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$") @@ -125,9 +127,7 @@ def _pull(dest: Path, stamp: Path) -> None: 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 + 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) @@ -172,9 +172,7 @@ def _changed_services(dest: Path, old: Optional[str], new: Optional[str]) -> lis if not old or not new or old == new: return [] git = str(binaries.Git) - name_status = subprocess.run( - [git, "-C", str(dest), "diff", "--name-status", f"{old}..{new}"], capture_output=True - ) # nosec B603 + name_status = subprocess.run([git, "-C", str(dest), "diff", "--name-status", f"{old}..{new}"], capture_output=True) # nosec B603 by_dir: dict[str, set[str]] = {} if name_status.returncode == 0: for raw in name_status.stdout.decode(errors="ignore").splitlines(): @@ -223,9 +221,7 @@ def _is_dirty(dest: Path) -> bool: if status.returncode == 0 and status.stdout.strip(): return True # local commits ahead of upstream (committed but not pushed); no upstream → treat as clean - ahead = subprocess.run( - [git, "-C", str(dest), "rev-list", "--count", "@{u}..HEAD"], capture_output=True - ) # nosec B603 + ahead = subprocess.run([git, "-C", str(dest), "rev-list", "--count", "@{u}..HEAD"], capture_output=True) # nosec B603 if ahead.returncode == 0: try: return int(ahead.stdout.strip() or b"0") > 0 diff --git a/unshackle/core/services.py b/unshackle/core/services.py index b4dda122..89815493 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -41,7 +41,9 @@ 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))}") + 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) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 86c77099..efd1e51f 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -338,7 +338,7 @@ class Hybrid: for crop in crop_results: crop_counts[crop] = crop_counts.get(crop, 0) + 1 most_common = max(crop_counts, key=crop_counts.get) - left, top, right, bottom = most_common # frame instead of leaving phantom bars from the source. + left, top, right, bottom = most_common # frame instead of leaving phantom bars from the source. l5_json = { "active_area": { diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index b18f19da..b86171c0 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -982,7 +982,9 @@ class DebugLogger: """ message = kwargs.pop("message", "") level = kwargs.pop("level", "DEBUG") - self.log(level=level, operation="service_call", message=message, request={"method": method, "url": url, **kwargs}) + self.log( + level=level, operation="service_call", message=message, request={"method": method, "url": url, **kwargs} + ) def log_drm_operation(self, drm_type: str, operation: str, **kwargs): """ diff --git a/unshackle/core/utils/firefox_cookie_extractor.py b/unshackle/core/utils/firefox_cookie_extractor.py index f9ab6ffe..75c00fe7 100644 --- a/unshackle/core/utils/firefox_cookie_extractor.py +++ b/unshackle/core/utils/firefox_cookie_extractor.py @@ -17,15 +17,15 @@ def get_firefox_root() -> Path: """Locates the operating system specific root directory for Firefox profiles.""" system = platform.system() home = Path.home() - if system == 'Windows': - return Path(os.getenv('APPDATA', '')) / 'Mozilla' / 'Firefox' - elif system == 'Darwin': - return home / 'Library' / 'Application Support' / 'Firefox' - elif system == 'Linux': + if system == "Windows": + return Path(os.getenv("APPDATA", "")) / "Mozilla" / "Firefox" + elif system == "Darwin": + return home / "Library" / "Application Support" / "Firefox" + elif system == "Linux": paths = [ - home / '.mozilla' / 'firefox', - home / 'snap' / 'firefox' / 'common' / '.mozilla' / 'firefox', - home / '.var' / 'app' / 'org.mozilla.firefox' / '.mozilla' / 'firefox' + home / ".mozilla" / "firefox", + home / "snap" / "firefox" / "common" / ".mozilla" / "firefox", + home / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox", ] for p in paths: if p.exists(): @@ -36,16 +36,16 @@ def get_firefox_root() -> Path: def get_latest_profile_path(ff_root: Path) -> Path: """Finds the most recently modified valid Firefox profile subdirectory.""" - search_dirs = [ff_root / 'Profiles', ff_root] + search_dirs = [ff_root / "Profiles", ff_root] valid_profiles = [] for base in search_dirs: if base.exists(): - valid_profiles.extend([p for p in base.iterdir() if p.is_dir() and (p / 'cookies.sqlite').exists()]) + valid_profiles.extend([p for p in base.iterdir() if p.is_dir() and (p / "cookies.sqlite").exists()]) if not valid_profiles: raise FileNotFoundError("No active Firefox profiles detected.") - return max(valid_profiles, key=lambda p: (p / 'cookies.sqlite').stat().st_mtime) + return max(valid_profiles, key=lambda p: (p / "cookies.sqlite").stat().st_mtime) def get_local_storage_data(profile_path: Path, hosts: List[str], tmp_dir_path: Path) -> Dict[str, str]: @@ -53,7 +53,7 @@ def get_local_storage_data(profile_path: Path, hosts: List[str], tmp_dir_path: P Extracts LocalStorage tokens from webappsstore.sqlite with strict origin matching. Operates within a secure temporary directory. """ - ls_db = profile_path / 'webappsstore.sqlite' + ls_db = profile_path / "webappsstore.sqlite" if not ls_db.exists(): return {} @@ -87,10 +87,10 @@ def get_firefox_cookies(service_settings: dict) -> Optional[CookieJar]: Extracts cookies and optionally localStorage from Firefox based on provided host patterns. Implements security hardening and data consistency (WAL support) as per PR review. """ - raw_hosts = service_settings.get('hosts', []) + raw_hosts = service_settings.get("hosts", []) # Filter empty or dangerously short hosts to prevent full store dumps (Finding #3) priority_hosts = [h.strip().lower() for h in raw_hosts if h and len(h.strip()) >= 3] - use_local_storage = service_settings.get('local_storage', False) + use_local_storage = service_settings.get("local_storage", False) if not priority_hosts: return None @@ -112,8 +112,8 @@ def get_firefox_cookies(service_settings: dict) -> Optional[CookieJar]: try: # Copy main DB and its WAL log to ensure fresh data capture (Finding #5) - shutil.copy2(profile_path / 'cookies.sqlite', temp_db) - wal_path = profile_path / 'cookies.sqlite-wal' + shutil.copy2(profile_path / "cookies.sqlite", temp_db) + wal_path = profile_path / "cookies.sqlite-wal" if wal_path.exists(): shutil.copy2(wal_path, temp_wal) @@ -134,11 +134,23 @@ def get_firefox_cookies(service_settings: dict) -> Optional[CookieJar]: # Final validation to ensure proper domain suffix if h == host or h.endswith(f".{host}"): c = Cookie( - version=0, name=n, value=v, port=None, port_specified=False, - domain=h, domain_specified=True, domain_initial_dot=h.startswith('.'), - path=p, path_specified=True, secure=bool(secure), expires=e, - discard=False, comment=None, comment_url=None, - rest={'HttpOnly': str(bool(httponly))}, rfc2109=False + version=0, + name=n, + value=v, + port=None, + port_specified=False, + domain=h, + domain_specified=True, + domain_initial_dot=h.startswith("."), + path=p, + path_specified=True, + secure=bool(secure), + expires=e, + discard=False, + comment=None, + comment_url=None, + rest={"HttpOnly": str(bool(httponly))}, + rfc2109=False, ) cookie_jar.set_cookie(c) conn.close() @@ -148,10 +160,23 @@ def get_firefox_cookies(service_settings: dict) -> Optional[CookieJar]: storage_data = get_local_storage_data(profile_path, priority_hosts, tmp_dir_path) for key, val in storage_data.items(): c = Cookie( - version=0, name=key, value=val, port=None, port_specified=False, - domain=".localstorage", domain_specified=True, domain_initial_dot=False, - path='/', path_specified=True, secure=False, expires=None, - discard=True, comment=None, comment_url=None, rest={}, rfc2109=False + version=0, + name=key, + value=val, + port=None, + port_specified=False, + domain=".localstorage", + domain_specified=True, + domain_initial_dot=False, + path="/", + path_specified=True, + secure=False, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, ) cookie_jar.set_cookie(c) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index 602c0550..c77d0597 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -10,8 +10,16 @@ from xml.sax.saxutils import escape from unshackle.core import binaries from unshackle.core.config import config -from unshackle.core.providers import (ExternalIds, MetadataResult, enrich_ids, fetch_external_ids, fuzzy_match, - get_available_providers, get_provider, search_metadata) +from unshackle.core.providers import ( + ExternalIds, + MetadataResult, + enrich_ids, + fetch_external_ids, + fuzzy_match, + get_available_providers, + get_provider, + search_metadata, +) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie from unshackle.core.titles.title import Title