diff --git a/backend/library_scan.py b/backend/library_scan.py new file mode 100644 index 0000000..6b00b4c --- /dev/null +++ b/backend/library_scan.py @@ -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//Season N/. 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///. 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 diff --git a/backend/models.py b/backend/models.py index 10baa38..c350274 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/musicbrainz.py b/backend/musicbrainz.py new file mode 100644 index 0000000..71bd667 --- /dev/null +++ b/backend/musicbrainz.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 549b966..e60d5e4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/server.py b/backend/server.py index 06bab66..c7ad3c3 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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: []}""" + 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}) diff --git a/docker-compose.yml b/docker-compose.yml index 91fb318..0aa349c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.js b/frontend/src/App.js index 5b1c7c7..29f3a11 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 && } {!hideChrome && user?.is_admin && active && } {children} + {!hideChrome && user && active && } ); @@ -72,33 +78,38 @@ function App() { - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + diff --git a/frontend/src/components/MusicPlayerBar.jsx b/frontend/src/components/MusicPlayerBar.jsx new file mode 100644 index 0000000..4ee0dc6 --- /dev/null +++ b/frontend/src/components/MusicPlayerBar.jsx @@ -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 ( +
+
+
+ {current.album_art_url ? : } +
+
+

{current.title}

+

{current.artist}

+
+ + + + {fmt(progress.position)} +
+
+
+ {fmt(progress.duration)} + + {sinkSupported && ( +
+ + {showOutputs && ( +
+ {outputDevices.length === 0 && ( +

No output devices found

+ )} + {outputDevices.map((d) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 4bf5a94..5e20cc6 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -32,6 +32,7 @@ export const Navbar = () => { Library Series Shelf + Music Wishlist {user.is_admin && Admin} {user.is_admin && Users} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 6c2d436..3d85713 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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)}`; +}; diff --git a/frontend/src/lib/musicPlayer.jsx b/frontend/src/lib/musicPlayer.jsx new file mode 100644 index 0000000..36a605f --- /dev/null +++ b/frontend/src/lib/musicPlayer.jsx @@ -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 ( + 0, + outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput, + }}> + {children} + + ); +}; + +export const useMusicPlayer = () => useContext(MusicPlayerCtx); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 0fe1eff..60c5eba 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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() { Radarr Import + + Library Scan + Queue @@ -119,7 +122,7 @@ export default function Admin() { {m.storage_type} {hlsStatusBadge(m)}
- {(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" && (
+ +
+
+ {artUrl ? : } +
+
+ Album +

{albumName}

+

{artistName} · {albumTracks.length} tracks

+
+
+ +
+ {albumTracks.map((t) => { + const playing = current?.id === t.id; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/pages/LibraryScan.jsx b/frontend/src/pages/LibraryScan.jsx new file mode 100644 index 0000000..92a7ec5 --- /dev/null +++ b/frontend/src/pages/LibraryScan.jsx @@ -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 ( +
+
+ Filesystem +

Library Scan

+

+ Scans the NAS mounts directly for movies, TV, and music that aren't already in the library — separate from Radarr/Sonarr, which are untouched. +

+ +
+ {KINDS.map((k) => ( + + ))} +
+ +
+ + + {kind === "music" && ( + + )} +
+ +

+ {loading ? "Scanning…" : `${importable.length} new of ${results.length} found on disk.`} +

+ +
+ {results.map((r) => ( + + ))} + {results.length === 0 && !loading && ( +
Nothing found on disk for this category.
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Music.jsx b/frontend/src/pages/Music.jsx new file mode 100644 index 0000000..6f9f6e1 --- /dev/null +++ b/frontend/src/pages/Music.jsx @@ -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 ( +
+
+ +

No music yet

+

Run a Library Scan in Admin to import tracks from the NAS.

+
+
+ ); + } + + return ( +
+
+ Music +

Your Music

+ +
+ {albums.map((a) => ( + + ))} +
+
+
+ ); +}