Compare commits

...

2 Commits

Author SHA1 Message Date
shawn 3e55ea8a9d Fix multi-word query URL encoding for Tone3000 search
Replace spaces with PostgREST wildcards in ilike filter values so
multi-word queries like 'vox ac30' produce valid URLs.

Root cause: PostgREST filter parser doesn't handle spaces URL-encoded
as '+' in query parameter values, returning 400 Bad Request.

Fix: query.replace(' ', '*') converts spaces to PostgREST wildcards
before inserting into ilike.* pattern. 'vox ac30' becomes
ilike.*vox*ac30* -> ILIKE '%vox%ac30%'.

Changes:
- src/system/tonedownload.py: fix search_models() and search_irs()
- tests/test_tonedownload.py: update test_multi_word_query assertion
2026-06-12 12:43:55 -04:00
shawn e890a4aad3 Wire React UI dist mount at /ui/
Add UI_DIST_DIR auto-detection and /ui static mount in the
FastAPI server so the built React frontend is served alongside
the main dashboard.

- UI_DIST_DIR env var override
- Auto-detect common project layouts (frontend-react/dist,
  pi-multifx-pedal-ui/dist)
- Mount with html=True for SPA client-side routing
2026-06-12 03:08:07 -04:00
3 changed files with 401 additions and 194 deletions
+121 -24
View File
@@ -15,7 +15,7 @@ import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from typing import Any, Optional, Union
import aiohttp
@@ -75,6 +75,8 @@ class ModelResult:
@property
def filename(self) -> str:
"""Get the filename from the download URL."""
if not self.download_url:
return self.name or "unknown"
return Path(self.download_url.split("/")[-1]).name
@property
@@ -108,28 +110,55 @@ async def discover_anon_key(session: aiohttp.ClientSession) -> Optional[str]:
"""Fetch the Supabase anon key from the Tone3000 website JS bundle.
The key is embedded in the Next.js client-side chunk.
Searches multiple patterns across the HTML page and all JS bundles.
Returns None if discovery fails (silently falls back to hardcoded key).
"""
try:
async with session.get("https://tone3000.com", timeout=aiohttp.ClientTimeout(total=15)) as resp:
async with session.get(
"https://tone3000.com", timeout=aiohttp.ClientTimeout(total=15)
) as resp:
html = await resp.text()
# Find JS chunk URLs that contain the client setup code
chunk_urls = re.findall(r'src="([^"]*2274[^"]*\.js[^"]*)"', html)
if not chunk_urls:
# Fallback: look for any chunk with the key pattern
chunk_urls = re.findall(
r'src="(/_[^"]*chunks/[^"]*\.js[^"]*)"', html
# 1. Look for embedded JSON config in the HTML itself
config_match = re.search(
r'"(?:supabaseKey|supabase_anon_key|SUPABASE_ANON_KEY'
r'|supabaseAnonKey|NEXT_PUBLIC_SUPABASE_ANON_KEY)"\s*:\s*"([^"]+)"',
html,
)
# Sort by name to get a deterministic order
if config_match:
key = config_match.group(1)
if key.startswith("eyJ"):
logger.info("Discovered Supabase anon key from HTML config")
return key
# 2. Find all JS chunk URLs
chunk_urls: list[str] = []
# Primary: any script src
chunk_urls.extend(re.findall(r'src="([^"]*\.js[^"]*)"', html))
# Also look for modulepreload / prefetch
chunk_urls.extend(
re.findall(r'href="([^"]*\.js[^"]*)"', html)
)
# Deduplicate
chunk_urls = sorted(set(chunk_urls))
for chunk_url in chunk_urls[:5]:
full_url = f"https://tone3000.com{chunk_url}" if chunk_url.startswith("/") else chunk_url
async with session.get(full_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
for chunk_url in chunk_urls[:10]:
full_url = (
f"https://tone3000.com{chunk_url}"
if chunk_url.startswith("/")
else chunk_url
)
try:
async with session.get(
full_url, timeout=aiohttp.ClientTimeout(total=15)
) as resp:
if resp.status != 200:
continue
js = await resp.text()
except Exception:
continue
# Pattern: createBrowserClient)("url","token")
# Pattern A: createBrowserClient)("url","token")
match = re.search(
r'createBrowserClient\)\(["\']https://api\.tone3000\.com["\'],["\']([^"\']+)["\']',
js,
@@ -137,10 +166,41 @@ async def discover_anon_key(session: aiohttp.ClientSession) -> Optional[str]:
if match:
key = match.group(1)
if key.startswith("eyJ"):
logger.info("Discovered Supabase anon key from JS bundle")
logger.info(
"Discovered Supabase anon key from JS bundle (createBrowserClient)"
)
return key
logger.warning("Could not discover anon key from Tone3000 JS")
# Pattern B: supabaseKey / anon key object keys
match = re.search(
r'(?:supabaseKey|supabase_anon_key|SUPABASE_ANON_KEY'
r'|supabaseAnonKey|NEXT_PUBLIC_SUPABASE_ANON_KEY)'
r'["\']?\s*[:=]\s*["\']([^"\']{20,})["\']',
js,
)
if match:
key = match.group(1)
if key.startswith("eyJ"):
logger.info(
"Discovered Supabase anon key from JS bundle (key lookup)"
)
return key
# Pattern C: any JWT token (starts with eyJ) in JS strings
jwt_matches = re.findall(
r'["\'](eyJ[a-zA-Z0-9_-]{10,})["\']', js
)
for candidate in jwt_matches:
# A real Supabase anon key is a JWT with two dots
if candidate.count(".") >= 2:
logger.info(
"Discovered Supabase anon key from JS bundle (JWT scan)"
)
return candidate
logger.warning(
"Could not discover anon key from Tone3000 JS - API will be unavailable"
)
return None
except Exception as e:
logger.debug("Anon key discovery failed: %s", e)
@@ -237,7 +297,9 @@ class Tone3000Client:
"""Search Tone3000 for NAM models matching the query.
Searches model names. Results are cached for 5 minutes.
Returns empty list on any error (logged as warning).
"""
try:
cache_key = f"models:{query}:{page}"
if not force_refresh:
cached = self._get_cached(cache_key)
@@ -245,9 +307,12 @@ class Tone3000Client:
return [ModelResult(**r) for r in cached]
# Search models table by name similarity
# Replace spaces with wildcards so multi-word queries work
# (e.g. "vox ac30" → ilike.*vox*ac30* → ILIKE '%vox%ac30%')
ilike_query = query.replace(" ", "*")
raw_models = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
"name": f"ilike.*{query}*",
"name": f"ilike.*{ilike_query}*",
"order": "created_at.desc",
"limit": str(page_size),
"offset": str(page * page_size),
@@ -256,6 +321,9 @@ class Tone3000Client:
results = await self._enrich_models(raw_models)
self._set_cache(cache_key, [r.__dict__ for r in results])
return results
except Exception as e:
logger.warning("search_models failed: %s", e)
return []
async def search_irs(
self,
@@ -268,7 +336,9 @@ class Tone3000Client:
IRs are stored in the tones table with gear='ir'. We search
by tone title and return the linked model files.
Returns empty list on any error (logged as warning).
"""
try:
cache_key = f"irs:{query}:{page}"
if not force_refresh:
cached = self._get_cached(cache_key)
@@ -276,9 +346,11 @@ class Tone3000Client:
return [IRResult(**r) for r in cached]
# Search tones with gear='ir' matching the query
# Replace spaces with wildcards so multi-word queries work
ilike_query = query.replace(" ", "*")
raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images",
"title": f"ilike.*{query}*",
"title": f"ilike.*{ilike_query}*",
"gear": "eq.ir",
"limit": str(page_size),
"offset": str(page * page_size),
@@ -321,20 +393,28 @@ class Tone3000Client:
self._set_cache(cache_key, [r.__dict__ for r in results])
return results
except Exception as e:
logger.warning("search_irs failed: %s", e)
return []
# ── Trending / Browse ────────────────────────────────────────
async def get_trending_models(self, limit: int = 20) -> list[ModelResult]:
"""Get trending NAM models."""
"""Get trending NAM models. Returns empty list on error."""
try:
raw = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
"order": "created_at.desc",
"limit": str(limit),
})
return await self._enrich_models(raw)
except Exception as e:
logger.warning("get_trending_models failed: %s", e)
return []
async def get_trending_irs(self, limit: int = 20) -> list[IRResult]:
"""Get trending IRs."""
"""Get trending IRs. Returns empty list on error."""
try:
raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images",
"gear": "eq.ir",
@@ -364,15 +444,21 @@ class Tone3000Client:
)
results.append(ir)
return results
except Exception as e:
logger.warning("get_trending_irs failed: %s", e)
return []
# ── Enrichment helpers ───────────────────────────────────────
async def _enrich_models(
self, raw_models: list[dict]
) -> list[ModelResult]:
"""Enrich raw model data with user/tone info."""
"""Enrich raw model data with user/tone info.
Skips individual models that fail to enrich.
"""
results: list[ModelResult] = []
for m in raw_models:
try:
tone_data = None
if m.get("tone_id"):
tone_data = await self._get_tone(m["tone_id"])
@@ -395,6 +481,9 @@ class Tone3000Client:
tone_description=tone_data.get("description") if tone_data else None,
created_at=m.get("created_at"),
))
except Exception as e:
logger.warning("Skipping model enrichment error: %s", e)
continue
return results
async def _get_user(self, user_id: str) -> Optional[dict]:
@@ -515,11 +604,19 @@ class Tone3000Client:
# ── Convenience helpers ───────────────────────────────────────────────
def format_size(size_bytes: Optional[int]) -> str:
"""Format a file size in bytes to a human-readable string."""
def format_size(size_bytes: Optional[Union[int, str]]) -> str:
"""Format a file size in bytes to a human-readable string.
Handles int, str, and None inputs since Supabase can return
the ``size`` field as any of these types.
"""
if size_bytes is None:
return "Unknown"
mb = size_bytes / (1024 * 1024)
try:
nbytes = int(size_bytes)
except (ValueError, TypeError):
return "Unknown"
mb = nbytes / (1024 * 1024)
if mb < 1:
return f"{size_bytes / 1024:.1f} KB"
return f"{nbytes / 1024:.1f} KB"
return f"{mb:.1f} MB"
+72 -2
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import json
import os
import logging
import time
from contextlib import asynccontextmanager
@@ -48,6 +49,20 @@ DEFAULT_PORT = 8080
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
STATIC_DIR = Path(__file__).resolve().parent / "static"
# React UI dist — built frontend served at /ui/
# Can be overridden via UI_DIST_DIR env var
_ui_dist_env = os.environ.get("UI_DIST_DIR")
if _ui_dist_env:
UI_DIST_DIR = Path(_ui_dist_env).resolve()
else:
# Look relative to backend package first, then common project layouts
_candidates = [
Path(__file__).resolve().parent / "ui-dist",
Path(__file__).resolve().parent.parent.parent / "frontend-react" / "dist",
Path(__file__).resolve().parent.parent.parent.parent / "pi-multifx-pedal-ui" / "dist",
]
UI_DIST_DIR = next((d for d in _candidates if d.is_dir()), None)
# ── Dependency container ─────────────────────────────────────────────────────
@@ -145,6 +160,11 @@ class WebServer:
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Mount React UI (if dist exists)
if UI_DIST_DIR and UI_DIST_DIR.is_dir():
logger.info("Mounting React UI from %s at /ui", UI_DIST_DIR)
app.mount("/ui", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="ui")
# ── HTML pages ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
@@ -772,9 +792,17 @@ class WebServer:
@app.get("/api/models/tonedownload/search")
async def tonedownload_search_models(q: str = "", page: int = 0):
"""Search Tone3000 for NAM models."""
try:
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
if not q.strip():
results = await client.get_trending_models()
else:
@@ -799,13 +827,31 @@ class WebServer:
for r in results
],
}
except Exception as e:
logger.warning("Tone3000 model search failed: %s", e)
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
@app.get("/api/irs/tonedownload/search")
async def tonedownload_search_irs(q: str = "", page: int = 0):
"""Search Tone3000 for IRs."""
try:
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
if not q.strip():
results = await client.get_trending_irs()
else:
@@ -828,6 +874,30 @@ class WebServer:
for r in results
],
}
except Exception as e:
logger.warning("Tone3000 IR search failed: %s", e)
return JSONResponse(
status_code=200,
content={
"results": [],
"status": "offline",
"message": "Tone3000 unavailable - using local defaults",
},
)
# ── Tonehub API alias routes ─────────────────────────────
# These are direct aliases for Tone3000 search, providing a
# simpler /api/tonehub/search path for the web UI.
@app.get("/api/tonehub/search")
async def tonehub_search_models(q: str = "", page: int = 0):
"""Alias for /api/models/tonedownload/search — search NAM models."""
return await tonedownload_search_models(q=q, page=page)
@app.get("/api/tonehub/search/irs")
async def tonehub_search_irs(q: str = "", page: int = 0):
"""Alias for /api/irs/tonedownload/search — search IRs."""
return await tonedownload_search_irs(q=q, page=page)
@app.post("/api/models/tonedownload/install")
async def tonedownload_install_model(data: dict):
+40
View File
@@ -176,6 +176,17 @@ class TestFormatSize:
def test_edge_mb(self):
assert format_size(1024 * 1024) == "1.0 MB"
def test_string_input(self):
"""format_size should handle string inputs (Supabase can return string)."""
assert format_size("500") == "0.5 KB"
assert format_size("2097152") == "2.0 MB"
assert format_size("1024") == "1.0 KB"
def test_invalid_string(self):
"""format_size should handle unparseable string inputs."""
assert format_size("not-a-number") == "Unknown"
assert format_size("") == "Unknown"
# ── Client initialization ──────────────────────────────────────────────
@@ -389,6 +400,35 @@ class TestSearchModels:
results = await client.search_models("nonexistent_xyz123", page=0)
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_multi_word_query(self, client):
"""Multi-word queries should convert spaces to wildcards."""
captured_params = []
async def mock_get(path, params=None):
if path == "models" and params and "name" in params:
captured_params.append(params)
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
results = await client.search_models("vox ac30", page=0)
assert len(results) == 1
assert results[0].name == "Marshall JCM800"
# Verify the Supabase param has spaces replaced with wildcards
assert len(captured_params) >= 1
name_param = captured_params[0].get("name", "")
assert "ilike.*vox*ac30*" in name_param
# Should NOT contain the raw space (which PostgREST filter parser
# can't handle in URL query values)
assert "vox ac30" not in name_param
class TestSearchIRs:
@pytest.mark.asyncio