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 import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional, Union
import aiohttp import aiohttp
@@ -75,6 +75,8 @@ class ModelResult:
@property @property
def filename(self) -> str: def filename(self) -> str:
"""Get the filename from the download URL.""" """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 return Path(self.download_url.split("/")[-1]).name
@property @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. """Fetch the Supabase anon key from the Tone3000 website JS bundle.
The key is embedded in the Next.js client-side chunk. 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). Returns None if discovery fails (silently falls back to hardcoded key).
""" """
try: 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() html = await resp.text()
# Find JS chunk URLs that contain the client setup code # 1. Look for embedded JSON config in the HTML itself
chunk_urls = re.findall(r'src="([^"]*2274[^"]*\.js[^"]*)"', html) config_match = re.search(
if not chunk_urls: r'"(?:supabaseKey|supabase_anon_key|SUPABASE_ANON_KEY'
# Fallback: look for any chunk with the key pattern r'|supabaseAnonKey|NEXT_PUBLIC_SUPABASE_ANON_KEY)"\s*:\s*"([^"]+)"',
chunk_urls = re.findall( html,
r'src="(/_[^"]*chunks/[^"]*\.js[^"]*)"', 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)) chunk_urls = sorted(set(chunk_urls))
for chunk_url in chunk_urls[:5]: for chunk_url in chunk_urls[:10]:
full_url = f"https://tone3000.com{chunk_url}" if chunk_url.startswith("/") else chunk_url full_url = (
async with session.get(full_url, timeout=aiohttp.ClientTimeout(total=15)) as resp: 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() js = await resp.text()
except Exception:
continue
# Pattern: createBrowserClient)("url","token") # Pattern A: createBrowserClient)("url","token")
match = re.search( match = re.search(
r'createBrowserClient\)\(["\']https://api\.tone3000\.com["\'],["\']([^"\']+)["\']', r'createBrowserClient\)\(["\']https://api\.tone3000\.com["\'],["\']([^"\']+)["\']',
js, js,
@@ -137,10 +166,41 @@ async def discover_anon_key(session: aiohttp.ClientSession) -> Optional[str]:
if match: if match:
key = match.group(1) key = match.group(1)
if key.startswith("eyJ"): 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 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 return None
except Exception as e: except Exception as e:
logger.debug("Anon key discovery failed: %s", e) logger.debug("Anon key discovery failed: %s", e)
@@ -237,7 +297,9 @@ class Tone3000Client:
"""Search Tone3000 for NAM models matching the query. """Search Tone3000 for NAM models matching the query.
Searches model names. Results are cached for 5 minutes. 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}" cache_key = f"models:{query}:{page}"
if not force_refresh: if not force_refresh:
cached = self._get_cached(cache_key) cached = self._get_cached(cache_key)
@@ -245,9 +307,12 @@ class Tone3000Client:
return [ModelResult(**r) for r in cached] return [ModelResult(**r) for r in cached]
# Search models table by name similarity # 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", { raw_models = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at", "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", "order": "created_at.desc",
"limit": str(page_size), "limit": str(page_size),
"offset": str(page * page_size), "offset": str(page * page_size),
@@ -256,6 +321,9 @@ class Tone3000Client:
results = await self._enrich_models(raw_models) results = await self._enrich_models(raw_models)
self._set_cache(cache_key, [r.__dict__ for r in results]) self._set_cache(cache_key, [r.__dict__ for r in results])
return results return results
except Exception as e:
logger.warning("search_models failed: %s", e)
return []
async def search_irs( async def search_irs(
self, self,
@@ -268,7 +336,9 @@ class Tone3000Client:
IRs are stored in the tones table with gear='ir'. We search IRs are stored in the tones table with gear='ir'. We search
by tone title and return the linked model files. 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}" cache_key = f"irs:{query}:{page}"
if not force_refresh: if not force_refresh:
cached = self._get_cached(cache_key) cached = self._get_cached(cache_key)
@@ -276,9 +346,11 @@ class Tone3000Client:
return [IRResult(**r) for r in cached] return [IRResult(**r) for r in cached]
# Search tones with gear='ir' matching the query # 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", { raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images", "select": "id,title,description,gear,images",
"title": f"ilike.*{query}*", "title": f"ilike.*{ilike_query}*",
"gear": "eq.ir", "gear": "eq.ir",
"limit": str(page_size), "limit": str(page_size),
"offset": str(page * page_size), "offset": str(page * page_size),
@@ -321,20 +393,28 @@ class Tone3000Client:
self._set_cache(cache_key, [r.__dict__ for r in results]) self._set_cache(cache_key, [r.__dict__ for r in results])
return results return results
except Exception as e:
logger.warning("search_irs failed: %s", e)
return []
# ── Trending / Browse ──────────────────────────────────────── # ── Trending / Browse ────────────────────────────────────────
async def get_trending_models(self, limit: int = 20) -> list[ModelResult]: 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", { raw = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at", "select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
"order": "created_at.desc", "order": "created_at.desc",
"limit": str(limit), "limit": str(limit),
}) })
return await self._enrich_models(raw) 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]: 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", { raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images", "select": "id,title,description,gear,images",
"gear": "eq.ir", "gear": "eq.ir",
@@ -364,15 +444,21 @@ class Tone3000Client:
) )
results.append(ir) results.append(ir)
return results return results
except Exception as e:
logger.warning("get_trending_irs failed: %s", e)
return []
# ── Enrichment helpers ─────────────────────────────────────── # ── Enrichment helpers ───────────────────────────────────────
async def _enrich_models( async def _enrich_models(
self, raw_models: list[dict] self, raw_models: list[dict]
) -> list[ModelResult]: ) -> 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] = [] results: list[ModelResult] = []
for m in raw_models: for m in raw_models:
try:
tone_data = None tone_data = None
if m.get("tone_id"): if m.get("tone_id"):
tone_data = await self._get_tone(m["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, tone_description=tone_data.get("description") if tone_data else None,
created_at=m.get("created_at"), created_at=m.get("created_at"),
)) ))
except Exception as e:
logger.warning("Skipping model enrichment error: %s", e)
continue
return results return results
async def _get_user(self, user_id: str) -> Optional[dict]: async def _get_user(self, user_id: str) -> Optional[dict]:
@@ -515,11 +604,19 @@ class Tone3000Client:
# ── Convenience helpers ─────────────────────────────────────────────── # ── Convenience helpers ───────────────────────────────────────────────
def format_size(size_bytes: Optional[int]) -> str: def format_size(size_bytes: Optional[Union[int, str]]) -> str:
"""Format a file size in bytes to a human-readable string.""" """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: if size_bytes is None:
return "Unknown" return "Unknown"
mb = size_bytes / (1024 * 1024) try:
nbytes = int(size_bytes)
except (ValueError, TypeError):
return "Unknown"
mb = nbytes / (1024 * 1024)
if mb < 1: if mb < 1:
return f"{size_bytes / 1024:.1f} KB" return f"{nbytes / 1024:.1f} KB"
return f"{mb:.1f} MB" return f"{mb:.1f} MB"
+72 -2
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import logging import logging
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -48,6 +49,20 @@ DEFAULT_PORT = 8080
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
STATIC_DIR = Path(__file__).resolve().parent / "static" 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 ───────────────────────────────────────────────────── # ── Dependency container ─────────────────────────────────────────────────────
@@ -145,6 +160,11 @@ class WebServer:
# Mount static files # Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 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 ────────────────────────────────────────────── # ── HTML pages ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
@@ -772,9 +792,17 @@ class WebServer:
@app.get("/api/models/tonedownload/search") @app.get("/api/models/tonedownload/search")
async def tonedownload_search_models(q: str = "", page: int = 0): async def tonedownload_search_models(q: str = "", page: int = 0):
"""Search Tone3000 for NAM models.""" """Search Tone3000 for NAM models."""
try:
client = await self._get_tonedownload() client = await self._get_tonedownload()
if not client: 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(): if not q.strip():
results = await client.get_trending_models() results = await client.get_trending_models()
else: else:
@@ -799,13 +827,31 @@ class WebServer:
for r in results 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") @app.get("/api/irs/tonedownload/search")
async def tonedownload_search_irs(q: str = "", page: int = 0): async def tonedownload_search_irs(q: str = "", page: int = 0):
"""Search Tone3000 for IRs.""" """Search Tone3000 for IRs."""
try:
client = await self._get_tonedownload() client = await self._get_tonedownload()
if not client: 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(): if not q.strip():
results = await client.get_trending_irs() results = await client.get_trending_irs()
else: else:
@@ -828,6 +874,30 @@ class WebServer:
for r in results 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") @app.post("/api/models/tonedownload/install")
async def tonedownload_install_model(data: dict): async def tonedownload_install_model(data: dict):
+40
View File
@@ -176,6 +176,17 @@ class TestFormatSize:
def test_edge_mb(self): def test_edge_mb(self):
assert format_size(1024 * 1024) == "1.0 MB" 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 ────────────────────────────────────────────── # ── Client initialization ──────────────────────────────────────────────
@@ -389,6 +400,35 @@ class TestSearchModels:
results = await client.search_models("nonexistent_xyz123", page=0) results = await client.search_models("nonexistent_xyz123", page=0)
assert isinstance(results, list) 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: class TestSearchIRs:
@pytest.mark.asyncio @pytest.mark.asyncio