feat: play real music from local library in Memories slideshow

Scans MUSIC_DIR (mounted at /music in Docker) with music-metadata to
read embedded BPM/genre tags and classify tracks as upbeat/cinematic/
dreamy. Falls back to untagged general pool when mood bucket is thin.
Slideshow plays real audio via <audio> element with range-request
streaming; Web Audio synth is kept as fallback when MUSIC_DIR is unset.
Shows now-playing title/artist in the slideshow bottom bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 15:26:18 -04:00
1 parent d35836131a
commit 6f4e90a1c8
6 files changed
+493 -24

No files matched your search

+79
View File
@@ -6,16 +6,24 @@ import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import { openDb } from '../worker/db.js';
import { scan, getGroups } from './dedupe.js';
import { scanMusicLibrary, initMusicTable, musicScanActive } from '../worker/scanMusic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '3000', 10);
const DB_PATH = process.env.DB_PATH ?? './data/library.db';
const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
const MUSIC_DIR = process.env.MUSIC_DIR ?? null;
const app = express();
const db = openDb(DB_PATH);
const posterDirAbs = path.resolve(POSTER_DIR);
// Music table + background scan on startup
initMusicTable(db);
if (MUSIC_DIR) {
scanMusicLibrary(MUSIC_DIR, db).catch(() => {});
}
app.use(express.json());
// ── Photo Sync state ──────────────────────────────────────────────────────────
@@ -745,6 +753,77 @@ app.get('/api/fs/browse', (req, res) => {
// ── SPA fallback ──────────────────────────────────────────────────────────────
// ── Music API ─────────────────────────────────────────────────────────────────
const AUDIO_MIME = {
'.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4',
'.ogg': 'audio/ogg', '.wav': 'audio/wav', '.aac': 'audio/aac',
'.opus': 'audio/ogg', '.wma': 'audio/x-ms-wma',
};
// GET /api/music?mood=upbeat — returns up to 60 shuffled tracks for that mood
app.get('/api/music', (req, res) => {
if (!MUSIC_DIR) return res.json({ tracks: [], scanning: false });
const mood = req.query.mood ?? 'upbeat';
const count = db.raw.prepare('SELECT COUNT(*) AS n FROM music').get().n;
// mood-specific + general pool (untagged), shuffled
const tracks = db.raw.prepare(`
SELECT id, title, artist, album, duration, mood FROM music
WHERE mood = ? OR mood = 'general'
ORDER BY RANDOM()
LIMIT 60
`).all(mood);
res.json({ tracks, total: count, scanning: musicScanActive });
});
// GET /api/music/scan-status
app.get('/api/music/scan-status', (req, res) => {
const count = db.raw.prepare('SELECT COUNT(*) AS n FROM music').get().n;
res.json({ count, scanning: musicScanActive, enabled: !!MUSIC_DIR });
});
// POST /api/music/scan — trigger a rescan
app.post('/api/music/scan', (req, res) => {
if (!MUSIC_DIR) return res.status(400).json({ error: 'MUSIC_DIR not configured' });
if (musicScanActive) return res.json({ started: false });
scanMusicLibrary(MUSIC_DIR, db).catch(() => {});
res.json({ started: true });
});
// GET /api/music/stream/:id — stream an audio file (with range support for seeking)
app.get('/api/music/stream/:id', (req, res) => {
const row = db.raw.prepare('SELECT filepath FROM music WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).end();
const fp = row.filepath;
// Safety: must be under MUSIC_DIR
if (MUSIC_DIR && !fp.startsWith(path.resolve(MUSIC_DIR))) return res.status(403).end();
let stat;
try { stat = fs.statSync(fp); } catch { return res.status(404).end(); }
const contentType = AUDIO_MIME[path.extname(fp).toLowerCase()] ?? 'audio/mpeg';
const range = req.headers.range;
if (range) {
const [s, e] = range.replace('bytes=', '').split('-');
const start = parseInt(s, 10);
const end = e ? parseInt(e, 10) : stat.size - 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': contentType,
});
fs.createReadStream(fp, { start, end }).pipe(res);
} else {
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
});
fs.createReadStream(fp).pipe(res);
}
});
// ─────────────────────────────────────────────────────────────────────────────
const distPath = path.resolve(__dirname, '../dist');
app.use(express.static(distPath));
app.get(/^(?!\/api|\/posters)/, (_req, res) => {