tone3000: IR downloads, model detail, arch filter, metadata

- NAM/IR tab toggle in Downloads overlay
- Architecture filter (All/Nano/Feather/Standard) - filters via
  Supabase architecture_version column
- Model detail overlay: tap a search result to see full description,
  gear info, author, pack, architecture, thumbnail before downloading
- IR search + install with .meta.json metadata saving
- Backend: search_models/get_trending_models accept arch param
- Backend: tone_gear field added to model search response
This commit is contained in:
2026-06-16 19:59:38 -04:00
parent 5cdc751312
commit 177d412c84
3 changed files with 117 additions and 23 deletions
+13 -6
View File
@@ -293,6 +293,7 @@ class Tone3000Client:
page: int = 0,
page_size: int = 20,
force_refresh: bool = False,
arch: Optional[str] = None,
) -> list[ModelResult]:
"""Search Tone3000 for NAM models matching the query.
@@ -300,7 +301,7 @@ class Tone3000Client:
Returns empty list on any error (logged as warning).
"""
try:
cache_key = f"models:{query}:{page}"
cache_key = f"models:{query}:{page}:{arch}"
if not force_refresh:
cached = self._get_cached(cache_key)
if cached is not None:
@@ -310,13 +311,16 @@ class Tone3000Client:
# 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", {
params: dict[str, str] = {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
"name": f"ilike.*{ilike_query}*",
"order": "created_at.desc",
"limit": str(page_size),
"offset": str(page * page_size),
})
}
if arch:
params["architecture_version"] = f"eq.{arch}"
raw_models = await self._supabase_get("models", params)
results = await self._enrich_models(raw_models)
self._set_cache(cache_key, [r.__dict__ for r in results])
@@ -399,14 +403,17 @@ class Tone3000Client:
# ── Trending / Browse ────────────────────────────────────────
async def get_trending_models(self, limit: int = 20) -> list[ModelResult]:
async def get_trending_models(self, limit: int = 20, arch: Optional[str] = None) -> list[ModelResult]:
"""Get trending NAM models. Returns empty list on error."""
try:
raw = await self._supabase_get("models", {
params: dict[str, str] = {
"select": "id,name,model_url,size,pack_name,user_id,tone_id,architecture_version,created_at",
"order": "created_at.desc",
"limit": str(limit),
})
}
if arch:
params["architecture_version"] = f"eq.{arch}"
raw = await self._supabase_get("models", params)
return await self._enrich_models(raw)
except Exception as e:
logger.warning("get_trending_models failed: %s", e)
+22 -3
View File
@@ -1751,7 +1751,7 @@ class WebServer:
return {"online": ok}
@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, arch: str = ""):
"""Search Tone3000 for NAM models."""
try:
client = await self._get_tonedownload()
@@ -1764,10 +1764,11 @@ class WebServer:
"message": "Tone3000 unavailable - using local defaults",
},
)
arch_val = arch.strip() or None
if not q.strip():
results = await client.get_trending_models()
results = await client.get_trending_models(arch=arch_val)
else:
results = await client.search_models(q.strip(), page=page)
results = await client.search_models(q.strip(), page=page, arch=arch_val)
return {
"results": [
{
@@ -1781,6 +1782,7 @@ class WebServer:
"thumbnail": r.thumbnail_url,
"download_url": r.download_url,
"tone_description": r.tone_description,
"tone_gear": r.tone_gear,
"pack_name": r.pack_name,
"architecture": r.architecture,
"created_at": r.created_at,
@@ -1995,6 +1997,23 @@ class WebServer:
path = await client.download_ir(ir)
if not path:
raise HTTPException(status_code=500, detail="Download failed")
# Save IR metadata
import json
meta = {
"id": data.get("id"),
"name": data.get("name"),
"filename": data.get("filename"),
"size_bytes": data.get("size"),
"author": data.get("author"),
"author_avatar": data.get("author_avatar"),
"thumbnail": data.get("thumbnail"),
"tone_description": data.get("tone_description"),
"created_at": data.get("created_at"),
"download_date": datetime.datetime.now().isoformat(),
"source": "tone3000",
}
meta_path = path.with_suffix(".meta.json")
meta_path.write_text(json.dumps(meta, indent=2))
ir_loader = self.deps.ir_loader
if ir_loader:
ir_loader.get_irs()
+82 -14
View File
@@ -226,7 +226,18 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
<!-- Downloads Overlay -->
<div class="overlay" id="overlayDownloads">
<div class="overlay-hdr"><span class="overlay-title">⬇ Downloads</span><button class="overlay-close" id="closeDownloads"></button></div>
<div class="overlay-body">
<!-- Tab + filter nav -->
<div class="settings-nav">
<button class="sn-btn active" id="dlTabNam" onclick="switchDlTab('nam')">🎛 NAM</button>
<button class="sn-btn" id="dlTabIr" onclick="switchDlTab('ir')">📦 IR</button>
</div>
<div class="settings-nav" style="border-bottom:1px solid var(--border);padding-top:0">
<button class="sn-btn active" data-arch="" onclick="setArch(this,'')">All</button>
<button class="sn-btn" data-arch="nano" onclick="setArch(this,'nano')">Nano</button>
<button class="sn-btn" data-arch="feather" onclick="setArch(this,'feather')">Feather</button>
<button class="sn-btn" data-arch="standard" onclick="setArch(this,'standard')">Standard</button>
</div>
<div class="overlay-body" style="padding-top:4px">
<div style="display:flex;gap:6px;margin-bottom:6px">
<input id="dlSearch" placeholder="Search Tone3000..." style="flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px">
<button class="sr-action primary" id="dlSearchBtn">🔍</button>
@@ -235,6 +246,14 @@ body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webki
</div>
</div>
<!-- Model Detail Overlay -->
<div class="overlay" id="overlayDetail">
<div class="overlay-hdr"><span class="overlay-title" id="dlDetailTitle">Model</span><button class="overlay-close"></button></div>
<div class="overlay-body">
<div id="dlDetailContent"></div>
</div>
</div>
<!-- Settings Overlay -->
<div class="overlay" id="overlaySettings">
<div class="overlay-hdr"><span class="overlay-title">⚙ Settings</span><button class="overlay-close" id="closeSettings"></button></div>
@@ -1006,31 +1025,80 @@ function init(){
try{await API.saveSnapshot(currentChannel,slot);toast('Saved to slot '+slot);loadSnapshots();}catch(e){toast(e.message);}
};
// Downloads search
document.getElementById('dlSearchBtn').onclick=async()=>{
// Downloads - state
let dlTab='nam', dlArch='';
function switchDlTab(tab){
dlTab=tab;
document.getElementById('dlTabNam').className='sn-btn'+(tab==='nam'?' active':'');
document.getElementById('dlTabIr').className='sn-btn'+(tab==='ir'?' active':'');
const archRow=document.querySelectorAll('#overlayDownloads > .settings-nav')[1];
if(archRow)archRow.style.display=tab==='nam'?'flex':'none';
doDlSearch();
}
function setArch(btn,arch){
document.querySelectorAll('#overlayDownloads .settings-nav')[1].querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
dlArch=arch;
doDlSearch();
}
async function doDlSearch(){
const q=document.getElementById('dlSearch').value.trim();
if(!q)return;
const res=document.getElementById('dlResults');
res.innerHTML='<div style="text-align:center;padding:16px"><span class="spinner"></span></div>';
try{
const data=await API._fetch('/api/models/tonedownload/search?q='+encodeURIComponent(q)+'&page=0');
const endpoint=dlTab==='nam'?'/api/models/tonedownload/search':'/api/irs/tonedownload/search';
const params='?q='+encodeURIComponent(q)+(dlArch?'&arch='+dlArch:'')+'&page=0';
const data=await API._fetch(endpoint+params);
const items=data.results||[];
res.innerHTML='';
if(items.length===0){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results</div>';return;}
if(items.length===0){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results'+(q?'':' \u2014 tap Search or type a query')+'</div>';return;}
items.forEach(item=>{
const d=document.createElement('div');
d.className='preset-card';
d.style.cursor='default';
d.innerHTML='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+(item.pack_name?' · '+item.pack_name:'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+(item.size_display||'')+(item.architecture?' · '+item.architecture:'')+'</div><button class="sr-action primary" style="margin-top:4px;align-self:flex-end">Get</button>';
d.querySelector('button').onclick=async()=>{
d.querySelector('button').textContent='...';
try{await API._fetch('/api/models/tonedownload/install',{method:'POST',body:JSON.stringify({id:item.id,download_url:item.download_url,name:item.name,filename:item.filename,size:item.size,author:item.author,author_avatar:item.author_avatar,thumbnail:item.thumbnail,tone_description:item.tone_description,pack_name:item.pack_name,architecture:item.architecture,created_at:item.created_at}),timeout:60000});toast('Downloaded!');d.querySelector('button').textContent='✓';}catch(e){toast(e.message);d.querySelector('button').textContent='Retry';}
};
d.style.cursor='pointer';
const meta=[];
if(item.size_display)meta.push(item.size_display);
if(item.architecture)meta.push(item.architecture);
if(item.tone_gear&&item.tone_gear!=='nam')meta.push(item.tone_gear);
d.innerHTML='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+(item.pack_name?' \u00b7 '+item.pack_name:'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+meta.join(' \u00b7 ')+'</div>';
d.onclick=()=>showDlDetail(item);
res.appendChild(d);
});
}catch(e){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim)">Error</div>';}
};
document.getElementById('dlSearch').onkeydown=(e)=>{if(e.key==='Enter')document.getElementById('dlSearchBtn').click();};
}
function showDlDetail(item){
const c=document.getElementById('dlDetailContent');
const isIr=dlTab==='ir';
let html='<div style="margin-bottom:8px">';
if(item.thumbnail)html+='<img src="'+item.thumbnail+'" style="width:100%;border-radius:8px;margin-bottom:6px;max-height:160px;object-fit:cover">';
html+='<div style="font-size:14px;font-weight:700;margin-bottom:4px">'+(item.name||'Unknown')+'</div>';
html+='<div style="font-size:11px;color:var(--text-sec);margin-bottom:6px">by '+(item.author||'Unknown')+'</div>';
if(item.pack_name)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Pack: '+item.pack_name+'</div>';
if(item.architecture)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Arch: '+item.architecture+'</div>';
if(item.size_display)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Size: '+item.size_display+'</div>';
if(item.tone_gear)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Gear: '+item.tone_gear+'</div>';
if(item.tone_description)html+='<div style="font-size:10px;color:var(--text2);margin-top:6px;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);line-height:1.4">'+(item.tone_description||'')+'</div>';
html+='</div>';
html+='<button class="sr-action primary" id="dlDetailBtn" style="width:100%;min-height:42px;font-size:13px">Download'+(isIr?' IR':'')+'</button>';
c.innerHTML=html;
document.getElementById('dlDetailTitle').textContent=(item.name||'Model');
document.getElementById('overlayDetail').classList.add('open');
document.getElementById('dlDetailBtn').onclick=async()=>{
const btn=document.getElementById('dlDetailBtn');
btn.textContent='...';
try{
const endpoint=isIr?'/api/irs/tonedownload/install':'/api/models/tonedownload/install';
await API._fetch(endpoint,{method:'POST',body:JSON.stringify({id:item.id,download_url:item.download_url,name:item.name,filename:item.filename,size:item.size,author:item.author,author_avatar:item.author_avatar,thumbnail:item.thumbnail,tone_description:item.tone_description,pack_name:item.pack_name,architecture:item.architecture,created_at:item.created_at}),timeout:60000});
toast('Downloaded!');
btn.textContent='Downloaded';
btn.className='sr-action';
}catch(e){toast(e.message);btn.textContent='Retry';}
};
}
// Wire downloads search
document.getElementById('dlSearchBtn').onclick=doDlSearch;
document.getElementById('dlSearch').onkeydown=(e)=>{if(e.key==='Enter')doDlSearch();};
// Start polling
startPolling();