style: run ruff format over the codebase

This commit is contained in:
imSp4rky
2026-07-15 14:49:27 -06:00
parent d853ef9600
commit e31e60a418
39 changed files with 492 additions and 186 deletions
+9 -2
View File
@@ -15,8 +15,15 @@ from types import SimpleNamespace
import pytest import pytest
from unshackle.core.cdm import loader as loader_mod 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, from unshackle.core.cdm.detect import (
is_remote_playready_cdm, is_remote_widevine_cdm, is_widevine_cdm) 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 from unshackle.core.cdm.loader import _stamp_remote
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
+1
View File
@@ -16,6 +16,7 @@ from typing import Any, Optional
from uuid import UUID from uuid import UUID
import pytest import pytest
# lxml.etree: XML parser used to build ContentProtection fixtures for DASH.get_drm # lxml.etree: XML parser used to build ContentProtection fixtures for DASH.get_drm
from lxml import etree from lxml import etree
+7 -2
View File
@@ -9,8 +9,13 @@ from pathlib import Path
import pytest import pytest
from unshackle.core.utilities import (close_debug_logger, get_debug_logger, init_debug_logger, log_event, from unshackle.core.utilities import (
timed_operation) 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.redact import redact_all, redact_path, redact_text, redact_url, safe_display_url
from unshackle.core.utils.subprocess import log_tool_run from unshackle.core.utils.subprocess import log_tool_run
+18 -6
View File
@@ -12,12 +12,24 @@ import struct
import pytest import pytest
from unshackle.core.manifests.ism_init import (NAL_START_CODE, PIFF_SENC_UUID, box, build_avcc, build_dec3, from unshackle.core.manifests.ism_init import (
build_hvcc, build_init_segment, full_box, NAL_START_CODE,
parse_codec_private_data_colour, parse_codec_private_data_vui, PIFF_SENC_UUID,
parse_hevc_sps_format, read_per_sample_iv_size, read_track_id, box,
remove_emulation_prevention, split_nal_units, build_avcc,
synthesize_aac_codec_private_data) 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. # Real CodecPrivateData taken from a Smooth Streaming manifest.
VIDEO_HEVC_CPD = ( VIDEO_HEVC_CPD = (
+2 -2
View File
@@ -4,11 +4,11 @@ undecidable from the manifest; the post-mux bitstream probe names it."""
from __future__ import annotations 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.manifests import ISM
from unshackle.core.tracks import Video 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 from tests.core.test_ism_init import VIDEO_HEVC_HLG_CPD # isort: skip
VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD)
def manifest_xml(cpd: str, fourcc: str = "HVC1") -> str: def manifest_xml(cpd: str, fourcc: str = "HVC1") -> str:
+25 -5
View File
@@ -12,8 +12,14 @@ from typing import Any, Optional
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.panel import Panel from rich.panel import Panel
from unshackle.core.music.display import (MusicHeaderInfo, TrackRow, format_track_detail_quality, render_album_header, from unshackle.core.music.display import (
render_artwork_preview, render_track_panel) MusicHeaderInfo,
TrackRow,
format_track_detail_quality,
render_album_header,
render_artwork_preview,
render_track_panel,
)
from unshackle.core.titles.music import Song 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: def test_render_track_panel_returns_panel() -> None:
rows = [ 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(
TrackRow(song=make_song(track=2, name="Second"), quality_label="OGG 320", layout="Stereo", duration_str="4:00", hires=True), 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)) panel = render_track_panel(rows, total=len(rows))
assert isinstance(panel, Panel) assert isinstance(panel, Panel)
@@ -112,7 +130,9 @@ def test_render_track_panel_returns_panel() -> None:
def test_render_track_panel_singular_label() -> 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)) text = render_to_text(render_track_panel(rows, total=1))
# The count node reads "1 Track" (singular); the panel title is always # The count node reads "1 Track" (singular); the panel title is always
# "Available Tracks", so we only check the count node phrasing here. # "Available Tracks", so we only check the count node phrasing here.
+11 -3
View File
@@ -6,9 +6,17 @@ from typing import Any, Optional
import pytest import pytest
from unshackle.core.music.extract import (build_music_from_songs, classify_release_kind, dedupe_track_options, from unshackle.core.music.extract import (
duration_seconds, first_number, first_text, format_duration, format_names, build_music_from_songs,
year_from_value) 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.music.models import MusicTrackOption
from unshackle.core.titles.music import Music, Song from unshackle.core.titles.music import Music, Song
+1 -3
View File
@@ -115,9 +115,7 @@ def hand_built_ctx(body: dict[str, Any]) -> click.Context:
ctx = click.Context(parsed.command) ctx = click.Context(parsed.command)
ctx.params = {"repack": body.get("repack", False)} ctx.params = {"repack": body.get("repack", False)}
for name in ctx.params: for name in ctx.params:
ctx.set_parameter_source( ctx.set_parameter_source(name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT)
name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT
)
return ctx return ctx
+1 -3
View File
@@ -240,9 +240,7 @@ def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path):
def test_changed_services_up_to_date(monkeypatch): def test_changed_services_up_to_date(monkeypatch):
monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo.binaries, "Git", "git")
# same HEAD before/after → no diff computed at all # same HEAD before/after → no diff computed at all
monkeypatch.setattr( monkeypatch.setattr(service_repo.subprocess, "run", lambda *a, **k: pytest.fail("no git diff when HEAD unchanged"))
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") == []
@@ -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 # Base language vs regional variant that CLDR rates at distance 0 (paradigm locales). These are
# the pairs that collapse under naive exact matching. # the pairs that collapse under naive exact matching.
COLLAPSING_PAIRS = [ COLLAPSING_PAIRS = [
("ar", "ar-EG"), ("en", "en-US"), ("pt", "pt-BR"), ("fr", "fr-FR"), ("ar", "ar-EG"),
("es", "es-ES"), ("de", "de-DE"), ("it", "it-IT"), ("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. # 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. # Includes two-regional splits (no bare base) as services more commonly ship them.
DISTINCT_PAIRS = [ DISTINCT_PAIRS = [
("es", "es-419"), ("fr", "fr-CA"), ("fr-FR", "fr-CA"), ("pt-PT", "pt-BR"), ("es-ES", "es-419"), ("es", "es-419"),
("en-US", "en-GB"), ("de-DE", "de-AT"), ("it-IT", "it-CH"), ("es-419", "es-AR"), ("no", "nn"), ("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, # 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 # 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. # language references (Warner Bros. Discovery / Netflix) and include regional and script variants.
REAL_WORLD_AUDIO_LANGS = [ REAL_WORLD_AUDIO_LANGS = [
"ar", "ar-EG", "ar-SA", "bn-IN", "cmn-TW", "cs", "da", "de", "de-AT", "de-CH", "el", "en", "ar",
"en-GB", "en-AU", "es-419", "es-AR", "es-ES", "fi", "fr-CA", "fr-FR", "he", "hi", "hu", "id", "ar-EG",
"it", "it-CH", "ja", "ko", "nl", "no", "pl", "pt-BR", "pt-PT", "ro", "ru", "sk", "sr", "ar-SA",
"sr-Latn", "sv", "ta", "th", "tr", "uk", "vi", "yue", "zh-Hans", "zh-Hant", "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",
] ]
@@ -9,8 +9,13 @@ import pytest
from aiohttp import web from aiohttp import web
from unshackle.core.api import handlers from unshackle.core.api import handlers
from unshackle.core.api.download_manager import (DownloadJob, JobStatus, _redact_parameters, _redact_text, from unshackle.core.api.download_manager import (
_secret_values) DownloadJob,
JobStatus,
_redact_parameters,
_redact_text,
_secret_values,
)
from unshackle.core.api.errors import APIError, APIErrorCode from unshackle.core.api.errors import APIError, APIErrorCode
pytestmark = pytest.mark.unit 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): async def test_cdm_override_allowlist_permits_only_named_device(stub_handler):
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]}) stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]})
assert isinstance( assert isinstance(await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response)
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response
)
with pytest.raises(APIError) as ei: with pytest.raises(APIError) as ei:
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "other"}) await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "other"})
assert ei.value.error_code == APIErrorCode.FORBIDDEN assert ei.value.error_code == APIErrorCode.FORBIDDEN
+7 -2
View File
@@ -6,8 +6,13 @@ import json
import pytest import pytest
from unshackle.core.api.errors import (APIError, APIErrorCode, build_error_response, categorize_exception, from unshackle.core.api.errors import (
handle_api_exception) APIError,
APIErrorCode,
build_error_response,
categorize_exception,
handle_api_exception,
)
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
+10 -3
View File
@@ -5,9 +5,16 @@ from __future__ import annotations
import pytest import pytest
from langcodes import Language from langcodes import Language
from unshackle.core.api.handlers import (sanitize_log, serialize_audio_track, serialize_drm, serialize_subtitle_track, from unshackle.core.api.handlers import (
serialize_title, serialize_video_track, validate_download_parameters, sanitize_log,
validate_service) 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.episode import Episode
from unshackle.core.titles.movie import Movie from unshackle.core.titles.movie import Movie
from unshackle.core.tracks import Audio, Subtitle, Video from unshackle.core.tracks import Audio, Subtitle, Video
+7 -5
View File
@@ -9,8 +9,12 @@ from __future__ import annotations
import pytest import pytest
from unshackle.core.api.progress import (DOWNLOAD_PROGRESS_CEILING, build_job_progress_callables, from unshackle.core.api.progress import (
track_progress_label, track_progress_weight) DOWNLOAD_PROGRESS_CEILING,
build_job_progress_callables,
track_progress_label,
track_progress_weight,
)
pytestmark = pytest.mark.unit 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(): def test_active_tracks_labels_reported_and_cleared_on_done():
updates: list[dict] = [] updates: list[dict] = []
cbs = build_job_progress_callables( cbs = build_job_progress_callables([Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append)
[Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append
)
cbs[0](total=100, completed=10) # video downloading cbs[0](total=100, completed=10) # video downloading
assert updates[-1]["active_tracks"] == ["video 2160p DV"] assert updates[-1]["active_tracks"] == ["video 2160p DV"]
@@ -6,8 +6,16 @@ from enum import Enum
import pytest import pytest
from unshackle.core.remote_service import (_build_title, _build_tracks, _deserialize_audio, _deserialize_subtitle, from unshackle.core.remote_service import (
_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.episode import Episode
from unshackle.core.titles.movie import Movie from unshackle.core.titles.movie import Movie
from unshackle.core.tracks import Audio, Subtitle, Video from unshackle.core.tracks import Audio, Subtitle, Video
+35 -14
View File
@@ -90,8 +90,11 @@ def test_failed_subtitle_does_not_truncate_video_or_audio():
h = Harness(fail_ids={"s-he"}) h = Harness(fail_ids={"s-he"})
download_tracks_in_passes( download_tracks_in_passes(
[video, audio, sub], 4, h.run_one, [video, audio, sub],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 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 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"}) h = Harness(fail_ids={"s-fr"})
download_tracks_in_passes( download_tracks_in_passes(
[video, good, bad], 4, h.run_one, [video, good, bad],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 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 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): with pytest.raises(RuntimeError):
download_tracks_in_passes( download_tracks_in_passes(
[video, sub], 4, h.run_one, [video, sub],
skip_subtitle_errors=False, on_subtitle_skipped=h.on_subtitle_skipped, 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 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()) h = Harness(fail_ids=set())
download_tracks_in_passes( download_tracks_in_passes(
[video, audio], 4, h.run_one, [video, audio],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
) )
assert set(h.completed) == {"v", "a"} 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"}) h = Harness(fail_ids={"s-he"})
download_tracks_in_passes( download_tracks_in_passes(
[video, sub], 4, h.run_one, [video, sub],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 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 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"}) h = Harness(fail_ids={"s-en", "s-he"})
download_tracks_in_passes( download_tracks_in_passes(
[video, audio, s1, s2], 4, h.run_one, [video, audio, s1, s2],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 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 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"}) h = Harness(fail_ids={"en-forced", "en-sdh"})
download_tracks_in_passes( download_tracks_in_passes(
[make_video(), forced, sdh], 4, h.run_one, [make_video(), forced, sdh],
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped, 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 assert [t.id for t in h.skipped] == ["en-forced", "en-sdh"] # same language, distinct ids
+1 -5
View File
@@ -103,11 +103,7 @@ def test_merge_dedupes_repeated_style_blocks_before_cues():
def test_merge_skips_malformed_timing_line(): def test_merge_skips_malformed_timing_line():
vtt = ( vtt = "WEBVTT\n\ngarbage --> not-a-time\nBroken\n\n00:00:01.000 --> 00:00:02.000\nGood\n"
"WEBVTT\n\n"
"garbage --> not-a-time\nBroken\n\n"
"00:00:01.000 --> 00:00:02.000\nGood\n"
)
out = merge_segmented_webvtt(vtt) out = merge_segmented_webvtt(vtt)
assert "Good" in out assert "Good" in out
assert "Broken" not in out assert "Broken" not in out
+32 -8
View File
@@ -47,8 +47,16 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY,
from unshackle.core.credential import Credential from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine
from unshackle.core.events import events from unshackle.core.events import events
from unshackle.core.music import (MusicAudioIntegrityError, MusicMetadataResult, MusicPlanner, MusicRenderer, from unshackle.core.music import (
file_md5, verify_music_audio, write_music_manifest, write_music_metadata) 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.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.service import Service from unshackle.core.service import Service
from unshackle.core.services import Services 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.attachment import Attachment
from unshackle.core.tracks.dv_fixup import apply_dv_fixup from unshackle.core.tracks.dv_fixup import apply_dv_fixup
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (find_font_with_fallbacks, find_missing_langs, get_debug_logger, from unshackle.core.utilities import (
get_system_fonts, init_debug_logger, is_close_match, log_event, find_font_with_fallbacks,
suggest_font_packages, time_elapsed_since) 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 import tags
from unshackle.core.utils.bitrate import apply_real_bitrates 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, from unshackle.core.utils.click_types import (
SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice, AUDIO_CODEC_LIST,
SubtitleCodecChoice) 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.collections import ci_get, merge_dict
from unshackle.core.utils.selector import select_multiple from unshackle.core.utils.selector import select_multiple
from unshackle.core.utils.subprocess import ffprobe from unshackle.core.utils.subprocess import ffprobe
+1 -3
View File
@@ -620,9 +620,7 @@ class DownloadQueueManager:
f"Initialized download queue manager: max_concurrent={max_concurrent_downloads}, retention_hours={job_retention_hours}" f"Initialized download queue manager: max_concurrent={max_concurrent_downloads}, retention_hours={job_retention_hours}"
) )
def create_job( def create_job(self, service: str, title_id: str, owner_key: Optional[str] = None, **parameters) -> DownloadJob:
self, service: str, title_id: str, owner_key: Optional[str] = None, **parameters
) -> DownloadJob:
"""Create a new download job and add it to the queue.""" """Create a new download job and add it to the queue."""
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
job = DownloadJob( job = DownloadJob(
+1 -3
View File
@@ -63,9 +63,7 @@ def main(argv: list[str]) -> int:
except Exception as e: except Exception as e:
log.error(f"Failed to write progress update: {e}") log.error(f"Failed to write progress update: {e}")
output_files = _perform_download( output_files = _perform_download(job_id, service, title_id, params, progress_callback=progress_callback)
job_id, service, title_id, params, progress_callback=progress_callback
)
result = {"status": "success", "output_files": output_files} result = {"status": "success", "output_files": output_files}
+7 -7
View File
@@ -929,9 +929,7 @@ def enforce_download_gates(params: Dict[str, Any]) -> None:
requested_cdm = params.get("cdm") requested_cdm = params.get("cdm")
if requested_cdm: if requested_cdm:
allowed = (config.serve or {}).get("cdm_overrides") allowed = (config.serve or {}).get("cdm_overrides")
permitted = allowed is True or ( permitted = allowed is True or (isinstance(allowed, (list, tuple, set)) and requested_cdm in allowed)
isinstance(allowed, (list, tuple, set)) and requested_cdm in allowed
)
if not permitted: if not permitted:
raise APIError( raise APIError(
APIErrorCode.FORBIDDEN, APIErrorCode.FORBIDDEN,
@@ -1005,9 +1003,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
**service_specific_defaults, **service_specific_defaults,
**filtered_params, **filtered_params,
} }
job = manager.create_job( job = manager.create_job(normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults)
normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults
)
return web.json_response( return web.json_response(
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202 {"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) new_job = manager.create_job(job.service, job.title_id, owner_key=caller_key(request), **job.parameters)
return web.json_response( 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, status=202,
) )
+30 -11
View File
@@ -10,17 +10,36 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
from unshackle.core import __version__ from unshackle.core import __version__
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception 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, from unshackle.core.api.handlers import (
clear_finished_download_jobs_handler, clear_temp_handler, cancel_download_job_handler,
delete_history_handler, download_handler, download_history_handler, clear_cache_handler,
env_check_handler, get_allowed_services, get_download_job_handler, clear_finished_download_jobs_handler,
list_download_jobs_handler, list_titles_handler, list_tracks_handler, clear_temp_handler,
prioritize_download_job_handler, profiles_handler, refresh_services_handler, delete_history_handler,
retry_download_job_handler, search_handler, server_config_handler, download_handler,
session_create_handler, session_delete_handler, session_info_handler, download_history_handler,
session_license_handler, session_prompt_get_handler, env_check_handler,
session_prompt_post_handler, session_segments_handler, get_allowed_services,
session_titles_handler, session_tracks_handler) 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.services import Services
from unshackle.core.update_checker import UpdateChecker from unshackle.core.update_checker import UpdateChecker
+1 -3
View File
@@ -99,9 +99,7 @@ class SessionStore:
if entry.auth_status not in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT): if entry.auth_status not in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT):
elapsed = (datetime.now(timezone.utc) - entry.last_accessed).total_seconds() elapsed = (datetime.now(timezone.utc) - entry.last_accessed).total_seconds()
if elapsed > self._ttl: if elapsed > self._ttl:
log.info( log.info(f"Session {sanitize_log(session_id)} expired (elapsed={elapsed:.0f}s, ttl={self._ttl}s)")
f"Session {sanitize_log(session_id)} expired (elapsed={elapsed:.0f}s, ttl={self._ttl}s)"
)
del self._sessions[session_id] del self._sessions[session_id]
return None return None
+7 -2
View File
@@ -224,8 +224,13 @@ class ClearKeyCENC:
try: try:
subprocess.run( subprocess.run(
cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cmd,
encoding="utf-8", errors="replace", check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
+7 -2
View File
@@ -439,8 +439,13 @@ class PlayReady:
try: try:
subprocess.run( subprocess.run(
cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cmd,
encoding="utf-8", errors="replace", check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}" error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
+6 -2
View File
@@ -19,8 +19,12 @@ from requests import Session
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack
from unshackle.core.drm import DRM_T, PlayReady, Widevine from unshackle.core.drm import DRM_T, PlayReady, Widevine
from unshackle.core.events import events from unshackle.core.events import events
from unshackle.core.manifests.ism_init import (build_init_segment, parse_codec_private_data_vui, from unshackle.core.manifests.ism_init import (
read_per_sample_iv_size, read_track_id) 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.session import RnetSession
from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Track, Tracks, Video from unshackle.core.tracks import Audio, DownloadContext, Subtitle, Track, Tracks, Video
from unshackle.core.utilities import log_event, try_ensure_utf8 from unshackle.core.utilities import log_event, try_ensure_utf8
+67 -22
View File
@@ -29,9 +29,13 @@ s32 = struct.Struct(">i")
# 3x3 transformation matrix (identity), as stored in tkhd/mvhd. # 3x3 transformation matrix (identity), as stored in tkhd/mvhd.
UNITY_MATRIX = ( UNITY_MATRIX = (
s32.pack(0x10000) + s32.pack(0) * 3 s32.pack(0x10000)
+ s32.pack(0) + s32.pack(0x10000) + s32.pack(0) * 2 + s32.pack(0) * 3
+ s32.pack(0) * 2 + s32.pack(0x40000000) + s32.pack(0)
+ s32.pack(0x10000)
+ s32.pack(0) * 2
+ s32.pack(0) * 2
+ s32.pack(0x40000000)
) )
TRACK_ENABLED = 0x1 TRACK_ENABLED = 0x1
@@ -635,27 +639,54 @@ def build_init_segment(
# --- mvhd --- # --- mvhd ---
mvhd = full_box( mvhd = full_box(
b"mvhd", 1, 0, b"mvhd",
u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(timescale) + u64.pack(duration) 1,
+ s1616.pack(1) + s88.pack(1) + u16.pack(0) + u32.pack(0) * 2 0,
+ UNITY_MATRIX + u32.pack(0) * 6 + u32.pack(0xFFFFFFFF), 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 ---
tkhd = full_box( tkhd = full_box(
b"tkhd", 1, TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW, b"tkhd",
u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(track_id) + u32.pack(0) 1,
+ u64.pack(duration) + u32.pack(0) * 2 + s16.pack(0) + s16.pack(0) TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW,
+ s88.pack(1 if stream_type == "audio" else 0) + u16.pack(0) + UNITY_MATRIX u64.pack(EPOCH)
+ u1616.pack(width) + u1616.pack(height), + 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 --- # --- mdhd + hdlr ---
packed_lang = ((ord(lang[0]) - 0x60) << 10) | ((ord(lang[1]) - 0x60) << 5) | (ord(lang[2]) - 0x60) packed_lang = ((ord(lang[0]) - 0x60) << 10) | ((ord(lang[1]) - 0x60) << 5) | (ord(lang[2]) - 0x60)
mdhd = full_box( mdhd = full_box(
b"mdhd", 1, 0, b"mdhd",
u64.pack(EPOCH) + u64.pack(EPOCH) + u32.pack(timescale) + u64.pack(duration) 1,
+ u16.pack(packed_lang) + u16.pack(0), 0,
u64.pack(EPOCH)
+ u64.pack(EPOCH)
+ u32.pack(timescale)
+ u64.pack(duration)
+ u16.pack(packed_lang)
+ u16.pack(0),
) )
if stream_type == "audio": if stream_type == "audio":
hdlr = full_box(b"hdlr", 0, 0, u32.pack(0) + b"soun" + u32.pack(0) * 3 + b"SoundHandler\0") 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 sample_entry_payload = u8.pack(0) * 6 + u16.pack(1) # reserved + data reference index
if stream_type == "video": if stream_type == "video":
sample_entry_payload += ( sample_entry_payload += (
u16.pack(0) + u16.pack(0) + u32.pack(0) * 3 u16.pack(0)
+ u16.pack(width) + u16.pack(height) + u16.pack(0)
+ u1616.pack(0x48) + u1616.pack(0x48) + u32.pack(0) + u16.pack(1) + u32.pack(0) * 3
+ u8.pack(0) * 32 + u16.pack(0x18) + s16.pack(-1) + 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"): if fourcc in ("H264", "AVC1", "DAVC"):
config_box = build_avcc(cpd, nal_length_size) 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 # 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. # (decoders read the real rate from the codec config), matching ffmpeg.
sample_entry_payload += ( sample_entry_payload += (
u32.pack(0) * 2 + u16.pack(channels) + u16.pack(bits_per_sample) u32.pack(0) * 2
+ u16.pack(0) + u16.pack(0) + u32.pack((sampling_rate if sampling_rate <= 0xFFFF else 0) << 16) + 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 fourcc in ("AACL", "AACH", "AAC"):
if not cpd: if not cpd:
@@ -749,7 +792,9 @@ def build_init_segment(
# --- mvex (mehd + trex) signals a fragmented file --- # --- mvex (mehd + trex) signals a fragmented file ---
mehd = full_box(b"mehd", 1, 0, u64.pack(duration)) mehd = full_box(b"mehd", 1, 0, u64.pack(duration))
trex = full_box( 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), u32.pack(track_id) + u32.pack(1) + u32.pack(0) + u32.pack(0) + u32.pack(0),
) )
mvex = box(b"mvex", mehd + trex) mvex = box(b"mvex", mehd + trex)
+11 -2
View File
@@ -1,6 +1,15 @@
from .display import MusicHeaderInfo, TrackRow, render_album_header, render_artwork_preview, render_track_panel 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, from .extract import (
first_number, first_text, format_duration, format_names, year_from_value) 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 .hasher import file_md5
from .integrity import MusicAudioIntegrityError, MusicAudioIntegrityResult, verify_music_audio from .integrity import MusicAudioIntegrityError, MusicAudioIntegrityResult, verify_music_audio
from .manifest import write_music_manifest from .manifest import write_music_manifest
+3 -1
View File
@@ -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.""" """Verify a downloaded music audio file after it is playable/decrypted."""
path = Path(path) path = Path(path)
if not path.exists(): if not path.exists():
+9 -3
View File
@@ -21,7 +21,8 @@ class MusicPlanner:
plan = MusicDownloadPlan( plan = MusicDownloadPlan(
kind=getattr(music, "kind", "music"), kind=getattr(music, "kind", "music"),
title=getattr(music, "title", None) or (first_song.album if first_song else ""), 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 "", 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), year=getattr(music, "year", None) or (first_song.year if first_song else None),
released=self._first_text( released=self._first_text(
@@ -180,9 +181,14 @@ class MusicPlanner:
if any(option.atmos for option in options): if any(option.atmos for option in options):
return "Dolby Atmos" 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" 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" return "Lossless"
if "AAC" in codecs: if "AAC" in codecs:
return "AAC" return "AAC"
+1 -3
View File
@@ -57,9 +57,7 @@ class MusicRenderer:
metadata.get("released_at"), metadata.get("released_at"),
) )
) )
length = self._format_total_duration( length = self._format_total_duration(getattr(music, "total_duration", None) or self._sum_duration(music))
getattr(music, "total_duration", None) or self._sum_duration(music)
)
quality = self._quality_summary( quality = self._quality_summary(
self._first_text( self._first_text(
getattr(music, "quality", None), getattr(music, "quality", None),
+17 -2
View File
@@ -15,8 +15,23 @@ except ImportError: # pragma: no cover - optional artwork enhancement
try: try:
from mutagen.flac import FLAC, Picture from mutagen.flac import FLAC, Picture
from mutagen.id3 import (APIC, COMM, TALB, TCOM, TCON, TCOP, TDRC, TIT2, TPE1, TPE2, TPOS, TRCK, TSRC, TXXX, from mutagen.id3 import (
ID3NoHeaderError) APIC,
COMM,
TALB,
TCOM,
TCON,
TCOP,
TDRC,
TIT2,
TPE1,
TPE2,
TPOS,
TRCK,
TSRC,
TXXX,
ID3NoHeaderError,
)
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
except ImportError: # pragma: no cover - optional tagging dependency except ImportError: # pragma: no cover - optional tagging dependency
+3 -3
View File
@@ -33,9 +33,9 @@ class Hola(Proxy):
""" """
query = query.lower() query = query.lower()
p = subprocess.check_output( p = subprocess.check_output([self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT).decode(
[self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT "utf-8", errors="replace"
).decode("utf-8", errors="replace") )
if "Transaction error: temporary ban detected." in p: if "Transaction error: temporary ban detected." in p:
raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.") raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.")
+5 -9
View File
@@ -20,6 +20,7 @@ class DirtyServiceRepo(Exception):
self.path = path self.path = path
super().__init__(str(path)) super().__init__(str(path))
DEFAULT_TTL = 24 * 60 * 60 # refresh at most once a day (mirrors UpdateChecker) 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 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`.""" """Fetch-timestamp file path, kept OUTSIDE the clone so it never shows up in `git status`."""
return dest.parent / f"{dest.name}{STAMP_SUFFIX}" return dest.parent / f"{dest.name}{STAMP_SUFFIX}"
# owner/repo shorthand: exactly two path-like segments, no scheme # owner/repo shorthand: exactly two path-like segments, no scheme
SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$") SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
@@ -125,9 +127,7 @@ def _pull(dest: Path, stamp: Path) -> None:
if _is_dirty(dest): if _is_dirty(dest):
raise DirtyServiceRepo(dest) raise DirtyServiceRepo(dest)
try: try:
subprocess.run( subprocess.run([str(binaries.Git), "-C", str(dest), "pull", "--ff-only"], check=True, capture_output=True) # nosec B603
[str(binaries.Git), "-C", str(dest), "pull", "--ff-only"], check=True, capture_output=True
) # nosec B603
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
log.warning("could not update service repo at %s, using existing copy: %s", dest, _stderr(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) _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: if not old or not new or old == new:
return [] return []
git = str(binaries.Git) git = str(binaries.Git)
name_status = subprocess.run( name_status = subprocess.run([git, "-C", str(dest), "diff", "--name-status", f"{old}..{new}"], capture_output=True) # nosec B603
[git, "-C", str(dest), "diff", "--name-status", f"{old}..{new}"], capture_output=True
) # nosec B603
by_dir: dict[str, set[str]] = {} by_dir: dict[str, set[str]] = {}
if name_status.returncode == 0: if name_status.returncode == 0:
for raw in name_status.stdout.decode(errors="ignore").splitlines(): 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(): if status.returncode == 0 and status.stdout.strip():
return True return True
# local commits ahead of upstream (committed but not pushed); no upstream → treat as clean # local commits ahead of upstream (committed but not pushed); no upstream → treat as clean
ahead = subprocess.run( ahead = subprocess.run([git, "-C", str(dest), "rev-list", "--count", "@{u}..HEAD"], capture_output=True) # nosec B603
[git, "-C", str(dest), "rev-list", "--count", "@{u}..HEAD"], capture_output=True
) # nosec B603
if ahead.returncode == 0: if ahead.returncode == 0:
try: try:
return int(ahead.stdout.strip() or b"0") > 0 return int(ahead.stdout.strip() or b"0") > 0
+3 -1
View File
@@ -41,7 +41,9 @@ for _dir in _service_dirs:
for _path in _dir.glob("*/__init__.py"): for _path in _dir.glob("*/__init__.py"):
tag = _path.parent.stem tag = _path.parent.stem
if tag in _seen: 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: else:
_seen[tag] = _path _seen[tag] = _path
_SERVICES = sorted(_seen.values(), key=lambda x: x.parent.stem) _SERVICES = sorted(_seen.values(), key=lambda x: x.parent.stem)
+1 -1
View File
@@ -338,7 +338,7 @@ class Hybrid:
for crop in crop_results: for crop in crop_results:
crop_counts[crop] = crop_counts.get(crop, 0) + 1 crop_counts[crop] = crop_counts.get(crop, 0) + 1
most_common = max(crop_counts, key=crop_counts.get) 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 = { l5_json = {
"active_area": { "active_area": {
+3 -1
View File
@@ -982,7 +982,9 @@ class DebugLogger:
""" """
message = kwargs.pop("message", "") message = kwargs.pop("message", "")
level = kwargs.pop("level", "DEBUG") 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): def log_drm_operation(self, drm_type: str, operation: str, **kwargs):
""" """
@@ -17,15 +17,15 @@ def get_firefox_root() -> Path:
"""Locates the operating system specific root directory for Firefox profiles.""" """Locates the operating system specific root directory for Firefox profiles."""
system = platform.system() system = platform.system()
home = Path.home() home = Path.home()
if system == 'Windows': if system == "Windows":
return Path(os.getenv('APPDATA', '')) / 'Mozilla' / 'Firefox' return Path(os.getenv("APPDATA", "")) / "Mozilla" / "Firefox"
elif system == 'Darwin': elif system == "Darwin":
return home / 'Library' / 'Application Support' / 'Firefox' return home / "Library" / "Application Support" / "Firefox"
elif system == 'Linux': elif system == "Linux":
paths = [ paths = [
home / '.mozilla' / 'firefox', home / ".mozilla" / "firefox",
home / 'snap' / 'firefox' / 'common' / '.mozilla' / 'firefox', home / "snap" / "firefox" / "common" / ".mozilla" / "firefox",
home / '.var' / 'app' / 'org.mozilla.firefox' / '.mozilla' / 'firefox' home / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox",
] ]
for p in paths: for p in paths:
if p.exists(): if p.exists():
@@ -36,16 +36,16 @@ def get_firefox_root() -> Path:
def get_latest_profile_path(ff_root: Path) -> Path: def get_latest_profile_path(ff_root: Path) -> Path:
"""Finds the most recently modified valid Firefox profile subdirectory.""" """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 = [] valid_profiles = []
for base in search_dirs: for base in search_dirs:
if base.exists(): 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: if not valid_profiles:
raise FileNotFoundError("No active Firefox profiles detected.") 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]: 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. Extracts LocalStorage tokens from webappsstore.sqlite with strict origin matching.
Operates within a secure temporary directory. Operates within a secure temporary directory.
""" """
ls_db = profile_path / 'webappsstore.sqlite' ls_db = profile_path / "webappsstore.sqlite"
if not ls_db.exists(): if not ls_db.exists():
return {} 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. Extracts cookies and optionally localStorage from Firefox based on provided host patterns.
Implements security hardening and data consistency (WAL support) as per PR review. 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) # 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] 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: if not priority_hosts:
return None return None
@@ -112,8 +112,8 @@ def get_firefox_cookies(service_settings: dict) -> Optional[CookieJar]:
try: try:
# Copy main DB and its WAL log to ensure fresh data capture (Finding #5) # Copy main DB and its WAL log to ensure fresh data capture (Finding #5)
shutil.copy2(profile_path / 'cookies.sqlite', temp_db) shutil.copy2(profile_path / "cookies.sqlite", temp_db)
wal_path = profile_path / 'cookies.sqlite-wal' wal_path = profile_path / "cookies.sqlite-wal"
if wal_path.exists(): if wal_path.exists():
shutil.copy2(wal_path, temp_wal) 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 # Final validation to ensure proper domain suffix
if h == host or h.endswith(f".{host}"): if h == host or h.endswith(f".{host}"):
c = Cookie( c = Cookie(
version=0, name=n, value=v, port=None, port_specified=False, version=0,
domain=h, domain_specified=True, domain_initial_dot=h.startswith('.'), name=n,
path=p, path_specified=True, secure=bool(secure), expires=e, value=v,
discard=False, comment=None, comment_url=None, port=None,
rest={'HttpOnly': str(bool(httponly))}, rfc2109=False 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) cookie_jar.set_cookie(c)
conn.close() 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) storage_data = get_local_storage_data(profile_path, priority_hosts, tmp_dir_path)
for key, val in storage_data.items(): for key, val in storage_data.items():
c = Cookie( c = Cookie(
version=0, name=key, value=val, port=None, port_specified=False, version=0,
domain=".localstorage", domain_specified=True, domain_initial_dot=False, name=key,
path='/', path_specified=True, secure=False, expires=None, value=val,
discard=True, comment=None, comment_url=None, rest={}, rfc2109=False 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) cookie_jar.set_cookie(c)
+10 -2
View File
@@ -10,8 +10,16 @@ from xml.sax.saxutils import escape
from unshackle.core import binaries from unshackle.core import binaries
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.providers import (ExternalIds, MetadataResult, enrich_ids, fetch_external_ids, fuzzy_match, from unshackle.core.providers import (
get_available_providers, get_provider, search_metadata) 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.episode import Episode
from unshackle.core.titles.movie import Movie from unshackle.core.titles.movie import Movie
from unshackle.core.titles.title import Title from unshackle.core.titles.title import Title