'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, artistKey: t.grandparentRatingKey, albumKey: t.parentRatingKey }; }; 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 = 'featured', key, key2], 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', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' }; const seg = `
${tabs.map(t => `${TAB_NAME[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}${items === 'raw' ? '' : ''}
${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(); } }))); }; 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'; const ctx = { sec, card, listing, st, v }; if (kind === 'featured') return musicFeatured({ sec, card, listing, st }); if (kind === 'mixes') return musicMixes(ctx); if (kind === 'mix') return musicMixPage(ctx, key, key2, arguments[0][3]); if (kind === 'genre') return musicGenrePage(ctx, key); if (kind === 'decade') return musicDecadePage(ctx, key); if (kind === 'smart') return musicSmartPage(ctx, key); if (kind === 'family') return musicFamilyPage(ctx, key); if (kind === 'favorites') return musicFavorites(ctx); if (kind === 'stats') return musicStats(ctx); 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, fam] = await Promise.all([px.get('/playlists?playlistType=audio'), mmApi('/family').catch(() => [])]); listing('raw', `

Your playlists

${(mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('') || '

No playlists yet.

'}

Family playlists

Shared with everyone who has Music. Anyone can add and remove songs.

${fam.map(famCard).join('')}
`); $('#fnew').onclick = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { const r = await mmApi('/family', mmOpt('POST', { name: n })); location.hash = '#/music/family/' + r.id; } catch (e) { toast(e.message); } }); return; } if (kind === 'artist') { const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || ''; v.innerHTML = `
${icon('back')}Artists

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

${about ? `
About

${esc(about).replace(/\n/g, '
')}

` : ''}
${icon('shuffle')}More like this

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)}

${info.summary ? `
About

${esc(info.summary).replace(/\n/g, '
')}

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

No playable tracks

'}
`; v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => mfToggle(tracks[+b.dataset.fi])); mfLoad().then(mfPaint); if (album) mmOfflineButton($('#pof'), 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'; } // ---- 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) => `
Featured ${kind}

${esc(o.title)}

${o.artist ? `

${esc(o.artist)}

` : ''}
Open
`; listing('raw', `
${err ? `

${esc(err)}

` : ''}
${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}
`); const v = $('#view'); mmVerse(document.getElementById('mverse')); 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.', `
${all.map(x => ``).join('')}
`); 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([]); }; }