media-rip/backend/app/services/theme_loader.py
xpltd efc2ead796 M001: media.rip() v1.0 — complete application
Full-featured self-hosted yt-dlp web frontend:
- Python 3.12+ / FastAPI backend with async SQLite, SSE transport, session isolation
- Vue 3 / TypeScript / Pinia frontend with real-time progress, theme picker
- 3 built-in themes (cyberpunk/dark/light) + drop-in custom theme system
- Admin auth (bcrypt), purge system, cookie upload, file serving
- Docker multi-stage build, GitHub Actions CI/CD
- 179 backend tests, 29 frontend tests (208 total)

Slices: S01 (Foundation), S02 (SSE+Sessions), S03 (Frontend),
        S04 (Admin+Auth), S05 (Themes), S06 (Docker+CI)
2026-03-18 20:00:17 -05:00

87 lines
2.8 KiB
Python

"""
Theme loader service — discovers custom themes from /themes volume.
Each theme is a directory containing at minimum:
- metadata.json: { "name": "Theme Name", "author": "Author", "description": "..." }
- theme.css: CSS variable overrides inside [data-theme="<dirname>"] selector
Optional:
- preview.png: Preview thumbnail for the theme picker
- assets/: Additional assets (fonts, images) served statically
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def scan_themes(themes_dir: str | Path) -> list[dict[str, Any]]:
"""Scan a directory for valid theme packs.
Returns a list of theme metadata dicts with the directory name as 'id'.
Skips directories missing metadata.json or theme.css.
"""
themes_path = Path(themes_dir)
if not themes_path.is_dir():
logger.debug("Themes directory does not exist: %s", themes_dir)
return []
themes: list[dict[str, Any]] = []
for entry in sorted(themes_path.iterdir()):
if not entry.is_dir():
continue
metadata_file = entry / "metadata.json"
css_file = entry / "theme.css"
if not metadata_file.exists():
logger.warning("Theme '%s' missing metadata.json — skipping", entry.name)
continue
if not css_file.exists():
logger.warning("Theme '%s' missing theme.css — skipping", entry.name)
continue
try:
meta = json.loads(metadata_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("Theme '%s' has invalid metadata.json: %s — skipping", entry.name, e)
continue
theme_info = {
"id": entry.name,
"name": meta.get("name", entry.name),
"author": meta.get("author"),
"description": meta.get("description"),
"has_preview": (entry / "preview.png").exists(),
"path": str(entry),
}
themes.append(theme_info)
logger.info("Discovered custom theme: %s (%s)", theme_info["name"], entry.name)
return themes
def get_theme_css(themes_dir: str | Path, theme_id: str) -> str | None:
"""Read the CSS for a specific custom theme.
Returns None if the theme doesn't exist or lacks theme.css.
"""
css_path = Path(themes_dir) / theme_id / "theme.css"
if not css_path.is_file():
return None
# Security: verify the resolved path is inside themes_dir
try:
css_path.resolve().relative_to(Path(themes_dir).resolve())
except ValueError:
logger.warning("Path traversal attempt in theme CSS: %s", theme_id)
return None
return css_path.read_text(encoding="utf-8")