'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)\//.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 = '

Music

Loading…

'; 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 = `

Music

${esc(e.message)}

`; $('#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 = `

Music

Play your Plex music library here.

You'll sign in on plex.tv in a new tab. This app never sees your Plex password.

${esc(msg)}
`; 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 = `Tap to open Plex sign-in`; await plexWaitPin(pin, w); } catch (e) { $('#pe').textContent = e.message; } }; } async function plexPickServer() { const v = $('#view'); v.innerHTML = '

Music

Finding your Plex servers…

'; try { const servers = await jfetch('/api/plex/servers'); if (!servers.length) { v.innerHTML = '

Music

No Plex servers found on this account.

'; return; } v.innerHTML = `

Music

Choose a server

${servers.map(s => ` `).join('')}
`; 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 = `

Know the server's address? Enter it (for example http://192.168.1.10:32400).

`; $('#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 = `

Music

${esc(e.message)}

`; } } async function musicRoute([kind = 'albums', 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 = ['albums', 'artists', 'playlists', 'recent']; const seg = `
${tabs.map(t => `${t[0].toUpperCase() + t.slice(1)}`).join('')}
`; const card = (href, thumb, title, sub, round) => ` ${esc(title)}${esc(sub)}`; const listing = (items, html) => { v.innerHTML = `

Music

${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾` : ''}

${seg}
${html || '

Nothing here

'}
`; 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(); } }))); }; $('#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 === '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 = `
${icon('back')}Artists

${esc(mc.title2 || mc.parentTitle || 'Artist')}

Albums

${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`; 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 = `
${icon('back')}${album ? 'Albums' : 'Playlists'}

${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 = () => { 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'; }