Extract WebUI asset helpers

- move Vite manifest handling and SPA route rules into core/webui
- keep web_server.py focused on Flask route wiring
- add tests for asset rendering and manifest reload behavior
- keep image URL normalization coverage alongside the metadata helpers
This commit is contained in:
Antti Kettunen 2026-05-02 22:24:52 +03:00
parent 497a6f41ed
commit 32bf52cc18
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
5 changed files with 274 additions and 100 deletions

19
core/webui/__init__.py Normal file
View file

@ -0,0 +1,19 @@
"""WebUI delivery helpers."""
from core.webui.assets import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
default_static_url_builder,
get_webui_vite_manifest_path,
load_webui_vite_manifest,
)
from core.webui.spa import should_serve_webui_spa
__all__ = [
"build_webui_vite_assets",
"clear_webui_vite_manifest_cache",
"default_static_url_builder",
"get_webui_vite_manifest_path",
"load_webui_vite_manifest",
"should_serve_webui_spa",
]

126
core/webui/assets.py Normal file
View file

@ -0,0 +1,126 @@
"""WebUI Vite asset helpers."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Callable
from utils.logging_config import get_logger as _create_logger
logger = _create_logger("webui.assets")
DEFAULT_WEBUI_VITE_ENTRY = "src/app/main.tsx"
DEFAULT_WEBUI_VITE_BASE = "/static/dist/"
DEFAULT_WEBUI_VITE_DEV_URL = "http://127.0.0.1:5173"
DEFAULT_WEBUI_VITE_DEV_ENV = "SOULSYNC_WEBUI_VITE_DEV"
DEFAULT_WEBUI_VITE_URL_ENV = "SOULSYNC_WEBUI_VITE_URL"
_MANIFEST_CACHE: dict[str, tuple[float | None, dict[str, Any]]] = {}
def get_webui_vite_manifest_path() -> Path:
"""Return the generated Vite manifest path inside the repo."""
return Path(__file__).resolve().parents[2] / "webui" / "static" / "dist" / ".vite" / "manifest.json"
def clear_webui_vite_manifest_cache() -> None:
"""Clear the in-process manifest cache. Primarily useful for tests."""
_MANIFEST_CACHE.clear()
def default_static_url_builder(filename: str) -> str:
"""Build a Flask-style static URL without requiring Flask imports."""
return f"/static/{filename.lstrip('/')}"
def _env_truthy(value: str | None) -> bool:
return (value or "").lower() in {"1", "true", "yes", "on"}
def _resolve_dev_mode(dev: bool | None) -> bool:
if dev is not None:
return bool(dev)
return _env_truthy(os.environ.get(DEFAULT_WEBUI_VITE_DEV_ENV))
def _resolve_dev_url(dev_url: str | None) -> str:
if dev_url:
return dev_url.rstrip("/")
return os.environ.get(DEFAULT_WEBUI_VITE_URL_ENV, DEFAULT_WEBUI_VITE_DEV_URL).rstrip("/")
def load_webui_vite_manifest(manifest_path: str | Path | None = None) -> dict[str, Any]:
"""Load and cache the generated Vite manifest."""
path = Path(manifest_path) if manifest_path else get_webui_vite_manifest_path()
cache_key = str(path)
manifest_mtime = None
if path.exists():
try:
manifest_mtime = path.stat().st_mtime
except OSError:
manifest_mtime = None
cached = _MANIFEST_CACHE.get(cache_key)
if cached and cached[0] == manifest_mtime:
return cached[1]
if path.exists():
try:
with path.open("r", encoding="utf-8") as handle:
manifest = json.load(handle)
except Exception as exc:
logger.warning("Failed to load webui manifest: %s", exc)
manifest = {}
else:
manifest = {}
_MANIFEST_CACHE[cache_key] = (manifest_mtime, manifest)
return manifest
def build_webui_vite_assets(
placement: str = "body",
*,
dev: bool | None = None,
dev_url: str | None = None,
entry: str = DEFAULT_WEBUI_VITE_ENTRY,
manifest_path: str | Path | None = None,
manifest_loader: Callable[[], dict[str, Any]] | None = None,
static_url_builder: Callable[[str], str] | None = None,
) -> str:
"""Return HTML tags for the WebUI bundle or dev client."""
if placement not in ("head", "body"):
return ""
dev_mode = _resolve_dev_mode(dev)
vite_url = _resolve_dev_url(dev_url)
static_url = static_url_builder or default_static_url_builder
if dev_mode:
if placement == "head":
return ""
base = DEFAULT_WEBUI_VITE_BASE.rstrip("/")
return "\n".join([
f'<script type="module" src="{vite_url}{base}/@vite/client"></script>',
f'<script type="module" src="{vite_url}{base}/{entry.lstrip("/")}"></script>',
])
loader = manifest_loader or (lambda: load_webui_vite_manifest(manifest_path))
manifest = loader()
entry_meta = manifest.get(entry)
if not entry_meta:
return ""
if placement == "head":
return "\n".join(
f'<link rel="stylesheet" href="{static_url(f"dist/{css_file}")}">'
for css_file in entry_meta.get("css", [])
)
entry_file = entry_meta.get("file")
if not entry_file:
return ""
return f'<script type="module" src="{static_url(f"dist/{entry_file}")}"></script>'

23
core/webui/spa.py Normal file
View file

@ -0,0 +1,23 @@
"""WebUI SPA routing helpers."""
from __future__ import annotations
EXACT_EXCLUDED_PATHS = {"/callback", "/status"}
PREFIX_EXCLUDED_PATHS = (
"/api",
"/auth",
"/callback/",
"/deezer/",
"/socket.io",
"/static",
"/stream",
"/tidal/",
)
def should_serve_webui_spa(pathname: str) -> bool:
"""Return True when a request path should fall through to the SPA."""
normalized = pathname.rstrip("/") or "/"
if normalized in EXACT_EXCLUDED_PATHS:
return False
return not normalized.startswith(PREFIX_EXCLUDED_PATHS)

View file

@ -0,0 +1,96 @@
"""Tests for the WebUI asset helpers."""
from __future__ import annotations
import json
import os
import time
import pytest
from core.webui import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
load_webui_vite_manifest,
should_serve_webui_spa,
)
def test_build_webui_vite_assets_renders_dev_scripts():
html = build_webui_vite_assets("body", dev=True, dev_url="http://127.0.0.1:5173")
assert html == (
'<script type="module" src="http://127.0.0.1:5173/static/dist/@vite/client"></script>\n'
'<script type="module" src="http://127.0.0.1:5173/static/dist/src/app/main.tsx"></script>'
)
def test_build_webui_vite_assets_renders_manifest_assets():
manifest = {
"src/app/main.tsx": {
"css": ["assets/main.css"],
"file": "assets/main.js",
}
}
html_head = build_webui_vite_assets(
"head",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
html_body = build_webui_vite_assets(
"body",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
assert html_head == '<link rel="stylesheet" href="/assets/dist/assets/main.css">'
assert html_body == '<script type="module" src="/assets/dist/assets/main.js"></script>'
@pytest.mark.parametrize(
"pathname",
[
"/api/issues",
"/auth/spotify",
"/callback",
"/callback/extra",
"/deezer/callback",
"/socket.io",
"/static/app.js",
"/stream/file",
"/tidal/callback",
"/status",
],
)
def test_should_serve_webui_spa_blocks_reserved_paths(pathname):
assert should_serve_webui_spa(pathname) is False
@pytest.mark.parametrize(
"pathname",
[
"/",
"/issues",
"/issues?issueId=7",
"/artists/Opeth",
"/discover",
],
)
def test_should_serve_webui_spa_allows_client_routes(pathname):
assert should_serve_webui_spa(pathname) is True
def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path):
clear_webui_vite_manifest_cache()
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/one.js"}}))
first = load_webui_vite_manifest(manifest_path)
assert first["src/app/main.tsx"]["file"] == "assets/one.js"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/two.js"}}))
future = time.time() + 10
os.utime(manifest_path, (future, future))
second = load_webui_vite_manifest(manifest_path)
assert second["src/app/main.tsx"]["file"] == "assets/two.js"

View file

@ -20,7 +20,7 @@ from pathlib import Path
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, url_for
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
@ -97,6 +97,7 @@ from core.metadata.cache import get_metadata_cache
from core.metadata import registry as metadata_registry
from core.metadata import is_internal_image_host
from core.metadata import normalize_image_url as fix_artist_image_url
from core.webui import build_webui_vite_assets, should_serve_webui_spa
from core.metadata.registry import (
clear_cached_metadata_client,
get_metadata_source_label,
@ -292,13 +293,12 @@ app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
# Cache-bust query string for static assets — appended to every
# url_for('static', ...) URL via the context processor below. Computed
# once per process start so each server restart invalidates the
# browser's cached copy of every JS/CSS file. This is the surefire
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
# itself changes, so the browser cannot reuse a previously-cached
# response no matter what its Cache-Control header said.
# Cache-bust query string for static assets. Computed once per process
# start so each server restart invalidates the browser's cached copy of
# every JS/CSS file. This is the surefire fix for "user has stale JS
# even after Ctrl+Shift+R" — the URL itself changes, so the browser
# cannot reuse a previously-cached response no matter what its
# Cache-Control header said.
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@ -371,100 +371,10 @@ def _log_rejected_socketio_origin():
request.headers.get('X-Forwarded-Proto', ''),
)
# --- WebUI asset injection ---
_webui_vite_manifest = None
_webui_vite_manifest_mtime = None
_webui_vite_entry = 'src/app/main.tsx'
_webui_vite_manifest_path = Path(base_dir) / 'webui' / 'static' / 'dist' / '.vite' / 'manifest.json'
_webui_vite_dev = os.environ.get('SOULSYNC_WEBUI_VITE_DEV', '').lower() in ('1', 'true', 'yes', 'on')
_webui_vite_url = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
_webui_vite_base = '/static/dist/'
def _should_serve_webui_spa(pathname: str) -> bool:
normalized = pathname.rstrip('/') or '/'
excluded_exact_paths = {'/callback', '/status'}
excluded_prefixes = (
'/api',
'/auth',
'/callback/',
'/deezer/',
'/socket.io',
'/static',
'/stream',
'/tidal/',
)
if normalized in excluded_exact_paths:
return False
return not normalized.startswith(excluded_prefixes)
def _webui_vite_dev_asset_url(asset_path: str) -> str:
return f'{_webui_vite_url}{_webui_vite_base.rstrip("/")}/{asset_path.lstrip("/")}'
def _load_webui_vite_manifest():
global _webui_vite_manifest, _webui_vite_manifest_mtime
manifest_mtime = None
if _webui_vite_manifest_path.exists():
try:
manifest_mtime = _webui_vite_manifest_path.stat().st_mtime
except OSError:
manifest_mtime = None
if _webui_vite_manifest is not None and manifest_mtime == _webui_vite_manifest_mtime:
return _webui_vite_manifest
if _webui_vite_manifest_path.exists():
try:
with _webui_vite_manifest_path.open('r', encoding='utf-8') as handle:
_webui_vite_manifest = json.load(handle)
_webui_vite_manifest_mtime = manifest_mtime
except Exception as exc:
logger.warning(f"Failed to load webui manifest: {exc}")
_webui_vite_manifest = {}
_webui_vite_manifest_mtime = manifest_mtime
else:
_webui_vite_manifest = {}
_webui_vite_manifest_mtime = None
return _webui_vite_manifest
def _build_webui_vite_assets(placement='body'):
if placement not in ('head', 'body'):
return ''
if _webui_vite_dev:
if placement == 'head':
return ''
return '\n'.join([
f'<script type="module" src="{_webui_vite_dev_asset_url("@vite/client")}"></script>',
f'<script type="module" src="{_webui_vite_dev_asset_url(_webui_vite_entry)}"></script>',
])
manifest = _load_webui_vite_manifest()
entry = manifest.get(_webui_vite_entry)
if not entry:
return ''
if placement == 'head':
head_assets = []
for css_file in entry.get('css', []):
head_assets.append(f'<link rel="stylesheet" href="{url_for("static", filename=f"dist/{css_file}")}">')
return '\n'.join(head_assets)
entry_file = entry.get('file')
if not entry_file:
return ''
return f'<script type="module" src="{url_for("static", filename=f"dist/{entry_file}")}"></script>'
@app.context_processor
def inject_webui_assets():
return {
'vite_assets': _build_webui_vite_assets,
'vite_assets': build_webui_vite_assets,
}
# --- Profile Context (before_request hook) ---
@ -3439,7 +3349,7 @@ def service_worker():
@app.route('/<path:page>')
def spa_catch_all(page):
# Serve index.html for client-side routes; let Flask handle real routes first.
if not _should_serve_webui_spa(f'/{page}'):
if not should_serve_webui_spa(f'/{page}'):
abort(404)
return index()