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