diff --git a/README.md b/README.md
index eb15b5b..ba7d2e7 100644
--- a/README.md
+++ b/README.md
@@ -170,3 +170,11 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions
**Music, Featured:** the first tab of Music shows a featured artist and a featured album, picked at random. They stay the same for the day on that device; *Pick again* rolls new ones. *Based on* chooses which playlists they come from (nothing chosen means the whole library), and that choice follows you between devices. *Play mix* shuffles the artist's songs; *Play* plays the album in order.
+**Music, more** (Plex music; what is about a person is kept by the hub in `music-*.json` in their folder, and `music-family.json` for Family playlists):
+- **Mixes tab:** a Daily mix (what you play, your favorites and a few surprises), Most played this month, Forgotten favorites, Moods (calm, upbeat, focus, worship, built from your genres), On this day (albums released today in past years), Decades, Genres (each with a featured album and a shuffle), *Library radio* (an endless shuffle that keeps adding songs; it uses the playlists chosen on Featured), and **Smart playlists**: your own rules (genres, years, added recently, not played in a while, played at least N times, favorites only, order, size) that pick songs each time you open them.
+- **Favorites:** a heart on songs (in lists, the full player and the song menu); the Favorites page plays them. Kept by the hub, so they follow you between devices.
+- **Stats tab:** songs played, time listened, artists, day streak, minutes per day or month, top artists, songs and albums, and recently played; for a week, month, year or all time. A song counts when it has been played for half its length. *Clear my history* starts over.
+- **Family playlists** (Playlists tab, and *Add to playlist* on any song): shared by everyone who has Music; anyone can add or remove songs, only the owner or an admin renames or deletes one.
+- **Also:** reorder the queue with the arrows; *More like this* on an artist or song; an About note on albums and artists (Plex's own summary); **Save offline** on an album (plays with no signal); the player takes its colors from the album cover; **Share a line** in the lyrics makes a picture of the lyric over the cover; Featured shows a verse of the day when you have the Bible tab.
+- Not included: crossfade or gapless (the player already keeps the next songs downloaded for quick changes), searching lyrics by a line, and a speaker picker.
+
diff --git a/musicdata.js b/musicdata.js
new file mode 100644
index 0000000..928bef1
--- /dev/null
+++ b/musicdata.js
@@ -0,0 +1,107 @@
+// Music data kept by the hub (Plex only lets the hub read the library, so what is about a person lives here): what they played (for history, stats and mixes), their
+// favorites, their smart playlists (rules that pick songs from the Plex library), and Family playlists that everyone with Music can add to. Songs are remembered
+// by their Plex key plus enough to show them; the player fetches the song itself from Plex when it is played.
+const fs = require('fs'), path = require('path'), crypto = require('crypto');
+const atomicWrite = require('./atomic');
+const users = require('./users');
+const { readBody } = require('./body');
+
+const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FAMILY = () => path.join(DATA_DIR, 'music-family.json');
+const mine = n => path.join(users.dir(), n);
+const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
+const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
+const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
+const fail = (code, message) => Object.assign(new Error(message), { code });
+const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
+const keyOk = k => /^\d{1,12}$/.test(String(k || ''));
+const newId = () => crypto.randomBytes(6).toString('hex');
+const DAY = 864e5, MAX_PLAYS = 20000, MAX_FAVS = 5000, MAX_FAMILY_TRACKS = 2000;
+
+/** A song as it is kept: its Plex key and what is needed to list it. */
+function song(b) {
+ if (!b || !keyOk(b.key)) throw fail(400, 'That is not a song from your music library');
+ return { key: String(b.key), title: str(b.title, 200) || 'Song', artist: str(b.artist, 200), album: str(b.album, 200), thumb: str(b.thumb, 300), artistKey: keyOk(b.artistKey) ? String(b.artistKey) : '', albumKey: keyOk(b.albumKey) ? String(b.albumKey) : '', secs: Math.max(0, Math.min(36000, Math.round(+b.secs || (+b.dur || 0) / 1000 || 0))) };
+}
+
+// ---- what was played
+const plays = () => read(mine('music-history.json'), []);
+function recordPlay(b) {
+ const s = song(b), l = plays(), last = l[l.length - 1], now = Date.now();
+ if (last && last.key === s.key && now - last.t < 20000) return { ok: true, duplicate: true }; // (the same song counted twice by a reload)
+ l.push({ ...s, t: now }); write(mine('music-history.json'), l.slice(-MAX_PLAYS)); return { ok: true };
+}
+const since = (l, days) => days ? l.filter(p => p.t >= Date.now() - days * DAY) : l;
+const top = (l, kf, mk, n) => { const m = new Map(); for (const p of l) { const k = kf(p); if (!k) continue; const e = m.get(k) || { ...mk(p), plays: 0, secs: 0 }; e.plays++; e.secs += p.secs || 0; m.set(k, e); } return [...m.values()].sort((a, b) => b.plays - a.plays || b.secs - a.secs).slice(0, n).map(e => ({ ...e, minutes: Math.round(e.secs / 60) })); };
+const dayKey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
+/** Summary numbers for a stretch of time: totals, top artists, songs and albums, minutes per day (or per month for a long stretch), and the listening streak. */
+function stats(range) {
+ const days = { week: 7, month: 30, year: 365, all: 0 }[range] ?? 30, all = plays(), l = since(all, days);
+ const perDay = new Map(); for (const p of l) perDay.set(dayKey(p.t), (perDay.get(dayKey(p.t)) || 0) + (p.secs || 0) / 60);
+ const series = []; if (days && days <= 31) for (let i = days - 1; i >= 0; i--) { const k = dayKey(Date.now() - i * DAY); series.push({ d: k, minutes: Math.round(perDay.get(k) || 0) }); }
+ else { const m = new Map(); for (const [k, v] of perDay) m.set(k.slice(0, 7), (m.get(k.slice(0, 7)) || 0) + v); const keys = [...m.keys()].sort(); for (const k of (days ? keys.slice(-12) : keys.slice(-24))) series.push({ d: k, minutes: Math.round(m.get(k)) }); }
+ const played = new Set(all.map(p => dayKey(p.t))); let streak = 0; for (let i = played.has(dayKey(Date.now())) ? 0 : 1; played.has(dayKey(Date.now() - i * DAY)); i++) streak++;
+ return { range, plays: l.length, minutes: Math.round(l.reduce((n, p) => n + (p.secs || 0), 0) / 60), artistsHeard: new Set(l.map(p => p.artist).filter(Boolean)).size, streak,
+ artists: top(l, p => p.artist, p => ({ name: p.artist, key: p.artistKey, thumb: p.thumb }), 10), songs: top(l, p => p.key, p => ({ key: p.key, title: p.title, artist: p.artist, thumb: p.thumb }), 10),
+ albums: top(l, p => p.albumKey || p.album, p => ({ key: p.albumKey, title: p.album, artist: p.artist, thumb: p.thumb }), 8), series };
+}
+
+// ---- favorites
+const favs = () => read(mine('music-favs.json'), []);
+const saveFavs = l => write(mine('music-favs.json'), l.slice(-MAX_FAVS));
+
+// ---- smart playlists: rules the player turns into a question for the Plex library
+const GENRE_OK = /^[\w .&'/+-]{1,60}$/;
+function rules(r = {}) {
+ const n = (v, lo, hi) => { v = Math.round(+v); return Number.isFinite(v) && v >= lo && v <= hi ? v : 0; };
+ return { genres: (Array.isArray(r.genres) ? r.genres : []).map(String).filter(g => GENRE_OK.test(g)).slice(0, 10), yearFrom: n(r.yearFrom, 1000, 2100), yearTo: n(r.yearTo, 1000, 2100), addedDays: n(r.addedDays, 1, 3650), notPlayedDays: n(r.notPlayedDays, 1, 3650), minPlays: n(r.minPlays, 1, 1000),
+ onlyFavorites: !!r.onlyFavorites, order: ['random', 'newest', 'mostPlayed', 'leastRecent'].includes(r.order) ? r.order : 'random', limit: n(r.limit, 5, 500) || 50 };
+}
+const smart = () => read(mine('music-smart.json'), []);
+
+// ---- Family playlists
+const family = () => read(FAMILY(), []);
+const saveFamily = l => write(FAMILY(), l);
+const view = p => ({ id: p.id, name: p.name, owner: p.owner, ownerName: p.ownerName, created: p.created, count: p.tracks.length, tracks: p.tracks });
+const canChange = (p, me) => me && (me.role === 'admin' || p.owner === me.id);
+
+async function handle(req, res) {
+ const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/music'.length) || '/', me = users.current(), q = u.searchParams;
+ try {
+ let m;
+ if (p === '/played' && req.method === 'POST') return json(res, 200, recordPlay(JSON.parse(await readBody(req, 2e4) || '{}')));
+ if (p === '/history' && req.method === 'GET') { const n = Math.min(200, Math.max(1, +q.get('limit') || 40)); return json(res, 200, plays().slice(-n).reverse()); }
+ if (p === '/stats' && req.method === 'GET') return json(res, 200, stats(q.get('range') || 'month'));
+ if (p === '/top' && req.method === 'GET') { const days = Math.min(3650, Math.max(0, +q.get('days') || 30)); return json(res, 200, top(since(plays(), days), x => x.key, x => ({ key: x.key, title: x.title, artist: x.artist }), Math.min(200, +q.get('limit') || 50))); }
+ if (p === '/history' && req.method === 'DELETE') { write(mine('music-history.json'), []); return json(res, 200, { ok: true }); }
+ // favorites
+ if (p === '/favs' && req.method === 'GET') return json(res, 200, favs().reverse());
+ if (p === '/favs' && req.method === 'POST') { const s = song(JSON.parse(await readBody(req, 2e4) || '{}')), l = favs().filter(x => x.key !== s.key); l.push({ ...s, t: Date.now() }); saveFavs(l); return json(res, 200, { ok: true }); }
+ if ((m = /^\/favs\/(\d{1,12})$/.exec(p)) && req.method === 'DELETE') { saveFavs(favs().filter(x => x.key !== m[1])); return json(res, 200, { ok: true }); }
+ // smart playlists
+ if (p === '/smart' && req.method === 'GET') return json(res, 200, smart());
+ if (p === '/smart' && req.method === 'POST') {
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name');
+ const l = smart(), old = b.id ? l.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Playlist not found'); if (!old && l.length >= 50) throw fail(400, 'That is plenty of smart playlists');
+ const e = old || { id: newId() }; Object.assign(e, { name, rules: rules(b.rules) }); if (!old) l.push(e); write(mine('music-smart.json'), l); return json(res, 200, e);
+ }
+ if ((m = /^\/smart\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { write(mine('music-smart.json'), smart().filter(x => x.id !== m[1])); return json(res, 200, { ok: true }); }
+ // family playlists
+ if (p === '/family' && req.method === 'GET') return json(res, 200, family().map(view));
+ if (p === '/family' && req.method === 'POST') {
+ const b = JSON.parse(await readBody(req, 2e5) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name'); const l = family(); if (l.length >= 100) throw fail(400, 'That is plenty of family playlists');
+ const e = { id: newId(), name, owner: me.id, ownerName: me.name, created: Date.now(), tracks: (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })) }; l.push(e); saveFamily(l); return json(res, 200, view(e));
+ }
+ if ((m = /^\/family\/([0-9a-f]{12})(?:\/(add|tracks\/(\d{1,12})))?$/.exec(p))) {
+ const l = family(), e = l.find(x => x.id === m[1]); if (!e) throw fail(404, 'Playlist not found');
+ if (!m[2] && req.method === 'GET') return json(res, 200, view(e));
+ if (!m[2] && req.method === 'POST') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can rename it'); const name = str(JSON.parse(await readBody(req, 1e4) || '{}').name, 60); if (!name) throw fail(400, 'Give the playlist a name'); e.name = name; saveFamily(l); return json(res, 200, view(e)); }
+ if (!m[2] && req.method === 'DELETE') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can delete it'); saveFamily(l.filter(x => x !== e)); return json(res, 200, { ok: true }); }
+ if (m[2] === 'add' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e5) || '{}'), add = (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })); let n = 0;
+ for (const t of add) if (!e.tracks.some(x => x.key === t.key) && e.tracks.length < MAX_FAMILY_TRACKS) { e.tracks.push(t); n++; } saveFamily(l); return json(res, 200, { ...view(e), added: n }); }
+ if (m[3] && req.method === 'DELETE') { e.tracks = e.tracks.filter(x => x.key !== m[3]); saveFamily(l); return json(res, 200, view(e)); } // (anyone in the family may take a song off)
+ }
+ return json(res, 404, { error: 'Not found' });
+ } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
+}
+
+module.exports = { handle, stats, rules, song };
diff --git a/package.json b/package.json
index 072d304..76a2662 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav; do node tools/$t-test.js || exit 1; done",
+ "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
diff --git a/public/app.js b/public/app.js
index 1949290..e6df3c1 100644
--- a/public/app.js
+++ b/public/app.js
@@ -95,6 +95,8 @@ const ICONS = {
cal: '
Looking for the lyrics…
${esc(sub || tracks.length + ' songs')}
${esc(about).replace(/\n/g, '
')}
No playable songs
'}Putting it together…
Albums released on ${new Date().toLocaleDateString([], { month: 'long', day: 'numeric' })} in past years
Rules that pick songs for you (for example: gospel from the 80s you have not played in a year).
+${esc(e.message)}
That genre is not in your library.
${albums.length} albums
+ + ${feat ? `${albums.length} albums
+Nothing from this decade.
'}That smart playlist is gone.
${esc(e.message)}
Nothing yet. Songs you play from now on are counted here.
'} +Nothing played yet.
'}${esc(e.message)}
${esc(text)}${esc(bbRef(b, c, v))} · Open the Bible`; + } catch { /* no Bible text: no verse */ } +} diff --git a/public/music.js b/public/music.js index db17e83..06b282b 100644 --- a/public/music.js +++ b/public/music.js @@ -32,7 +32,7 @@ const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor 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 }; + 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) { @@ -110,11 +110,11 @@ async function plexPickServer() { } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `
${esc(e.message)}
No playlists yet.
'}Shared with everyone who has Music. Anyone can add and remove songs.
${esc(about).replace(/\n/g, '
')}
${esc(sub)}
${esc(info.summary).replace(/\n/g, '
')}
No playable tracks
'}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) { @@ -206,8 +222,8 @@ async function musicFeatured({ sec, card, listing, st }) { const big = (kind, o, round) => ``; listing('raw', ` - ${err ? `${esc(err)}
` : ''}${esc(err)}
` : ''}