diff --git a/core/webui/__init__.py b/core/webui/__init__.py
new file mode 100644
index 00000000..79bc922c
--- /dev/null
+++ b/core/webui/__init__.py
@@ -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",
+]
diff --git a/core/webui/assets.py b/core/webui/assets.py
new file mode 100644
index 00000000..8c055921
--- /dev/null
+++ b/core/webui/assets.py
@@ -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'',
+ f'',
+ ])
+
+ 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''
+ for css_file in entry_meta.get("css", [])
+ )
+
+ entry_file = entry_meta.get("file")
+ if not entry_file:
+ return ""
+
+ return f''
diff --git a/core/webui/spa.py b/core/webui/spa.py
new file mode 100644
index 00000000..d572f262
--- /dev/null
+++ b/core/webui/spa.py
@@ -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)
diff --git a/tests/webui/test_assets.py b/tests/webui/test_assets.py
new file mode 100644
index 00000000..21660899
--- /dev/null
+++ b/tests/webui/test_assets.py
@@ -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 == (
+ '\n'
+ ''
+ )
+
+
+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 == ''
+ assert html_body == ''
+
+
+@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"
diff --git a/web_server.py b/web_server.py
index d4449315..5622751f 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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'',
- f'',
- ])
-
- 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'')
- return '\n'.join(head_assets)
-
- entry_file = entry.get('file')
- if not entry_file:
- return ''
- return f''
-
-
@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('/')
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()