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>
236 lines
12 KiB
JavaScript
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();
|
|
}
|