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.
This commit is contained in:
2026-06-09 18:26:51 +02:00
parent e0be00dce6
commit 7c1dccfe64
7 changed files with 68 additions and 34 deletions

View File

@@ -13,8 +13,8 @@ Series Identifier Convention:
All template helpers that handle series data use `key` for identification and All template helpers that handle series data use `key` for identification and
provide `folder` as display metadata only. provide `folder` as display metadata only.
""" """
import hashlib
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -27,10 +27,44 @@ logger = logging.getLogger(__name__)
# Configure templates directory # Configure templates directory
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
STATIC_DIR = Path(__file__).parent.parent / "web" / "static"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Version token for static asset cache-busting; changes on every server start. # Cache for static file hashes: {file_path: (mtime, hash)}
STATIC_VERSION: str = str(int(time.time())) _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( def get_base_context(
@@ -51,7 +85,7 @@ def get_base_context(
"title": title, "title": title,
"app_name": "Aniworld Download Manager", "app_name": "Aniworld Download Manager",
"version": APP_VERSION, "version": APP_VERSION,
"static_v": STATIC_VERSION, "static_version": get_static_version,
} }

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager</title> <title>AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- UX Enhancement and Mobile & Accessibility CSS --> <!-- UX Enhancement and Mobile & Accessibility CSS -->
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
</head> </head>
<body> <body>
@@ -727,22 +727,22 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script> <script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script> <script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script> <script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
<!-- External modules --> <!-- External modules -->
<script src="/static/js/localization.js?v={{ static_v }}"></script> <script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script> <script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
<!-- Index Page Modules --> <!-- Index Page Modules -->
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script> <script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script> <script src="/static/js/index/edit-modal.js?v={{ static_version('js/index/edit-modal.js') }}"></script>
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
<script src="/static/js/index/search.js?v={{ static_v }}"></script> <script src="/static/js/index/search.js?v={{ static_v }}"></script>
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script> <script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Initializing</title> <title>AniWorld Manager - Initializing</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.loading-container { .loading-container {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Login</title> <title>AniWorld Manager - Login</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.login-container { .login-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Download Queue - AniWorld Manager</title> <title>Download Queue - AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
@@ -234,19 +234,19 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script> <script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script> <script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script> <script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
<!-- Queue Page Modules --> <!-- Queue Page Modules -->
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-api.js?v={{ static_version('js/queue/queue-api.js') }}"></script>
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-renderer.js?v={{ static_version('js/queue/queue-renderer.js') }}"></script>
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script> <script src="/static/js/queue/progress-handler.js?v={{ static_version('js/queue/progress-handler.js') }}"></script>
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-socket-handler.js?v={{ static_version('js/queue/queue-socket-handler.js') }}"></script>
<script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script> <script src="/static/js/queue/queue-init.js?v={{ static_version('js/queue/queue-init.js') }}"></script>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Setup</title> <title>AniWorld Manager - Setup</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.setup-container { .setup-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Resolve Series</title> <title>AniWorld Manager - Resolve Series</title>
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.unresolved-container { .unresolved-container {