diff --git a/unshackle/commands/kv.py b/unshackle/commands/kv.py index 035f7f7..28c870d 100644 --- a/unshackle/commands/kv.py +++ b/unshackle/commands/kv.py @@ -12,7 +12,7 @@ from unshackle.core.vault import Vault from unshackle.core.vaults import Vaults -def _load_vaults(vault_names: list[str]) -> Vaults: +def load_vaults(vault_names: list[str]) -> Vaults: """Load and validate vaults by name.""" vaults = Vaults() for vault_name in vault_names: @@ -30,7 +30,7 @@ def _load_vaults(vault_names: list[str]) -> Vaults: return vaults -def _process_service_keys(from_vault: Vault, service: str, log: logging.Logger) -> dict[str, str]: +def process_service_keys(from_vault: Vault, service: str, log: logging.Logger) -> dict[str, str]: """Get and validate keys from a vault for a specific service.""" content_keys = list(from_vault.get_keys(service)) @@ -41,9 +41,9 @@ def _process_service_keys(from_vault: Vault, service: str, log: logging.Logger) return {kid: key for kid, key in content_keys if kid not in bad_keys} -def _copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int: +def copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int: """Copy data for a single service between vaults.""" - content_keys = _process_service_keys(from_vault, service, log) + content_keys = process_service_keys(from_vault, service, log) total_count = len(content_keys) if total_count == 0: @@ -95,7 +95,7 @@ def copy(to_vault_name: str, from_vault_names: list[str], service: Optional[str] log = logging.getLogger("kv") all_vault_names = [to_vault_name] + list(from_vault_names) - vaults = _load_vaults(all_vault_names) + vaults = load_vaults(all_vault_names) to_vault = vaults.vaults[0] from_vaults = vaults.vaults[1:] @@ -112,7 +112,7 @@ def copy(to_vault_name: str, from_vault_names: list[str], service: Optional[str] services_to_copy = [service] if service else from_vault.get_services() for service_tag in services_to_copy: - added = _copy_service_data(to_vault, from_vault, service_tag, log) + added = copy_service_data(to_vault, from_vault, service_tag, log) total_added += added if total_added > 0: @@ -164,7 +164,7 @@ def add(file: Path, service: str, vaults: list[str]) -> None: log = logging.getLogger("kv") service = Services.get_tag(service) - vaults_ = _load_vaults(list(vaults)) + vaults_ = load_vaults(list(vaults)) data = file.read_text(encoding="utf8") kid_keys: dict[str, str] = {} @@ -194,7 +194,7 @@ def prepare(vaults: list[str]) -> None: """Create Service Tables on Vaults if not yet created.""" log = logging.getLogger("kv") - vaults_ = _load_vaults(vaults) + vaults_ = load_vaults(vaults) for vault in vaults_: if hasattr(vault, "has_table") and hasattr(vault, "create_table"): diff --git a/unshackle/core/cacher.py b/unshackle/core/cacher.py index ba0c6a8..28cee47 100644 --- a/unshackle/core/cacher.py +++ b/unshackle/core/cacher.py @@ -91,7 +91,7 @@ class Cacher: except jwt.DecodeError: pass - self.expiration = self._resolve_datetime(expiration) if expiration else None + self.expiration = self.resolve_datetime(expiration) if expiration else None payload = {"data": self.data, "expiration": self.expiration, "version": self.version} payload["crc32"] = zlib.crc32(jsonpickle.dumps(payload).encode("utf8")) @@ -109,7 +109,7 @@ class Cacher: return self.path.stat() @staticmethod - def _resolve_datetime(timestamp: EXP_T) -> datetime: + def resolve_datetime(timestamp: EXP_T) -> datetime: """ Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime. @@ -118,15 +118,15 @@ class Cacher: datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) >>> iso8601 = now.isoformat() '2022-06-27T09:49:13.657208' - >>> Cacher._resolve_datetime(iso8601) + >>> Cacher.resolve_datetime(iso8601) datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) - >>> Cacher._resolve_datetime(iso8601 + "Z") + >>> Cacher.resolve_datetime(iso8601 + "Z") datetime.datetime(2022, 6, 27, 9, 49, 13, 657208) - >>> Cacher._resolve_datetime(3600) + >>> Cacher.resolve_datetime(3600) datetime.datetime(2022, 6, 27, 10, 52, 50, 657208) - >>> Cacher._resolve_datetime('3600') + >>> Cacher.resolve_datetime('3600') datetime.datetime(2022, 6, 27, 10, 52, 51, 657208) - >>> Cacher._resolve_datetime(7800.113) + >>> Cacher.resolve_datetime(7800.113) datetime.datetime(2022, 6, 27, 11, 59, 13, 770208) In the int/float examples you may notice that it did not return now + 3600 seconds diff --git a/unshackle/core/session.py b/unshackle/core/session.py index dd5dc5b..3a4f704 100644 --- a/unshackle/core/session.py +++ b/unshackle/core/session.py @@ -79,7 +79,7 @@ class CurlSession(Session): ) self.log = logging.getLogger(self.__class__.__name__) - def _get_sleep_time(self, response: Response | None, attempt: int) -> float | None: + def get_sleep_time(self, response: Response | None, attempt: int) -> float | None: if response: retry_after = response.headers.get("Retry-After") if retry_after: @@ -123,7 +123,7 @@ class CurlSession(Session): ) if attempt < self.max_retries: - if sleep_duration := self._get_sleep_time(response, attempt + 1): + if sleep_duration := self.get_sleep_time(response, attempt + 1): if sleep_duration > 0: time.sleep(sleep_duration) else: diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 72b8e60..6cf12c9 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -25,7 +25,7 @@ from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests from unshackle.core.drm import DRM_T, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.utilities import get_boxes, try_ensure_utf8, get_extension +from unshackle.core.utilities import get_boxes, get_extension, try_ensure_utf8 from unshackle.core.utils.subprocess import ffprobe diff --git a/unshackle/core/update_checker.py b/unshackle/core/update_checker.py index 5ca6502..8d601fc 100644 --- a/unshackle/core/update_checker.py +++ b/unshackle/core/update_checker.py @@ -28,21 +28,21 @@ class UpdateChecker: DEFAULT_CHECK_INTERVAL = 24 * 60 * 60 @classmethod - def _get_cache_file(cls) -> Path: + def get_cache_file(cls) -> Path: """Get the path to the update check cache file.""" from unshackle.core.config import config return config.directories.cache / "update_check.json" @classmethod - def _load_cache_data(cls) -> dict: + def load_cache_data(cls) -> dict: """ Load cache data from file. Returns: Cache data dictionary or empty dict if loading fails """ - cache_file = cls._get_cache_file() + cache_file = cls.get_cache_file() if not cache_file.exists(): return {} @@ -54,7 +54,7 @@ class UpdateChecker: return {} @staticmethod - def _parse_version(version_string: str) -> str: + def parse_version(version_string: str) -> str: """ Parse and normalize version string by removing 'v' prefix. @@ -107,7 +107,7 @@ class UpdateChecker: return None data = response.json() - latest_version = cls._parse_version(data.get("tag_name", "")) + latest_version = cls.parse_version(data.get("tag_name", "")) return latest_version if cls._is_valid_version(latest_version) else None @@ -125,7 +125,7 @@ class UpdateChecker: Returns: True if we should check for updates, False otherwise """ - cache_data = cls._load_cache_data() + cache_data = cls.load_cache_data() if not cache_data: return True @@ -144,7 +144,7 @@ class UpdateChecker: latest_version: The latest version found, if any current_version: The current version being used """ - cache_file = cls._get_cache_file() + cache_file = cls.get_cache_file() try: cache_file.parent.mkdir(parents=True, exist_ok=True) @@ -231,7 +231,7 @@ class UpdateChecker: Returns: The latest version string if an update is available from cache, None otherwise """ - cache_data = cls._load_cache_data() + cache_data = cls.load_cache_data() if not cache_data: return None diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index a09f41c..dae0dc6 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -485,7 +485,7 @@ def extract_font_family(font_path: Path) -> Optional[str]: return None -def _get_windows_fonts() -> dict[str, Path]: +def get_windows_fonts() -> dict[str, Path]: """ Get fonts from Windows registry. @@ -504,7 +504,7 @@ def _get_windows_fonts() -> dict[str, Path]: } -def _scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None: +def scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None: """ Scan a single directory for fonts. @@ -524,7 +524,7 @@ def _scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Lo log.debug(f"Failed to process {font_file}: {e}") -def _get_unix_fonts() -> dict[str, Path]: +def get_unix_fonts() -> dict[str, Path]: """ Get fonts from Linux/macOS standard directories. @@ -546,11 +546,9 @@ def _get_unix_fonts() -> dict[str, Path]: continue try: - _scan_font_directory(font_dir, fonts, log) + scan_font_directory(font_dir, fonts, log) except Exception as e: log.warning(f"Failed to scan {font_dir}: {e}") - - log.debug(f"Discovered {len(fonts)} system font families") return fonts @@ -565,8 +563,8 @@ def get_system_fonts() -> dict[str, Path]: Dictionary mapping font family names to their file paths """ if sys.platform == "win32": - return _get_windows_fonts() - return _get_unix_fonts() + return get_windows_fonts() + return get_unix_fonts() # Common Windows font names mapped to their Linux equivalents @@ -754,9 +752,9 @@ class DebugLogger: if self.enabled: self.log_path.parent.mkdir(parents=True, exist_ok=True) self.file_handle = open(self.log_path, "a", encoding="utf-8") - self._log_session_start() + self.log_session_start() - def _log_session_start(self): + def log_session_start(self): """Log the start of a new session with environment information.""" import platform @@ -821,11 +819,11 @@ class DebugLogger: if service: entry["service"] = service if context: - entry["context"] = self._sanitize_data(context) + entry["context"] = self.sanitize_data(context) if request: - entry["request"] = self._sanitize_data(request) + entry["request"] = self.sanitize_data(request) if response: - entry["response"] = self._sanitize_data(response) + entry["response"] = self.sanitize_data(response) if duration_ms is not None: entry["duration_ms"] = duration_ms if success is not None: @@ -840,7 +838,7 @@ class DebugLogger: for key, value in kwargs.items(): if key not in entry: - entry[key] = self._sanitize_data(value) + entry[key] = self.sanitize_data(value) try: self.file_handle.write(json.dumps(entry, default=str) + "\n") @@ -848,7 +846,7 @@ class DebugLogger: except Exception as e: print(f"Failed to write debug log: {e}", file=sys.stderr) - def _sanitize_data(self, data: Any) -> Any: + def sanitize_data(self, data: Any) -> Any: """ Sanitize data for JSON serialization. Handles complex objects and removes sensitive information. @@ -860,7 +858,7 @@ class DebugLogger: return data if isinstance(data, (list, tuple)): - return [self._sanitize_data(item) for item in data] + return [self.sanitize_data(item) for item in data] if isinstance(data, dict): sanitized = {} @@ -883,7 +881,7 @@ class DebugLogger: if should_redact: sanitized[key] = "[REDACTED]" else: - sanitized[key] = self._sanitize_data(value) + sanitized[key] = self.sanitize_data(value) return sanitized if isinstance(data, bytes): diff --git a/unshackle/core/utils/webvtt.py b/unshackle/core/utils/webvtt.py index 76a8a36..9379fc6 100644 --- a/unshackle/core/utils/webvtt.py +++ b/unshackle/core/utils/webvtt.py @@ -3,8 +3,11 @@ import sys import typing from typing import Optional +import pysubs2 from pycaption import Caption, CaptionList, CaptionNode, CaptionReadError, WebVTTReader, WebVTTWriter +from unshackle.core.config import config + class CaptionListExt(CaptionList): @typing.no_type_check @@ -142,7 +145,24 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] """ MPEG_TIMESCALE = 90_000 - vtt = WebVTTReaderExt().read(vtt_raw) + # Check config for conversion method preference + conversion_method = config.subtitle.get("conversion_method", "auto") + use_pysubs2 = conversion_method in ("pysubs2", "auto") + + if use_pysubs2: + # Try using pysubs2 first for more lenient parsing + try: + # Use pysubs2 to parse and normalize the VTT + subs = pysubs2.SSAFile.from_string(vtt_raw) + # Convert back to WebVTT string for pycaption processing + normalized_vtt = subs.to_string("vtt") + vtt = WebVTTReaderExt().read(normalized_vtt) + except Exception: + # Fall back to direct pycaption parsing + vtt = WebVTTReaderExt().read(vtt_raw) + else: + # Use pycaption directly + vtt = WebVTTReaderExt().read(vtt_raw) for lang in vtt.get_languages(): prev_caption = None duplicate_index: list[int] = []