feat(tonedownload): Tone3000 NAM + IR browser, real anon key, tests

- Tonedownload.py: Tone3000 API client with search/download/cache/enrich
- Server.py: 4 endpoints (search models, search IRs, install model, install IR)
- Models.html + irs.html: Browse Tone3000 tab with search, trending, offline
- Models.js + irs.js: Tone3000 search UI with install tracking via localStorage
- Style.css: Card layout for search results with thumbnail, meta, install btn
- Tests: 47 tests covering data classes, cache, API, search, download, enrichment
- Anon key: Updated from placeholder to real Supabase anon key (verified: API works)
This commit is contained in:
2026-06-09 01:39:12 -04:00
parent b4237f2f1d
commit a31342b478
8 changed files with 1789 additions and 2 deletions
+525
View File
@@ -0,0 +1,525 @@
"""Tone3000 API client for searching and downloading NAM models and IRs.
Tone3000 (successor to ToneHunt) is a Supabase-backed Next.js app that
hosts NAM .nam model files and IR .wav files. Its Supabase public anon key
is embedded in the client-side JavaScript — we use it here as a read-only
API client for the Supabase REST API.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import aiohttp
logger = logging.getLogger(__name__)
# ── Constants ─────────────────────────────────────────────────────────
SUPABASE_URL = "https://api.tone3000.com"
SUPABASE_REST = f"{SUPABASE_URL}/rest/v1"
SUPABASE_STORAGE = f"{SUPABASE_URL}/storage/v1/object/public"
# Anon key for Supabase read-only access. Extracted from the Tone3000
# Next.js client bundle. This is a public key designed to be embedded
# in client-side JS — it only allows read access to public tables.
# If it ever changes, the client will dynamically re-fetch it from
# the Tone3000 homepage JS bundles.
SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd6eWJpdW9weGtkeGJ5dG5vamRzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzgwODIxNjUsImV4cCI6MjA1MzY1ODE2NX0.Gq66BJXjtLsqP2nAGXm9Xb9PAjoeZalWUj66K4nmVSU"
# Cache TTL in seconds
CACHE_TTL = 300 # 5 minutes
# Default download directory
DEFAULT_MODELS_DIR = Path.home() / ".pedal" / "models"
DEFAULT_IRS_DIR = Path.home() / ".pedal" / "irs"
# ── Data types ────────────────────────────────────────────────────────
@dataclass
class ModelResult:
"""A search result representing a NAM model on Tone3000."""
id: int
name: str
download_url: str
size_bytes: Optional[int] = None
architecture: Optional[str] = None
pack_name: Optional[str] = None
author_id: Optional[str] = None
author_username: Optional[str] = None
author_avatar_url: Optional[str] = None
tone_id: Optional[int] = None
tone_title: Optional[str] = None
tone_gear: Optional[str] = None
tone_images: list[str] = field(default_factory=list)
tone_description: Optional[str] = None
downloads: int = 0
likes: int = 0
created_at: Optional[str] = None
@property
def is_ir(self) -> bool:
"""Check if this model is an impulse response (.wav file)."""
return self.tone_gear == "ir" or (self.download_url or "").lower().endswith(".wav")
@property
def filename(self) -> str:
"""Get the filename from the download URL."""
return Path(self.download_url.split("/")[-1]).name
@property
def display_name(self) -> str:
return self.tone_title or self.name or self.filename
@property
def thumbnail_url(self) -> Optional[str]:
if self.tone_images:
return self.tone_images[0]
return None
@dataclass
class IRResult(ModelResult):
"""A search result representing an IR file."""
sample_rate: Optional[int] = None
num_taps: Optional[int] = None
length_ms: Optional[float] = None
@property
def is_ir(self) -> bool:
return True
# ── Anon key discovery ────────────────────────────────────────────────
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.
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:
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
)
# Sort by name to get a deterministic order
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:
js = await resp.text()
# Pattern: createBrowserClient)("url","token")
match = re.search(
r'createBrowserClient\)\(["\']https://api\.tone3000\.com["\'],["\']([^"\']+)["\']',
js,
)
if match:
key = match.group(1)
if key.startswith("eyJ"):
logger.info("Discovered Supabase anon key from JS bundle")
return key
logger.warning("Could not discover anon key from Tone3000 JS")
return None
except Exception as e:
logger.debug("Anon key discovery failed: %s", e)
return None
# ── API Client ────────────────────────────────────────────────────────
class Tone3000Client:
"""Client for the Tone3000 Supabase REST API.
Uses the public anon key embedded in the Tone3000 web client for
read-only access to the models, tones, and users tables.
"""
def __init__(
self,
anon_key: str = SUPABASE_ANON_KEY,
models_dir: Path = DEFAULT_MODELS_DIR,
irs_dir: Path = DEFAULT_IRS_DIR,
session: Optional[aiohttp.ClientSession] = None,
) -> None:
self._anon_key = anon_key
self._models_dir = models_dir
self._irs_dir = irs_dir
self._session = session
self._cache: dict[str, tuple[float, list[dict]]] = {}
self._headers = {
"apikey": anon_key,
"Authorization": f"Bearer {anon_key}",
"Accept": "application/json",
}
self._named_user_cache: dict[str, Optional[dict]] = {}
self._named_tone_cache: dict[int, Optional[dict]] = {}
# ── Session management ───────────────────────────────────────
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
headers=self._headers,
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
# ── Caching ──────────────────────────────────────────────────
def _get_cached(self, key: str) -> Optional[list[dict]]:
entry = self._cache.get(key)
if entry and (time.monotonic() - entry[0]) < CACHE_TTL:
return entry[1]
return None
def _set_cache(self, key: str, data: list[dict]) -> None:
self._cache[key] = (time.monotonic(), data)
# ── Core API calls ───────────────────────────────────────────
async def _supabase_get(
self, path: str, params: Optional[dict] = None
) -> list[dict]:
"""Make a GET request to the Supabase REST API."""
session = await self._get_session()
url = f"{SUPABASE_REST}/{path}"
try:
async with session.get(url, params=params) as resp:
if resp.status in (200, 206):
data = await resp.json()
return data if isinstance(data, list) else [data]
else:
text = await resp.text()
logger.warning(
"Supabase API error %d: %s", resp.status, text[:200]
)
return []
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning("Supabase request failed: %s", e)
return []
# ── Search ───────────────────────────────────────────────────
async def search_models(
self,
query: str,
page: int = 0,
page_size: int = 20,
force_refresh: bool = False,
) -> list[ModelResult]:
"""Search Tone3000 for NAM models matching the query.
Searches model names. Results are cached for 5 minutes.
"""
cache_key = f"models:{query}:{page}"
if not force_refresh:
cached = self._get_cached(cache_key)
if cached is not None:
return [ModelResult(**r) for r in cached]
# Search models table by name similarity
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}*",
"order": "created_at.desc",
"limit": str(page_size),
"offset": str(page * page_size),
})
results = await self._enrich_models(raw_models)
self._set_cache(cache_key, [r.__dict__ for r in results])
return results
async def search_irs(
self,
query: str,
page: int = 0,
page_size: int = 20,
force_refresh: bool = False,
) -> list[IRResult]:
"""Search Tone3000 for IRs (impulse responses).
IRs are stored in the tones table with gear='ir'. We search
by tone title and return the linked model files.
"""
cache_key = f"irs:{query}:{page}"
if not force_refresh:
cached = self._get_cached(cache_key)
if cached is not None:
return [IRResult(**r) for r in cached]
# Search tones with gear='ir' matching the query
raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images",
"title": f"ilike.*{query}*",
"gear": "eq.ir",
"limit": str(page_size),
"offset": str(page * page_size),
})
results: list[IRResult] = []
seen_ids: set[int] = set()
for tone in raw_tones:
tone_models = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,created_at",
"tone_id": f"eq.{tone['id']}",
"limit": "5",
})
for m in tone_models:
if m["id"] in seen_ids:
continue
seen_ids.add(m["id"])
author = await self._get_user(m.get("user_id", ""))
ir = IRResult(
id=m["id"],
name=m.get("name", ""),
download_url=m.get("model_url", ""),
size_bytes=m.get("size"),
pack_name=m.get("pack_name"),
author_id=m.get("user_id"),
author_username=author.get("username") if author else None,
author_avatar_url=author.get("avatar_url") if author else None,
tone_id=tone["id"],
tone_title=tone.get("title"),
tone_gear="ir",
tone_images=tone.get("images", []),
tone_description=tone.get("description"),
created_at=m.get("created_at"),
)
results.append(ir)
self._set_cache(cache_key, [r.__dict__ for r in results])
return results
# ── Trending / Browse ────────────────────────────────────────
async def get_trending_models(self, limit: int = 20) -> list[ModelResult]:
"""Get trending NAM models."""
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)
async def get_trending_irs(self, limit: int = 20) -> list[IRResult]:
"""Get trending IRs."""
raw_tones = await self._supabase_get("tones", {
"select": "id,title,description,gear,images",
"gear": "eq.ir",
"order": "created_at.desc",
"limit": str(limit),
})
results: list[IRResult] = []
for tone in raw_tones:
tone_models = await self._supabase_get("models", {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,created_at",
"tone_id": f"eq.{tone['id']}",
"limit": "3",
})
for m in tone_models[:1]:
author = await self._get_user(m.get("user_id", ""))
ir = IRResult(
id=m["id"],
name=m.get("name", ""),
download_url=m.get("model_url", ""),
size_bytes=m.get("size"),
tone_title=tone.get("title"),
tone_images=tone.get("images", []),
author_username=author.get("username") if author else None,
author_avatar_url=author.get("avatar_url") if author else None,
created_at=m.get("created_at"),
)
results.append(ir)
return results
# ── Enrichment helpers ───────────────────────────────────────
async def _enrich_models(
self, raw_models: list[dict]
) -> list[ModelResult]:
"""Enrich raw model data with user/tone info."""
results: list[ModelResult] = []
for m in raw_models:
tone_data = None
if m.get("tone_id"):
tone_data = await self._get_tone(m["tone_id"])
author = await self._get_user(m.get("user_id", ""))
results.append(ModelResult(
id=m["id"],
name=m.get("name", ""),
download_url=m.get("model_url", ""),
size_bytes=m.get("size"),
architecture=m.get("architecture_version"),
pack_name=m.get("pack_name"),
author_id=m.get("user_id"),
author_username=author.get("username") if author else None,
author_avatar_url=author.get("avatar_url") if author else None,
tone_id=m.get("tone_id"),
tone_title=tone_data.get("title") if tone_data else None,
tone_gear=tone_data.get("gear") if tone_data else None,
tone_images=tone_data.get("images", []) if tone_data else [],
tone_description=tone_data.get("description") if tone_data else None,
created_at=m.get("created_at"),
))
return results
async def _get_user(self, user_id: str) -> Optional[dict]:
if not user_id:
return None
if user_id in self._named_user_cache:
return self._named_user_cache[user_id]
data = await self._supabase_get("users", {
"select": "id,username,avatar_url,display_name",
"id": f"eq.{user_id}",
"limit": "1",
})
result = data[0] if data else None
self._named_user_cache[user_id] = result
return result
async def _get_tone(self, tone_id: int) -> Optional[dict]:
if tone_id in self._named_tone_cache:
return self._named_tone_cache[tone_id]
data = await self._supabase_get("tones", {
"select": "id,title,description,gear,images",
"id": f"eq.{tone_id}",
"limit": "1",
})
result = data[0] if data else None
self._named_tone_cache[tone_id] = result
return result
# ── Download ─────────────────────────────────────────────────
async def download_model(
self,
model: ModelResult,
progress_callback: Optional[callable] = None,
) -> Optional[Path]:
"""Download a NAM model to the pedal's models directory.
Returns the local path to the downloaded file, or None on failure.
"""
dest_dir = self._models_dir
dest_dir.mkdir(parents=True, exist_ok=True)
filename = model.filename
if not filename.endswith(".nam"):
filename += ".nam"
dest_path = dest_dir / filename
return await self._download_file(model.download_url, dest_path, progress_callback)
async def download_ir(
self,
ir: IRResult,
progress_callback: Optional[callable] = None,
) -> Optional[Path]:
"""Download an IR file to the pedal's IRs directory."""
dest_dir = self._irs_dir
dest_dir.mkdir(parents=True, exist_ok=True)
filename = ir.filename
if not filename.endswith(".wav"):
filename += ".wav"
dest_path = dest_dir / filename
return await self._download_file(ir.download_url, dest_path, progress_callback)
async def _download_file(
self,
url: str,
dest_path: Path,
progress_callback: Optional[callable] = None,
) -> Optional[Path]:
"""Download a file with optional progress reporting."""
if not url:
logger.error("No download URL available")
return None
session = await self._get_session()
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=120)
) as resp:
if resp.status not in (200, 302, 307):
logger.warning("Download failed: HTTP %d", resp.status)
return None
total = int(resp.headers.get("Content-Length", 0)) or 0
downloaded = 0
with open(dest_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
f.write(chunk)
downloaded += len(chunk)
if progress_callback and total:
progress_callback(downloaded / total)
logger.info(
"Downloaded %s (%d bytes)", dest_path.name, downloaded
)
return dest_path
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
logger.error("Download failed: %s", e)
if dest_path.exists():
dest_path.unlink()
return None
# ── Connectivity check ───────────────────────────────────────
async def ping(self) -> bool:
"""Check if the Tone3000 API is reachable."""
try:
data = await self._supabase_get("models", {"limit": "1"})
return len(data) > 0
except Exception:
return False
# ── Convenience helpers ───────────────────────────────────────────────
def format_size(size_bytes: Optional[int]) -> str:
"""Format a file size in bytes to a human-readable string."""
if size_bytes is None:
return "Unknown"
mb = size_bytes / (1024 * 1024)
if mb < 1:
return f"{size_bytes / 1024:.1f} KB"
return f"{mb:.1f} MB"
+146
View File
@@ -31,6 +31,13 @@ from ..dsp.ir_loader import IRLoader
from ..dsp.pipeline import AudioPipeline from ..dsp.pipeline import AudioPipeline
from ..presets.manager import PresetManager from ..presets.manager import PresetManager
from ..presets.types import FXBlock, FXType, Preset from ..presets.types import FXBlock, FXType, Preset
from ..system.tonedownload import (
Tone3000Client,
ModelResult,
IRResult,
discover_anon_key,
format_size,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -118,6 +125,7 @@ class WebServer:
self._manager = ConnectionManager() self._manager = ConnectionManager()
self._server: Optional[uvicorn.Server] = None self._server: Optional[uvicorn.Server] = None
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._tonedownload: Optional[Tone3000Client] = None
self._app = self._build_app() self._app = self._build_app()
# ── App factory ───────────────────────────────────────────────────── # ── App factory ─────────────────────────────────────────────────────
@@ -750,6 +758,129 @@ class WebServer:
self._manager.disconnect(ws) self._manager.disconnect(ws)
logger.info("WebSocket client disconnected (%d active)", self._manager.active_count) logger.info("WebSocket client disconnected (%d active)", self._manager.active_count)
# ── Tone3000 downloader endpoints ───────────────────────
@app.get("/api/tonedownload/status")
async def tonedownload_status():
"""Check if the Tone3000 API is reachable."""
client = await self._get_tonedownload()
if not client:
return {"online": False, "error": "Client not initialized"}
ok = await client.ping()
return {"online": ok}
@app.get("/api/models/tonedownload/search")
async def tonedownload_search_models(q: str = "", page: int = 0):
"""Search Tone3000 for NAM models."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
if not q.strip():
results = await client.get_trending_models()
else:
results = await client.search_models(q.strip(), page=page)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"pack_name": r.pack_name,
"architecture": r.architecture,
"created_at": r.created_at,
}
for r in results
],
}
@app.get("/api/irs/tonedownload/search")
async def tonedownload_search_irs(q: str = "", page: int = 0):
"""Search Tone3000 for IRs."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
if not q.strip():
results = await client.get_trending_irs()
else:
results = await client.search_irs(q.strip(), page=page)
return {
"results": [
{
"id": r.id,
"name": r.display_name,
"filename": r.filename,
"size": r.size_bytes,
"size_display": format_size(r.size_bytes),
"author": r.author_username or "Unknown",
"author_avatar": r.author_avatar_url,
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"created_at": r.created_at,
}
for r in results
],
}
@app.post("/api/models/tonedownload/install")
async def tonedownload_install_model(data: dict):
"""Download a .nam file to the pedal's models directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "model.nam")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
model = ModelResult(
id=data.get("id", 0),
name=name,
download_url=url,
size_bytes=data.get("size"),
pack_name=data.get("pack_name"),
tone_title=data.get("display_name", name),
)
path = await client.download_model(model)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
# Refresh the model list via NAM host
nam = self.deps.nam_host
if nam:
nam.list_available_models()
return {"ok": True, "path": str(path), "filename": path.name}
@app.post("/api/irs/tonedownload/install")
async def tonedownload_install_ir(data: dict):
"""Download a .wav IR to the pedal's IRs directory."""
client = await self._get_tonedownload()
if not client:
raise HTTPException(status_code=503, detail="Tone3000 client unavailable")
url = data.get("download_url", "")
name = data.get("name", "ir.wav")
if not url:
raise HTTPException(status_code=400, detail="download_url required")
ir = IRResult(
id=0,
name=name,
download_url=url,
size_bytes=data.get("size"),
tone_title=data.get("display_name", name),
)
path = await client.download_ir(ir)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
ir_loader = self.deps.ir_loader
if ir_loader:
ir_loader.get_irs()
return {"ok": True, "path": str(path), "filename": path.name}
return app return app
# ── Network/BT state gathering ────────────────────────────────── # ── Network/BT state gathering ──────────────────────────────────
@@ -847,6 +978,21 @@ class WebServer:
"bluetooth": self._gather_bt_state(), "bluetooth": self._gather_bt_state(),
} }
# ── Tone3000 client lazy init ────────────────────────────────
async def _get_tonedownload(self) -> Optional[Tone3000Client]:
"""Lazily initialize the Tone3000 download client."""
if self._tonedownload is None:
from aiohttp import ClientSession
async with ClientSession() as session:
key = await discover_anon_key(session)
if not key:
# Fall back to hardcoded key
from ..system.tonedownload import SUPABASE_ANON_KEY
key = SUPABASE_ANON_KEY
self._tonedownload = Tone3000Client(anon_key=key)
return self._tonedownload
# ── Lifecycle ──────────────────────────────────────────────────── # ── Lifecycle ────────────────────────────────────────────────────
def start(self) -> None: def start(self) -> None:
+111 -1
View File
@@ -92,4 +92,114 @@ async function uploadIR() {
} }
} }
document.addEventListener('DOMContentLoaded', loadIRs); document.addEventListener('DOMContentLoaded', loadIRs);
// ── Tone3000 Browser ────────────────────────────────────────────────
const INSTALLED_IRS_KEY = 'tonedownload_installed_irs';
function getInstalledIRs() {
try { return JSON.parse(localStorage.getItem(INSTALLED_IRS_KEY) || '[]'); } catch { return []; }
}
function markIRInstalled(url) {
const installed = getInstalledIRs();
if (!installed.includes(url)) {
installed.push(url);
localStorage.setItem(INSTALLED_IRS_KEY, JSON.stringify(installed));
}
}
function isIRInstalled(url) {
return getInstalledIRs().includes(url);
}
async function searchToneIRs() {
const input = document.getElementById('ir-tone-search');
const query = input.value.trim();
const resultsEl = document.getElementById('ir-tone-results');
const loaderEl = document.getElementById('ir-tone-loader');
const offlineEl = document.getElementById('ir-tone-offline');
resultsEl.innerHTML = '';
offlineEl.style.display = 'none';
if (!query) {
loadTrendingIRs();
return;
}
loaderEl.style.display = 'block';
try {
const data = await apiGet('/irs/tonedownload/search?q=' + encodeURIComponent(query));
loaderEl.style.display = 'none';
renderToneIRResults(resultsEl, data.results || []);
} catch (e) {
loaderEl.style.display = 'none';
if (e.message.includes('503') || e.message.includes('Failed to fetch')) {
offlineEl.style.display = 'block';
} else {
resultsEl.innerHTML = `<p class="text-muted">Error: ${escapeHtml(e.message)}</p>`;
}
}
}
async function loadTrendingIRs() {
const resultsEl = document.getElementById('ir-tone-results');
const loaderEl = document.getElementById('ir-tone-loader');
const offlineEl = document.getElementById('ir-tone-offline');
resultsEl.innerHTML = '';
offlineEl.style.display = 'none';
loaderEl.style.display = 'block';
try {
const data = await apiGet('/irs/tonedownload/search?q=');
loaderEl.style.display = 'none';
renderToneIRResults(resultsEl, data.results || []);
} catch (e) {
loaderEl.style.display = 'none';
if (e.message.includes('503') || e.message.includes('Failed to fetch')) {
offlineEl.style.display = 'block';
} else {
resultsEl.innerHTML = `<p class="text-muted">Error: ${escapeHtml(e.message)}</p>`;
}
}
}
function renderToneIRResults(container, results) {
if (!results || results.length === 0) {
container.innerHTML = '<p class="text-muted">No results found.</p>';
return;
}
let html = '';
for (const r of results) {
const installed = isIRInstalled(r.download_url);
const thumb = r.thumbnail
? `<img src="${escapeHtml(r.thumbnail)}" alt="" class="tonedownload-thumb" loading="lazy" onerror="this.style.display='none'">`
: '<div class="tonedownload-thumb tonedownload-thumb-placeholder">📊</div>';
html += `<div class="tonedownload-card ${installed ? 'installed' : ''}">
${thumb}
<div class="tonedownload-card-body">
<div class="tonedownload-card-name">${escapeHtml(r.name)}</div>
<div class="tonedownload-card-meta">
${escapeHtml(r.author)} · ${escapeHtml(r.size_display)}
</div>
${r.tone_description ? `<div class="tonedownload-card-desc">${escapeHtml(r.tone_description.substring(0, 120))}</div>` : ''}
</div>
<div class="tonedownload-card-actions">
${installed
? '<span class="tonedownload-installed-badge">✓ Installed</span>'
: `<button class="btn btn-primary tonedownload-install-btn"
onclick="installToneIR('${escapeHtml(r.download_url)}', '${escapeHtml(r.name)}', ${r.size || 'null'}, '${escapeHtml(r.filename)}')">
Install
</button>`
}
</div>
</div>`;
}
container.innerHTML = html;
}
+163 -1
View File
@@ -92,4 +92,166 @@ async function uploadModel() {
} }
} }
document.addEventListener('DOMContentLoaded', loadModels); document.addEventListener('DOMContentLoaded', loadModels);
// ── Tone3000 Browser ────────────────────────────────────────────────
const INSTALLED_MODELS_KEY = 'tonedownload_installed_models';
function getInstalledModels() {
try {
return JSON.parse(localStorage.getItem(INSTALLED_MODELS_KEY) || '[]');
} catch { return []; }
}
function markInstalled(url) {
const installed = getInstalledModels();
if (!installed.includes(url)) {
installed.push(url);
localStorage.setItem(INSTALLED_MODELS_KEY, JSON.stringify(installed));
}
}
function isInstalled(url) {
return getInstalledModels().includes(url);
}
async function searchToneModels() {
const input = document.getElementById('model-tone-search');
const query = input.value.trim();
const resultsEl = document.getElementById('model-tone-results');
const loaderEl = document.getElementById('model-tone-loader');
const offlineEl = document.getElementById('model-tone-offline');
resultsEl.innerHTML = '';
offlineEl.style.display = 'none';
if (!query) {
loadTrendingModels();
return;
}
loaderEl.style.display = 'block';
try {
const data = await apiGet('/models/tonedownload/search?q=' + encodeURIComponent(query));
loaderEl.style.display = 'none';
renderToneResults(resultsEl, data.results || [], 'model');
} catch (e) {
loaderEl.style.display = 'none';
if (e.message.includes('503') || e.message.includes('Failed to fetch')) {
offlineEl.style.display = 'block';
} else {
resultsEl.innerHTML = `<p class="text-muted">Error: ${escapeHtml(e.message)}</p>`;
}
}
}
async function loadTrendingModels() {
const resultsEl = document.getElementById('model-tone-results');
const loaderEl = document.getElementById('model-tone-loader');
const offlineEl = document.getElementById('model-tone-offline');
resultsEl.innerHTML = '';
offlineEl.style.display = 'none';
loaderEl.style.display = 'block';
try {
const data = await apiGet('/models/tonedownload/search?q=');
loaderEl.style.display = 'none';
renderToneResults(resultsEl, data.results || [], 'model');
} catch (e) {
loaderEl.style.display = 'none';
if (e.message.includes('503') || e.message.includes('Failed to fetch')) {
offlineEl.style.display = 'block';
} else {
resultsEl.innerHTML = `<p class="text-muted">Error: ${escapeHtml(e.message)}</p>`;
}
}
}
function renderToneResults(container, results, type) {
if (!results || results.length === 0) {
container.innerHTML = '<p class="text-muted">No results found.</p>';
return;
}
let html = '';
for (const r of results) {
const installed = isInstalled(r.download_url);
const thumb = r.thumbnail
? `<img src="${escapeHtml(r.thumbnail)}" alt="" class="tonedownload-thumb" loading="lazy" onerror="this.style.display='none'">`
: '<div class="tonedownload-thumb tonedownload-thumb-placeholder">🎸</div>';
html += `<div class="tonedownload-card ${installed ? 'installed' : ''}">
${thumb}
<div class="tonedownload-card-body">
<div class="tonedownload-card-name">${escapeHtml(r.name)}</div>
<div class="tonedownload-card-meta">
${escapeHtml(r.author)} · ${escapeHtml(r.size_display)}
${r.architecture ? ` · ${escapeHtml(r.architecture)}` : ''}
</div>
${r.tone_description ? `<div class="tonedownload-card-desc">${escapeHtml(r.tone_description.substring(0, 120))}</div>` : ''}
</div>
<div class="tonedownload-card-actions">
${installed
? '<span class="tonedownload-installed-badge">✓ Installed</span>'
: `<button class="btn btn-primary tonedownload-install-btn"
onclick="installToneModel('${escapeHtml(r.download_url)}', '${escapeHtml(r.name)}', ${r.size || 'null'}, '${escapeHtml(r.filename)}')">
Install
</button>`
}
</div>
</div>`;
}
container.innerHTML = html;
}
async function installToneModel(downloadUrl, displayName, size, filename) {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Downloading...';
try {
const data = await apiPost('/models/tonedownload/install', {
download_url: downloadUrl,
name: filename || displayName + '.nam',
display_name: displayName,
size: size,
});
markInstalled(downloadUrl);
btn.textContent = '✓ Done';
btn.style.background = 'var(--success)';
btn.style.borderColor = 'var(--success)';
// Refresh model list
loadModels();
} catch (e) {
btn.disabled = false;
btn.textContent = 'Install';
alert('Install failed: ' + e.message);
}
}
async function installToneIR(downloadUrl, displayName, size, filename) {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Downloading...';
try {
const data = await apiPost('/irs/tonedownload/install', {
download_url: downloadUrl,
name: filename || displayName + '.wav',
display_name: displayName,
size: size,
});
markInstalled(downloadUrl);
btn.textContent = '✓ Done';
btn.style.background = 'var(--success)';
btn.style.borderColor = 'var(--success)';
loadIRs();
} catch (e) {
btn.disabled = false;
btn.textContent = 'Install';
alert('Install failed: ' + e.message);
}
}
+127
View File
@@ -994,4 +994,131 @@ body {
.bt-midi-device code { .bt-midi-device code {
font-size: 0.72rem; font-size: 0.72rem;
}
/* ── Tone3000 Downloader ──────────────────────────────────────────── */
.badge-tone3000 {
background: linear-gradient(135deg, #0000FF, #FF0000);
color: white;
font-size: 0.65rem;
vertical-align: middle;
margin-left: 6px;
}
.tonedownload-search-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-items: center;
}
.tonedownload-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.85rem;
}
.tonedownload-input::placeholder {
color: var(--text-muted);
}
.tonedownload-offline {
padding: 10px;
background: rgba(255, 167, 38, 0.1);
border: 1px solid var(--warning);
border-radius: var(--radius-sm);
color: var(--warning);
font-size: 0.82rem;
margin-bottom: 10px;
}
.tonedownload-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.tonedownload-card {
display: flex;
gap: 10px;
padding: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
align-items: center;
}
.tonedownload-card.installed {
border-color: var(--success);
opacity: 0.8;
}
.tonedownload-thumb {
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
background: var(--bg-card);
}
.tonedownload-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.tonedownload-card-body {
flex: 1;
min-width: 0;
}
.tonedownload-card-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tonedownload-card-meta {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 2px;
}
.tonedownload-card-desc {
font-size: 0.72rem;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tonedownload-card-actions {
flex-shrink: 0;
}
.tonedownload-install-btn {
padding: 4px 12px;
font-size: 0.78rem;
}
.tonedownload-installed-badge {
display: inline-block;
color: var(--success);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
} }
+20
View File
@@ -42,6 +42,26 @@
<button class="btn btn-primary" onclick="uploadIR()">Upload</button> <button class="btn btn-primary" onclick="uploadIR()">Upload</button>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
</div>
<div class="card-body">
<p class="hint">Search and download impulse responses directly from the Tone3000 community.</p>
<div class="tonedownload-search-row">
<input type="text" id="ir-tone-search" class="file-input tonedownload-input"
placeholder="Search IRs (e.g. Celestion V30)..." onkeydown="if(event.key==='Enter')searchToneIRs()">
<button class="btn btn-primary" onclick="searchToneIRs()">Search</button>
<button class="btn btn-secondary" onclick="loadTrendingIRs()">Trending</button>
</div>
<div id="ir-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
<div id="ir-tone-offline" class="tonedownload-offline" style="display:none">
<span>⚠ Offline — search unavailable</span>
</div>
<div id="ir-tone-results" class="tonedownload-results"></div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+20
View File
@@ -42,6 +42,26 @@
<button class="btn btn-primary" onclick="uploadModel()">Upload</button> <button class="btn btn-primary" onclick="uploadModel()">Upload</button>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h2>Browse Tone3000 <span class="badge badge-tone3000">BETA</span></h2>
</div>
<div class="card-body">
<p class="hint">Search and download NAM models directly from the Tone3000 community.</p>
<div class="tonedownload-search-row">
<input type="text" id="model-tone-search" class="file-input tonedownload-input"
placeholder="Search models (e.g. Marshall JCM800)..." onkeydown="if(event.key==='Enter')searchToneModels()">
<button class="btn btn-primary" onclick="searchToneModels()">Search</button>
<button class="btn btn-secondary" onclick="loadTrendingModels()">Trending</button>
</div>
<div id="model-tone-loader" class="loading" style="display:none">Searching Tone3000...</div>
<div id="model-tone-offline" class="tonedownload-offline" style="display:none">
<span>⚠ Offline — search unavailable</span>
</div>
<div id="model-tone-results" class="tonedownload-results"></div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+677
View File
@@ -0,0 +1,677 @@
"""Tests for the Tone3000 downloader client.
Tests use mocked HTTP responses so no network access is needed.
The Web UI integration tests cover the /api/models/tonedownload/* and
/api/irs/tonedownload/* endpoints via the TestClient.
Run with:
pytest tests/test_tonedownload.py -v
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.system.tonedownload import (
Tone3000Client,
ModelResult,
IRResult,
format_size,
discover_anon_key,
CACHE_TTL,
)
# ── Test constants ─────────────────────────────────────────────────────
FAKE_ANON_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test"
SAMPLE_MODEL_ROW = {
"id": 1,
"name": "Marshall JCM800",
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/test.nam",
"size": 512000,
"pack_name": "Marshall Pack",
"user_id": "u1",
"tone_id": 10,
"architecture_version": "1",
"created_at": "2025-01-01T00:00:00Z",
}
SAMPLE_TONE_ROW = {
"id": 10,
"title": "Marshall JCM800 Sound",
"description": "Classic rock tone",
"gear": "amp",
"images": ["https://example.com/thumb.jpg"],
}
SAMPLE_IR_TONE_ROW = {
"id": 20,
"title": "V30 Cabinet",
"description": "4x12 V30 IR",
"gear": "ir",
"images": [],
}
SAMPLE_IR_MODEL_ROW = {
"id": 100,
"name": "v30_cabinet.wav",
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/v30.wav",
"size": 256000,
"user_id": "u2",
"tone_id": 20,
"created_at": "2025-02-01T00:00:00Z",
}
SAMPLE_USER_ROW = {
"id": "u1",
"username": "testuser",
"avatar_url": "https://example.com/avatar.png",
"display_name": "Test User",
}
# ── Fixtures ───────────────────────────────────────────────────────────
@pytest.fixture
def mock_session():
"""Create a mock aiohttp.ClientSession that returns empty lists by default."""
session = MagicMock()
resp = MagicMock()
resp.status = 200
resp.json = AsyncMock(return_value=[])
resp.text = AsyncMock(return_value="[]")
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=None)
session.get = MagicMock(return_value=resp)
session.closed = False
session.close = AsyncMock()
return session
@pytest.fixture
def client(mock_session):
"""Tone3000Client with a mocked session and known anon key."""
c = Tone3000Client(anon_key=FAKE_ANON_KEY, session=mock_session)
yield c
# Teardown: ensure close is called if the test doesn't
if c._session and not c._session.closed:
pass # tests manage session lifecycle
# ── Data class tests ───────────────────────────────────────────────────
class TestModelResult:
def test_basic_attributes(self):
m = ModelResult(id=1, name="test.nam", download_url="https://example.com/test.nam")
assert m.id == 1
assert m.filename == "test.nam"
assert m.display_name == "test.nam"
assert m.is_ir is False
assert m.thumbnail_url is None
def test_with_tone_title(self):
m = ModelResult(
id=1,
name="test.nam",
download_url="https://example.com/test.nam",
tone_title="My Tone",
)
assert m.display_name == "My Tone"
def test_with_images(self):
m = ModelResult(
id=1,
name="test.nam",
download_url="https://example.com/test.nam",
tone_images=["https://example.com/thumb.jpg"],
)
assert m.thumbnail_url == "https://example.com/thumb.jpg"
def test_wav_is_ir(self):
m = ModelResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
assert m.is_ir is True
def test_ir_flag_from_gear(self):
m = ModelResult(id=1, name="test.wav", download_url="https://example.com/test.wav", tone_gear="ir")
assert m.is_ir is True
class TestIRResult:
def test_basic_attributes(self):
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
assert ir.is_ir is True
def test_sample_rate(self):
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav", sample_rate=48000)
assert ir.sample_rate == 48000
assert ir.is_ir is True
# ── format_size tests ──────────────────────────────────────────────────
class TestFormatSize:
def test_none(self):
assert format_size(None) == "Unknown"
def test_bytes(self):
assert format_size(500) == "0.5 KB"
def test_megabytes(self):
assert format_size(2 * 1024 * 1024) == "2.0 MB"
def test_large_megabytes(self):
assert format_size(15 * 1024 * 1024) == "15.0 MB"
def test_edge_kb(self):
assert format_size(1023) == "1.0 KB"
def test_edge_mb(self):
assert format_size(1024 * 1024) == "1.0 MB"
# ── Client initialization ──────────────────────────────────────────────
class TestClientInit:
def test_default_anon_key(self):
"""Should use the hardcoded constant when no key provided."""
c = Tone3000Client()
assert c._anon_key is not None
assert len(c._anon_key) > 50
def test_custom_anon_key(self):
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
assert c._anon_key == FAKE_ANON_KEY
def test_custom_dirs(self):
models_dir = Path("/tmp/test-models")
irs_dir = Path("/tmp/test-irs")
c = Tone3000Client(
anon_key=FAKE_ANON_KEY,
models_dir=models_dir,
irs_dir=irs_dir,
)
assert c._models_dir == models_dir
assert c._irs_dir == irs_dir
# ── Session management ─────────────────────────────────────────────────
class TestSession:
def test_get_session_creates_if_none(self, client):
client._session = None
assert client._session is None
assert client._anon_key == FAKE_ANON_KEY
@pytest.mark.asyncio
async def test_close_no_session(self, client):
client._session = None
await client.close()
@pytest.mark.asyncio
async def test_close_closed_session(self, client, mock_session):
mock_session.closed = True
await client.close()
# ── Caching ────────────────────────────────────────────────────────────
class TestCaching:
def test_cache_miss(self, client):
assert client._get_cached("test:key") is None
def test_cache_hit(self, client):
data = [{"id": 1, "name": "test"}]
client._set_cache("test:key", data)
result = client._get_cached("test:key")
assert result == data
def test_cache_expiry(self, client):
data = [{"id": 1}]
client._set_cache("test:expire", data)
client._cache["test:expire"] = (0.0, data)
assert client._get_cached("test:expire") is None
# ── API calls (mocked) ─────────────────────────────────────────────────
class TestSupabaseGet:
@pytest.mark.asyncio
async def test_success(self, client):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models", {"limit": "1"})
assert result == [{"id": 1}]
@pytest.mark.asyncio
async def test_error_status(self, client):
mock_resp = MagicMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Internal error")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models")
assert result == []
@pytest.mark.asyncio
async def test_network_error(self, client):
from aiohttp import ClientError
client._session.get = MagicMock(side_effect=ClientError("Connection refused"))
result = await client._supabase_get("models")
assert result == []
@pytest.mark.asyncio
async def test_single_object_response(self, client):
"""Should wrap a single object in a list."""
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"id": 1})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models", {"id": "eq.1"})
assert result == [{"id": 1}]
# ── Ping ───────────────────────────────────────────────────────────────
class TestPing:
@pytest.mark.asyncio
async def test_online(self, client):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
assert await client.ping() is True
@pytest.mark.asyncio
async def test_offline(self, client):
from aiohttp import ClientError
client._session.get = MagicMock(side_effect=ClientError("Timeout"))
assert await client.ping() is False
# ── Search ─────────────────────────────────────────────────────────────
class TestSearchModels:
@pytest.mark.asyncio
async def test_search_returns_results(self, client):
async def mock_get(path, params=None):
if path == "models" and params and "name" in 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("marshall", page=0)
assert len(results) == 1
assert results[0].name == "Marshall JCM800"
assert results[0].display_name == "Marshall JCM800 Sound"
assert results[0].author_username == "testuser"
@pytest.mark.asyncio
async def test_search_cached(self, client):
call_count = 0
async def mock_get(path, params=None):
nonlocal call_count
call_count += 1
if path == "models":
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
await client.search_models("marshall")
first_count = call_count
await client.search_models("marshall")
assert call_count == first_count
@pytest.mark.asyncio
async def test_force_refresh(self, client):
call_count = 0
async def mock_get(path, params=None):
nonlocal call_count
call_count += 1
if path == "models":
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
await client.search_models("marshall")
await client.search_models("marshall", force_refresh=True)
assert call_count >= 2
@pytest.mark.asyncio
async def test_empty_query(self, client):
results = await client.search_models("nonexistent_xyz123", page=0)
assert isinstance(results, list)
class TestSearchIRs:
@pytest.mark.asyncio
async def test_search_returns_results(self, client):
async def mock_get(path, params=None):
if path == "tones" and "ir" in str(params.get("gear", "")):
return [SAMPLE_IR_TONE_ROW]
if path == "models":
return [SAMPLE_IR_MODEL_ROW]
if path == "users":
return [{"id": "u2", "username": "irtest", "avatar_url": None, "display_name": None}]
return []
client._supabase_get = mock_get
results = await client.search_irs("v30", page=0)
assert len(results) >= 1
assert results[0].is_ir is True
assert results[0].tone_title == "V30 Cabinet"
@pytest.mark.asyncio
async def test_empty_results(self, client):
async def mock_get(path, params=None):
if path == "tones":
return []
return []
client._supabase_get = mock_get
results = await client.search_irs("nonexistent_ir", page=0)
assert results == []
# ── Trending ───────────────────────────────────────────────────────────
class TestTrending:
@pytest.mark.asyncio
async def test_trending_models(self, client):
async def mock_get(path, params=None):
if path == "models":
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.get_trending_models(limit=3)
assert len(results) == 1
@pytest.mark.asyncio
async def test_trending_irs(self, client):
async def mock_get(path, params=None):
if path == "tones":
return [SAMPLE_IR_TONE_ROW]
if path == "models":
return [SAMPLE_IR_MODEL_ROW]
if path == "users":
return [{"id": "u2", "username": "irtest", "avatar_url": None}]
return []
client._supabase_get = mock_get
results = await client.get_trending_irs(limit=3)
assert len(results) == 1
# ── Download ───────────────────────────────────────────────────────────
class TestDownload:
@pytest.mark.asyncio
async def test_download_model_success(self, client, tmp_path):
client._models_dir = tmp_path
async def _chunks():
yield b"testdata"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "9"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
model = ModelResult(id=1, name="test_model", download_url="https://example.com/test.nam")
path = await client.download_model(model)
assert path is not None
assert path.exists()
assert path.suffix == ".nam"
assert path.read_bytes() == b"testdata"
@pytest.mark.asyncio
async def test_download_model_no_url(self, client):
model = ModelResult(id=1, name="test", download_url="")
path = await client.download_model(model)
assert path is None
@pytest.mark.asyncio
async def test_download_model_http_error(self, client, tmp_path):
client._models_dir = tmp_path
mock_resp = MagicMock()
mock_resp.status = 404
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
path = await client.download_model(model)
assert path is None
@pytest.mark.asyncio
async def test_download_ir_success(self, client, tmp_path):
client._irs_dir = tmp_path
async def _chunks():
yield b"wavdata"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "9"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
ir = IRResult(id=1, name="cabinet", download_url="https://example.com/cabinet.wav")
path = await client.download_ir(ir)
assert path is not None
assert path.exists()
assert path.suffix == ".wav"
assert path.read_bytes() == b"wavdata"
@pytest.mark.asyncio
async def test_download_progress(self, client, tmp_path):
client._models_dir = tmp_path
async def _chunks():
yield b"data1"
yield b"data2"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "12"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
progress_values = []
def cb(ratio):
progress_values.append(ratio)
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
path = await client.download_model(model, progress_callback=cb)
assert path is not None
assert len(progress_values) > 0
# ── User/Tone enrichment ──────────────────────────────────────────────
class TestEnrichment:
@pytest.mark.asyncio
async def test_get_user_sync(self, client):
"""User lookup works (sync test via mock)."""
from src.system.tonedownload import Tone3000Client
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
result = await c._get_user("")
assert result is None
await c.close()
@pytest.mark.asyncio
async def test_get_tone_sync(self, client):
from src.system.tonedownload import Tone3000Client
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
result = await c._get_tone(9999)
assert result is None
await c.close()
@pytest.mark.asyncio
async def test_get_user_cached(self, client):
async def mock_get(path, params=None):
return [SAMPLE_USER_ROW]
client._supabase_get = mock_get
user = await client._get_user("u1")
assert user["username"] == "testuser"
# Second call — cached
user2 = await client._get_user("u1")
assert user2["username"] == "testuser"
@pytest.mark.asyncio
async def test_get_user_empty_id(self, client):
user = await client._get_user("")
assert user is None
@pytest.mark.asyncio
async def test_get_tone_cached(self, client):
async def mock_get(path, params=None):
return [SAMPLE_TONE_ROW]
client._supabase_get = mock_get
tone = await client._get_tone(10)
assert tone["title"] == "Marshall JCM800 Sound"
# Second call — cached
tone2 = await client._get_tone(10)
assert tone2["title"] == "Marshall JCM800 Sound"
# ── Anon key discovery ────────────────────────────────────────────────
class TestAnonKeyDiscovery:
@pytest.mark.asyncio
async def test_discover_timeout(self, client):
mock_resp = MagicMock()
mock_resp.__aenter__ = AsyncMock(side_effect=TimeoutError("Timed out"))
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
key = await discover_anon_key(client._session)
assert key is None
# ── Integration via real HTTP (optional) ───────────────────────────────
@pytest.mark.skipif(
True,
reason="Real HTTP test — enable manually to verify against live Tone3000 API",
)
class TestRealHTTP:
"""Tests that hit the real Tone3000 API."""
@pytest.mark.asyncio
async def test_ping(self):
c = Tone3000Client()
ok = await c.ping()
assert ok is True
await c.close()
@pytest.mark.asyncio
async def test_search_models(self):
c = Tone3000Client()
results = await c.search_models("marshall", page=0, page_size=5)
assert len(results) > 0
for r in results:
assert r.display_name
assert r.download_url
await c.close()
@pytest.mark.asyncio
async def test_search_irs(self):
c = Tone3000Client()
results = await c.search_irs("v30", page=0, page_size=5)
assert len(results) > 0
for r in results:
assert r.is_ir is True
await c.close()
@pytest.mark.asyncio
async def test_trending_models(self):
c = Tone3000Client()
results = await c.get_trending_models(limit=5)
assert len(results) > 0
await c.close()
@pytest.mark.asyncio
async def test_download(self, tmp_path):
c = Tone3000Client(models_dir=tmp_path, irs_dir=tmp_path)
results = await c.search_models("marshall", page=0, page_size=1)
if results:
path = await c.download_model(results[0])
assert path is not None
assert path.exists()
assert path.stat().st_size > 1000
await c.close()