feat(fonts): add Linux font support for ASS/SSA subtitles

Implements cross-platform font discovery and intelligent fallback system for ASS/SSA subtitle rendering on Linux/macOS systems.

Windows support has not been tested
This commit is contained in:
Andy
2025-11-03 20:23:45 +00:00
parent f00790f31b
commit 8b0b3045e3
4 changed files with 441 additions and 37 deletions

View File

@@ -57,8 +57,8 @@ from unshackle.core.titles.episode import Episode
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (get_debug_logger, get_system_fonts, init_debug_logger, is_close_match,
time_elapsed_since)
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
is_close_match, suggest_font_packages, time_elapsed_since)
from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
SubtitleCodecChoice, VideoCodecChoice)
@@ -69,7 +69,7 @@ from unshackle.core.vaults import Vaults
class dl:
@staticmethod
def _truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str:
def truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str:
"""Truncate PSSH string for display when not in debug mode."""
if logging.root.level == logging.DEBUG or not pssh_string:
return pssh_string
@@ -80,6 +80,115 @@ class dl:
return pssh_string[: max_width - 3] + "..."
def find_custom_font(self, font_name: str) -> Optional[Path]:
"""
Find font in custom fonts directory.
Args:
font_name: Font family name to find
Returns:
Path to font file, or None if not found
"""
family_dir = Path(config.directories.fonts, font_name)
if family_dir.exists():
fonts = list(family_dir.glob("*.*tf"))
return fonts[0] if fonts else None
return None
def prepare_temp_font(
self,
font_name: str,
matched_font: Path,
system_fonts: dict[str, Path],
temp_font_files: list[Path]
) -> Path:
"""
Copy system font to temp and log if using fallback.
Args:
font_name: Requested font name
matched_font: Path to matched system font
system_fonts: Dictionary of available system fonts
temp_font_files: List to track temp files for cleanup
Returns:
Path to temp font file
"""
# Find the matched name for logging
matched_name = next(
(name for name, path in system_fonts.items() if path == matched_font),
None
)
if matched_name and matched_name.lower() != font_name.lower():
self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'")
# Create unique temp file path
safe_name = font_name.replace(" ", "_").replace("/", "_")
temp_path = config.directories.temp / f"font_{safe_name}{matched_font.suffix}"
# Copy if not already exists
if not temp_path.exists():
shutil.copy2(matched_font, temp_path)
temp_font_files.append(temp_path)
return temp_path
def _attach_subtitle_fonts(
self,
font_names: list[str],
title: Title_T,
temp_font_files: list[Path]
) -> tuple[int, list[str]]:
"""
Attach fonts for subtitle rendering.
Args:
font_names: List of font names requested by subtitles
title: Title object to attach fonts to
temp_font_files: List to track temp files for cleanup
Returns:
Tuple of (fonts_attached_count, missing_fonts_list)
"""
system_fonts = get_system_fonts()
self.log.info(f"Discovered {len(system_fonts)} system font families")
font_count = 0
missing_fonts = []
for font_name in set(font_names):
# Try custom fonts first
if custom_font := self.find_custom_font(font_name):
title.tracks.add(Attachment(path=custom_font, name=f"{font_name} ({custom_font.stem})"))
font_count += 1
continue
# Try system fonts with fallback
if system_font := find_font_with_fallbacks(font_name, system_fonts):
temp_path = self.prepare_temp_font(font_name, system_font, system_fonts, temp_font_files)
title.tracks.add(Attachment(path=temp_path, name=f"{font_name} ({system_font.stem})"))
font_count += 1
else:
self.log.warning(f"Subtitle uses font '{font_name}' but it could not be found")
missing_fonts.append(font_name)
return font_count, missing_fonts
def _suggest_missing_fonts(self, missing_fonts: list[str]) -> None:
"""
Show package installation suggestions for missing fonts.
Args:
missing_fonts: List of font names that couldn't be found
"""
if suggestions := suggest_font_packages(missing_fonts):
self.log.info("Install font packages to improve subtitle rendering:")
for package_cmd, fonts in suggestions.items():
self.log.info(f" $ sudo apt install {package_cmd}")
self.log.info(f" → Provides: {', '.join(fonts)}")
@click.command(
short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
cls=Services,
@@ -794,6 +903,7 @@ class dl:
continue
console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2)))
temp_font_files = []
if isinstance(title, Episode) and not self.tmdb_searched:
kind = "tv"
@@ -1435,26 +1545,16 @@ class dl:
if line.startswith("Style: "):
font_names.append(line.removesuffix("Style: ").split(",")[1])
font_count = 0
system_fonts = get_system_fonts()
for font_name in set(font_names):
family_dir = Path(config.directories.fonts, font_name)
fonts_from_system = [file for name, file in system_fonts.items() if name.startswith(font_name)]
if family_dir.exists():
fonts = family_dir.glob("*.*tf")
for font in fonts:
title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})"))
font_count += 1
elif fonts_from_system:
for font in fonts_from_system:
title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})"))
font_count += 1
else:
self.log.warning(f"Subtitle uses font [text2]{font_name}[/] but it could not be found...")
font_count, missing_fonts = self._attach_subtitle_fonts(
font_names, title, temp_font_files
)
if font_count:
self.log.info(f"Attached {font_count} fonts for the Subtitles")
if missing_fonts and sys.platform != "win32":
self._suggest_missing_fonts(missing_fonts)
# Handle DRM decryption BEFORE repacking (must decrypt first!)
service_name = service.__class__.__name__.upper()
decryption_method = config.decryption_map.get(service_name, config.decryption)
@@ -1608,8 +1708,17 @@ class dl:
video_track.delete()
for track in title.tracks:
track.delete()
# Clear temp font attachment paths and delete other attachments
for attachment in title.tracks.attachments:
attachment.delete()
if attachment.path and attachment.path in temp_font_files:
attachment.path = None
else:
attachment.delete()
# Clean up temp fonts
for temp_path in temp_font_files:
temp_path.unlink(missing_ok=True)
else:
# dont mux
@@ -1752,7 +1861,7 @@ class dl:
)
with self.DRM_TABLE_LOCK:
pssh_display = self._truncate_pssh_for_display(drm.pssh.dumps(), "Widevine")
pssh_display = self.truncate_pssh_for_display(drm.pssh.dumps(), "Widevine")
cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="fold"))
pre_existing_tree = next(
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
@@ -1921,7 +2030,7 @@ class dl:
)
with self.DRM_TABLE_LOCK:
pssh_display = self._truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady")
pssh_display = self.truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady")
cek_tree = Tree(
Text.assemble(
("PlayReady", "cyan"),

View File

@@ -21,6 +21,7 @@ from uuid import uuid4
import chardet
import requests
from construct import ValidationError
from fontTools import ttLib
from langcodes import Language, closest_match
from pymp4.parser import Box
from unidecode import unidecode
@@ -29,6 +30,30 @@ from unshackle.core.cacher import Cacher
from unshackle.core.config import config
from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
"""
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:
"""
@@ -428,21 +453,254 @@ def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
return ext
def get_system_fonts() -> dict[str, Path]:
if sys.platform == "win32":
import winreg
def extract_font_family(font_path: Path) -> Optional[str]:
"""
Extract font family name from TTF/OTF file using fontTools.
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)]
}
else:
# TODO: Get System Fonts for Linux and mac OS
return {}
Args:
font_path: Path to the font file
Returns:
Font family name if successfully extracted, None otherwise
"""
try:
font = ttLib.TTFont(font_path)
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:
# Silently ignore font parsing errors (corrupted fonts, etc.)
pass
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 populate 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}")
log.debug(f"Discovered {len(fonts)} system font families")
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 font by case-insensitive name match.
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 fallback matching.
Tries multiple strategies in order:
1. Exact match (case-sensitive)
2. Case-insensitive match
3. Alias lookup (Windows → Linux font equivalents)
4. Partial/prefix match
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 if no match found
"""
if not system_fonts:
return None
# Strategy 1: Exact match (case-sensitive)
if font_name in system_fonts:
return system_fonts[font_name]
# Strategy 2: Case-insensitive match
if result := find_case_insensitive(font_name, system_fonts):
return result
# Strategy 3: Alias lookup
if font_name in FONT_ALIASES:
for alias in FONT_ALIASES[font_name]:
# Try exact match for alias
if alias in system_fonts:
return system_fonts[alias]
# Try case-insensitive match for alias
if result := find_case_insensitive(alias, system_fonts):
return result
# Strategy 4: Partial/prefix match as last resort
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 couldn't be found
Returns:
Dictionary mapping package names to lists of fonts they would provide
"""
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):