build-container / build (push) Successful in 6m21s
- Mixes tab: daily mix, most played, forgotten favorites, moods, on this day, decades, genres, smart playlists (rules), library radio - Favorites (hearts, kept by the hub) and Stats (plays, time, top artists/songs/albums, streak, recently played) - Family playlists shared by everyone with Music; queue reorder; More like this; album/artist About notes - Save albums offline; player takes colors from the cover; Share a lyric line as a picture; verse of the day on Featured - musicdata.js keeps plays, favorites, smart rules and Family playlists, with tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
11 KiB
JavaScript
108 lines
11 KiB
JavaScript
// 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 };
|