mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
style: run ruff format over the codebase
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user