build-container / build (push) Successful in 7m35s
- Featured is the first Music tab; picks stay for the day on a device, Pick again rolls new ones - Based on: choose the playlists they come from (synced across devices) - Play mix shuffles the artist's songs; Play plays the album Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
230 lines
19 KiB
JavaScript
230 lines
19 KiB
JavaScript
'use strict';
|
|
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
|
|
const jfetch = async (path, opts) => {
|
|
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
|
|
return j;
|
|
};
|
|
const px = {
|
|
secs: null,
|
|
async get(path) {
|
|
const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
|
|
if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
|
|
if (!r.ok) throw new Error('Plex error ' + r.status);
|
|
return (await r.json()).MediaContainer || {};
|
|
},
|
|
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv|store)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
|
// All music libraries on the server; the chosen one is remembered per server.
|
|
async sections() {
|
|
if (px.secs) return px.secs;
|
|
const d = (await px.get('/library/sections')).Directory || [];
|
|
px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
|
|
if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
|
|
return px.secs;
|
|
},
|
|
async section() {
|
|
const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
|
|
return (secs.find(x => x.key === want) || secs[0]).key;
|
|
},
|
|
serverKey: '',
|
|
};
|
|
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
|
|
const mapTrack = (t, mc) => {
|
|
const part = t.Media?.[0]?.Part?.[0];
|
|
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
|
|
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
|
|
};
|
|
|
|
async function viewMusic(args) {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
|
|
try {
|
|
const st = await jfetch('/api/plex/status');
|
|
if (!location.hash.startsWith('#/music')) return;
|
|
if (!st.signedIn) return plexSignIn();
|
|
if (!st.server) return plexPickServer();
|
|
await musicRoute(args, st);
|
|
renderNpCard();
|
|
} catch (e) {
|
|
if (e.auth) return plexSignIn();
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
|
|
$('#rs').onclick = plexPickServer;
|
|
}
|
|
}
|
|
|
|
// The sign-in on plex.tv may take over this very tab (installed apps, some browsers) and then come back, so the pending sign-in is remembered
|
|
// and picked up again when this page loads.
|
|
const PLEX_PIN = 'plex_pin';
|
|
async function plexWaitPin(pin, w) {
|
|
for (let i = 0; i < 150; i++) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
if (!location.hash.startsWith('#/music') && !location.hash.startsWith('#/video')) return;
|
|
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
|
if (r.ok) { try { localStorage.removeItem(PLEX_PIN); } catch {} try { w && w.close(); } catch {} return route(); }
|
|
}
|
|
try { localStorage.removeItem(PLEX_PIN); } catch {}
|
|
const e = $('#pe'); if (e) e.textContent = 'Timed out waiting for Plex. Try again.';
|
|
}
|
|
function plexSignIn(msg = '') {
|
|
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
|
|
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
|
|
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
|
|
let pend = null; try { pend = JSON.parse(localStorage.getItem(PLEX_PIN)); } catch {}
|
|
if (pend && Date.now() - pend.t < 10 * 60e3) { $('#pe').textContent = 'Finishing your Plex sign-in…'; plexWaitPin(pend, null).catch(e => { const x = $('#pe'); if (x) x.textContent = e.message; }); }
|
|
$('#pl').onclick = async () => {
|
|
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
|
try {
|
|
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
|
|
try { localStorage.setItem(PLEX_PIN, JSON.stringify({ id: pin.id, code: pin.code, t: Date.now() })); } catch {}
|
|
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
|
|
await plexWaitPin(pin, w);
|
|
} catch (e) { $('#pe').textContent = e.message; }
|
|
};
|
|
}
|
|
|
|
async function plexPickServer() {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Finding your Plex servers…</p></div>';
|
|
try {
|
|
const servers = await jfetch('/api/plex/servers');
|
|
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
|
|
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
|
|
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
|
|
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
|
|
b.disabled = true; $('#pe').textContent = '';
|
|
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
|
|
catch (e) {
|
|
b.disabled = false; $('#pe').textContent = e.message;
|
|
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
|
|
$('#pm').innerHTML = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
|
|
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
|
|
<div class="bar"><button class="btn">Try this address</button></div></form>`;
|
|
$('#pf2').onsubmit = async ev => {
|
|
ev.preventDefault(); $('#pe').textContent = '';
|
|
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
|
|
catch (e2) { $('#pe').textContent = e2.message; }
|
|
};
|
|
}
|
|
});
|
|
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
|
|
}
|
|
|
|
async function musicRoute([kind = 'featured', key], st) {
|
|
px.serverKey = st.server;
|
|
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
|
|
const tabs = ['featured', 'albums', 'artists', 'playlists', 'recent'];
|
|
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
|
|
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
|
|
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
|
|
const listing = (items, html) => {
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}${items === 'raw' ? '' : '<input type="search" id="mq" placeholder="Filter" autocomplete="off">'}
|
|
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
|
|
plexHomeBadge(st);
|
|
const pl2 = $('#plib');
|
|
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
|
|
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
|
|
if ($('#mq')) $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
|
|
};
|
|
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
|
|
|
|
if (kind === 'featured') return musicFeatured({ sec, card, listing, st });
|
|
|
|
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
|
|
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
|
|
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
|
|
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
|
|
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
|
|
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
|
|
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
|
|
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
|
|
|
|
if (kind === 'artist') {
|
|
const mc = await px.get(`/library/metadata/${key}/children`);
|
|
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
|
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
|
return;
|
|
}
|
|
if (kind === 'album' || kind === 'playlist') {
|
|
const album = kind === 'album';
|
|
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 => { 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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
|
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
|
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
|
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
|
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
|
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
|
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
|
|
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();
|
|
return;
|
|
}
|
|
location.hash = '#/music/albums';
|
|
}
|
|
|
|
// ---- Featured: an artist and an album picked at random, either from the whole library or only from the playlists you choose. The pick stays for the day on this
|
|
// device (so Music does not change every time you open it); "Pick again" rolls new ones, and "Based on" chooses the playlists.
|
|
const featKey = () => 'music_feat:' + px.serverKey, featPls = () => store.get('music_feat_pls', []);
|
|
const pick = a => a[Math.floor(Math.random() * a.length)];
|
|
/** Two random choices: an artist { id, title, thumb } and an album { id, title, artist, thumb }. */
|
|
async function featuredPick(sec, pls) {
|
|
if (pls.length) { // only what is in the chosen playlists
|
|
const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] }))));
|
|
const artists = new Map(), albums = new Map();
|
|
for (const t of lists.flatMap(l => l.Metadata || [])) {
|
|
if (t.grandparentRatingKey) artists.set(t.grandparentRatingKey, { id: t.grandparentRatingKey, title: t.grandparentTitle, thumb: t.grandparentThumb });
|
|
if (t.parentRatingKey) albums.set(t.parentRatingKey, { id: t.parentRatingKey, title: t.parentTitle, artist: t.grandparentTitle || '', thumb: t.parentThumb || t.thumb });
|
|
}
|
|
if (!artists.size && !albums.size) throw new Error('The playlists you chose have no songs in them yet');
|
|
return { artist: artists.size ? pick([...artists.values()]) : null, album: albums.size ? pick([...albums.values()]) : null };
|
|
}
|
|
const one = async type => { // a random item of a type: ask how many there are, then fetch one at a random place
|
|
const n = (await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=0&X-Plex-Container-Size=0`)).totalSize || 0; if (!n) return null;
|
|
const m = ((await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=${Math.floor(Math.random() * n)}&X-Plex-Container-Size=1`)).Metadata || [])[0];
|
|
return m ? { id: m.ratingKey, title: m.title, artist: m.parentTitle || '', thumb: m.thumb } : null;
|
|
};
|
|
const [artist, album] = await Promise.all([one(8), one(9)]); return { artist, album };
|
|
}
|
|
async function musicFeatured({ sec, card, listing, st }) {
|
|
const day = new Date().toDateString(), pls = featPls(); let saved = store.get(featKey(), null);
|
|
const fresh = !saved || saved.day !== day || saved.sec !== sec || JSON.stringify(saved.pls) !== JSON.stringify(pls);
|
|
let p = fresh ? null : saved.p, err = '';
|
|
if (!p) { try { p = await featuredPick(sec, pls); store.set(featKey(), { day, sec, pls, p }); } catch (e) { err = e.message; p = { artist: null, album: null }; } }
|
|
const names = pls.length ? (await px.get('/playlists?playlistType=audio').then(m => (m.Metadata || []).filter(x => pls.includes(x.ratingKey)).map(x => x.title)).catch(() => [])) : [];
|
|
const big = (kind, o, round) => `<div class="featcard"><a href="#/music/${kind}/${esc(o.id)}"><img class="art ${round ? 'round' : ''}" alt="" src="${px.art(o.thumb, 400)}"></a><div class="featinfo"><small class="sub">Featured ${kind}</small><h2>${esc(o.title)}</h2>${o.artist ? `<p class="sub">${esc(o.artist)}</p>` : ''}
|
|
<div class="bar" style="justify-content:flex-start"><button class="btn" data-fp="${kind}">${icon('play')}${kind === 'artist' ? 'Play mix' : 'Play'}</button><a class="btn ghost" href="#/music/${kind}/${esc(o.id)}">Open</a></div></div></div>`;
|
|
listing('raw', `<div class="bar" style="justify-content:flex-start;margin:0 0 10px;flex-wrap:wrap"><button class="btn ghost" id="fre">${icon('shuffle')}Pick again</button><button class="btn ghost" id="fpl">Based on: ${pls.length ? esc(names.slice(0, 2).join(', ') || pls.length + ' playlist' + (pls.length > 1 ? 's' : '')) + (names.length > 2 ? ' +' + (names.length - 2) : '') : 'Whole library'} ▾</button></div>
|
|
${err ? `<p class="err">${esc(err)}</p>` : ''}<div class="featgrid">${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}</div>`);
|
|
const v = $('#view');
|
|
v.querySelectorAll('[data-fp]').forEach(b => b.onclick = async () => {
|
|
b.disabled = true;
|
|
try {
|
|
if (b.dataset.fp === 'album') { const mc = await px.get(`/library/metadata/${p.album.id}/children`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(tr, 0); }
|
|
else { const mc = await px.get(`/library/metadata/${p.artist.id}/allLeaves`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(shuffled(tr), 0, true); else toast('No songs found for this artist'); }
|
|
} catch (e) { toast(e.message); }
|
|
b.disabled = false;
|
|
});
|
|
$('#fre').onclick = () => { store.del(featKey()); route(); };
|
|
$('#fpl').onclick = async () => {
|
|
let all = []; try { all = ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(x => !x.smart || true); } catch (e) { return toast(e.message); }
|
|
if (!all.length) return toast('Make a playlist first, then choose it here');
|
|
const chosen = new Set(pls), el = openSheet('Based on', 'Pick the playlists the featured artist and album come from. None chosen means your whole library.', `<div class="list" style="max-height:50dvh;overflow:auto">${all.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-pl="${esc(x.ratingKey)}"${chosen.has(x.ratingKey) ? ' checked' : ''}><span class="grow"><b>${esc(x.title)}</b><small>${x.leafCount || 0} songs</small></span></label>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="fall" style="flex:1;justify-content:center">Whole library</button><button class="btn" id="fok" style="flex:1;justify-content:center">Use these</button></div>`);
|
|
const save = ids => { store.set('music_feat_pls', ids); store.del(featKey()); closeSheet(); route(); };
|
|
el.querySelector('#fok').onclick = () => save([...el.querySelectorAll('[data-pl]:checked')].map(c => c.dataset.pl));
|
|
el.querySelector('#fall').onclick = () => save([]);
|
|
};
|
|
}
|