Files
Aniworld/src/server/utils/template_helpers.py
Lukas 7c1dccfe64 perf(web): use content hash for static asset cache busting
Switch from timestamp-based to MD5 content hash versioning.
Cache now only invalidates when file content actually changes.
2026-06-09 18:26:51 +02:00

259 lines
7.0 KiB
Python

"""
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())
]