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>
This commit is contained in:
1 parent
48faa6682f
commit
7cdfbef160
16 files changed
+2006
-730
No files matched your search
+122
@@ -0,0 +1,122 @@
|
||||
// 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); }
|
||||
}
|
||||
Reference in new issue
Block a user