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
+167
View File
@@ -0,0 +1,167 @@
"""Filesystem scanner for content already sitting on the NAS mounts —
covers movies/tv placed outside Radarr/Sonarr, and the music library
(nothing else in StreamHoard touches music). Read-only: never writes files,
just walks paths and reports what it finds. Callers (server.py) cross-check
results against the DB to decide what's new vs. already imported.
"""
import re
from pathlib import Path
from typing import Dict, List, Optional
from mutagen import File as MutagenFile
MOVIE_EXTS = {".mp4", ".mkv", ".avi", ".m4v", ".mov"}
AUDIO_EXTS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".ogg"}
MOVIES_DIR = Path("/mnt/nas/video/movies")
TV_DIR = Path("/mnt/nas/video/tv")
MUSIC_DIR = Path("/mnt/nas/music/Music")
_YEAR_RE = re.compile(r"[\[\(]\s*(\d{4})\s*[\]\)]")
_SEASON_DIR_RE = re.compile(r"season\s*(\d+)", re.IGNORECASE)
_EPISODE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,3})")
_TRACK_NUM_RE = re.compile(r"^(\d{1,3})[\s._-]+(.*)$")
def _clean_title(name: str) -> str:
name = _YEAR_RE.sub("", name)
return re.sub(r"\s+", " ", name).strip(" -_.")
def _parse_year(name: str) -> Optional[int]:
m = _YEAR_RE.search(name)
return int(m.group(1)) if m else None
def _largest_video_file(files: List[Path]) -> Optional[Path]:
vids = [f for f in files if f.suffix.lower() in MOVIE_EXTS]
if not vids:
return None
return max(vids, key=lambda f: f.stat().st_size)
def scan_movies() -> List[Dict]:
"""Each movies/ entry is either a standalone video file or a folder containing one."""
if not MOVIES_DIR.is_dir():
return []
out = []
for entry in sorted(MOVIES_DIR.iterdir()):
if entry.name.startswith("@") or entry.name.startswith("#"):
continue # Synology internal dirs (@eaDir, #recycle)
if entry.is_file():
if entry.suffix.lower() not in MOVIE_EXTS:
continue
title_raw = entry.stem
file_path = entry
elif entry.is_dir():
try:
files = [f for f in entry.rglob("*") if f.is_file()]
except OSError:
continue
file_path = _largest_video_file(files)
if not file_path:
continue
title_raw = entry.name
else:
continue
out.append({
"title": _clean_title(title_raw) or title_raw,
"year": _parse_year(title_raw),
"storage_path": str(file_path),
"size_bytes": file_path.stat().st_size,
})
return out
def scan_tv() -> List[Dict]:
"""tv/<Show>/Season N/<episode files>. Groups by show, one entry per episode."""
if not TV_DIR.is_dir():
return []
out = []
for show_dir in sorted(TV_DIR.iterdir()):
if not show_dir.is_dir() or show_dir.name.startswith("@") or show_dir.name.startswith("#"):
continue
show_title = _clean_title(show_dir.name) or show_dir.name
for season_dir in sorted(show_dir.iterdir()):
if not season_dir.is_dir():
continue
m = _SEASON_DIR_RE.search(season_dir.name)
season_number = int(m.group(1)) if m else 1
for f in sorted(season_dir.iterdir()):
if not f.is_file() or f.suffix.lower() not in MOVIE_EXTS:
continue
em = _EPISODE_RE.search(f.stem)
if em:
season_number = int(em.group(1))
episode_number = int(em.group(2))
title = _clean_title(f.stem[em.end():]) or f"Episode {episode_number}"
else:
episode_number = None
title = _clean_title(f.stem)
out.append({
"show_title": show_title,
"season_number": season_number,
"episode_number": episode_number,
"title": title,
"storage_path": str(f),
"size_bytes": f.stat().st_size,
})
return out
def _read_tags(path: Path) -> Dict:
try:
tags = MutagenFile(path, easy=True)
except Exception:
tags = None
duration = 0.0
artist = album = title = track_number = None
if tags is not None:
if getattr(tags, "info", None) is not None:
duration = getattr(tags.info, "length", 0.0) or 0.0
artist = (tags.get("artist") or [None])[0]
album = (tags.get("album") or [None])[0]
title = (tags.get("title") or [None])[0]
tn = (tags.get("tracknumber") or [None])[0]
if tn:
try:
track_number = int(str(tn).split("/")[0])
except ValueError:
track_number = None
return {"duration": duration, "artist": artist, "album": album, "title": title, "track_number": track_number}
def scan_music() -> List[Dict]:
"""Music/<Artist>/<Album>/<NN Title.ext>. Prefers embedded tags, falls back to path/filename."""
if not MUSIC_DIR.is_dir():
return []
out = []
for artist_dir in sorted(MUSIC_DIR.iterdir()):
if not artist_dir.is_dir() or artist_dir.name.startswith("@") or artist_dir.name.startswith("#"):
continue
for album_dir in sorted(artist_dir.iterdir()):
if not album_dir.is_dir():
continue
for f in sorted(album_dir.iterdir()):
if not f.is_file() or f.suffix.lower() not in AUDIO_EXTS:
continue
tags = _read_tags(f)
track_number = tags["track_number"]
title = tags["title"]
if not title:
fm = _TRACK_NUM_RE.match(f.stem)
if fm:
track_number = track_number or int(fm.group(1))
title = fm.group(2)
else:
title = f.stem
out.append({
"artist": tags["artist"] or artist_dir.name,
"album": tags["album"] or album_dir.name,
"title": _clean_title(title) or title,
"track_number": track_number,
"duration_seconds": round(tags["duration"], 1),
"storage_path": str(f),
"size_bytes": f.stat().st_size,
})
return out
+23
View File
@@ -119,6 +119,29 @@ class Movie(MovieBase):
created_at: str = Field(default_factory=_now_iso)
# ---------- Music ----------
class TrackUpdate(BaseModel):
title: Optional[str] = None
artist: Optional[str] = None
album: Optional[str] = None
track_number: Optional[int] = None
album_art_url: Optional[str] = None
class Track(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
title: str
artist: str = "Unknown Artist"
album: str = "Unknown Album"
album_art_url: str = ""
track_number: Optional[int] = None
duration_seconds: float = 0
storage_type: str = "scan" # scan is currently the only source for tracks
storage_path: str
created_at: str = Field(default_factory=_now_iso)
# ---------- Subtitles ----------
class Subtitle(BaseModel):
model_config = ConfigDict(extra="ignore")
+42
View File
@@ -0,0 +1,42 @@
"""MusicBrainz + Cover Art Archive clients — both free, no API key.
MusicBrainz rate limit is 1 req/sec for unauthenticated clients; callers must pace requests.
"""
import requests
from typing import Optional, Dict
_HEADERS = {"User-Agent": "StreamHoard/1.0 (self-hosted media library)"}
MB_BASE = "https://musicbrainz.org/ws/2"
COVER_ART_BASE = "https://coverartarchive.org"
def lookup_release(artist: str, album: str) -> Optional[Dict]:
"""Best-effort match for an artist+album against MusicBrainz's release index."""
query = f'release:"{album}" AND artist:"{artist}"'
try:
r = requests.get(f"{MB_BASE}/release/", params={"query": query, "fmt": "json", "limit": 1},
headers=_HEADERS, timeout=15)
r.raise_for_status()
releases = (r.json() or {}).get("releases") or []
except Exception:
return None
if not releases:
return None
rel = releases[0]
return {
"mbid": rel.get("id"),
"title": rel.get("title") or album,
"artist": (rel.get("artist-credit") or [{}])[0].get("name") or artist,
"year": (rel.get("date") or "")[:4] or None,
}
def cover_art_url(mbid: str) -> Optional[str]:
"""Returns the front cover URL if the Cover Art Archive has one for this release, else None."""
url = f"{COVER_ART_BASE}/release/{mbid}/front"
try:
r = requests.head(url, headers=_HEADERS, timeout=10, allow_redirects=True)
if r.status_code == 200:
return r.url
except Exception:
pass
return None
+1
View File
@@ -25,3 +25,4 @@ python-multipart>=0.0.9
jq>=1.6.0
typer>=0.9.0
docker>=7.1.0
mutagen>=1.47.0
+235 -10
View File
@@ -5,7 +5,7 @@ import mimetypes
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from typing import List, Optional
from typing import List, Optional, Dict
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks
from fastapi.responses import StreamingResponse, FileResponse, Response
@@ -27,6 +27,7 @@ from models import (
TranscodeJob, QueuePauseToggle,
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
Track, TrackUpdate,
)
from auth import hash_password, verify_password, create_token, decode_token
from seed import SAMPLE_MOVIES
@@ -34,6 +35,8 @@ import tmdb as tmdb_client
import radarr as radarr_client
import sonarr as sonarr_client
import services as services_client
import library_scan
import musicbrainz as musicbrainz_client
import trakt as trakt_client
import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, srt_to_vtt
@@ -489,11 +492,11 @@ async def stream_movie(
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
if movie.get("storage_type") == "radarr":
file_path = Path(movie["storage_path"]) # absolute path on Radarr-mounted storage
if movie.get("storage_type") in ("radarr", "scan"):
file_path = Path(movie["storage_path"]) # absolute path on NAS-mounted storage
else:
file_path = VIDEOS_DIR / movie["storage_path"]
if not file_path.is_file():
@@ -555,7 +558,7 @@ async def _process_job(job: dict):
{"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
if doc.get("storage_type") not in ("local", "radarr", "sonarr") or not doc.get("storage_path"):
if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
await db.transcode_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
@@ -563,7 +566,7 @@ async def _process_job(job: dict):
return
source = (
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr")
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr", "scan")
else VIDEOS_DIR / doc["storage_path"]
)
out_dir = HLS_DIR / job["movie_id"]
@@ -650,8 +653,8 @@ async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user:
raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'")
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr/scanned movies can be transcoded")
if movie.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
@@ -1184,9 +1187,9 @@ async def stream_episode(
decode_token(token)
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Episode has no local file")
file_path = Path(ep["storage_path"]) if ep["storage_type"] == "sonarr" else VIDEOS_DIR / ep["storage_path"]
file_path = Path(ep["storage_path"]) if ep["storage_type"] in ("sonarr", "scan") else VIDEOS_DIR / ep["storage_path"]
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing")
file_size = file_path.stat().st_size
content_type = mimetypes.guess_type(str(file_path))[0] or "video/mp4"
@@ -1339,6 +1342,228 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
return {"ok": True, "shows_imported": created_shows, "episodes_imported": created_eps}
# ============ LIBRARY SCAN (filesystem — separate from Radarr/Sonarr) ============
@api.get("/scan/movies")
async def scan_movies(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_movies)
imported_paths = set(await db.movies.distinct("storage_path", {"storage_type": {"$in": ["radarr", "scan", "local"]}}))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.get("/scan/tv")
async def scan_tv(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_tv)
imported_paths = set(await db.episodes.distinct("storage_path", {"storage_type": {"$in": ["sonarr", "scan", "local"]}}))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.get("/scan/music")
async def scan_music(user: dict = Depends(require_admin)):
found = await asyncio.to_thread(library_scan.scan_music)
imported_paths = set(await db.tracks.distinct("storage_path"))
for item in found:
item["already_imported"] = item["storage_path"] in imported_paths
return found
@api.post("/scan/import")
async def scan_import(payload: dict, user: dict = Depends(require_admin)):
"""Body: {kind: "movies"|"tv"|"music", items: [<scan result dicts as returned by GET /scan/*>]}"""
kind = payload.get("kind")
items = payload.get("items") or []
if kind not in ("movies", "tv", "music"):
raise HTTPException(status_code=400, detail="kind must be movies, tv, or music")
if not items:
raise HTTPException(status_code=400, detail="No items provided")
s = await get_settings()
auto = s.get("auto_transcode", "off")
created = 0
if kind == "movies":
for it in items:
if await db.movies.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
movie = Movie(
title=it.get("title") or "Untitled", year=it.get("year") or 2024,
storage_type="scan", storage_path=it["storage_path"],
).model_dump()
await db.movies.insert_one(movie)
if auto in ("quick", "abr"):
await _enqueue_transcode(movie["id"], auto, triggered_by="auto")
created += 1
elif kind == "tv":
shows_by_title: Dict[str, str] = {}
for it in items:
if await db.episodes.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
show_title = it.get("show_title") or "Untitled"
show_id = shows_by_title.get(show_title)
if not show_id:
existing = await db.shows.find_one({"title": show_title}, {"_id": 0})
if existing:
show_id = existing["id"]
else:
show_doc = Show(title=show_title, year=2024, rating="NR").model_dump()
await db.shows.insert_one(show_doc)
show_id = show_doc["id"]
shows_by_title[show_title] = show_id
edoc = Episode(
show_id=show_id, season_number=it.get("season_number") or 1,
episode_number=it.get("episode_number") or 0,
title=it.get("title") or "", storage_type="scan", storage_path=it["storage_path"],
).model_dump()
await db.episodes.insert_one(edoc)
if auto in ("quick", "abr"):
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode")
created += 1
for show_id in set(shows_by_title.values()):
seasons = await db.episodes.distinct("season_number", {"show_id": show_id})
ecount = await db.episodes.count_documents({"show_id": show_id})
await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}})
elif kind == "music":
for it in items:
if await db.tracks.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
continue
track = Track(
title=it.get("title") or "Untitled", artist=it.get("artist") or "Unknown Artist",
album=it.get("album") or "Unknown Album", track_number=it.get("track_number"),
duration_seconds=it.get("duration_seconds") or 0, storage_path=it["storage_path"],
).model_dump()
await db.tracks.insert_one(track)
created += 1
return {"ok": True, "imported": created}
# ============ MUSIC ============
@api.get("/tracks", response_model=List[Track])
async def list_tracks(user: dict = Depends(get_current_user)):
docs = await db.tracks.find({}, {"_id": 0}).sort([("artist", 1), ("album", 1), ("track_number", 1)]).to_list(10000)
return docs
@api.patch("/tracks/{track_id}", response_model=Track)
async def update_track(track_id: str, payload: TrackUpdate, user: dict = Depends(require_admin)):
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
res = await db.tracks.find_one_and_update(
{"id": track_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
)
if not res:
raise HTTPException(status_code=404, detail="Track not found")
return res
@api.delete("/tracks/{track_id}")
async def delete_track(track_id: str, user: dict = Depends(require_admin)):
res = await db.tracks.delete_one({"id": track_id})
if res.deleted_count == 0:
raise HTTPException(status_code=404, detail="Track not found")
return {"ok": True}
_music_enrich_status = {"running": False, "total": 0, "done": 0, "updated": 0, "error": ""}
async def _run_music_enrich():
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
try:
pairs = await db.tracks.aggregate([
{"$match": {"album_art_url": {"$in": [None, ""]}}},
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
]).to_list(5000)
_music_enrich_status["total"] = len(pairs)
for p in pairs:
artist = p["_id"]["artist"]; album = p["_id"]["album"]
try:
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
if release and release.get("mbid"):
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
if art_url:
res = await db.tracks.update_many(
{"artist": artist, "album": album},
{"$set": {"album_art_url": art_url}},
)
_music_enrich_status["updated"] += res.modified_count
except Exception as e:
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
_music_enrich_status["done"] += 1
await asyncio.sleep(1.1) # MusicBrainz rate limit: 1 req/sec, unauthenticated
except Exception as e:
_music_enrich_status["error"] = str(e)
finally:
_music_enrich_status["running"] = False
@api.post("/music/enrich")
async def start_music_enrich(user: dict = Depends(require_admin)):
if _music_enrich_status["running"]:
raise HTTPException(status_code=409, detail="Enrichment already running")
asyncio.create_task(_run_music_enrich())
return {"ok": True}
@api.get("/music/enrich/status")
async def music_enrich_status(user: dict = Depends(require_admin)):
return _music_enrich_status
@api.get("/stream/track/{track_id}")
async def stream_track(
track_id: str, request: Request,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
decode_token(token)
track = await db.tracks.find_one({"id": track_id}, {"_id": 0})
if not track: raise HTTPException(status_code=404, detail="Track not found")
file_path = Path(track["storage_path"])
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing on disk")
file_size = file_path.stat().st_size
content_type = mimetypes.guess_type(str(file_path))[0] or "audio/mpeg"
range_header = request.headers.get("range") or request.headers.get("Range")
if range_header and range_header.startswith("bytes="):
try:
start_str, end_str = range_header.replace("bytes=", "").split("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
raise HTTPException(status_code=416, detail="Invalid range")
if start >= file_size:
raise HTTPException(status_code=416, detail="Range out of bounds")
end = min(end, file_size - 1)
length = end - start + 1
def iter_file():
with file_path.open("rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk: break
remaining -= len(chunk)
yield chunk
return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
"Content-Type": content_type,
})
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
# ============ TRAKT ============
async def _trakt_get_token(user_id: str) -> Optional[dict]:
return await db.trakt_tokens.find_one({"user_id": user_id}, {"_id": 0})
+2
View File
@@ -46,6 +46,8 @@ services:
- ${MEDIA_ROOT_HOST:-./media}:/media
# Radarr/Sonarr report absolute file paths under this same path — must match exactly
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro
# NAS music library — used by the admin Library Scan (movies/tv/music), read-only
- ${MEDIA_NAS_MUSIC_ROOT:-/mnt/nas/music}:/mnt/nas/music:ro
# Docker socket — lets the admin Services panel check/restart sibling containers
- /var/run/docker.sock:/var/run/docker.sock
networks:
+11
View File
@@ -2,9 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./lib/auth";
import { ProfileProvider, useProfile } from "./lib/profile";
import { MusicPlayerProvider } from "./lib/musicPlayer";
import Navbar from "./components/Navbar";
import GrainOverlay from "./components/GrainOverlay";
import ServiceStatus from "./components/ServiceStatus";
import MusicPlayerBar from "./components/MusicPlayerBar";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Browse from "./pages/Browse";
@@ -23,6 +25,9 @@ import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport";
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
import Users from "./pages/Users";
import Music from "./pages/Music";
import AlbumDetail from "./pages/AlbumDetail";
import LibraryScan from "./pages/LibraryScan";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -53,6 +58,7 @@ const Shell = ({ children }) => {
{!hideChrome && user && active && <Navbar />}
{!hideChrome && user?.is_admin && active && <ServiceStatus />}
{children}
{!hideChrome && user && active && <MusicPlayerBar />}
<GrainOverlay />
</>
);
@@ -72,6 +78,7 @@ function App() {
<BrowserRouter>
<AuthProvider>
<ProfileProvider>
<MusicPlayerProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
@@ -86,11 +93,14 @@ function App() {
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
<Route path="/music" element={<ProfileGate><Music /></ProfileGate>} />
<Route path="/music/album/:artist/:album" element={<ProfileGate><AlbumDetail /></ProfileGate>} />
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -99,6 +109,7 @@ function App() {
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
</MusicPlayerProvider>
</ProfileProvider>
</AuthProvider>
</BrowserRouter>
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { Play, Pause, SkipBack, SkipForward, Music2, Speaker } from "lucide-react";
import { useMusicPlayer } from "../lib/musicPlayer";
const fmt = (s) => {
if (!s || !isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, "0");
return `${m}:${sec}`;
};
export default function MusicPlayerBar() {
const {
current, isPlaying, progress, togglePlay, next, prev, seek, hasNext, hasPrev,
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
} = useMusicPlayer();
const [showOutputs, setShowOutputs] = useState(false);
useEffect(() => {
if (showOutputs) refreshOutputDevices();
}, [showOutputs, refreshOutputDevices]);
if (!current) return null;
const pct = progress.duration ? (progress.position / progress.duration) * 100 : 0;
const onSeek = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
seek(ratio * (progress.duration || 0));
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-t border-[#222] px-4 md:px-8 py-3" data-testid="music-player-bar">
<div className="max-w-[1500px] mx-auto flex items-center gap-4">
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] flex items-center justify-center overflow-hidden">
{current.album_art_url ? <img src={current.album_art_url} alt="" className="w-full h-full object-cover" /> : <Music2 size={18} className="text-[#8A8A8A]" />}
</div>
<div className="min-w-0 w-40 md:w-56">
<p className="text-white text-sm truncate">{current.title}</p>
<p className="text-[#8A8A8A] text-xs truncate">{current.artist}</p>
</div>
<button onClick={prev} disabled={!hasPrev} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-prev">
<SkipBack size={16} />
</button>
<button onClick={togglePlay} className="w-8 h-8 flex items-center justify-center bg-[#D9381E] hover:bg-[#ED4B32] text-white" data-testid="music-play-pause">
{isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
</button>
<button onClick={next} disabled={!hasNext} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-next">
<SkipForward size={16} />
</button>
<span className="text-[10px] text-[#8A8A8A] w-9 text-right shrink-0">{fmt(progress.position)}</span>
<div className="flex-1 h-1 bg-white/10 cursor-pointer hidden sm:block" onClick={onSeek} data-testid="music-seek">
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-[#8A8A8A] w-9 shrink-0">{fmt(progress.duration)}</span>
{sinkSupported && (
<div className="relative shrink-0">
<button onClick={() => setShowOutputs((v) => !v)} className="text-[#8A8A8A] hover:text-white" title="Output device" data-testid="music-output-toggle">
<Speaker size={16} />
</button>
{showOutputs && (
<div className="absolute bottom-full right-0 mb-2 w-56 bg-[#0F0F0F] border border-[#222] py-1" data-testid="music-output-list">
{outputDevices.length === 0 && (
<p className="px-3 py-2 text-xs text-[#8A8A8A]">No output devices found</p>
)}
{outputDevices.map((d) => (
<button key={d.deviceId} onClick={() => { selectOutput(d.deviceId); setShowOutputs(false); }}
className={`w-full text-left px-3 py-2 text-xs truncate ${sinkId === d.deviceId ? "text-[#D9381E]" : "text-white hover:bg-white/5"}`}
data-testid={`music-output-${d.deviceId}`}>
{d.label}
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+1
View File
@@ -32,6 +32,7 @@ export const Navbar = () => {
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
<NavLink to="/music" className={linkClass} data-testid="nav-music">Music</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
{user.is_admin && <NavLink to="/admin/users" className={linkClass} data-testid="nav-users">Users</NavLink>}
+7 -1
View File
@@ -19,7 +19,7 @@ export const getStreamUrl = (movie) => {
if (movie.hls_status === "done" && movie.hls_path) {
return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`;
}
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
if (movie.storage_type === "local" || movie.storage_type === "radarr" || movie.storage_type === "scan") {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
}
return movie.video_url;
@@ -31,3 +31,9 @@ export const getSubtitleUrl = (sub) => {
const token = localStorage.getItem("kino_token") || "";
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
};
export const getTrackStreamUrl = (track) => {
if (!track) return "";
const token = localStorage.getItem("kino_token") || "";
return `${API}/stream/track/${track.id}?auth=${encodeURIComponent(token)}`;
};
+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);
+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>
);
}