Files
unshackle/unshackle/core/utilities.py
T
imSp4rky d8739b3561 feat(dl): add --require-audio/--require-video and --warn-only alias
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.
2026-08-23 12:24:45 -06:00

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",
)