Switch from timestamp-based to MD5 content hash versioning. Cache now only invalidates when file content actually changes.
259 lines
7.0 KiB
Python
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())
|
|
]
|