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:
parent
497a6f41ed
commit
32bf52cc18
5 changed files with 274 additions and 100 deletions
19
core/webui/__init__.py
Normal file
19
core/webui/__init__.py
Normal 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
126
core/webui/assets.py
Normal 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
23
core/webui/spa.py
Normal 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)
|
||||||
96
tests/webui/test_assets.py
Normal file
96
tests/webui/test_assets.py
Normal 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"
|
||||||
110
web_server.py
110
web_server.py
|
|
@ -20,7 +20,7 @@ from pathlib import Path
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
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 flask_socketio import SocketIO, emit, join_room, leave_room
|
||||||
from utils.logging_config import get_logger, setup_logging
|
from utils.logging_config import get_logger, setup_logging
|
||||||
from utils.async_helpers import run_async
|
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 registry as metadata_registry
|
||||||
from core.metadata import is_internal_image_host
|
from core.metadata import is_internal_image_host
|
||||||
from core.metadata import normalize_image_url as fix_artist_image_url
|
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 (
|
from core.metadata.registry import (
|
||||||
clear_cached_metadata_client,
|
clear_cached_metadata_client,
|
||||||
get_metadata_source_label,
|
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
|
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
|
# Cache-bust query string for static assets. Computed once per process
|
||||||
# url_for('static', ...) URL via the context processor below. Computed
|
# start so each server restart invalidates the browser's cached copy of
|
||||||
# once per process start so each server restart invalidates the
|
# every JS/CSS file. This is the surefire fix for "user has stale JS
|
||||||
# browser's cached copy of every JS/CSS file. This is the surefire
|
# even after Ctrl+Shift+R" — the URL itself changes, so the browser
|
||||||
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
|
# cannot reuse a previously-cached response no matter what its
|
||||||
# itself changes, so the browser cannot reuse a previously-cached
|
# Cache-Control header said.
|
||||||
# response no matter what its Cache-Control header said.
|
|
||||||
import time as _cache_bust_time
|
import time as _cache_bust_time
|
||||||
_STATIC_CACHE_BUST = str(int(_cache_bust_time.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', ''),
|
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
|
@app.context_processor
|
||||||
def inject_webui_assets():
|
def inject_webui_assets():
|
||||||
return {
|
return {
|
||||||
'vite_assets': _build_webui_vite_assets,
|
'vite_assets': build_webui_vite_assets,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Profile Context (before_request hook) ---
|
# --- Profile Context (before_request hook) ---
|
||||||
|
|
@ -3439,7 +3349,7 @@ def service_worker():
|
||||||
@app.route('/<path:page>')
|
@app.route('/<path:page>')
|
||||||
def spa_catch_all(page):
|
def spa_catch_all(page):
|
||||||
# Serve index.html for client-side routes; let Flask handle real routes first.
|
# 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)
|
abort(404)
|
||||||
return index()
|
return index()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue