Add filesystem Library Scan (movies/tv/music) and a full Music library

- New admin Library Scan page walks the NAS mounts directly for movies,
  TV, and music not already in Radarr/Sonarr/local storage, and imports
  them with storage_type=scan (fully integrated: streamable, transcodable,
  listed in Admin) — Radarr/Sonarr import flows are untouched.
- New Music section (nav tab, album-grid browse, per-album track list,
  persistent bottom player with queue/seek) backed by a Track model and
  range-request audio streaming endpoint.
- MusicBrainz + Cover Art Archive integration (free, no API key) to fill
  in album art for scanned tracks, paced to their 1 req/sec rate limit.
- Mounted the NAS music share into CT104 (new mp2 LXC mountpoint,
  CIFS read-only) alongside the existing video mount.
- Optional Bluetooth/output-device picker on the player bar via the Audio
  Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge
  only, since browsers can't pair new BT devices directly.
This commit is contained in:
Myron Blair
2026-07-26 20:25:41 -05:00
parent f7626ced2e
commit 514cbad4ab
15 changed files with 975 additions and 40 deletions
+92
View File
@@ -0,0 +1,92 @@
import { createContext, useContext, useRef, useState, useCallback, useEffect } from "react";
import { getTrackStreamUrl } from "./api";
const MusicPlayerCtx = createContext(null);
export const MusicPlayerProvider = ({ children }) => {
const audioRef = useRef(new Audio());
const [queue, setQueue] = useState([]);
const [index, setIndex] = useState(-1);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState({ position: 0, duration: 0 });
const [outputDevices, setOutputDevices] = useState([]);
const [sinkId, setSinkId] = useState("default");
const sinkSupported = typeof audioRef.current.setSinkId === "function";
const current = index >= 0 ? queue[index] : null;
const refreshOutputDevices = useCallback(async () => {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outs = devices.filter((d) => d.kind === "audiooutput");
setOutputDevices(outs.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Output ${i + 1}` })));
} catch { /* enumeration unsupported/denied — picker just won't populate */ }
}, []);
const selectOutput = useCallback(async (deviceId) => {
if (!sinkSupported) return;
try {
await audioRef.current.setSinkId(deviceId);
setSinkId(deviceId);
} catch { /* device may have disappeared (e.g. BT speaker turned off) — keep current output */ }
}, [sinkSupported]);
const playAt = useCallback((newQueue, startIndex) => {
setQueue(newQueue);
setIndex(startIndex);
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!current) { audio.pause(); return; }
audio.src = getTrackStreamUrl(current);
audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
}, [current]);
useEffect(() => {
const audio = audioRef.current;
const onTime = () => setProgress({ position: audio.currentTime, duration: audio.duration || 0 });
const onEnded = () => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
else setIsPlaying(false);
};
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded);
return () => {
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded);
};
}, [index, queue]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!current) return;
if (audio.paused) audio.play().then(() => setIsPlaying(true));
else { audio.pause(); setIsPlaying(false); }
}, [current]);
const next = useCallback(() => {
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
}, [index, queue]);
const prev = useCallback(() => {
if (index > 0) setIndex(index - 1);
}, [index]);
const seek = useCallback((seconds) => {
audioRef.current.currentTime = seconds;
}, []);
return (
<MusicPlayerCtx.Provider value={{
current, isPlaying, progress, playAt, togglePlay, next, prev, seek,
hasNext: index < queue.length - 1, hasPrev: index > 0,
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
}}>
{children}
</MusicPlayerCtx.Provider>
);
};
export const useMusicPlayer = () => useContext(MusicPlayerCtx);