From c8399ccaa7752bc40a854f8aa6efdd82702d869a Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:52:29 -0400 Subject: [PATCH] Plex playlists you can edit (create, add, remove, rename, delete, save queue), sleep timer Co-Authored-By: Claude Sonnet 5.5 --- public/app.js | 25 ++++++++++++----- public/index.html | 1 + public/music-extras.js | 64 ++++++++++++++++++++++++++++++++++++++++++ server.js | 10 ++++--- tools/plex-test.js | 14 ++++++++- 5 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 public/music-extras.js diff --git a/public/app.js b/public/app.js index 62ffe71..e60c502 100644 --- a/public/app.js +++ b/public/app.js @@ -1153,17 +1153,22 @@ async function musicRoute([kind = 'albums', key], st) { const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`), px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]); const info = (meta.Metadata || [])[0] || {}; - const tracks = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); + const tracks = (mc.Metadata || []).map(t => { const m = mapTrack(t, mc); if (m && !album) m.pid = t.playlistItemID; return m; }).filter(Boolean); + const mine = !album && !info.smart; const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks'; const thumb = info.thumb || info.composite || mc.thumb; v.innerHTML = `

${esc(title)}

${esc(sub)}

-
+
${mine ? '' : ''}
${tracks.map((t, i) => `
`).join('') || '

No playable tracks

'}
`; v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p)); - v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m])); + v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); }); + if (mine) { + $('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title); + $('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } }); + } $('#pa').onclick = () => tracks.length && playQueue(tracks, 0); $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true); markPlaying(); @@ -1273,11 +1278,13 @@ function recover(now) { function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); } const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); }; const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); }; -function trackSheet(t) { +function trackSheet(t, extra) { actionSheet(t.title, t.artist, [ { label: 'Play next', icon: 'next', run: () => playNextTrack(t) }, { label: 'Add to queue', icon: 'files', run: () => queueTrack(t) }, { label: 'Play now', icon: 'play', run: () => playQueue([t], 0) }, + { label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) }, + ...(extra || []), ]); } // Restore the last queue and position on launch (paused, so nothing starts by itself). @@ -1293,13 +1300,17 @@ function openQueue() { const el = openSheet('Up next', pl.q.length + ' tracks', `
${upcoming.map(({ t, i }) => `
${i === pl.i ? '' : ``}
`).join('')}
-
`); +
+
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; + el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); }; + el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); }; el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); }; el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); }); el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); }); } function advance(auto) { + if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; } if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; } if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; } loadCur(auto ? 0 : 220); @@ -1412,11 +1423,11 @@ function renderNpCard() {
0:000:00
-
`; + `; const sk = el.querySelector('#cnk'); sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); }; sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; - el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnl').onclick = lyrOpen; + el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle; el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); modeUI(); syncUI(); diff --git a/public/index.html b/public/index.html index 09d2ea2..a15b528 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,7 @@ + diff --git a/public/music-extras.js b/public/music-extras.js new file mode 100644 index 0000000..2932b43 --- /dev/null +++ b/public/music-extras.js @@ -0,0 +1,64 @@ +// Music extras: Plex playlists you can change (create, add, remove, rename, delete, save the queue) and a sleep timer. +// Uses the player globals from app.js (px, pl, jfetch, actionSheet, textSheet, confirmSheet, toast, syncUI, saveState). + +// ---- Plex playlists. The hub only lets /plex/playlists... writes through, so nothing else on the server can be changed from here. +const plx = { + _m: null, + async mid() { return plx._m || (plx._m = (await px.get('/identity')).machineIdentifier); }, + async uri(keys) { return `server://${await plx.mid()}/com.plexapp.plugins.library/library/metadata/${keys.join(',')}`; }, + async call(method, path) { + const r = await fetch('/plex' + path, { method, headers: { Accept: 'application/json' } }); + if (!r.ok) throw new Error('Plex said no (' + r.status + '). Your Plex account may not be allowed to change playlists.'); + return r.json().catch(() => ({})); + }, + async list() { return ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(p => !p.smart); }, + async create(title, keys) { return plx.call('POST', `/playlists?type=audio&smart=0&title=${encodeURIComponent(title)}&uri=${encodeURIComponent(await plx.uri(keys))}`); }, + async add(id, keys) { return plx.call('PUT', `/playlists/${encodeURIComponent(id)}/items?uri=${encodeURIComponent(await plx.uri(keys))}`); }, + remove: (id, pid) => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}/items/${encodeURIComponent(pid)}`), + rename: (id, title) => plx.call('PUT', `/playlists/${encodeURIComponent(id)}?title=${encodeURIComponent(title)}`), + del: id => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}`), +}; + +/** Pick a playlist (or make a new one) and put these tracks in it. */ +async function addToPlaylist(tracks, suggest = '') { + const keys = tracks.map(t => t.key).filter(Boolean); if (!keys.length) return; + let lists = []; + try { lists = await plx.list(); } catch (e) { return toast(e.message); } + const newOne = () => textSheet('New playlist', 'Playlist name', 'Create', async name => { + try { await plx.create(name, keys); toast('Playlist “' + name + '” created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); } + }, suggest); + actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [ + { label: 'New playlist…', icon: 'plus', run: newOne }, + ...lists.map(p => ({ label: p.title, icon: 'music', run: async () => { try { await plx.add(p.ratingKey, keys); toast('Added to ' + p.title); if (location.hash === '#/music/playlist/' + p.ratingKey) route(); } catch (e) { toast(e.message); } } })), + ]); +} + +// ---- Sleep timer: fades the music out and pauses after a set time, or when the current song ends. +const sleepT = { end: 0, eot: false, timer: null, fade: null }; +function sleepClear() { clearTimeout(sleepT.timer); clearInterval(sleepT.fade); sleepT.end = 0; sleepT.eot = false; pl.a.volume = 1; } +function sleepLabel() { + if (sleepT.eot) return 'Sleep · end of song'; + if (sleepT.end) return 'Sleep · ' + Math.max(1, Math.round((sleepT.end - Date.now()) / 6e4)) + ' min'; + return 'Sleep'; +} +function sleepFade() { + clearInterval(sleepT.fade); + const t0 = Date.now(), v0 = pl.a.volume || 1; + sleepT.fade = setInterval(() => { + const f = Math.min(1, (Date.now() - t0) / 8000); + pl.a.volume = Math.max(0, v0 * (1 - f)); + if (f >= 1) { clearInterval(sleepT.fade); pl.a.pause(); pl.a.volume = 1; sleepT.end = 0; const b = document.getElementById('cnz'); if (b) b.textContent = 'Sleep'; toast('Sleep timer: music stopped'); } + }, 200); +} +function sleepSet(minutes) { + sleepClear(); + if (minutes === 'eot') sleepT.eot = true; + else if (minutes > 0) { sleepT.end = Date.now() + minutes * 6e4; sleepT.timer = setTimeout(sleepFade, minutes * 6e4); } + const b = document.getElementById('cnz'); if (b) b.textContent = sleepLabel(); + toast(minutes ? (minutes === 'eot' ? 'Music will stop when this song ends' : 'Music will fade out in ' + minutes + ' minutes') : 'Sleep timer off'); +} +function sleepSheet() { + const opts = [[15, '15 minutes'], [30, '30 minutes'], [45, '45 minutes'], [60, '1 hour'], [90, '1.5 hours'], ['eot', 'When this song ends']].map(([v, label]) => ({ label, icon: 'pause', run: () => sleepSet(v) })); + if (sleepT.end || sleepT.eot) opts.push({ label: 'Turn off the timer', icon: 'x', run: () => sleepSet(0) }); + actionSheet('Sleep timer', sleepLabel() === 'Sleep' ? 'Fades the music out and pauses' : sleepLabel(), opts); +} diff --git a/server.js b/server.js index c4f8dd5..d9ccb52 100644 --- a/server.js +++ b/server.js @@ -99,7 +99,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views|music-extras)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +113,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'music-extras.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; @@ -323,6 +323,8 @@ async function plexApi(req, res) { } catch (e) { json(res, 502, { error: e.message }); } } +// Only playlists may be changed through the proxy (create, add, remove, rename, delete); everything else on the Plex server stays read-only. +const PLEX_WRITABLE = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/; async function plexProxy(req, res) { const st = plexLoad(); if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); } @@ -330,7 +332,7 @@ async function plexProxy(req, res) { res.on('close', () => ac.abort()); // A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response. const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } }; - const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, + const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, method: req.method, headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } }); let up = null; try { up = await attempt(st); } catch { up = null; } @@ -526,7 +528,7 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); - if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res); + if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res); return serveStatic(req, res); }); diff --git a/tools/plex-test.js b/tools/plex-test.js index bd5ce55..2b4eb9f 100644 --- a/tools/plex-test.js +++ b/tools/plex-test.js @@ -30,9 +30,10 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica rs.writeHead(404); rs.end(); }); // ---- fake Plex server: /identity is open; everything else needs the server token - const seen = []; + const seen = [], plexReqs = []; const makePlex = () => listen((rq, rs) => { if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } }); + plexReqs.push(rq.method + ' ' + rq.url); seen.push(rq.headers['x-plex-token']); if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); } jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } }); @@ -70,6 +71,17 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json); r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status); + // ================= playlists can be changed, nothing else can ================= + plexReqs.length = 0; + r = await call('/plex/playlists?type=audio&smart=0&title=Road&uri=x', undefined, 'POST'); ok('a playlist can be created through the hub', r.status === 200 && plexReqs.includes('POST /playlists?type=audio&smart=0&title=Road&uri=x'), [r.status, plexReqs]); + r = await call('/plex/playlists/7/items?uri=x', undefined, 'PUT'); ok('and have tracks added', r.status === 200 && plexReqs.includes('PUT /playlists/7/items?uri=x'), [r.status, plexReqs]); + r = await call('/plex/playlists/7/items/55', undefined, 'DELETE'); ok('and have a track removed', r.status === 200 && plexReqs.includes('DELETE /playlists/7/items/55'), [r.status, plexReqs]); + r = await call('/plex/playlists/7', undefined, 'DELETE'); ok('and be deleted', r.status === 200 && plexReqs.includes('DELETE /playlists/7'), [r.status, plexReqs]); + plexReqs.length = 0; + const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']]; + const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); } + ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]); + // ================= repairing itself ================= tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);