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

+84 -22
View File
@@ -195,17 +195,31 @@ export default function MemoriesView() {
const [ssIndex, setSsIndex] = useState(0); const [ssIndex, setSsIndex] = useState(0);
const [opacity, setOpacity] = useState(1); const [opacity, setOpacity] = useState(1);
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
const [tracks, setTracks] = useState([]);
const [trackIdx, setTrackIdx] = useState(0);
const [scanInfo, setScanInfo] = useState(null);
const audioCtxRef = useRef(null); const audioCtxRef = useRef(null);
const musicRef = useRef(null); const musicRef = useRef(null);
const timerRef = useRef(null); const timerRef = useRef(null);
const audioRef = useRef(null);
useEffect(() => { useEffect(() => {
fetchMemories() fetchMemories()
.then(d => setGroups(groupByYear(d.photos))) .then(d => setGroups(groupByYear(d.photos)))
.catch(e => setError(e.message)); .catch(e => setError(e.message));
fetch('/api/music/scan-status')
.then(r => r.json()).then(setScanInfo).catch(() => {});
}, []); }, []);
// Re-fetch track list whenever mood changes
useEffect(() => {
fetch(`/api/music?mood=${musicStyle}`)
.then(r => r.json())
.then(d => { setTracks(d.tracks ?? []); setTrackIdx(0); })
.catch(() => setTracks([]));
}, [musicStyle]);
useEffect(() => () => { stopMusic(); clearInterval(timerRef.current); }, []); useEffect(() => () => { stopMusic(); clearInterval(timerRef.current); }, []);
const allPhotos = groups ? groups.flatMap(g => g.photos) : []; const allPhotos = groups ? groups.flatMap(g => g.photos) : [];
@@ -230,10 +244,13 @@ export default function MemoriesView() {
} }
function toggleMute() { function toggleMute() {
if (!musicRef.current || !audioCtxRef.current) return;
const next = !muted; const next = !muted;
setMuted(next); setMuted(next);
musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5); if (tracks.length > 0) {
if (audioRef.current) audioRef.current.volume = next ? 0 : 1;
} else if (musicRef.current && audioCtxRef.current) {
musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5);
}
} }
// ── Slideshow ── // ── Slideshow ──
@@ -248,7 +265,8 @@ export default function MemoriesView() {
function startSlideshow(idx) { function startSlideshow(idx) {
setSsIndex(idx); setOpacity(1); setSlideshow(true); setSsIndex(idx); setOpacity(1); setSlideshow(true);
startMusic(musicStyle); setTrackIdx(0);
if (tracks.length === 0) startMusic(musicStyle); // synth fallback
} }
function stopSlideshow() { function stopSlideshow() {
@@ -256,6 +274,7 @@ export default function MemoriesView() {
setSlideshow(false); setSlideshow(false);
stopMusic(); stopMusic();
setMuted(false); setMuted(false);
if (audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; }
} }
useEffect(() => { useEffect(() => {
@@ -304,25 +323,46 @@ export default function MemoriesView() {
{!noPhotos && ( {!noPhotos && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* Music style picker */} {/* Music style picker */}
<div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
{MUSIC_STYLES.map(s => ( <div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}>
<button {MUSIC_STYLES.map(s => (
key={s.key} <button
onClick={() => setMusicStyle(s.key)} key={s.key}
title={s.label} onClick={() => setMusicStyle(s.key)}
style={{ title={s.label}
background: musicStyle === s.key ? '#1e2a3a' : 'transparent', style={{
border: 'none', borderRadius: 6, background: musicStyle === s.key ? '#1e2a3a' : 'transparent',
color: musicStyle === s.key ? '#7ab8f5' : '#555', border: 'none', borderRadius: 6,
cursor: 'pointer', padding: '5px 10px', color: musicStyle === s.key ? '#7ab8f5' : '#555',
fontSize: 13, display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', padding: '5px 10px',
transition: 'background 0.15s, color 0.15s', fontSize: 13, display: 'flex', alignItems: 'center', gap: 5,
}} transition: 'background 0.15s, color 0.15s',
> }}
<span>{s.icon}</span> >
<span style={{ fontSize: 12 }}>{s.label}</span> <span>{s.icon}</span>
</button> <span style={{ fontSize: 12 }}>{s.label}</span>
))} </button>
))}
</div>
{scanInfo && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#444' }}>
{scanInfo.scanning ? '⟳ Scanning music…' : scanInfo.enabled ? `${(scanInfo.total ?? tracks.length || scanInfo.count).toLocaleString()} tracks` : ''}
</span>
{scanInfo.enabled && !scanInfo.scanning && (
<button
onClick={() => {
fetch('/api/music/scan', { method: 'POST' }).then(() =>
setScanInfo(s => ({ ...s, scanning: true }))
);
}}
style={{ background: 'none', border: 'none', color: '#444', cursor: 'pointer', fontSize: 11, padding: 0 }}
>
Rescan
</button>
)}
</div>
)}
</div> </div>
{/* Play button */} {/* Play button */}
@@ -414,6 +454,19 @@ export default function MemoriesView() {
<button onClick={() => advance(1)} style={navBtn('right')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>›</button> <button onClick={() => advance(1)} style={navBtn('right')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>›</button>
</div> </div>
{/* Hidden audio element for real music */}
{tracks.length > 0 && (
<audio
ref={audioRef}
key={tracks[trackIdx % tracks.length]?.id}
src={`/api/music/stream/${tracks[trackIdx % tracks.length]?.id}`}
autoPlay
onEnded={() => setTrackIdx(i => (i + 1) % tracks.length)}
onError={() => setTrackIdx(i => (i + 1) % tracks.length)}
style={{ display: 'none' }}
/>
)}
{/* Bottom bar */} {/* Bottom bar */}
<div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Dots */} {/* Dots */}
@@ -427,6 +480,15 @@ export default function MemoriesView() {
))} ))}
</div> </div>
{/* Now playing */}
{tracks.length > 0 && tracks[trackIdx % tracks.length] && (
<div style={{ fontSize: 11, color: '#555', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0 }}>
{tracks[trackIdx % tracks.length].title}
{tracks[trackIdx % tracks.length].artist
? ` — ${tracks[trackIdx % tracks.length].artist}` : ''}
</div>
)}
<span style={{ fontSize: 12, color: '#555', flexShrink: 0 }}> <span style={{ fontSize: 12, color: '#555', flexShrink: 0 }}>
{style?.icon} {ssIndex + 1} / {allPhotos.length} {style?.icon} {ssIndex + 1} / {allPhotos.length}
</span> </span>
+5
View File
@@ -11,6 +11,7 @@ services:
PORT: "3000" PORT: "3000"
DB_PATH: /data/library.db DB_PATH: /data/library.db
POSTER_DIR: /data/posters POSTER_DIR: /data/posters
MUSIC_DIR: /music
NODE_ENV: production NODE_ENV: production
volumes: volumes:
- type: volume - type: volume
@@ -19,6 +20,10 @@ services:
- type: bind - type: bind
source: /media/Plex/Gallery source: /media/Plex/Gallery
target: /photos target: /photos
- type: bind
source: /DATA/Media/Music
target: /music
read_only: true
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
+229 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "shpm", "name": "luma",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shpm", "name": "luma",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
@@ -14,6 +14,7 @@
"exifr": "^7.1.3", "exifr": "^7.1.3",
"express": "^4.19.2", "express": "^4.19.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"music-metadata": "^11.15.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
@@ -25,6 +26,16 @@
"vite": "^8.2.1" "vite": "^8.2.1"
} }
}, },
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -867,6 +878,52 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"token-types": "^6.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/inflate/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@tokenizer/inflate/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -1258,6 +1315,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
"strtok3": "^10.3.4",
"token-types": "^6.1.1",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1446,6 +1521,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1884,6 +1979,86 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/music-metadata": {
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.15.0.tgz",
"integrity": "sha512-TN+kO1/oOc8UzDW5N3vSDncBcv9WyNnQQe/NFMcFWq6G1+zVeYUkGvkYpQ/S8wb8vKtUD7i78dIaY0cv+cmPRA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Borewit"
},
{
"type": "buymeacoffee",
"url": "https://buymeacoffee.com/borewit"
}
],
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.2",
"@tokenizer/token": "^0.3.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"file-type": "^21.3.4",
"media-typer": "^2.0.0",
"strtok3": "^10.3.5",
"token-types": "^6.1.2",
"uint8array-extras": "^1.5.0",
"win-guid": "^0.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/music-metadata/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/music-metadata/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/music-metadata/node_modules/media-typer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/music-metadata/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -2407,6 +2582,22 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2476,6 +2667,24 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2496,6 +2705,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/uint8array-extras": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2613,6 +2834,12 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
},
"node_modules/win-guid": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
"license": "MIT"
} }
} }
} }
+1
View File
@@ -19,6 +19,7 @@
"exifr": "^7.1.3", "exifr": "^7.1.3",
"express": "^4.19.2", "express": "^4.19.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"music-metadata": "^11.15.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
+79
View File
@@ -6,16 +6,24 @@ import { fileURLToPath } from 'url';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { openDb } from '../worker/db.js'; import { openDb } from '../worker/db.js';
import { scan, getGroups } from './dedupe.js'; import { scan, getGroups } from './dedupe.js';
import { scanMusicLibrary, initMusicTable, musicScanActive } from '../worker/scanMusic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '3000', 10); const PORT = parseInt(process.env.PORT ?? '3000', 10);
const DB_PATH = process.env.DB_PATH ?? './data/library.db'; const DB_PATH = process.env.DB_PATH ?? './data/library.db';
const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters'; const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
const MUSIC_DIR = process.env.MUSIC_DIR ?? null;
const app = express(); const app = express();
const db = openDb(DB_PATH); const db = openDb(DB_PATH);
const posterDirAbs = path.resolve(POSTER_DIR); 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()); app.use(express.json());
// ── Photo Sync state ────────────────────────────────────────────────────────── // ── Photo Sync state ──────────────────────────────────────────────────────────
@@ -745,6 +753,77 @@ app.get('/api/fs/browse', (req, res) => {
// ── SPA fallback ────────────────────────────────────────────────────────────── // ── 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'); const distPath = path.resolve(__dirname, '../dist');
app.use(express.static(distPath)); app.use(express.static(distPath));
app.get(/^(?!\/api|\/posters)/, (_req, res) => { app.get(/^(?!\/api|\/posters)/, (_req, res) => {
+95
View File
@@ -0,0 +1,95 @@
import fs from 'fs';
import path from 'path';
import { parseFile } from 'music-metadata';
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.opus', '.wma']);
// Genre keywords → mood (case-insensitive substring match, first hit wins)
const UPBEAT_KEYS = ['rock', 'pop', 'dance', 'electronic', 'hip hop', 'hip-hop', 'r&b', 'rnb',
'reggae', 'funk', 'disco', 'punk', 'metal', 'latin', 'techno', 'house',
'edm', 'trap', 'country'];
const DREAMY_KEYS = ['ambient', 'classical', 'chill', 'lounge', 'jazz', 'new age', 'folk',
'acoustic', 'meditation', 'sleep', 'indie folk', 'singer-songwriter', 'blues',
'orchestral', 'soundtrack', 'cinematic'];
function classifyMood(bpm, genre) {
if (bpm != null) {
if (bpm >= 120) return 'upbeat';
if (bpm >= 80) return 'cinematic';
return 'dreamy';
}
if (genre) {
const g = genre.toLowerCase();
if (UPBEAT_KEYS.some(k => g.includes(k))) return 'upbeat';
if (DREAMY_KEYS.some(k => g.includes(k))) return 'dreamy';
return 'cinematic';
}
return 'general'; // no tags — all moods can use
}
function* walkDir(dir) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) yield* walkDir(full);
else if (AUDIO_EXTS.has(path.extname(e.name).toLowerCase())) yield full;
}
}
export let musicScanActive = false;
export function initMusicTable(db) {
db.raw.exec(`
CREATE TABLE IF NOT EXISTS music (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filepath TEXT NOT NULL UNIQUE,
title TEXT,
artist TEXT,
album TEXT,
bpm REAL,
genre TEXT,
duration REAL,
mood TEXT NOT NULL DEFAULT 'general',
scanned_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_music_mood ON music(mood);
`);
}
export async function scanMusicLibrary(musicDir, db) {
if (musicScanActive) return 0;
musicScanActive = true;
initMusicTable(db);
const hasTrack = db.raw.prepare('SELECT 1 FROM music WHERE filepath = ? LIMIT 1');
const insert = db.raw.prepare(`
INSERT OR IGNORE INTO music (filepath, title, artist, album, bpm, genre, duration, mood)
VALUES (@filepath, @title, @artist, @album, @bpm, @genre, @duration, @mood)
`);
let added = 0;
try {
for (const fp of walkDir(path.resolve(musicDir))) {
if (hasTrack.get(fp)) continue;
try {
const { common, format } = await parseFile(fp, { duration: true, skipCovers: true });
const bpm = common.bpm ?? null;
const genre = common.genre?.[0] ?? null;
insert.run({
filepath: fp,
title: common.title ?? path.basename(fp, path.extname(fp)),
artist: common.artist ?? null,
album: common.album ?? null,
bpm, genre, duration: format.duration ?? null,
mood: classifyMood(bpm, genre),
});
added++;
} catch {}
}
} finally {
musicScanActive = false;
}
return added;
}