mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
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:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user