Files
stemify/web/plex.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

236 lines
12 KiB
JavaScript

// Plex: any number of servers, each with its music sections switched on or off. Lists from
// several sections are merged page by page so they read as one library.
import { S, save } from "./store.js";
import { uuid } from "./util.js";
export const headers = token => ({
Accept: "application/json",
"X-Plex-Product": "Stemify Web",
"X-Plex-Client-Identifier": S.clientId,
...(token ? { "X-Plex-Token": token } : {}),
});
export const server = id => S.servers.find(s => s.id === id);
export async function px(srv, path, params = {}) {
if (typeof srv === "string") srv = server(srv);
if (!srv) throw new Error("That Plex server isn't set up any more");
const u = new URL(srv.url + path, location.href);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: headers(srv.token), signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`Plex ${r.status} from ${srv.name}`);
return (await r.json()).MediaContainer || {};
}
export function img(srvId, thumb, size = 300) {
const s = server(srvId);
return thumb && s
? `${s.url}/photo/:/transcode?width=${size}&height=${size}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}&X-Plex-Token=${s.token}`
: "";
}
export function partUrl(t) {
const s = server(t.srv);
return s && t.part ? `${s.url}${t.part}?X-Plex-Token=${s.token}` : null;
}
export function track(srvId, m) {
const part = m.Media?.[0]?.Part?.[0];
return {
id: `p${srvId}:${m.ratingKey}`, srv: srvId, key: m.ratingKey,
title: m.title, artist: m.originalTitle || m.grandparentTitle || "", album: m.parentTitle || "",
albumKey: m.parentRatingKey, artistKey: m.grandparentRatingKey,
thumb: m.thumb || m.parentThumb || m.grandparentThumb, duration: m.duration || part?.duration || 0,
part: part?.key, file: part?.file, index: m.index,
meta: { titleSort: m.titleSort, addedAt: m.addedAt, lastViewedAt: m.lastViewedAt, viewCount: m.viewCount, userRating: m.userRating },
};
}
// Sections that are switched on, one per (server, section). A Plex server reachable two ways
// (the env proxy and a sign-in) only counts once.
export function sources() {
const seen = new Set(), out = [];
for (const srv of S.servers) {
if (srv.mid && seen.has(srv.mid)) continue;
if (srv.mid) seen.add(srv.mid);
for (const sec of srv.sections || []) if (sec.on) out.push({ srv, sec: sec.key });
}
return out;
}
export const liveServers = () => [...new Map(sources().map(s => [s.srv.id, s.srv])).values()];
// Fills in the server's identity and music sections. New sections start switched on.
export async function refreshServer(srv) {
const [id, secs] = await Promise.all([px(srv, "/").catch(() => ({})), px(srv, "/library/sections")]);
if (id.machineIdentifier) srv.mid = id.machineIdentifier;
if (id.friendlyName && (!srv.name || srv.name === srv.url)) srv.name = id.friendlyName;
const old = new Map((srv.sections || []).map(s => [s.key, s]));
srv.sections = (secs.Directory || []).filter(d => d.type === "artist")
.map(d => ({ key: String(d.key), title: d.title, on: old.get(String(d.key))?.on ?? true }));
save();
return srv;
}
export function addServer(srv) {
const i = S.servers.findIndex(s => s.id === srv.id || (srv.mid && s.mid === srv.mid && !s.env));
if (i >= 0) S.servers[i] = { ...S.servers[i], ...srv, sections: S.servers[i].sections };
else S.servers.push({ sections: [], ...srv });
save();
return S.servers[i >= 0 ? i : S.servers.length - 1];
}
export const manualServer = (url, token) => ({ id: "m" + uuid().slice(0, 8), url, token, name: url });
// PIN sign-in, then every server on the account that answers from this browser.
export async function signIn(status) {
const pin = await (await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: headers() })).json();
const auth = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(S.clientId)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=Stemify%20Web`;
window.open(auth, "_blank", "width=800,height=740");
status("Waiting for Plex sign-in…");
let token = null;
for (let n = 0; n < 150 && !token; n++) {
await new Promise(r => setTimeout(r, 2000));
token = (await (await fetch(`https://plex.tv/api/v2/pins/${pin.id}`, { headers: headers() })).json()).authToken;
}
if (!token) throw new Error("Sign-in timed out");
status("Finding your servers…");
const res = await (await fetch("https://plex.tv/api/v2/resources?includeHttps=1", { headers: headers(token) })).json();
const https = location.protocol === "https:";
const found = await Promise.all(res.filter(r => r.provides?.includes("server")).map(async srv => {
const conns = (srv.connections || []).filter(c => !https || c.protocol === "https")
.sort((a, b) => (b.local - a.local) || (a.relay - b.relay));
for (const c of conns) {
try {
const r = await fetch(`${c.uri}/identity`, { headers: headers(srv.accessToken || token), signal: AbortSignal.timeout(3500) });
if (r.ok) return { id: srv.clientIdentifier, mid: srv.clientIdentifier, url: c.uri, token: srv.accessToken || token, name: srv.name };
} catch {}
}
return null;
}));
const ok = found.filter(Boolean);
if (!ok.length) throw new Error("Signed in, but no Plex server answered from this browser");
for (const f of ok) await refreshServer(addServer(f)).catch(() => {});
return ok;
}
// ---- sorting -------------------------------------------------------------------------------
// Same options as the iOS app, plus Random. [title, Plex key, starts descending, up, down].
const AZ = ["A to Z", "Z to A"], NEW = ["Oldest First", "Newest First"], MOST = ["Fewest First", "Most First"];
const RANDOM = ["Random", "random", false, "Shuffled", "Shuffled"];
export const SORTS = {
artists: [["Name", "titleSort", false, ...AZ], ["Date Added", "addedAt", true, ...NEW], ["Last Played", "lastViewedAt", true, ...NEW],
["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"], RANDOM],
albums: [["Title", "titleSort", false, ...AZ], ["Artist", "artist.titleSort,year", false, ...AZ], ["Year", "year", true, ...NEW],
["Release Date", "originallyAvailableAt", true, ...NEW], ["Date Added", "addedAt", true, ...NEW], ["Last Played", "lastViewedAt", true, ...NEW],
["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"], RANDOM],
songs: [["Title", "titleSort", false, ...AZ], ["Artist", "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", false, ...AZ],
["Album", "album.titleSort,album.index,album.id,track.absoluteIndex", false, ...AZ], ["Date Added", "addedAt", true, ...NEW],
["Last Played", "lastViewedAt", true, ...NEW], ["Plays", "viewCount", true, ...MOST], ["Rating", "userRating", true, "Lowest First", "Highest First"],
["Duration", "duration", true, "Shortest First", "Longest First"], RANDOM],
playlists: [["Title", "titleSort", false, ...AZ], ["Date Added", "addedAt", true, ...NEW], ["Last Updated", "updatedAt", true, ...NEW],
["Last Played", "lastViewedAt", true, ...NEW], ["Plays", "viewCount", true, ...MOST], ["Duration", "duration", true, "Shortest First", "Longest First"], RANDOM],
};
// Track lists inside an album, artist, playlist or genre are sorted in the browser.
SORTS.tracks = [["Default", "", false, "In Order", "Reversed"], ...SORTS.songs];
export function sortState(screen) {
const opts = SORTS[screen === "album" || screen === "artist" || screen === "playlist" || screen === "search" ? "tracks" : screen] || SORTS.tracks;
const st = S.sorts[screen] || {};
const i = opts[st.i] ? st.i : 0, o = opts[i];
const desc = o[2] !== !!st.rev;
return { opts, i, o, desc, random: o[1] === "random", param: o[1] === "random" ? "random" : o[1].split(",").map((f, j) => j || !desc ? f : f + ":desc").join(",") };
}
// Picking the current field again flips it, like Plexamp and the iOS app.
export function pickSort(screen, i, dir) {
const st = S.sorts[screen] ||= {};
const opts = sortState(screen).opts;
if (dir != null) st.rev = dir !== opts[st.i ?? 0][2];
else if ((st.i ?? 0) === i) st.rev = !st.rev;
else { st.i = i; st.rev = false; }
save();
}
// Value Plex sorts by, for merging pages from several sections and for sorting in the browser.
const FIELD = {
titleSort: m => m.titleSort || m.title,
"artist.titleSort": m => m.type === "track" ? m.grandparentTitle : m.parentTitle,
"album.titleSort": m => m.parentTitle,
originallyAvailableAt: m => m.originallyAvailableAt || "",
};
const coll = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
export function comparator(param) {
const keys = param.split(",").map(k => { const [f, d] = k.split(":"); return { get: FIELD[f] || (m => m[f]), dir: d === "desc" ? -1 : 1 }; });
return (a, b) => {
for (const { get, dir } of keys) {
let x = get(a), y = get(b);
if (x == null && y == null) continue;
if (typeof x === "number" || typeof y === "number") { x ??= -Infinity; y ??= -Infinity; if (x !== y) return (x < y ? -1 : 1) * dir; }
else { const c = coll.compare(x ?? "", y ?? ""); if (c) return c * dir; }
}
return 0;
};
}
// Sorts plain tracks (from plexTrack) the way a Plex sort key would.
export function sortTracks(tracks, st) {
if (!st.o[1]) return st.desc !== st.o[2] ? tracks.slice().reverse() : tracks;
if (st.random) return tracks.map(t => [Math.random(), t]).sort((a, b) => a[0] - b[0]).map(x => x[1]);
const asMeta = t => ({ type: "track", title: t.title, grandparentTitle: t.artist, parentTitle: t.album, duration: t.duration, ...t.meta });
const cmp = comparator(st.param);
return tracks.slice().sort((a, b) => cmp(asMeta(a), asMeta(b)));
}
// ---- merged paging ----------------------------------------------------------------------------
// Pulls pages from every source and merges them by the sort, or interleaves them for Random.
export class Pager {
constructor(srcs, path, params, size = 120) {
this.cmp = params.sort && params.sort !== "random" ? comparator(params.sort) : null;
this.random = params.sort === "random";
this.st = srcs.map(s => ({ ...s, buf: [], start: 0, total: Infinity }));
this.path = path; this.params = params; this.size = size;
this.total = 0;
}
get done() { return this.st.every(s => !s.buf.length && s.start >= s.total); }
async fill(s) {
if (s.buf.length || s.start >= s.total) return;
const mc = await px(s.srv, typeof this.path === "function" ? this.path(s) : this.path,
{ ...this.params, ...(s.params || {}), "X-Plex-Container-Start": s.start, "X-Plex-Container-Size": this.size });
const items = mc.Metadata || [];
const first = s.total === Infinity;
s.total = items.length ? mc.totalSize ?? mc.size ?? 0 : s.start;
if (first) this.total += s.total;
s.start += items.length;
for (const m of items) m._srv = s.srv.id;
s.buf.push(...items);
}
async next(n = this.size) {
const out = [];
while (out.length < n) {
await Promise.all(this.st.map(s => this.fill(s).catch(e => { s.total = s.start; s.err = e; })));
const live = this.st.filter(s => s.buf.length);
if (!live.length) break;
let pick;
if (live.length === 1) pick = live[0];
else if (this.random) {
let r = Math.random() * live.reduce((a, s) => a + s.buf.length + s.total - s.start, 0);
pick = live.find(s => (r -= s.buf.length + s.total - s.start) < 0) || live[0];
} else pick = this.cmp ? live.reduce((a, s) => this.cmp(s.buf[0], a.buf[0]) < 0 ? s : a) : live[0];
// A single live source can hand over its whole buffer at once.
out.push(...(live.length === 1 ? pick.buf.splice(0, n - out.length) : [pick.buf.shift()]));
}
if (!out.length && this.st.every(s => s.err)) throw this.st[0].err;
return out;
}
}
// Every page of every source, for lists that are only ever shown whole (genres, playlists).
export async function everything(srcs, path, params = {}) {
const per = await Promise.all(srcs.map(async s => {
const mc = await px(s.srv, typeof path === "function" ? path(s) : path, params).catch(() => ({}));
return (mc.Metadata || mc.Directory || []).map(m => (m._srv = s.srv.id, m._sec = s.sec, m));
}));
return per.flat();
}