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