// stemserve: the index of tracks that have stems, Stem Now, and the live separation queue. import { S } from "./store.js"; import { emit, toast } from "./util.js"; export const stemBase = () => (S.stemUrl || location.origin).replace(/\/+$/, ""); export const stemQuery = extra => { const q = new URLSearchParams(extra); if (S.stemToken) q.set("token", S.stemToken); const s = q.toString(); return s ? "?" + s : ""; }; const keyPath = k => k.split("/").map(encodeURIComponent).join("/"); export const fmtParams = (fmt = S.fmt) => fmt.startsWith("opus") ? { fmt: "opus", br: fmt.slice(4) } : {}; export const api = (path, extra) => stemBase() + path + stemQuery(extra); // Same matching as the apps: the stems folder mirrors the music folder, so the Plex path's // suffix identifies a track. Short or duration-mismatched suffixes only count under a root // learned from an earlier confident match. export const Stems = { entries: [], bySource: new Map(), byKey: new Map(), roots: [], etag: null, ok: false, norm(p) { p = p.normalize("NFC").replaceAll("\\", "/"); const slash = p.lastIndexOf("/"), dot = p.lastIndexOf("."); if (dot > slash + 1) p = p.slice(0, dot); return p.toLowerCase(); }, async refresh() { try { const r = await fetch(api("/api/index"), { headers: this.etag ? { "If-None-Match": this.etag } : {}, signal: AbortSignal.timeout(15000) }); this.ok = r.ok || r.status === 304; if (r.status !== 200) return false; this.etag = r.headers.get("ETag"); this.entries = (await r.json()).tracks || []; this.bySource = new Map(); this.byKey = new Map(); for (const e of this.entries) { const k = this.norm(e.source); if (!this.bySource.has(k)) this.bySource.set(k, e); this.byKey.set(e.key, e); } emit("stems"); return true; } catch { this.ok = false; return false; } }, find(t) { if (t.stem) return this.byKey.get(t.stem.key) || t.stem; if (!t.file || !this.entries.length) return null; const path = this.norm(t.file); for (const root of this.roots) if (path.startsWith(root)) { const hit = this.bySource.get(path.slice(root.length)); if (hit) return hit; } const parts = path.split("/"); for (let i = 1; i < parts.length; i++) { const hit = this.bySource.get(parts.slice(i).join("/")); if (!hit) continue; const okDur = !hit.duration || !t.duration || Math.abs(hit.duration * 1000 - t.duration) < 3000; if (parts.length - i >= 2 && okDur) { const root = parts.slice(0, i).join("/") + "/"; if (!this.roots.includes(root)) this.roots.push(root); return hit; } } return null; }, url(e, s, fmt) { return `${stemBase()}/file/${keyPath(e.key)}/${encodeURIComponent(s.file)}${stemQuery(fmtParams(fmt))}`; }, prepare(e, fmt = S.fmt) { if (e && fmt.startsWith("opus")) fetch(api("/api/prepare", { key: e.key, ...fmtParams(fmt) }), { method: "POST" }).catch(() => {}); }, }; export const stemTrack = e => { const parts = e.key.split("/"); return { id: "s" + e.key, title: parts.at(-1), artist: parts.at(-3) || "", album: parts.at(-2) || "", duration: (e.duration || 0) * 1000, stem: e }; }; // ---- live queue ------------------------------------------------------------------------------ // Polls /api/queue: quickly while stemd has work, slowly otherwise. When stemd finishes a track // the index is refreshed, and every list on screen updates its badges. export const Live = { pending: new Set(), current: null, done: null, failed: new Set(), timer: null, state(t) { if (!t.file) return null; if (this.current && (t.file === this.current || t.file.endsWith("/" + this.current))) return "stemming"; if (this.pending.has(t.file)) return "queued"; if (this.failed.has(t.file)) return "failed"; return null; }, get busy() { return !!this.current || this.pending.size > 0; }, async poll() { clearTimeout(this.timer); try { const r = await fetch(api("/api/queue"), { cache: "no-store", signal: AbortSignal.timeout(8000) }); if (r.ok) { const j = await r.json(); const before = new Set(Stems.entries.map(e => e.key)); const changed = j.done !== this.done; this.pending = new Set(j.pending || []); this.current = j.current || null; this.failed = new Set(j.failed || []); if (changed && this.done !== null && await Stems.refresh()) { const fresh = Stems.entries.filter(e => !before.has(e.key)); if (fresh.length) emit("newstems", fresh); } this.done = j.done; emit("live"); } } catch {} this.timer = setTimeout(() => this.poll(), this.busy ? 4000 : 30000); }, }; export async function stemNow(tracks) { const seen = new Set(); const paths = tracks.filter(t => t.file && !Stems.find(t) && !seen.has(t.file) && seen.add(t.file)).map(t => t.file); if (!paths.length) return toast(tracks.length === 1 ? "Already has stems" : "These all have stems already"); try { const r = await fetch(api("/api/stem"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paths }) }); const j = await r.json().catch(() => ({})); if (!r.ok) return toast(`Couldn't queue: ${j.error || r.status}`); for (const p of paths) Live.pending.add(p); emit("live"); toast(`Stemming ${paths.length === 1 ? "1 track" : paths.length + " tracks"} next`); Live.poll(); } catch (e) { toast("Couldn't queue: " + e.message); } }