Lidarr's smallest unit is a MusicBrainz release, so asking for one track means monitoring a whole discography. Soulseek trades individual files but has no concept of a request. muse bridges the two: search, click, and the song lands in the library correctly tagged. The design point is that the user picks the exact track before anything downloads, so muse holds artist, album, year and track number as ground truth and tags deterministically rather than inferring them afterwards from whatever a stranger named the file. Search is Deezer rather than MusicBrainz, decided on measurement: MusicBrainz put eight covers above the real recording for "radiohead creep" and never surfaced Queen for "bohemian rhapsody" across 90 results, because its index matches a word in a cover's title as readily as in the artist field. Re-ranking cannot rescue a result set missing the right row. Imports are os.link(), so downloads keep seeding from the Soulseek share while the library gets a second name for the same bytes.
136 lines
5.1 KiB
HTML
136 lines
5.1 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}
|
|
.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>
|
|
</div></header>
|
|
|
|
<main class="wrap">
|
|
<div id="results"></div>
|
|
<h2>Requests</h2>
|
|
<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();
|
|
};
|
|
});
|
|
}
|
|
|
|
async function poll() {
|
|
try {
|
|
const { jobs } = await (await fetch('/api/jobs')).json();
|
|
$('#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>
|
|
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
|
|
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
|
|
} catch {}
|
|
}
|
|
poll();
|
|
setInterval(poll, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|