""" Template integration utilities for FastAPI application. This module provides utilities for template rendering with common context and helper functions. Series Identifier Convention: - `key`: Primary identifier for all series operations (URL-safe, e.g., "attack-on-titan") - `folder`: Metadata only for filesystem operations and display (e.g., "Attack on Titan (2013)") All template helpers that handle series data use `key` for identification and provide `folder` as display metadata only. """ import hashlib import logging from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import Request from fastapi.templating import Jinja2Templates from src.server.utils.version import APP_VERSION logger = logging.getLogger(__name__) # Configure templates directory TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" STATIC_DIR = Path(__file__).parent.parent / "web" / "static" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # Cache for static file hashes: {file_path: (mtime, hash)} _hash_cache: Dict[str, tuple[float, str]] = {} def get_static_version(file_path: str) -> str: """ Get cache-busting version for a static file based on content hash. Hash is computed once and cached; cache is invalidated when file mtime changes. Args: file_path: Relative path to static file (e.g., 'css/styles.css') Returns: 8-character hex hash of file content, or empty string if file not found """ full_path = STATIC_DIR / file_path if not full_path.exists(): logger.warning(f"Static file not found: {file_path}") return "" current_mtime = full_path.stat().st_mtime # Check cache validity if file_path in _hash_cache: cached_mtime, cached_hash = _hash_cache[file_path] if cached_mtime == current_mtime: return cached_hash # Compute new hash file_hash = hashlib.md5(full_path.read_bytes()).hexdigest()[:8] _hash_cache[file_path] = (current_mtime, file_hash) return file_hash def get_base_context( request: Request, title: str = "Aniworld" ) -> Dict[str, Any]: """ Get base context for all templates. Args: request: FastAPI request object title: Page title Returns: Dictionary with base context variables """ return { "request": request, "title": title, "app_name": "Aniworld Download Manager", "version": APP_VERSION, "static_version": get_static_version, } def render_template( template_name: str, request: Request, context: Optional[Dict[str, Any]] = None, title: Optional[str] = None ): """ Render a template with base context. Args: template_name: Name of the template file request: FastAPI request object context: Additional context variables title: Page title (optional) Returns: TemplateResponse object """ base_context = get_base_context( request, title or template_name.replace('.html', '').replace('_', ' ').title() ) if context: base_context.update(context) return templates.TemplateResponse(template_name, base_context) def validate_template_exists(template_name: str) -> bool: """ Check if a template file exists. Args: template_name: Name of the template file Returns: True if template exists, False otherwise """ template_path = TEMPLATES_DIR / template_name return template_path.exists() def list_available_templates() -> list[str]: """ Get list of all available template files. Returns: List of template file names """ if not TEMPLATES_DIR.exists(): return [] return [ f.name for f in TEMPLATES_DIR.glob("*.html") if f.is_file() ] def prepare_series_context( series_data: List[Dict[str, Any]], sort_by: str = "name" ) -> List[Dict[str, Any]]: """ Prepare series data for template rendering. This function ensures series data follows the identifier convention: - `key` is used as the primary identifier for all operations - `folder` is included as metadata for display purposes Args: series_data: List of series dictionaries from the API sort_by: Field to sort by ("name", "key", or "folder") Returns: List of series dictionaries prepared for template use Raises: ValueError: If series_data contains items without required 'key' field Example: >>> series = [ ... {"key": "attack-on-titan", "name": "Attack on Titan", ... "folder": "Attack on Titan (2013)"}, ... {"key": "one-piece", "name": "One Piece", ... "folder": "One Piece (1999)"} ... ] >>> prepared = prepare_series_context(series, sort_by="name") """ if not series_data: return [] prepared = [] for series in series_data: if "key" not in series: logger.warning( "Series data missing 'key' field: %s", series.get("name", "unknown") ) continue prepared_item = { "key": series["key"], "name": series.get("name", series["key"]), "folder": series.get("folder", ""), **{k: v for k, v in series.items() if k not in ("key", "name", "folder")} } prepared.append(prepared_item) # Sort by specified field if sort_by in ("name", "key", "folder"): prepared.sort(key=lambda x: x.get(sort_by, "").lower()) return prepared def get_series_by_key( series_data: List[Dict[str, Any]], key: str ) -> Optional[Dict[str, Any]]: """ Find a series in the data by its key. Uses `key` as the identifier (not `folder`) following the project identifier convention. Args: series_data: List of series dictionaries key: The unique series key to search for Returns: The series dictionary if found, None otherwise Example: >>> series = [{"key": "attack-on-titan", "name": "Attack on Titan"}] >>> result = get_series_by_key(series, "attack-on-titan") >>> result["name"] 'Attack on Titan' """ for series in series_data: if series.get("key") == key: return series return None def filter_series_by_missing_episodes( series_data: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """ Filter series to only include those with missing episodes. Args: series_data: List of series dictionaries with 'missing_episodes' field Returns: Filtered list containing only series with missing episodes Note: Identification uses `key`, not `folder`. """ return [ series for series in series_data if series.get("missing_episodes") and any(episodes for episodes in series["missing_episodes"].values()) ]