Requesting songs one at a time does not scale past a handful, so this adds a paste-a-list import. It accepts "Artist - Title", en dashes, "Title by Artist", numbered lists, bare titles, and Deezer album/playlist URLs, which get expanded into their tracks. The queue is the load-bearing part. Every request previously spawned its own asyncio task, so a 50-track paste would have launched 50 concurrent Soulseek searches -- each 10-40s of polling and real load on slskd -- and drowned the client. Work now goes through an asyncio.Queue drained by MUSE_WORKERS consumers, default 3. Requests return immediately and the batch drains at a civil rate. The queue is in memory, so jobs left 'queued' or 'searching' are re-queued at startup rather than stranded by a restart. Ranking needed two fixes found by testing against the real API: Import was calling deezer_search(limit=1), which fetches two rows, so the ranker was choosing between a demo and a rehearsal take while the studio version -- six times the popularity -- sat unseen further down. It now pulls a real candidate pool. More seriously, the Deezer ranker had no title-relevance term at all: only artist match, popularity and penalties. A different song by the right artist therefore competed on equal footing, and "Oasis - Live Forever" resolved to "Cloudburst" as soon as a variant penalty tipped the balance. Title overlap now dominates, and the variant penalty is small enough only to break ties between versions of the same song. Derivative terms match on word boundaries rather than substrings, so a bare "live" catches "Come As You Are (Live)" without penalising "Deliver Me" or "Olive", and a term is ignored when the user typed it -- "oasis live forever" still works. Verified against live slskd: six tracks pasted, six resolved to the correct song, five imported with correct album/year/track and link count 2, in about two minutes. The sixth returned an empty Soulseek search and is retryable from the UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
8.6 KiB
HTML
221 lines
8.6 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>muse</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0f1115; --panel:#171a21; --line:#252a34; --fg:#e7e9ee;
|
|
--dim:#8b93a3; --accent:#5b8cff; --ok:#3fb950; --err:#f85149; --warn:#d29922;
|
|
}
|
|
@media (prefers-color-scheme:light){
|
|
:root{--bg:#f6f7f9;--panel:#fff;--line:#e2e5ea;--fg:#12151a;--dim:#5f6672;}
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--fg);
|
|
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
header{position:sticky;top:0;z-index:5;background:var(--bg);
|
|
border-bottom:1px solid var(--line);padding:14px 18px}
|
|
.wrap{max-width:900px;margin:0 auto}
|
|
h1{margin:0 0 10px;font-size:17px;letter-spacing:.2px}
|
|
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
|
|
form{display:flex;gap:8px}
|
|
input{flex:1;padding:11px 13px;border-radius:9px;border:1px solid var(--line);
|
|
background:var(--panel);color:var(--fg);font-size:15px}
|
|
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
button{padding:11px 18px;border-radius:9px;border:0;background:var(--accent);
|
|
color:#fff;font-weight:600;cursor:pointer;font-size:15px}
|
|
button:disabled{opacity:.5;cursor:default}
|
|
main{padding:18px}
|
|
.row{display:flex;gap:12px;align-items:center;padding:10px;border-radius:10px;
|
|
border:1px solid var(--line);background:var(--panel);margin-bottom:8px}
|
|
.row img{width:48px;height:48px;border-radius:6px;object-fit:cover;
|
|
background:var(--line);flex:none}
|
|
.meta{flex:1;min-width:0}
|
|
.t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.s{color:var(--dim);font-size:13px;white-space:nowrap;overflow:hidden;
|
|
text-overflow:ellipsis}
|
|
.get{background:transparent;border:1px solid var(--accent);color:var(--accent);
|
|
padding:7px 14px;font-size:14px}
|
|
.get.done{border-color:var(--ok);color:var(--ok)}
|
|
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
|
|
margin:26px 0 10px;display:flex;align-items:center;gap:10px}
|
|
h2 .count{text-transform:none;letter-spacing:0;font-weight:400;font-size:12px}
|
|
details{margin-top:10px}
|
|
summary{cursor:pointer;color:var(--dim);font-size:13px;user-select:none}
|
|
summary:hover{color:var(--fg)}
|
|
textarea{width:100%;min-height:120px;margin-top:9px;padding:11px 13px;
|
|
border-radius:9px;border:1px solid var(--line);background:var(--panel);
|
|
color:var(--fg);font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
resize:vertical}
|
|
textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
.hint{color:var(--dim);font-size:12px;margin-top:7px}
|
|
.bar{height:3px;border-radius:2px;background:var(--line);overflow:hidden;
|
|
margin-top:10px}
|
|
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s}
|
|
.retry{background:transparent;border:1px solid var(--line);color:var(--dim);
|
|
padding:5px 11px;font-size:12px;font-weight:400}
|
|
.skipped{color:var(--err);font-size:12px;margin-top:8px}
|
|
.pill{font-size:12px;padding:2px 9px;border-radius:99px;border:1px solid var(--line);
|
|
color:var(--dim);white-space:nowrap}
|
|
.pill.imported{color:var(--ok);border-color:var(--ok)}
|
|
.pill.failed{color:var(--err);border-color:var(--err)}
|
|
.pill.downloading,.pill.searching,.pill.queued{color:var(--warn);border-color:var(--warn)}
|
|
.empty{color:var(--dim);padding:22px;text-align:center}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header><div class="wrap">
|
|
<h1>muse <small>search a song → it lands in Navidrome</small></h1>
|
|
<form id="f">
|
|
<input id="q" placeholder="artist, song title…" autocomplete="off" autofocus>
|
|
<button id="go">Search</button>
|
|
</form>
|
|
<details id="imp">
|
|
<summary>Import a list — paste many songs at once</summary>
|
|
<textarea id="bulk" placeholder="One per line:
|
|
|
|
Radiohead - Creep
|
|
Juice WRLD - Lucid Dreams
|
|
Tory Lanez - The Color Violet
|
|
|
|
Or paste a Deezer album / playlist URL to expand it."></textarea>
|
|
<div style="display:flex;gap:9px;align-items:center;margin-top:9px">
|
|
<button id="imprun" type="button">Queue all</button>
|
|
<span class="hint" id="imphint">Top Deezer match is taken automatically — check the list below.</span>
|
|
</div>
|
|
<div id="impout"></div>
|
|
</details>
|
|
</div></header>
|
|
|
|
<main class="wrap">
|
|
<div id="results"></div>
|
|
<h2>Requests <span class="count" id="qcount"></span></h2>
|
|
<div class="bar" id="bar" style="display:none"><i style="width:0"></i></div>
|
|
<div id="jobs"><div class="empty">nothing requested yet</div></div>
|
|
</main>
|
|
|
|
<script>
|
|
const $ = s => document.querySelector(s);
|
|
const esc = s => (s??'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
|
|
$('#f').onsubmit = async e => {
|
|
e.preventDefault();
|
|
const q = $('#q').value.trim();
|
|
if (!q) return;
|
|
$('#go').disabled = true;
|
|
$('#results').innerHTML = '<div class="empty">searching MusicBrainz…</div>';
|
|
try {
|
|
const r = await fetch('/api/search?q=' + encodeURIComponent(q));
|
|
const { results } = await r.json();
|
|
render(results);
|
|
} catch (err) {
|
|
$('#results').innerHTML = '<div class="empty">search failed: ' + esc(err.message) + '</div>';
|
|
}
|
|
$('#go').disabled = false;
|
|
};
|
|
|
|
function render(list) {
|
|
if (!list || !list.length) {
|
|
$('#results').innerHTML = '<div class="empty">no matches</div>';
|
|
return;
|
|
}
|
|
$('#results').innerHTML = list.map((x, i) => `
|
|
<div class="row">
|
|
${x.cover ? `<img src="${esc(x.cover)}" loading="lazy" onerror="this.style.visibility='hidden'">`
|
|
: '<img>'}
|
|
<div class="meta">
|
|
<div class="t">${esc(x.title)}</div>
|
|
<div class="s">${esc(x.artist)}${x.album ? ' · ' + esc(x.album) : ''}${x.year ? ' · ' + esc(x.year) : ''}</div>
|
|
</div>
|
|
<button class="get" data-i="${i}">Get</button>
|
|
</div>`).join('');
|
|
|
|
$('#results').querySelectorAll('.get').forEach(b => {
|
|
b.onclick = async () => {
|
|
const x = list[+b.dataset.i];
|
|
b.disabled = true; b.textContent = '…';
|
|
await fetch('/api/request', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(x)
|
|
});
|
|
b.textContent = 'Queued'; b.classList.add('done');
|
|
poll();
|
|
};
|
|
});
|
|
}
|
|
|
|
$('#imprun').onclick = async () => {
|
|
const text = $('#bulk').value.trim();
|
|
if (!text) return;
|
|
const b = $('#imprun');
|
|
b.disabled = true; b.textContent = 'Queueing…';
|
|
$('#impout').innerHTML = '';
|
|
try {
|
|
const r = await fetch('/api/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text })
|
|
});
|
|
const d = await r.json();
|
|
$('#impout').innerHTML =
|
|
`<div class="hint">Queued ${d.queued.length}.</div>` +
|
|
(d.skipped.length
|
|
? `<div class="skipped">Skipped ${d.skipped.length}: ` +
|
|
d.skipped.map(s => esc(s.line) + ' (' + esc(s.why) + ')').join('; ') + '</div>'
|
|
: '');
|
|
if (d.queued.length) $('#bulk').value = '';
|
|
poll();
|
|
} catch (e) {
|
|
$('#impout').innerHTML = '<div class="skipped">import failed: ' + esc(e.message) + '</div>';
|
|
}
|
|
b.disabled = false; b.textContent = 'Queue all';
|
|
};
|
|
|
|
async function poll() {
|
|
try {
|
|
const { jobs, counts, pending } = await (await fetch('/api/jobs')).json();
|
|
|
|
const active = (counts.queued || 0) + (counts.searching || 0) + (counts.downloading || 0);
|
|
const done = counts.imported || 0;
|
|
$('#qcount').textContent = active
|
|
? `${active} in flight${pending ? ` · ${pending} waiting` : ''}`
|
|
: (done ? `${done} imported` : '');
|
|
|
|
// Progress only means something during a batch.
|
|
const bar = $('#bar'), total = active + done;
|
|
if (active && total) {
|
|
bar.style.display = 'block';
|
|
bar.firstElementChild.style.width = (done / total * 100).toFixed(1) + '%';
|
|
} else {
|
|
bar.style.display = 'none';
|
|
}
|
|
|
|
$('#jobs').innerHTML = jobs.length ? jobs.map(j => `
|
|
<div class="row">
|
|
<div class="meta">
|
|
<div class="t">${esc(j.title)}</div>
|
|
<div class="s">${esc(j.artist)}${j.detail ? ' · ' + esc(j.detail) : ''}</div>
|
|
</div>
|
|
${j.state === 'failed'
|
|
? `<button class="retry" data-id="${esc(j.id)}">Retry</button>` : ''}
|
|
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
|
|
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
|
|
|
|
$('#jobs').querySelectorAll('.retry').forEach(b => {
|
|
b.onclick = async () => {
|
|
b.disabled = true;
|
|
await fetch('/api/retry/' + b.dataset.id, { method: 'POST' });
|
|
poll();
|
|
};
|
|
});
|
|
} catch {}
|
|
}
|
|
poll();
|
|
setInterval(poll, 4000);
|
|
</script>
|
|
</body>
|
|
</html>
|