Files
stemify/web/stems.js
T
rootandClaude Opus 5.5 7cdfbef160 Web player overhaul; iOS random sort, stems-only filter, pooled libraries
Web: live Stem Now status that tracks every queued song (albums too),
right-click menus with Stem Now, Songs and Genres pages, a Stems Only
filter in place of the sidebar section, Random sort, several pooled
Plex servers and libraries, themes plus custom CSS, a reorganised
settings page that loads without a refresh, saved mixes, karaoke and
synced lyrics, stem playlists and offline downloads.

iOS: Random in every sort menu, a Stems Only toggle (sort menu and
Settings), and several music libraries pooled from the signed-in server.
Settings now persists karaoke and speed across launches.

stemd writes a status file that stemserve serves at /api/queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:29:45 -07:00

123 lines
5.4 KiB
JavaScript

// 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); }
}