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
+5 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
@@ -85,6 +85,9 @@ export default function Admin() {
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
@@ -119,7 +122,7 @@ export default function Admin() {
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2">
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && (
{(m.storage_type === "local" || m.storage_type === "radarr" || m.storage_type === "scan") && m.hls_status !== "running" && m.hls_status !== "pending" && (
<div className="flex gap-1">
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
title="Quick transcode (stream-copy, single bitrate)"
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Play, Pause, ArrowLeft, Music2 } from "lucide-react";
import { useMusicPlayer } from "../lib/musicPlayer";
export default function AlbumDetail() {
const { artist, album } = useParams();
const nav = useNavigate();
const [tracks, setTracks] = useState([]);
const { current, isPlaying, togglePlay, playAt } = useMusicPlayer();
const artistName = decodeURIComponent(artist);
const albumName = decodeURIComponent(album);
useEffect(() => {
api.get("/tracks").then(({ data }) => setTracks(data));
}, []);
const albumTracks = useMemo(
() => tracks.filter((t) => t.artist === artistName && t.album === albumName)
.sort((a, b) => (a.track_number || 0) - (b.track_number || 0)),
[tracks, artistName, albumName]
);
const artUrl = albumTracks.find((t) => t.album_art_url)?.album_art_url;
const playTrack = (t) => {
const idx = albumTracks.findIndex((x) => x.id === t.id);
if (current?.id === t.id) togglePlay();
else playAt(albumTracks, idx);
};
if (albumTracks.length === 0) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading</div>;
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid="album-detail-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<button onClick={() => nav(-1)} className="flex items-center gap-2 text-white/80 hover:text-white mb-8" data-testid="album-back">
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button>
<div className="flex items-center gap-6 mb-10">
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] flex items-center justify-center overflow-hidden">
{artUrl ? <img src={artUrl} alt="" className="w-full h-full object-cover" /> : <Music2 size={36} strokeWidth={1.5} className="text-[#8A8A8A]" />}
</div>
<div>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Album</span>
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2">{albumName}</h1>
<p className="text-[#8A8A8A] mt-2">{artistName} · {albumTracks.length} tracks</p>
</div>
</div>
<div className="space-y-2">
{albumTracks.map((t) => {
const playing = current?.id === t.id;
return (
<button key={t.id} onClick={() => playTrack(t)}
className={`w-full flex items-center gap-4 p-3 border transition-colors text-left group ${playing ? "border-[#D9381E] bg-[#0F0F0F]" : "border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F]"}`}
data-testid={`track-${t.id}`}>
<div className="w-9 h-9 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
{playing && isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
</div>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] w-6">{t.track_number || "-"}</span>
<h3 className="font-display text-base text-white truncate flex-1">{t.title}</h3>
{t.duration_seconds > 0 && <span className="text-xs text-[#8A8A8A]">{Math.floor(t.duration_seconds / 60)}:{Math.floor(t.duration_seconds % 60).toString().padStart(2, "0")}</span>}
</button>
);
})}
</div>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, Download, Check, Sparkles } from "lucide-react";
const KINDS = [
{ key: "movies", label: "Movies" },
{ key: "tv", label: "TV Shows" },
{ key: "music", label: "Music" },
];
export default function LibraryScan() {
const [kind, setKind] = useState("movies");
const [results, setResults] = useState([]);
const [selected, setSelected] = useState(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [enrich, setEnrich] = useState(null);
const load = async (k = kind) => {
setLoading(true);
setSelected(new Set());
try {
const { data } = await api.get(`/scan/${k}`);
setResults(data);
} catch (err) {
toast.error(err.response?.data?.detail || "Scan failed");
} finally {
setLoading(false);
}
};
useEffect(() => { load(kind); }, [kind]);
useEffect(() => {
if (!enrich?.running) return;
const t = setInterval(async () => {
const { data } = await api.get("/music/enrich/status");
setEnrich(data);
if (!data.running) clearInterval(t);
}, 2000);
return () => clearInterval(t);
}, [enrich?.running]);
const toggle = (path) => {
const s = new Set(selected);
s.has(path) ? s.delete(path) : s.add(path);
setSelected(s);
};
const importable = results.filter((r) => !r.already_imported);
const importSelected = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const items = results.filter((r) => selected.has(r.storage_path));
const { data } = await api.post("/scan/import", { kind, items });
toast.success(`Imported ${data.imported} item(s)`);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Import failed");
} finally {
setImporting(false);
}
};
const startEnrich = async () => {
try {
await api.post("/music/enrich");
const { data } = await api.get("/music/enrich/status");
setEnrich(data);
toast.success("Fetching metadata & covers from MusicBrainz…");
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start enrichment");
}
};
const label = (r) => {
if (kind === "movies") return `${r.title}${r.year ? ` (${r.year})` : ""}`;
if (kind === "tv") return `${r.show_title} · S${r.season_number}E${r.episode_number ?? "?"} · ${r.title}`;
return `${r.artist}${r.album}${r.title}`;
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-scan-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Filesystem</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Scan</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Scans the NAS mounts directly for movies, TV, and music that aren't already in the library separate from Radarr/Sonarr, which are untouched.
</p>
<div className="mt-8 flex gap-3 flex-wrap">
{KINDS.map((k) => (
<button key={k.key} onClick={() => setKind(k.key)}
className={`text-sm px-4 py-2 border transition-colors ${kind === k.key ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
data-testid={`scan-tab-${k.key}`}>
{k.label}
</button>
))}
</div>
<div className="mt-6 flex gap-3 flex-wrap items-center">
<button onClick={() => load()} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Rescan
</button>
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-import-btn">
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
</button>
{kind === "music" && (
<button onClick={startEnrich} disabled={enrich?.running} className="flex items-center gap-2 border border-white/10 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-enrich-btn">
<Sparkles size={14} strokeWidth={1.5} /> {enrich?.running ? `Fetching covers… ${enrich.done}/${enrich.total}` : "Fetch metadata & covers"}
</button>
)}
</div>
<p className="text-[#8A8A8A] mt-6 text-sm">
{loading ? "Scanning…" : `${importable.length} new of ${results.length} found on disk.`}
</p>
<div className="mt-6 border border-[#222]">
{results.map((r) => (
<button key={r.storage_path} onClick={() => !r.already_imported && toggle(r.storage_path)}
disabled={r.already_imported}
className={`w-full flex items-center gap-4 px-5 py-3 border-b border-[#222] last:border-b-0 text-left transition-colors ${
r.already_imported ? "opacity-40 cursor-not-allowed" : selected.has(r.storage_path) ? "bg-[#D9381E]/10" : "hover:bg-[#0F0F0F]"
}`}
data-testid={`scan-row-${r.storage_path}`}>
<div className={`w-5 h-5 shrink-0 border flex items-center justify-center ${selected.has(r.storage_path) ? "bg-[#D9381E] border-[#D9381E]" : "border-[#222]"}`}>
{selected.has(r.storage_path) && <Check size={12} strokeWidth={2} color="white" />}
</div>
<span className="text-white text-sm truncate flex-1">{label(r)}</span>
{r.already_imported && <span className="text-[10px] uppercase tracking-[0.2em] text-[#86efac] shrink-0">Imported</span>}
</button>
))}
{results.length === 0 && !loading && (
<div className="px-5 py-8 text-center text-[#8A8A8A] text-sm">Nothing found on disk for this category.</div>
)}
</div>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Music2 } from "lucide-react";
export default function Music() {
const [tracks, setTracks] = useState([]);
const nav = useNavigate();
useEffect(() => {
api.get("/tracks").then(({ data }) => setTracks(data));
}, []);
const albums = useMemo(() => {
const map = new Map();
for (const t of tracks) {
const key = `${t.artist}|||${t.album}`;
if (!map.has(key)) map.set(key, { artist: t.artist, album: t.album, album_art_url: t.album_art_url, count: 0 });
map.get(key).count += 1;
}
return Array.from(map.values()).sort((a, b) => a.artist.localeCompare(b.artist) || a.album.localeCompare(b.album));
}, [tracks]);
if (tracks.length === 0) {
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-empty-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
<Music2 size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No music yet</h1>
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Run a Library Scan in Admin to import tracks from the NAS.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Music</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Music</h1>
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{albums.map((a) => (
<button key={`${a.artist}|||${a.album}`}
onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)}
className="group relative aspect-square overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`album-card-${a.artist}-${a.album}`}>
{a.album_art_url ? (
<img src={a.album_art_url} alt={a.album} loading="lazy" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center"><Music2 size={32} strokeWidth={1.5} className="text-[#8A8A8A]" /></div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<p className="text-white text-sm truncate">{a.album}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</p>
</div>
</button>
))}
</div>
</div>
</div>
);
}