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:
1 parent
d35836131a
commit
6f4e90a1c8
6 files changed
+493
-24
No files matched your search
@@ -195,17 +195,31 @@ export default function MemoriesView() {
|
||||
const [ssIndex, setSsIndex] = useState(0);
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const [trackIdx, setTrackIdx] = useState(0);
|
||||
const [scanInfo, setScanInfo] = useState(null);
|
||||
|
||||
const audioCtxRef = useRef(null);
|
||||
const musicRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemories()
|
||||
.then(d => setGroups(groupByYear(d.photos)))
|
||||
.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); }, []);
|
||||
|
||||
const allPhotos = groups ? groups.flatMap(g => g.photos) : [];
|
||||
@@ -230,10 +244,13 @@ export default function MemoriesView() {
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!musicRef.current || !audioCtxRef.current) return;
|
||||
const next = !muted;
|
||||
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 ──
|
||||
@@ -248,7 +265,8 @@ export default function MemoriesView() {
|
||||
|
||||
function startSlideshow(idx) {
|
||||
setSsIndex(idx); setOpacity(1); setSlideshow(true);
|
||||
startMusic(musicStyle);
|
||||
setTrackIdx(0);
|
||||
if (tracks.length === 0) startMusic(musicStyle); // synth fallback
|
||||
}
|
||||
|
||||
function stopSlideshow() {
|
||||
@@ -256,6 +274,7 @@ export default function MemoriesView() {
|
||||
setSlideshow(false);
|
||||
stopMusic();
|
||||
setMuted(false);
|
||||
if (audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -304,25 +323,46 @@ export default function MemoriesView() {
|
||||
{!noPhotos && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{/* Music style picker */}
|
||||
<div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}>
|
||||
{MUSIC_STYLES.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setMusicStyle(s.key)}
|
||||
title={s.label}
|
||||
style={{
|
||||
background: musicStyle === s.key ? '#1e2a3a' : 'transparent',
|
||||
border: 'none', borderRadius: 6,
|
||||
color: musicStyle === s.key ? '#7ab8f5' : '#555',
|
||||
cursor: 'pointer', padding: '5px 10px',
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}>
|
||||
{MUSIC_STYLES.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setMusicStyle(s.key)}
|
||||
title={s.label}
|
||||
style={{
|
||||
background: musicStyle === s.key ? '#1e2a3a' : 'transparent',
|
||||
border: 'none', borderRadius: 6,
|
||||
color: musicStyle === s.key ? '#7ab8f5' : '#555',
|
||||
cursor: 'pointer', padding: '5px 10px',
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</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 */}
|
||||
<div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* Dots */}
|
||||
@@ -427,6 +480,15 @@ export default function MemoriesView() {
|
||||
))}
|
||||
</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 }}>
|
||||
{style?.icon} {ssIndex + 1} / {allPhotos.length}
|
||||
</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
PORT: "3000"
|
||||
DB_PATH: /data/library.db
|
||||
POSTER_DIR: /data/posters
|
||||
MUSIC_DIR: /music
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- type: volume
|
||||
@@ -19,6 +20,10 @@ services:
|
||||
- type: bind
|
||||
source: /media/Plex/Gallery
|
||||
target: /photos
|
||||
- type: bind
|
||||
source: /DATA/Media/Music
|
||||
target: /music
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
|
||||
Generated
+229
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "shpm",
|
||||
"name": "luma",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shpm",
|
||||
"name": "luma",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^13.0.3",
|
||||
@@ -14,6 +14,7 @@
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^4.19.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"music-metadata": "^11.15.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
@@ -25,6 +26,16 @@
|
||||
"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": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
@@ -867,6 +878,52 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
@@ -1258,6 +1315,24 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1446,6 +1521,26 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -1884,6 +1979,86 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"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": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -2407,6 +2582,22 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2476,6 +2667,24 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2496,6 +2705,18 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -2613,6 +2834,12 @@
|
||||
"funding": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"exifr": "^7.1.3",
|
||||
"express": "^4.19.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"music-metadata": "^11.15.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user