mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
Gate a title on audio or video languages that must exist, independent of what -l/-al/-vl select. --require-subs joins the same gate: its --s-lang conflict and download-all side effect are gone, so -sl now decides what to keep. Requiring a language that is also excluded fails at parse time.
1265 lines
42 KiB
Python
1265 lines
42 KiB
Python
import ast
|
|
import contextlib
|
|
import gzip
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import unicodedata
|
|
import zlib
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Any, Optional, Sequence, Union
|
|
from urllib.parse import ParseResult, urlparse
|
|
from uuid import uuid4
|
|
|
|
import chardet
|
|
import pycountry
|
|
from construct import ValidationError
|
|
from fontTools import ttLib
|
|
from langcodes import Language, closest_match
|
|
from langcodes.tag_parser import LanguageTagError
|
|
from pymp4.parser import Box
|
|
from unidecode import unidecode
|
|
|
|
from unshackle.core.config import config
|
|
from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
|
|
from unshackle.core.utils.redact import redact_all
|
|
|
|
"""
|
|
Utility functions for the unshackle media archival tool.
|
|
|
|
This module provides various utility functions including:
|
|
- Font discovery and fallback system for subtitle rendering
|
|
- Cross-platform system font scanning with Windows → Linux font family mapping
|
|
- Log file management and rotation
|
|
- IP geolocation with caching and provider rotation
|
|
- Language matching utilities
|
|
- MP4/ISOBMFF box parsing
|
|
- File sanitization and path handling
|
|
- Structured JSON debug logging
|
|
|
|
Font System:
|
|
The font subsystem enables cross-platform font discovery for ASS/SSA subtitles.
|
|
On Linux, it scans standard font directories and maps Windows font names (Arial,
|
|
Times New Roman) to their Linux equivalents (Liberation Sans, Liberation Serif).
|
|
|
|
Main Font Functions:
|
|
- get_system_fonts(): Discover installed fonts across platforms
|
|
- find_font_with_fallbacks(): Match fonts with intelligent fallback strategies
|
|
- suggest_font_packages(): Recommend packages to install for missing fonts
|
|
"""
|
|
|
|
|
|
def rotate_log_file(log_path: Path, keep: int = 20) -> Path:
|
|
"""
|
|
Update Log Filename and delete old log files.
|
|
It keeps only the 20 newest logs by default.
|
|
"""
|
|
if not log_path:
|
|
raise ValueError("A log path must be provided")
|
|
|
|
try:
|
|
log_path.relative_to(Path("")) # file name only
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
log_path = config.directories.logs / log_path
|
|
|
|
log_path = log_path.parent / log_path.name.format_map(
|
|
defaultdict(str, name="root", time=datetime.now().strftime("%Y%m%d-%H%M%S"))
|
|
)
|
|
|
|
if log_path.parent.exists():
|
|
log_files = [x for x in log_path.parent.iterdir() if x.suffix == log_path.suffix]
|
|
for log_file in log_files[::-1][keep - 1 :]:
|
|
log_file.unlink()
|
|
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
return log_path
|
|
|
|
|
|
def import_module_by_path(path: Path) -> ModuleType:
|
|
"""Import a Python file by Path as a Module."""
|
|
if not path:
|
|
raise ValueError("Path must be provided")
|
|
if not isinstance(path, Path):
|
|
raise TypeError(f"Expected path to be a {Path}, not {path!r}")
|
|
if not path.exists():
|
|
raise ValueError("Path does not exist")
|
|
|
|
# compute package hierarchy for relative import support
|
|
if path.is_relative_to(config.directories.core_dir):
|
|
name = []
|
|
_path = path.parent
|
|
while _path.stem != config.directories.core_dir.stem:
|
|
name.append(_path.stem)
|
|
_path = _path.parent
|
|
name = ".".join([config.directories.core_dir.stem] + name[::-1])
|
|
else:
|
|
# is outside the src package
|
|
if str(path.parent.parent) not in sys.path:
|
|
sys.path.insert(1, str(path.parent.parent))
|
|
name = path.parent.stem
|
|
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
|
|
return module
|
|
|
|
|
|
def sanitize_filename(filename: str, spacer: str = ".") -> str:
|
|
"""
|
|
Sanitise a string to be filename safe.
|
|
|
|
The spacer is safer to be a '.' for older DDL and p2p sharing spaces.
|
|
This includes files served on the web through direct links and such.
|
|
|
|
Set `unicode_filenames: true` in config to preserve the characters of the
|
|
original language (for example Korean, Japanese, or Chinese) instead of
|
|
transliterating them to ASCII equivalents.
|
|
"""
|
|
if not config.unicode_filenames:
|
|
filename = unidecode(filename)
|
|
filename = re.sub(r"\[\(+", "[", filename)
|
|
filename = re.sub(r"\)+\]", "]", filename)
|
|
filename = "".join(c for c in filename if unicodedata.category(c) not in ("Mn", "Cc"))
|
|
filename = filename.replace("/", " & ").replace(";", " & ") # e.g. multi-episode filenames
|
|
if spacer == ".":
|
|
filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern)
|
|
filename = re.sub(r"[:; ]", spacer, filename)
|
|
filename = re.sub(r"[\\*!?¿,'\"" "<>|$#~]", "", filename)
|
|
filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s
|
|
filename = filename.strip(" .") # strip leading and trailing spaces and dots for OS path safety
|
|
|
|
return filename
|
|
|
|
|
|
def is_close_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
|
|
"""Examine whether a language closely matches any of the given languages."""
|
|
languages = [x for x in languages if x]
|
|
if not languages:
|
|
return False
|
|
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE
|
|
|
|
|
|
def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool:
|
|
"""Examine whether a language exactly matches any of the given languages."""
|
|
languages = [x for x in languages if x]
|
|
if not languages:
|
|
return False
|
|
return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE
|
|
|
|
|
|
def partition_exclusions(tokens: Optional[Sequence[str]]) -> tuple[list[str], list[str]]:
|
|
"""
|
|
Split selection tokens into wanted values and values excluded with a leading '-'.
|
|
|
|
Both sides keep the order the user wrote them in and drop later repeats. A bare '-'
|
|
carries no value, so this function ignores it.
|
|
|
|
Example:
|
|
>>> partition_exclusions(["all", "-es"])
|
|
(['all'], ['es'])
|
|
"""
|
|
includes: list[str] = []
|
|
excludes: list[str] = []
|
|
for raw in tokens or []:
|
|
token = str(raw).strip()
|
|
if not token or token == "-":
|
|
continue
|
|
target = includes
|
|
if token.startswith("-"):
|
|
token = token[1:].strip()
|
|
if not token:
|
|
continue
|
|
target = excludes
|
|
if token not in target:
|
|
target.append(token)
|
|
return includes, excludes
|
|
|
|
|
|
def excluded_language_tags(
|
|
excludes: Sequence[str],
|
|
available: Sequence[Union[str, Language, None]],
|
|
exact: bool = False,
|
|
) -> set[str]:
|
|
"""
|
|
The language tags out of `available` that the exclusion tokens remove.
|
|
|
|
Matches through matching_languages so an exclusion drops exactly the tracks the same
|
|
token would select, including its exact-mode string preference. Untagged tracks are
|
|
never excluded.
|
|
"""
|
|
return {tag for token in excludes for tag in matching_languages(token, available, exact)}
|
|
|
|
|
|
def keep_forced_subtitle(
|
|
forced: bool,
|
|
language: Union[str, Language],
|
|
forced_s_lang: Sequence[str],
|
|
exact: bool = False,
|
|
) -> bool:
|
|
"""The -fsl filter: non-forced tracks always pass, forced tracks only when they match forced_s_lang."""
|
|
if not forced:
|
|
return True
|
|
if not forced_s_lang:
|
|
return False
|
|
match_func = is_exact_match if exact else is_close_match
|
|
return match_func(language, forced_s_lang)
|
|
|
|
|
|
def embedded_audio_langs(videos: Sequence[Any], keep_videos: bool) -> list[str]:
|
|
"""
|
|
Return the audio languages carried inside video tracks rather than beside them.
|
|
|
|
A muxed video keeps its audio in the video track, which a service declares by setting
|
|
``data["audio_language"]``. That audio is only available while the video is kept, so
|
|
dropping the video (``--audio-only``, ``--no-video``) drops the language with it.
|
|
"""
|
|
if not keep_videos:
|
|
return []
|
|
return [video.data["audio_language"] for video in videos if video.data.get("audio_language")]
|
|
|
|
|
|
def find_missing_langs(
|
|
requested: Sequence[str],
|
|
available: Sequence[Union[str, Language, None]],
|
|
*,
|
|
exact: bool = False,
|
|
) -> list[str]:
|
|
"""Return the requested language tokens that match no available language."""
|
|
match_func = is_exact_match if exact else is_close_match
|
|
skip = {"all", "best", "orig"}
|
|
return [tok for tok in requested if tok not in skip and not match_func(tok, available)]
|
|
|
|
|
|
def missing_required_langs(
|
|
required: Sequence[str],
|
|
available: Sequence[Union[str, Language, None]],
|
|
original: Optional[Union[str, Language]] = None,
|
|
*,
|
|
exact: bool = False,
|
|
) -> list[str]:
|
|
"""
|
|
Return the required language tokens that match no available track language.
|
|
|
|
"orig" becomes the title language, and drops out when the title has none. An exclusion
|
|
token ("-fr") asks for a language to be absent, so it is never a required language.
|
|
"""
|
|
wanted, _ = partition_exclusions(list(required))
|
|
return find_missing_langs(resolve_sort_langs(wanted, original), available, exact=exact)
|
|
|
|
|
|
def as_requested(tokens: Sequence[str], orig_token: Optional[str]) -> str:
|
|
"""Return language tokens as the user wrote them, so 'orig' reads as 'orig' and not the language it resolved to."""
|
|
return ", ".join("orig" if tok == orig_token else tok for tok in tokens)
|
|
|
|
|
|
def matching_languages(
|
|
language: Union[str, Language],
|
|
available: Sequence[Union[str, Language, None]],
|
|
exact: bool = False,
|
|
) -> set[str]:
|
|
"""
|
|
The languages out of `available` that `language` asks for, by the rules Tracks.by_language uses.
|
|
|
|
In exact mode CLDR tag distance is not sufficient on its own: it rates a base language and
|
|
its "paradigm" regional variant as the same language (distance 0 for en/en-US, pt/pt-BR), so
|
|
it cannot separate en-US from en. Follow RFC 4647 Lookup: prefer the string-equal tag, and
|
|
fall back to the distance rating only when no such tag is present (for example, zh matches
|
|
cmn).
|
|
"""
|
|
tags = {str(x) for x in available if x}
|
|
try:
|
|
want = Language.get(str(language)).to_tag().casefold()
|
|
except LanguageTagError:
|
|
# e.g. a config typo in language_priority; an unparseable tag matches nothing
|
|
return set()
|
|
if not exact:
|
|
return {tag for tag in tags if is_close_match(language, [tag])}
|
|
string_equal = {tag for tag in tags if Language.get(tag).to_tag().casefold() == want}
|
|
return string_equal or {tag for tag in tags if is_exact_match(language, [tag])}
|
|
|
|
|
|
def resolve_sort_langs(tokens: Sequence[str], original: Optional[Union[str, Language]] = None) -> list[str]:
|
|
"""
|
|
Prepare language tokens for a Tracks.sort_* by_language argument.
|
|
|
|
"orig" becomes the title language. This function drops it when the title has none.
|
|
"all" and "best" pass through, because the sort methods find them among the tracks. The
|
|
result keeps the first position of each language and drops later repeats.
|
|
"""
|
|
resolved: list[str] = []
|
|
for token in tokens:
|
|
language = str(original) if token == "orig" else str(token)
|
|
if token == "orig" and not original:
|
|
continue
|
|
if language not in resolved:
|
|
resolved.append(language)
|
|
return resolved
|
|
|
|
|
|
def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box: # type: ignore
|
|
"""
|
|
Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find.
|
|
|
|
This function searches through binary MP4 data to find and parse specific box types.
|
|
The MP4/ISOBMFF box format consists of:
|
|
- 4 bytes: size of the box (including size and type fields)
|
|
- 4 bytes: box type identifier (e.g., 'moov', 'trak', 'pssh')
|
|
- Remaining bytes: box data
|
|
|
|
The function uses slicing to directly locate the requested box type in the data
|
|
rather than recursively traversing the box hierarchy. This is efficient when
|
|
looking for specific box types regardless of their position in the hierarchy.
|
|
|
|
Parameters:
|
|
data: Binary data containing MP4/ISOBMFF boxes
|
|
box_type: 4-byte identifier of the box type to find (e.g., b'pssh')
|
|
as_bytes: If True, returns the box as bytes, otherwise returns parsed box object
|
|
|
|
Yields:
|
|
Box objects of the requested type found in the data
|
|
|
|
Notes:
|
|
- For each box found, the function updates the search offset to skip past
|
|
the current box to avoid finding the same box multiple times
|
|
- The function handles validation errors for certain box types (e.g., tenc)
|
|
- The size field sits 4 bytes before the box type identifier
|
|
"""
|
|
# using slicing to get to the wanted box is done because parsing the entire box and recursively
|
|
# scanning through each box and its children often wouldn't scan far enough to reach the wanted box.
|
|
# since it doesn't care what child box the wanted box is from, this works fine.
|
|
if not isinstance(data, (bytes, bytearray)):
|
|
raise ValueError("data must be bytes")
|
|
|
|
offset = 0
|
|
while offset < len(data):
|
|
try:
|
|
index = data[offset:].index(box_type)
|
|
except ValueError:
|
|
break
|
|
|
|
pos = offset + index
|
|
|
|
if pos < 4:
|
|
offset = pos + len(box_type)
|
|
continue
|
|
|
|
box_start = pos - 4
|
|
|
|
try:
|
|
box = Box.parse(data[box_start:])
|
|
if as_bytes:
|
|
box = Box.build(box)
|
|
|
|
yield box
|
|
|
|
box_size = len(Box.build(box))
|
|
offset = box_start + box_size
|
|
|
|
except IOError:
|
|
break
|
|
except ValidationError as e:
|
|
if box_type == b"tenc":
|
|
offset = pos + len(box_type)
|
|
continue
|
|
raise e
|
|
|
|
|
|
def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None) -> str:
|
|
"""
|
|
Convert a string to title case using AP/APA style.
|
|
Based on https://github.com/words/ap-style-title-case
|
|
|
|
Parameters:
|
|
text: The text string to title case with AP/APA style.
|
|
keep_spaces: To keep the original whitespace, or to use a normal space.
|
|
You need this only if you have special whitespace between words.
|
|
stop_words: Override the default stop words with your own ones.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
|
|
if not stop_words:
|
|
stop_words = (
|
|
"a",
|
|
"an",
|
|
"and",
|
|
"at",
|
|
"but",
|
|
"by",
|
|
"for",
|
|
"in",
|
|
"nor",
|
|
"of",
|
|
"on",
|
|
"or",
|
|
"so",
|
|
"the",
|
|
"to",
|
|
"up",
|
|
"yet",
|
|
)
|
|
|
|
splitter = re.compile(r"(\s+|[-‑–—])")
|
|
words = splitter.split(text)
|
|
|
|
return "".join(
|
|
[
|
|
[" ", word][keep_spaces]
|
|
if re.match(r"\s+", word)
|
|
else word
|
|
if splitter.match(word)
|
|
else word.lower()
|
|
if i != 0 and i != len(words) - 1 and word.lower() in stop_words
|
|
else word.capitalize()
|
|
for i, word in enumerate(words)
|
|
]
|
|
)
|
|
|
|
|
|
# Common country code aliases that differ from ISO 3166-1 alpha-2
|
|
COUNTRY_CODE_ALIASES = {
|
|
"uk": "gb", # United Kingdom -> Great Britain
|
|
}
|
|
|
|
|
|
def get_country_name(code: str) -> Optional[str]:
|
|
"""
|
|
Convert a 2-letter country code to full country name.
|
|
|
|
Args:
|
|
code: ISO 3166-1 alpha-2 country code (e.g., 'ca', 'us', 'gb', 'uk')
|
|
|
|
Returns:
|
|
Full country name (e.g., 'Canada', 'United States', 'United Kingdom') or None if not found
|
|
|
|
Examples:
|
|
>>> get_country_name('ca')
|
|
'Canada'
|
|
>>> get_country_name('US')
|
|
'United States'
|
|
>>> get_country_name('uk')
|
|
'United Kingdom'
|
|
"""
|
|
code = COUNTRY_CODE_ALIASES.get(code.lower(), code.lower())
|
|
|
|
try:
|
|
country = pycountry.countries.get(alpha_2=code.upper())
|
|
if country:
|
|
return country.name
|
|
except (KeyError, LookupError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_country_code(name: str) -> Optional[str]:
|
|
"""
|
|
Convert a country name to its 2-letter ISO 3166-1 alpha-2 code.
|
|
|
|
Args:
|
|
name: Full country name (e.g., 'Canada', 'United States', 'United Kingdom')
|
|
|
|
Returns:
|
|
2-letter country code in uppercase (e.g., 'CA', 'US', 'GB') or None if not found
|
|
|
|
Examples:
|
|
>>> get_country_code('Canada')
|
|
'CA'
|
|
>>> get_country_code('united states')
|
|
'US'
|
|
>>> get_country_code('United Kingdom')
|
|
'GB'
|
|
"""
|
|
try:
|
|
country = pycountry.countries.get(name=name.title())
|
|
if country:
|
|
return country.alpha_2.upper()
|
|
|
|
country = pycountry.countries.get(common_name=name.title())
|
|
if country:
|
|
return country.alpha_2.upper()
|
|
|
|
results = pycountry.countries.search_fuzzy(name)
|
|
if results:
|
|
return results[0].alpha_2.upper()
|
|
except (KeyError, LookupError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def time_elapsed_since(start: float) -> str:
|
|
"""
|
|
Get time elapsed since a timestamp as a string.
|
|
E.g., `1h56m2s`, `15m12s`, `0m55s`, e.t.c.
|
|
"""
|
|
elapsed = int(time.time() - start)
|
|
|
|
minutes, seconds = divmod(elapsed, 60)
|
|
hours, minutes = divmod(minutes, 60)
|
|
|
|
time_string = f"{minutes:d}m{seconds:d}s"
|
|
if hours:
|
|
time_string = f"{hours:d}h{time_string}"
|
|
|
|
return time_string
|
|
|
|
|
|
def try_ensure_utf8(data: bytes) -> bytes:
|
|
"""
|
|
Try to make sure that the given data is in UTF-8.
|
|
|
|
This function first decompresses gzip or zlib compressed input, which it detects
|
|
by magic bytes.
|
|
Callers hand in bytes read from files and from responses they fetched
|
|
themselves, so there is no Content-Encoding header left to consult.
|
|
|
|
Parameters:
|
|
data: Input data that may or may not yet be UTF-8 or another encoding.
|
|
|
|
Returns the input data encoded in UTF-8 if successful. If unable to detect the
|
|
encoding of the input data, then this function returns the original data as-received.
|
|
"""
|
|
if data[:2] == b"\x1f\x8b":
|
|
try:
|
|
data = gzip.decompress(data)
|
|
except (OSError, EOFError, zlib.error):
|
|
pass
|
|
elif data[:1] == b"\x78" and len(data) > 1 and data[1:2] in (b"\x01", b"\x5e", b"\x9c", b"\xda"):
|
|
try:
|
|
data = zlib.decompress(data)
|
|
except zlib.error:
|
|
pass
|
|
|
|
try:
|
|
data.decode("utf8")
|
|
return data
|
|
except UnicodeDecodeError:
|
|
try:
|
|
# CP-1252 is a superset of latin1 but has gaps. Replace unknown
|
|
# characters instead of failing on them.
|
|
return data.decode("cp1252", errors="replace").encode("utf8")
|
|
except UnicodeDecodeError:
|
|
try:
|
|
# last ditch effort to detect encoding
|
|
detection_result = chardet.detect(data)
|
|
if not detection_result["encoding"]:
|
|
return data.decode("utf-8", errors="replace").encode("utf-8")
|
|
return data.decode(detection_result["encoding"], errors="replace").encode("utf8")
|
|
except (UnicodeDecodeError, LookupError):
|
|
return data.decode("utf-8", errors="replace").encode("utf-8")
|
|
|
|
|
|
def get_free_port() -> int:
|
|
"""
|
|
Get an available port to use between a-b (inclusive).
|
|
|
|
This function frees the port as soon as it returns. Thus something else
|
|
can take the port before you use it.
|
|
"""
|
|
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
s.bind(("", 0))
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
|
|
"""
|
|
Get a URL or Path file extension/suffix.
|
|
|
|
Note: The returned value will begin with `.`.
|
|
"""
|
|
if isinstance(value, ParseResult):
|
|
value_parsed = value
|
|
elif isinstance(value, (str, Path)):
|
|
value_parsed = urlparse(str(value))
|
|
else:
|
|
raise TypeError(f"Expected {str}, {Path}, or {ParseResult}, got {type(value)}")
|
|
|
|
if value_parsed.path:
|
|
ext = os.path.splitext(value_parsed.path)[1]
|
|
if ext and ext != ".":
|
|
return ext
|
|
|
|
|
|
def extract_font_family(font_path: Path) -> Optional[str]:
|
|
"""
|
|
Extract font family name from TTF/OTF file using fontTools.
|
|
|
|
Args:
|
|
font_path: Path to the font file
|
|
|
|
Returns:
|
|
Font family name if successfully extracted, None otherwise
|
|
"""
|
|
# Suppress verbose fontTools logging during font table parsing
|
|
import io
|
|
|
|
logging.getLogger("fontTools").setLevel(logging.ERROR)
|
|
logging.getLogger("fontTools.ttLib").setLevel(logging.ERROR)
|
|
logging.getLogger("fontTools.ttLib.tables").setLevel(logging.ERROR)
|
|
logging.getLogger("fontTools.ttLib.tables._n_a_m_e").setLevel(logging.ERROR)
|
|
stderr_backup = sys.stderr
|
|
sys.stderr = io.StringIO()
|
|
|
|
try:
|
|
font = ttLib.TTFont(font_path, lazy=True)
|
|
name_table = font["name"]
|
|
|
|
# Try to get family name (nameID 1) for Windows platform (platformID 3)
|
|
# This matches the naming convention used in Windows registry
|
|
for record in name_table.names:
|
|
if record.nameID == 1 and record.platformID == 3:
|
|
return record.toUnicode()
|
|
|
|
# Fallback to other platforms if Windows name not found
|
|
for record in name_table.names:
|
|
if record.nameID == 1:
|
|
return record.toUnicode()
|
|
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
sys.stderr = stderr_backup
|
|
|
|
return None
|
|
|
|
|
|
def get_windows_fonts() -> dict[str, Path]:
|
|
"""
|
|
Get fonts from Windows registry.
|
|
|
|
Returns:
|
|
Dictionary mapping font family names to their file paths
|
|
"""
|
|
import winreg
|
|
|
|
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
|
|
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
|
|
total_fonts = winreg.QueryInfoKey(key)[1]
|
|
return {
|
|
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
|
|
for n in range(0, total_fonts)
|
|
for name, filename, _ in [winreg.EnumValue(key, n)]
|
|
}
|
|
|
|
|
|
def scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None:
|
|
"""
|
|
Scan a single directory for fonts.
|
|
|
|
Args:
|
|
font_dir: Directory to scan
|
|
fonts: Dictionary to fill with found fonts
|
|
log: Logger instance for error reporting
|
|
"""
|
|
font_files = list(font_dir.rglob("*.ttf")) + list(font_dir.rglob("*.otf"))
|
|
|
|
for font_file in font_files:
|
|
try:
|
|
if family_name := extract_font_family(font_file):
|
|
if family_name not in fonts:
|
|
fonts[family_name] = font_file
|
|
except Exception as e:
|
|
log.debug(f"Failed to process {font_file}: {e}")
|
|
|
|
|
|
def get_unix_fonts() -> dict[str, Path]:
|
|
"""
|
|
Get fonts from Linux/macOS standard directories.
|
|
|
|
Returns:
|
|
Dictionary mapping font family names to their file paths
|
|
"""
|
|
log = logging.getLogger("get_system_fonts")
|
|
fonts = {}
|
|
|
|
font_dirs = [
|
|
Path("/usr/share/fonts"),
|
|
Path("/usr/local/share/fonts"),
|
|
Path.home() / ".fonts",
|
|
Path.home() / ".local/share/fonts",
|
|
]
|
|
|
|
for font_dir in font_dirs:
|
|
if not font_dir.exists():
|
|
continue
|
|
|
|
try:
|
|
scan_font_directory(font_dir, fonts, log)
|
|
except Exception as e:
|
|
log.warning(f"Failed to scan {font_dir}: {e}")
|
|
return fonts
|
|
|
|
|
|
def get_system_fonts() -> dict[str, Path]:
|
|
"""
|
|
Get system fonts as a mapping of font family names to font file paths.
|
|
|
|
On Windows: Uses registry to get font display names
|
|
On Linux/macOS: Scans standard font directories and extracts family names using fontTools
|
|
|
|
Returns:
|
|
Dictionary mapping font family names to their file paths
|
|
"""
|
|
if sys.platform == "win32":
|
|
return get_windows_fonts()
|
|
return get_unix_fonts()
|
|
|
|
|
|
# Common Windows font names mapped to their Linux equivalents
|
|
# Ordered by preference (first match is used)
|
|
FONT_ALIASES = {
|
|
"Arial": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans", "FreeSans"],
|
|
"Arial Black": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans"],
|
|
"Arial Bold": ["Liberation Sans", "DejaVu Sans"],
|
|
"Arial Unicode MS": ["DejaVu Sans", "Noto Sans", "FreeSans"],
|
|
"Times New Roman": ["Liberation Serif", "DejaVu Serif", "Nimbus Roman", "FreeSerif"],
|
|
"Courier New": ["Liberation Mono", "DejaVu Sans Mono", "Nimbus Mono PS", "FreeMono"],
|
|
"Comic Sans MS": ["Comic Neue", "Comic Relief", "DejaVu Sans"],
|
|
"Georgia": ["Gelasio", "DejaVu Serif", "Liberation Serif"],
|
|
"Impact": ["Impact", "Anton", "Liberation Sans"],
|
|
"Trebuchet MS": ["Ubuntu", "DejaVu Sans", "Liberation Sans"],
|
|
"Verdana": ["DejaVu Sans", "Bitstream Vera Sans", "Liberation Sans"],
|
|
"Tahoma": ["DejaVu Sans", "Liberation Sans"],
|
|
"Adobe Arabic": ["Noto Sans Arabic", "DejaVu Sans"],
|
|
"Noto Sans Thai": ["Noto Sans Thai", "Noto Sans"],
|
|
}
|
|
|
|
|
|
def find_case_insensitive(font_name: str, fonts: dict[str, Path]) -> Optional[Path]:
|
|
"""
|
|
Find a font by a case-insensitive name.
|
|
|
|
Args:
|
|
font_name: Font family name to find
|
|
fonts: Dictionary of available fonts
|
|
|
|
Returns:
|
|
Path to matched font, or None if not found
|
|
"""
|
|
font_lower = font_name.lower()
|
|
for name, path in fonts.items():
|
|
if name.lower() == font_lower:
|
|
return path
|
|
return None
|
|
|
|
|
|
def find_font_with_fallbacks(font_name: str, system_fonts: dict[str, Path]) -> Optional[Path]:
|
|
"""
|
|
Find a font by name, with intelligent fallbacks.
|
|
|
|
Tries multiple strategies in order:
|
|
1. Exact name (case-sensitive)
|
|
2. Exact name (case-insensitive)
|
|
3. Alias lookup (Windows → Linux font equivalents)
|
|
4. Partial or prefix name
|
|
|
|
Args:
|
|
font_name: The requested font family name (e.g., "Arial", "Times New Roman")
|
|
system_fonts: Dictionary of available fonts (family name → path)
|
|
|
|
Returns:
|
|
Path to the matched font file, or None when no font matches
|
|
"""
|
|
if not system_fonts:
|
|
return None
|
|
|
|
if font_name in system_fonts:
|
|
return system_fonts[font_name]
|
|
|
|
if result := find_case_insensitive(font_name, system_fonts):
|
|
return result
|
|
|
|
if font_name in FONT_ALIASES:
|
|
for alias in FONT_ALIASES[font_name]:
|
|
if alias in system_fonts:
|
|
return system_fonts[alias]
|
|
if result := find_case_insensitive(alias, system_fonts):
|
|
return result
|
|
|
|
font_name_lower = font_name.lower()
|
|
for name, path in system_fonts.items():
|
|
if name.lower().startswith(font_name_lower):
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
# Mapping of font families to system packages that provide them
|
|
FONT_PACKAGES = {
|
|
"liberation": {
|
|
"debian": "fonts-liberation fonts-liberation2",
|
|
"fonts": ["Liberation Sans", "Liberation Serif", "Liberation Mono"],
|
|
},
|
|
"dejavu": {
|
|
"debian": "fonts-dejavu fonts-dejavu-core fonts-dejavu-extra",
|
|
"fonts": ["DejaVu Sans", "DejaVu Serif", "DejaVu Sans Mono"],
|
|
},
|
|
"noto": {
|
|
"debian": "fonts-noto fonts-noto-core",
|
|
"fonts": ["Noto Sans", "Noto Serif", "Noto Sans Mono", "Noto Sans Arabic", "Noto Sans Thai"],
|
|
},
|
|
"ubuntu": {
|
|
"debian": "fonts-ubuntu",
|
|
"fonts": ["Ubuntu", "Ubuntu Mono"],
|
|
},
|
|
}
|
|
|
|
|
|
def suggest_font_packages(missing_fonts: list[str]) -> dict[str, list[str]]:
|
|
"""
|
|
Suggest system packages to install for missing fonts.
|
|
|
|
Args:
|
|
missing_fonts: List of font family names that could not be found
|
|
|
|
Returns:
|
|
Dictionary mapping package names to lists of fonts they would give
|
|
"""
|
|
suggestions = {}
|
|
|
|
# Check which fonts from aliases would help
|
|
needed_aliases = set()
|
|
for font in missing_fonts:
|
|
if font in FONT_ALIASES:
|
|
needed_aliases.update(FONT_ALIASES[font])
|
|
|
|
# Map needed aliases to packages
|
|
for package_name, package_info in FONT_PACKAGES.items():
|
|
provided_fonts = package_info["fonts"]
|
|
matching_fonts = [f for f in provided_fonts if f in needed_aliases]
|
|
if matching_fonts:
|
|
suggestions[package_info["debian"]] = matching_fonts
|
|
|
|
return suggestions
|
|
|
|
|
|
class FPS(ast.NodeVisitor):
|
|
def visit_BinOp(self, node: ast.BinOp) -> float:
|
|
if isinstance(node.op, ast.Div):
|
|
return self.visit(node.left) / self.visit(node.right)
|
|
raise ValueError(f"Invalid operation: {node.op}")
|
|
|
|
def visit_Constant(self, node: ast.Constant) -> float:
|
|
if not isinstance(node.value, (int, float)):
|
|
raise ValueError(f"Invalid fps value: {node.value!r}")
|
|
return node.value
|
|
|
|
def visit_Expr(self, node: ast.Expr) -> float:
|
|
return self.visit(node.value)
|
|
|
|
@classmethod
|
|
def parse(cls, expr: str) -> float:
|
|
return cls().visit(ast.parse(expr).body[0])
|
|
|
|
|
|
"""
|
|
Structured JSON debug logging for unshackle.
|
|
|
|
Provides comprehensive debugging information for service developers and troubleshooting.
|
|
When enabled, logs all operations, requests, responses, DRM operations, and errors in JSON format.
|
|
"""
|
|
|
|
|
|
class DebugLogger:
|
|
"""
|
|
Structured JSON debug logger for unshackle.
|
|
|
|
Outputs JSON Lines format where each line is a complete JSON object.
|
|
This makes it easy to parse, filter, and analyse logs programmatically.
|
|
"""
|
|
|
|
def __init__(self, log_path: Optional[Path] = None, enabled: bool = False, log_keys: bool = False):
|
|
"""
|
|
Initialise the debug logger.
|
|
|
|
Args:
|
|
log_path: Path to the log file. None turns logging off.
|
|
enabled: True to turn debug logging on.
|
|
log_keys: True to log the content keys (to debug content key issues).
|
|
"""
|
|
self.enabled = enabled and log_path is not None
|
|
self.log_path = log_path
|
|
self.session_id = str(uuid4())[:8]
|
|
self.file_handle = None
|
|
self.log_keys = log_keys
|
|
|
|
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()
|
|
|
|
def log_session_start(self):
|
|
"""Log the ``session_start`` entry, with environment information."""
|
|
import platform
|
|
|
|
from unshackle.core import __code_hash__, __version__
|
|
|
|
self.log(
|
|
level="INFO",
|
|
operation="session_start",
|
|
message="Debug logging session started",
|
|
context={
|
|
"unshackle_version": __version__,
|
|
"unshackle_code_hash": __code_hash__ or None,
|
|
"python_version": sys.version,
|
|
"platform": platform.platform(),
|
|
"platform_system": platform.system(),
|
|
"platform_release": platform.release(),
|
|
},
|
|
)
|
|
|
|
def log(
|
|
self,
|
|
level: str = "DEBUG",
|
|
operation: str = "",
|
|
message: str = "",
|
|
context: Optional[dict[str, Any]] = None,
|
|
service: Optional[str] = None,
|
|
error: Optional[Exception] = None,
|
|
request: Optional[dict[str, Any]] = None,
|
|
response: Optional[dict[str, Any]] = None,
|
|
duration_ms: Optional[float] = None,
|
|
success: Optional[bool] = None,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Log a structured JSON entry.
|
|
|
|
Args:
|
|
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
operation: Name of the operation
|
|
message: Human-readable message
|
|
context: Additional context information
|
|
service: Service tag
|
|
error: Exception object if an error occurred
|
|
request: Request details (URL, method, headers, body)
|
|
response: Response details (status, headers, body)
|
|
duration_ms: Operation duration in milliseconds
|
|
success: Whether the operation succeeded
|
|
**kwargs: Additional fields to include in the log entry
|
|
"""
|
|
if not self.enabled or not self.file_handle:
|
|
return
|
|
|
|
entry = {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"session_id": self.session_id,
|
|
"level": level,
|
|
}
|
|
|
|
if operation:
|
|
entry["operation"] = operation
|
|
if message:
|
|
entry["message"] = redact_all(message)
|
|
if service:
|
|
entry["service"] = service
|
|
if context:
|
|
entry["context"] = self.sanitize_data(context)
|
|
if request:
|
|
entry["request"] = self.sanitize_data(request)
|
|
if response:
|
|
entry["response"] = self.sanitize_data(response)
|
|
if duration_ms is not None:
|
|
entry["duration_ms"] = duration_ms
|
|
if success is not None:
|
|
entry["success"] = success
|
|
|
|
if error:
|
|
entry["error"] = {
|
|
"type": type(error).__name__,
|
|
"message": redact_all(str(error)),
|
|
"traceback": [
|
|
redact_all(line) for line in traceback.format_exception(type(error), error, error.__traceback__)
|
|
],
|
|
}
|
|
|
|
for key, value in kwargs.items():
|
|
if key not in entry:
|
|
entry[key] = self.sanitize_data(value)
|
|
|
|
try:
|
|
self.file_handle.write(json.dumps(entry, default=str) + "\n")
|
|
self.file_handle.flush()
|
|
except Exception as e:
|
|
print(f"Failed to write debug log: {e}", file=sys.stderr)
|
|
|
|
def sanitize_data(self, data: Any) -> Any:
|
|
"""
|
|
Sanitise data for JSON serialization.
|
|
Handles complex objects and removes sensitive information.
|
|
"""
|
|
if data is None:
|
|
return None
|
|
|
|
if isinstance(data, str):
|
|
# mask secrets, collapse content/CDN URLs, and strip local path prefixes
|
|
return redact_all(data)
|
|
|
|
if isinstance(data, (int, float, bool)):
|
|
return data
|
|
|
|
if isinstance(data, (list, tuple)):
|
|
return [self.sanitize_data(item) for item in data]
|
|
|
|
if isinstance(data, dict):
|
|
sanitized = {}
|
|
for key, value in data.items():
|
|
key_lower = str(key).lower()
|
|
has_prefix = key_lower.startswith("has_")
|
|
|
|
is_always_sensitive = not has_prefix and any(
|
|
sensitive in key_lower for sensitive in ["password", "token", "secret", "auth", "cookie"]
|
|
)
|
|
|
|
is_key_field = (
|
|
"key" in key_lower
|
|
and not has_prefix
|
|
and not any(safe in key_lower for safe in ["_count", "_id", "_type", "kid", "keys_", "key_found"])
|
|
)
|
|
|
|
should_redact = is_always_sensitive or (is_key_field and not self.log_keys)
|
|
|
|
if should_redact:
|
|
sanitized[key] = "[REDACTED]"
|
|
else:
|
|
sanitized[key] = self.sanitize_data(value)
|
|
return sanitized
|
|
|
|
if isinstance(data, bytes):
|
|
try:
|
|
return data.hex()
|
|
except Exception:
|
|
return "[BINARY_DATA]"
|
|
|
|
if isinstance(data, Path):
|
|
return redact_all(str(data))
|
|
|
|
try:
|
|
return str(data)
|
|
except Exception:
|
|
return f"[{type(data).__name__}]"
|
|
|
|
def log_operation_start(self, operation: str, **kwargs) -> str:
|
|
"""
|
|
Log the start of an operation and return an operation ID.
|
|
|
|
Args:
|
|
operation: Name of the operation
|
|
**kwargs: Additional context
|
|
|
|
Returns:
|
|
Operation ID you can use to log the end of the operation
|
|
"""
|
|
op_id = str(uuid4())[:8]
|
|
self.log(
|
|
level="DEBUG",
|
|
operation=f"{operation}_start",
|
|
message=f"Starting operation: {operation}",
|
|
operation_id=op_id,
|
|
**kwargs,
|
|
)
|
|
return op_id
|
|
|
|
def log_operation_end(
|
|
self, operation: str, operation_id: str, success: bool = True, duration_ms: Optional[float] = None, **kwargs
|
|
):
|
|
"""
|
|
Log the end of an operation.
|
|
|
|
Args:
|
|
operation: Name of the operation
|
|
operation_id: Operation ID from log_operation_start
|
|
success: Whether the operation succeeded
|
|
duration_ms: Operation duration in milliseconds
|
|
**kwargs: Additional context
|
|
"""
|
|
self.log(
|
|
level="INFO" if success else "ERROR",
|
|
operation=f"{operation}_end",
|
|
message=f"Finished operation: {operation}",
|
|
operation_id=operation_id,
|
|
success=success,
|
|
duration_ms=duration_ms,
|
|
**kwargs,
|
|
)
|
|
|
|
def log_service_call(self, method: str, url: str, **kwargs):
|
|
"""
|
|
Log a service API call.
|
|
|
|
Args:
|
|
method: HTTP method (for example GET or POST)
|
|
url: Request URL
|
|
**kwargs: Additional request details (for example headers or body)
|
|
"""
|
|
message = kwargs.pop("message", "")
|
|
level = kwargs.pop("level", "DEBUG")
|
|
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):
|
|
"""
|
|
Log a DRM operation (PSSH extraction, license request, content key retrieval).
|
|
|
|
Args:
|
|
drm_type: DRM type (for example Widevine or PlayReady)
|
|
operation: DRM operation name
|
|
**kwargs: Additional context. Pass ``message=``/``level=`` to override the defaults.
|
|
"""
|
|
message = kwargs.pop("message", None) or f"{drm_type} {operation}"
|
|
level = kwargs.pop("level", "DEBUG")
|
|
self.log(level=level, operation=f"drm_{operation}", message=message, drm_type=drm_type, **kwargs)
|
|
|
|
def log_vault_query(self, vault_name: str, operation: str, **kwargs):
|
|
"""
|
|
Log a vault query operation.
|
|
|
|
Args:
|
|
vault_name: Name of the vault
|
|
operation: Vault operation (for example get_key or add_key)
|
|
**kwargs: Additional context. Pass ``message=``/``level=`` to override the defaults.
|
|
"""
|
|
message = kwargs.pop("message", None) or f"Vault {vault_name}: {operation}"
|
|
level = kwargs.pop("level", "DEBUG")
|
|
self.log(
|
|
level=level,
|
|
operation=f"vault_{operation}",
|
|
message=message,
|
|
vault=vault_name,
|
|
**kwargs,
|
|
)
|
|
|
|
def log_error(self, operation: str, error: Exception, **kwargs):
|
|
"""
|
|
Log an error with full context.
|
|
|
|
Args:
|
|
operation: Operation that failed
|
|
error: Exception that occurred
|
|
**kwargs: Additional context
|
|
"""
|
|
self.log(
|
|
level="ERROR",
|
|
operation=operation,
|
|
message=f"Error in {operation}: {str(error)}",
|
|
error=error,
|
|
success=False,
|
|
**kwargs,
|
|
)
|
|
|
|
def close(self):
|
|
"""Close the log file and clean up resources."""
|
|
if self.file_handle:
|
|
self.log(level="INFO", operation="session_end", message="Debug logging session ended")
|
|
self.file_handle.close()
|
|
self.file_handle = None
|
|
|
|
|
|
# Global debug logger instance
|
|
debug_logger: Optional[DebugLogger] = None
|
|
|
|
|
|
def get_debug_logger() -> Optional[DebugLogger]:
|
|
"""Get the global debug logger instance."""
|
|
return debug_logger
|
|
|
|
|
|
def log_event(operation: str, *, level: str = "DEBUG", message: str = "", **kwargs: Any) -> None:
|
|
"""Send a single structured debug-log entry. No-op when debug logging is off.
|
|
|
|
The canonical one-shot logging primitive. Replaces the
|
|
``if dl := get_debug_logger(): dl.log(...)`` guard boilerplate. To add logging to a new
|
|
feature, call ``log_event("my_feature_event", message="...", context={...})``.
|
|
"""
|
|
dl = debug_logger
|
|
if dl:
|
|
dl.log(level=level, operation=operation, message=message, **kwargs)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def timed_operation(operation: str, *, level: str = "DEBUG", message: str = "", **kwargs: Any):
|
|
"""Time a block and log ``operation`` once it finishes, with ``duration_ms``.
|
|
|
|
Logs at ``level`` with ``success=True`` on normal exit, or at ERROR with the exception and
|
|
``success=False`` if the block raises (then re-raises). No-op when debug logging is off,
|
|
so it is always safe to wrap a block in it.
|
|
|
|
Example::
|
|
|
|
with timed_operation("mux", context={"output": str(path)}):
|
|
run_mkvmerge(...)
|
|
"""
|
|
dl = debug_logger
|
|
if not dl:
|
|
yield
|
|
return
|
|
start = time.monotonic()
|
|
try:
|
|
yield
|
|
except Exception as e:
|
|
dl.log(
|
|
level="ERROR",
|
|
operation=operation,
|
|
message=message or f"{operation} failed",
|
|
error=e,
|
|
success=False,
|
|
duration_ms=round((time.monotonic() - start) * 1000, 1),
|
|
**kwargs,
|
|
)
|
|
raise
|
|
dl.log(
|
|
level=level,
|
|
operation=operation,
|
|
message=message,
|
|
success=True,
|
|
duration_ms=round((time.monotonic() - start) * 1000, 1),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def init_debug_logger(log_path: Optional[Path] = None, enabled: bool = False, log_keys: bool = False):
|
|
"""
|
|
Initialise the global debug logger.
|
|
|
|
Args:
|
|
log_path: Path to the log file
|
|
enabled: True to turn debug logging on
|
|
log_keys: True to log the content keys (to debug content key issues)
|
|
"""
|
|
global debug_logger
|
|
if debug_logger:
|
|
debug_logger.close()
|
|
debug_logger = DebugLogger(log_path=log_path, enabled=enabled, log_keys=log_keys)
|
|
|
|
|
|
def close_debug_logger():
|
|
"""Close the global debug logger."""
|
|
global debug_logger
|
|
if debug_logger:
|
|
debug_logger.close()
|
|
debug_logger = None
|
|
|
|
|
|
__all__ = (
|
|
"DebugLogger",
|
|
"get_debug_logger",
|
|
"init_debug_logger",
|
|
"close_debug_logger",
|
|
"log_event",
|
|
"timed_operation",
|
|
)
|