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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user