diff --git a/companion-api/docker-compose.yml b/companion-api/docker-compose.yml index 6402f62..15a2631 100644 --- a/companion-api/docker-compose.yml +++ b/companion-api/docker-compose.yml @@ -12,7 +12,6 @@ services: - ND_BASEURL=/navidrome # ... other env vars ... volumes: - # Absolute path to your music - /home/pi/navidrome:/music:ro - ./navidrome_data:/data @@ -23,15 +22,18 @@ services: ports: - "8000:8000" volumes: - # Mount the ACTUAL music directory — not the parent navidrome folder - # Host: /home/pi/navidrome/music → Container: /music - /home/pi/navidrome/music:/music:rw - # Persistent data: Smart DJ DB + visualizer frame cache - ./companion_data:/app/data + # Phase 1: separate named volumes for cover art and artist photos + # These persist across container rebuilds + - companion_cover_art:/app/data/cover_art + - companion_artist_photos:/app/data/artist_photos environment: - MUSIC_DIR=/music - DB_PATH=/app/data/smart_dj.db - VIS_CACHE_DIR=/app/data/vis_cache + - COVER_ART_DIR=/app/data/cover_art + - ARTIST_PHOTO_DIR=/app/data/artist_photos - NAVIDROME_URL=http://navidrome:4533/navidrome - SUBSONIC_USER=dallas - SUBSONIC_TOKEN=your_token @@ -43,3 +45,7 @@ services: memory: 1G depends_on: - navidrome + +volumes: + companion_cover_art: + companion_artist_photos: diff --git a/companion-api/main.py b/companion-api/main.py index a397894..bdd3c7f 100644 --- a/companion-api/main.py +++ b/companion-api/main.py @@ -1,134 +1,405 @@ """ Navidrome Companion API -Location: /home/pi/docker/navidrome/companion_api/main.py -Endpoints: - GET /health +Endpoints (existing - unchanged): + GET /health + POST /reindex PATCH /edit-metadata - PATCH /batch-edit-metadata — edit tags on multiple files at once - POST /upload-track — single file upload - POST /upload-tracks — multi-file batch upload with per-track metadata - GET /smart-dj/profile - GET /smart-dj/bulk-profiles - POST /bulk-fix - GET /visualizer/frames - POST /visualizer/precompute - WS /ws/push + PATCH /batch-edit-metadata + POST /upload-track + POST /upload-tracks + GET /smart-dj/profile + GET /smart-dj/bulk-profiles + POST /bulk-fix + GET /visualizer/frames + POST /visualizer/precompute + WS /ws/push + +Endpoints (Phase 1 - library database): + POST /library/scan + POST /library/sync-navidrome-ids + GET /library/songs + GET /library/albums + GET /library/artists + GET /library/search + GET /library/song/{song_id} + GET /library/cover-art/{song_id} + POST /library/cover-art/{song_id} + POST /library/artist-photo + GET /library/artist-photo/{artist_name} """ import os, re, json, asyncio, hashlib, sqlite3, subprocess, shutil, time, warnings from pathlib import Path from typing import Optional, List from contextlib import asynccontextmanager from urllib.parse import unquote +from datetime import datetime import httpx import numpy as np from fastapi import (FastAPI, HTTPException, UploadFile, File, Form, BackgroundTasks, WebSocket, WebSocketDisconnect, Query) -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, FileResponse from pydantic import BaseModel warnings.filterwarnings("ignore", category=UserWarning) warnings.filterwarnings("ignore", category=FutureWarning) from mutagen import File as MutagenFile -MUSIC_DIR = os.getenv("MUSIC_DIR", "/music") -DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db") -VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache") -NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome") -SUBSONIC_USER = os.getenv("SUBSONIC_USER") -SUBSONIC_TOKEN = os.getenv("SUBSONIC_TOKEN") -SUBSONIC_SALT = os.getenv("SUBSONIC_SALT") +MUSIC_DIR = os.getenv("MUSIC_DIR", "/music") +DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db") +VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache") +COVER_ART_DIR = os.getenv("COVER_ART_DIR", "/app/data/cover_art") +ARTIST_PHOTO_DIR = os.getenv("ARTIST_PHOTO_DIR", "/app/data/artist_photos") +NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome") +SUBSONIC_USER = os.getenv("SUBSONIC_USER") +SUBSONIC_TOKEN = os.getenv("SUBSONIC_TOKEN") +SUBSONIC_SALT = os.getenv("SUBSONIC_SALT") + +AUDIO_EXTS = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav', '.aiff', '.aif') +COVER_NAMES = ('folder.jpg', 'cover.jpg', 'artwork.jpg', 'front.jpg', + 'folder.png', 'cover.png', 'artwork.png', 'front.png') -# ── DB ───────────────────────────────────────────────────── +# ── Database ──────────────────────────────────────────────────────────────── + def init_db(): os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + os.makedirs(COVER_ART_DIR, exist_ok=True) + os.makedirs(ARTIST_PHOTO_DIR, exist_ok=True) with sqlite3.connect(DB_PATH) as c: + # Existing tables — untouched c.execute("""CREATE TABLE IF NOT EXISTS dj_profiles ( file_path TEXT PRIMARY KEY, bpm REAL, - silence_start REAL, silence_end REAL, - loudness_lufs REAL, + silence_start REAL, silence_end REAL, loudness_lufs REAL, analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""") c.execute("""CREATE TABLE IF NOT EXISTS file_index ( - basename TEXT, - full_path TEXT, - title_words TEXT, + basename TEXT, full_path TEXT, title_words TEXT, PRIMARY KEY (basename, full_path))""") + # Phase 1 — authoritative song metadata + c.execute("""CREATE TABLE IF NOT EXISTS songs ( + id TEXT PRIMARY KEY, + full_path TEXT UNIQUE NOT NULL, + relative_path TEXT NOT NULL, + navidrome_id TEXT, + title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + album_artist TEXT NOT NULL DEFAULT '', + genre TEXT NOT NULL DEFAULT '', + year INTEGER, + track_number INTEGER, + disc_number INTEGER, + duration REAL, + sort_title TEXT, + sort_artist TEXT, + sort_album TEXT, + sort_album_artist TEXT, + cover_art_path TEXT, + file_size INTEGER, + file_mtime REAL, + date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + date_modified TIMESTAMP)""") + + c.execute("""CREATE TABLE IF NOT EXISTS artist_photos ( + artist_name TEXT PRIMARY KEY, + photo_path TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""") + + c.execute("CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(sort_album, disc_number, track_number)") + c.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(sort_artist, sort_album)") + c.execute("CREATE INDEX IF NOT EXISTS idx_songs_album_artist ON songs(sort_album_artist, sort_album)") + c.execute("CREATE INDEX IF NOT EXISTS idx_songs_navidrome ON songs(navidrome_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre)") + + +# ── Sort helpers ──────────────────────────────────────────────────────────── + +_ARTICLES = re.compile(r'^(the|a|an)\s+', re.IGNORECASE) + +def sort_key(text: str) -> str: + if not text: + return '' + return _ARTICLES.sub('', text).lower().strip() + + +# ── Cover art discovery ───────────────────────────────────────────────────── + +def find_cover_art(song_path: str) -> Optional[str]: + directory = os.path.dirname(song_path) + for name in COVER_NAMES: + candidate = os.path.join(directory, name) + if os.path.isfile(candidate): + return candidate + try: + for f in os.listdir(directory): + if f.lower().endswith(('.jpg', '.jpeg', '.png')): + return os.path.join(directory, f) + except OSError: + pass + # Extract embedded art and cache it + song_id = hashlib.md5(song_path.encode()).hexdigest() + cached = os.path.join(COVER_ART_DIR, f"{song_id}.jpg") + if os.path.isfile(cached): + return cached + try: + audio = MutagenFile(song_path) + if audio is None: + return None + if hasattr(audio, 'tags') and audio.tags: + for key in list(audio.tags.keys()): + if key.startswith('APIC'): + with open(cached, 'wb') as f: + f.write(audio.tags[key].data) + return cached + try: + from mutagen.mp4 import MP4 + if isinstance(audio, MP4): + covers = audio.tags.get('covr', []) + if covers: + with open(cached, 'wb') as f: + f.write(bytes(covers[0])) + return cached + except Exception: + pass + if hasattr(audio, 'pictures') and audio.pictures: + with open(cached, 'wb') as f: + f.write(audio.pictures[0].data) + return cached + except Exception: + pass + return None + + +# ── Tag reader ────────────────────────────────────────────────────────────── + +def read_tags(full_path: str) -> dict: + try: + audio = MutagenFile(full_path, easy=True) + except Exception: + audio = None + + def get(key): + if audio and key in audio and audio[key]: + return audio[key][0] + return '' + + title = get('title') or Path(full_path).stem + artist = get('artist') or 'Unknown Artist' + album = get('album') or 'Unknown Album' + album_artist = get('albumartist') or artist + genre = get('genre') or '' + + year = None + raw = get('date') + if raw: + m = re.search(r'\d{4}', raw) + if m: + year = int(m.group()) + + track_number = None + raw = get('tracknumber') + if raw: + m = re.match(r'(\d+)', raw) + if m: + track_number = int(m.group(1)) + + disc_number = None + raw = get('discnumber') + if raw: + m = re.match(r'(\d+)', raw) + if m: + disc_number = int(m.group(1)) + + duration = None + if audio and hasattr(audio, 'info') and audio.info: + try: + duration = float(audio.info.length) + except Exception: + pass + + return dict(title=title, artist=artist, album=album, album_artist=album_artist, + genre=genre, year=year, track_number=track_number, + disc_number=disc_number, duration=duration) + + +# ── Library scan ───────────────────────────────────────────────────────────── + +def scan_library(full_rescan: bool = False) -> int: + """Walk MUSIC_DIR and upsert every audio file into the songs table.""" + print(f"Library scan started (full={full_rescan})...", flush=True) + count = skipped = 0 + with sqlite3.connect(DB_PATH) as c: + for root, dirs, files in os.walk(MUSIC_DIR): + dirs[:] = [d for d in dirs if not d.startswith('.')] + for filename in files: + if not filename.lower().endswith(AUDIO_EXTS): + continue + full_path = os.path.join(root, filename) + try: + stat = os.stat(full_path) + mtime = stat.st_mtime + fsize = stat.st_size + except OSError: + continue + + song_id = hashlib.md5(full_path.encode()).hexdigest() + relative = os.path.relpath(full_path, MUSIC_DIR) + + if not full_rescan: + row = c.execute( + "SELECT file_mtime FROM songs WHERE id = ?", (song_id,) + ).fetchone() + if row and row[0] and abs(row[0] - mtime) < 1.0: + skipped += 1 + continue + + tags = read_tags(full_path) + cover = find_cover_art(full_path) + + c.execute("""INSERT OR REPLACE INTO songs ( + id, full_path, relative_path, + title, artist, album, album_artist, genre, + year, track_number, disc_number, duration, + sort_title, sort_artist, sort_album, sort_album_artist, + cover_art_path, file_size, file_mtime, date_modified + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( + song_id, full_path, relative, + tags['title'], tags['artist'], tags['album'], tags['album_artist'], + tags['genre'], tags['year'], tags['track_number'], tags['disc_number'], + tags['duration'], + sort_key(tags['title']), sort_key(tags['artist']), + sort_key(tags['album']), sort_key(tags['album_artist']), + cover, fsize, mtime, datetime.utcnow().isoformat() + )) + count += 1 + + print(f"Library scan: {count} upserted, {skipped} unchanged", flush=True) + return count + def build_file_index(): - """Scan MUSIC_DIR and index every audio file by basename and title words.""" - exts = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav') with sqlite3.connect(DB_PATH) as c: c.execute("DELETE FROM file_index") count = 0 for root, _, files in os.walk(MUSIC_DIR): for f in files: - if f.lower().endswith(exts): - fp = os.path.join(root, f) - stem = Path(f).stem.lower() - words = set(re.split(r'[\s\-_\.]+', stem)) - words = {w for w in words if len(w) > 1 and not w.isdigit()} + if f.lower().endswith(AUDIO_EXTS): + fp = os.path.join(root, f) + stem = Path(f).stem.lower() + words = {w for w in re.split(r'[\s\-_\.]+', stem) + if len(w) > 1 and not w.isdigit()} c.execute("INSERT OR REPLACE INTO file_index VALUES (?,?,?)", (f.lower(), fp, " ".join(sorted(words)))) count += 1 - print(f" File index built: {count} files", flush=True) + print(f"File index built: {count} files", flush=True) +def update_song_in_db(full_path: str): + """Re-read tags and update the songs row. Inserts if missing.""" + song_id = hashlib.md5(full_path.encode()).hexdigest() + relative = os.path.relpath(full_path, MUSIC_DIR) + tags = read_tags(full_path) + cover = find_cover_art(full_path) + try: + stat = os.stat(full_path) + mtime = stat.st_mtime + fsize = stat.st_size + except OSError: + mtime = fsize = None + + with sqlite3.connect(DB_PATH) as c: + c.execute("""UPDATE songs SET + title=?, artist=?, album=?, album_artist=?, genre=?, + year=?, track_number=?, disc_number=?, duration=?, + sort_title=?, sort_artist=?, sort_album=?, sort_album_artist=?, + cover_art_path=?, file_size=?, file_mtime=?, date_modified=? + WHERE id=?""", ( + tags['title'], tags['artist'], tags['album'], tags['album_artist'], + tags['genre'], tags['year'], tags['track_number'], tags['disc_number'], + tags['duration'], + sort_key(tags['title']), sort_key(tags['artist']), + sort_key(tags['album']), sort_key(tags['album_artist']), + cover, fsize, mtime, datetime.utcnow().isoformat(), song_id + )) + if c.rowcount == 0: + c.execute("""INSERT OR REPLACE INTO songs ( + id, full_path, relative_path, + title, artist, album, album_artist, genre, + year, track_number, disc_number, duration, + sort_title, sort_artist, sort_album, sort_album_artist, + cover_art_path, file_size, file_mtime, date_modified + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( + song_id, full_path, relative, + tags['title'], tags['artist'], tags['album'], tags['album_artist'], + tags['genre'], tags['year'], tags['track_number'], tags['disc_number'], + tags['duration'], + sort_key(tags['title']), sort_key(tags['artist']), + sort_key(tags['album']), sort_key(tags['album_artist']), + cover, fsize, mtime, datetime.utcnow().isoformat() + )) + + +# ── Startup ───────────────────────────────────────────────────────────────── + @asynccontextmanager async def lifespan(app: FastAPI): init_db() os.makedirs(VIS_CACHE_DIR, exist_ok=True) - print(f"🎵 Companion API ready", flush=True) - print(f" MUSIC_DIR = {MUSIC_DIR}", flush=True) - print(f" DB_PATH = {DB_PATH}", flush=True) - print(f" VIS_CACHE = {VIS_CACHE_DIR}", flush=True) + print(f"Companion API ready", flush=True) + print(f" MUSIC_DIR = {MUSIC_DIR}", flush=True) + print(f" DB_PATH = {DB_PATH}", flush=True) + print(f" VIS_CACHE = {VIS_CACHE_DIR}", flush=True) + print(f" COVER_ART = {COVER_ART_DIR}", flush=True) + print(f" ARTIST_PHOTO = {ARTIST_PHOTO_DIR}", flush=True) try: - top = os.listdir(MUSIC_DIR) - dirs = [d for d in top if os.path.isdir(os.path.join(MUSIC_DIR, d))] - print(f" MUSIC_DIR has {len(dirs)} folders at top level", flush=True) + dirs = [d for d in os.listdir(MUSIC_DIR) + if os.path.isdir(os.path.join(MUSIC_DIR, d))] + print(f" MUSIC_DIR has {len(dirs)} top-level folders", flush=True) except Exception as e: - print(f" ⚠️ Cannot list MUSIC_DIR: {e}", flush=True) + print(f" Cannot list MUSIC_DIR: {e}", flush=True) build_file_index() + scan_library() yield app = FastAPI(title="Navidrome Companion API", lifespan=lifespan) -# ── Models ───────────────────────────────────────────────── +# ── Pydantic models ────────────────────────────────────────────────────────── + class MetadataUpdate(BaseModel): - relative_path: str - title: Optional[str] = None - artist: Optional[str] = None - album: Optional[str] = None - album_artist: Optional[str] = None - genre: Optional[str] = None - year: Optional[int] = None - track_number: Optional[int] = None + relative_path: str + title: Optional[str] = None + artist: Optional[str] = None + album: Optional[str] = None + album_artist: Optional[str] = None + genre: Optional[str] = None + year: Optional[int] = None + track_number: Optional[int] = None class BatchMetadataUpdate(BaseModel): - """Edit the same tags on multiple files at once.""" relative_paths: List[str] - title: Optional[str] = None - artist: Optional[str] = None - album: Optional[str] = None - album_artist: Optional[str] = None - genre: Optional[str] = None - year: Optional[int] = None + title: Optional[str] = None + artist: Optional[str] = None + album: Optional[str] = None + album_artist: Optional[str] = None + genre: Optional[str] = None + year: Optional[int] = None class TrackUploadMeta(BaseModel): - """Per-track metadata for multi-file upload.""" - filename: str - title: str - artist: str - album: str + filename: str + title: str + artist: str + album: str track_number: Optional[int] = None - genre: Optional[str] = None - year: Optional[int] = None + genre: Optional[str] = None + year: Optional[int] = None + album_artist: Optional[str] = None -# ── Push Manager (WebSocket) ────────────────────────────── +# ── Push manager ───────────────────────────────────────────────────────────── + class PushManager: def __init__(self): self.connections: list[WebSocket] = [] @@ -136,20 +407,20 @@ class PushManager: async def connect(self, ws: WebSocket): await ws.accept() self.connections.append(ws) - print(f"📱 Client connected ({len(self.connections)} total)") + print(f"Client connected ({len(self.connections)} total)") def disconnect(self, ws: WebSocket): if ws in self.connections: self.connections.remove(ws) - print(f"📱 Client disconnected ({len(self.connections)} total)") + print(f"Client disconnected ({len(self.connections)} total)") async def broadcast(self, event: str, data: dict): - msg = json.dumps({"event": event, "data": data}) + msg = json.dumps({"event": event, "data": data}) dead = [] for ws in self.connections: try: await ws.send_text(msg) - except: + except Exception: dead.append(ws) for ws in dead: self.connections.remove(ws) @@ -160,29 +431,17 @@ class PushManager: push = PushManager() -# ── Path Resolution ─────────────────────────────────────── +# ── Path resolution ────────────────────────────────────────────────────────── + def resolve_path(relative: str) -> Optional[str]: - """ - Resolve a Navidrome song.path to an actual file inside the container. - - Handles: - - URL-encoded paths (David%20Graham → David Graham) - - Double/triple encoding (%2520 → %20 → space) - - Navidrome's "music/" prefix when mounts differ - - Filename-only search as fallback - """ - # Fully URL-decode — keep decoding until stable decoded = relative for _ in range(5): - next_decoded = unquote(decoded) - if next_decoded == decoded: + next_d = unquote(decoded) + if next_d == decoded: break - decoded = next_decoded - + decoded = next_d cleaned = decoded.lstrip("/") - - # Log what we're looking for - print(f" resolve_path: '{relative}' → decoded: '{cleaned}'", flush=True) + print(f" resolve_path: '{relative}' -> '{cleaned}'", flush=True) direct = os.path.join(MUSIC_DIR, cleaned) if os.path.isfile(direct): @@ -199,54 +458,45 @@ def resolve_path(relative: str) -> Optional[str]: for root, _, files in os.walk(MUSIC_DIR): if target in files: return os.path.join(root, target) - - # Strategy 4: Fuzzy match via file_index DB - target_stem = Path(target).stem.lower() if target else "" - target_ext = Path(target).suffix.lower() if target else "" - # Remove track number prefix like "09 - " or "09. " - title_part = re.sub(r'^\d+[\s\.\-]+', '', target_stem).strip() - search_words = set(re.split(r'[\s\-_\.]+', title_part)) - search_words = {w for w in search_words if len(w) > 1 and not w.isdigit()} - if search_words: + target_stem = Path(target).stem.lower() if target else "" + target_ext = Path(target).suffix.lower() if target else "" + title_part = re.sub(r'^\d+[\s\.\-]+', '', target_stem).strip() + words = {w for w in re.split(r'[\s\-_\.]+', title_part) + if len(w) > 1 and not w.isdigit()} + if words: try: with sqlite3.connect(DB_PATH) as c: - rows = c.execute("SELECT basename, full_path, title_words FROM file_index").fetchall() - - best_match = None - best_score = 0.0 - + rows = c.execute( + "SELECT basename, full_path, title_words FROM file_index" + ).fetchall() + best, best_score = None, 0.0 for basename, full_path, title_words_str in rows: if not basename.endswith(target_ext): continue - file_words = set(title_words_str.split()) - if not file_words: - continue - overlap = len(search_words & file_words) - score = overlap / len(search_words) + fw = set(title_words_str.split()) + score = len(words & fw) / len(words) if fw else 0 if score > best_score: best_score = score - best_match = full_path - - if best_match and best_score >= 0.5: - print(f" resolve: fuzzy ({best_score:.0%}) → {os.path.basename(best_match)}", flush=True) - return best_match + best = full_path + if best and best_score >= 0.5: + print(f" resolve: fuzzy ({best_score:.0%}) -> {os.path.basename(best)}", flush=True) + return best except Exception as e: print(f" resolve: fuzzy error: {e}", flush=True) - + print(f" resolve_path: FAILED for '{cleaned}'", flush=True) return None -# ── Navidrome Scan ──────────────────────────────────────── +# ── Navidrome helpers ──────────────────────────────────────────────────────── + async def trigger_scan(): if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]): - print("⚠️ Subsonic credentials not set — skipping scan") + print("Subsonic credentials not set - skipping scan") return - params = { - "u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT, - "v": "1.16.1", "c": "CompanionAPI", "f": "json" - } + params = {"u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT, + "v": "1.16.1", "c": "CompanionAPI", "f": "json"} async with httpx.AsyncClient() as client: try: r = await client.get(f"{NAVIDROME_URL}/rest/startScan.view", params=params) @@ -255,31 +505,72 @@ async def trigger_scan(): print(f"Scan failed: {e}") -# ── Metadata ────────────────────────────────────────────── +async def sync_navidrome_ids_task(): + """Fetch all songs from Navidrome and write navidrome_id into our songs table.""" + if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]): + print("Subsonic credentials not set - cannot sync IDs") + return + print("Syncing Navidrome IDs...", flush=True) + base_params = {"u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT, + "v": "1.16.1", "c": "CompanionAPI", "f": "json", + "albumCount": 0, "artistCount": 0, "songCount": 500, "query": ""} + all_songs = [] + offset = 0 + async with httpx.AsyncClient(timeout=30) as client: + while True: + try: + r = await client.get( + f"{NAVIDROME_URL}/rest/search3.view", + params={**base_params, "songOffset": offset} + ) + songs = (r.json().get("subsonic-response", {}) + .get("searchResult3", {}).get("song", [])) + if not songs: + break + all_songs.extend(songs) + offset += len(songs) + if len(songs) < 500: + break + except Exception as e: + print(f"Navidrome fetch error: {e}", flush=True) + break + + matched = 0 + with sqlite3.connect(DB_PATH) as c: + for ns in all_songs: + nd_path = ns.get("path", "") + nd_id = ns.get("id", "") + if nd_path and nd_id: + c.execute("UPDATE songs SET navidrome_id = ? WHERE relative_path = ?", + (nd_id, nd_path)) + if c.rowcount: + matched += 1 + print(f"Navidrome ID sync: {matched}/{len(all_songs)} matched", flush=True) + + +# ── Metadata helpers ───────────────────────────────────────────────────────── + def apply_tags(path: str, u: MetadataUpdate): audio = MutagenFile(path, easy=True) if audio is None: raise ValueError(f"Unsupported format: {path}") - if u.title: audio['title'] = u.title - if u.artist: audio['artist'] = u.artist - if u.album: audio['album'] = u.album + if u.title: audio['title'] = u.title + if u.artist: audio['artist'] = u.artist + if u.album: audio['album'] = u.album if u.album_artist: audio['albumartist'] = u.album_artist - if u.genre: audio['genre'] = u.genre - if u.year: audio['date'] = str(u.year) + if u.genre: audio['genre'] = u.genre + if u.year: audio['date'] = str(u.year) if u.track_number: audio['tracknumber'] = str(u.track_number) audio.save() def apply_tags_dict(path: str, tags: dict): - """Apply tags from a plain dict — used by batch/multi-track endpoints.""" audio = MutagenFile(path, easy=True) if audio is None: raise ValueError(f"Unsupported format: {path}") - mapping = { - 'title': 'title', 'artist': 'artist', 'album': 'album', - 'album_artist': 'albumartist', 'genre': 'genre', - 'year': 'date', 'track_number': 'tracknumber' - } + mapping = {'title': 'title', 'artist': 'artist', 'album': 'album', + 'album_artist': 'albumartist', 'genre': 'genre', + 'year': 'date', 'track_number': 'tracknumber'} for key, tag_name in mapping.items(): val = tags.get(key) if val is not None and val != "": @@ -287,51 +578,39 @@ def apply_tags_dict(path: str, tags: dict): audio.save() -# ── Smart DJ Analysis ──────────────────────────────────── +# ── Analysis ───────────────────────────────────────────────────────────────── + def analyze(full_path: str) -> dict: import librosa cmd = ["ffmpeg", "-hide_banner", "-i", full_path, "-af", "silencedetect=noise=-50dB:d=0.5,ebur128", "-f", "null", "-"] out = subprocess.run(cmd, capture_output=True, text=True, timeout=120).stderr - # ffmpeg silencedetect outputs pairs: - # silence_start: 0 (leading silence begins) - # silence_end: 0.5 (leading silence ends) ← skip to here - # silence_start: 237 (trailing silence begins) ← crossfade trigger - # silence_end: 240 (trailing silence ends) - # - # We need: - # silence_start = LAST silence_start = where trailing silence begins - # silence_end = FIRST silence_end = where leading silence ends all_starts = re.findall(r"silence_start: ([\d\.]+)", out) - all_ends = re.findall(r"silence_end: ([\d\.]+)", out) - lu = re.search(r"I:\s+([\-\d\.]+) LUFS", out) + all_ends = re.findall(r"silence_end: ([\d\.]+)", out) + lu = re.search(r"I:\s+([\-\d\.]+) LUFS", out) - # trailing silence start = last silence_start (for crossfade trigger) sil_start = float(all_starts[-1]) if all_starts else 0.0 - # leading silence end = first silence_end (for skip-to point) - sil_end = float(all_ends[0]) if all_ends else 0.0 - loudness = float(lu.group(1)) if lu else -14.0 + sil_end = float(all_ends[0]) if all_ends else 0.0 + loudness = float(lu.group(1)) if lu else -14.0 - # Sanity: if sil_end is > 10s, it's probably not leading silence if sil_end > 10.0: sil_end = 0.0 - # Sanity: if sil_start is < 10s, it's probably not trailing silence - # (could be leading silence start = 0) - # Get track duration from ffmpeg to validate - dur_match = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", out) - if dur_match: - total_dur = int(dur_match.group(1)) * 3600 + int(dur_match.group(2)) * 60 + float(dur_match.group(3)) - if sil_start < total_dur * 0.5: - sil_start = total_dur # No meaningful trailing silence found - - print(f" analyze: trailing_start={sil_start:.1f}s, leading_end={sil_end:.1f}s, LUFS={loudness:.1f}", flush=True) + dur_m = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", out) + if dur_m: + total = (int(dur_m.group(1)) * 3600 + + int(dur_m.group(2)) * 60 + + float(dur_m.group(3))) + if sil_start < total * 0.5: + sil_start = total + + print(f" analyze: trailing={sil_start:.1f}s leading_end={sil_end:.1f}s LUFS={loudness:.1f}", + flush=True) y, sr = librosa.load(full_path, sr=22050, duration=30) tempo, _ = librosa.beat.beat_track(y=y, sr=sr) del y import gc; gc.collect() - try: bpm = float(tempo) except TypeError: @@ -339,7 +618,6 @@ def analyze(full_path: str) -> dict: profile = {"bpm": round(bpm, 1), "silence_start": round(sil_start, 3), "silence_end": round(sil_end, 3), "loudness_lufs": round(loudness, 1)} - with sqlite3.connect(DB_PATH) as c: c.execute("INSERT OR REPLACE INTO dj_profiles VALUES (?,?,?,?,?,CURRENT_TIMESTAMP)", (full_path, profile["bpm"], profile["silence_start"], @@ -347,29 +625,31 @@ def analyze(full_path: str) -> dict: return profile -# ── Mitsuha Visualizer Frames ───────────────────────────── -def gen_vis_frames(path: str, fps=30.0, fft_size=1024, pts=20) -> list: +# ── Visualizer (fixed: no eqBoost, uniform bin width) ──────────────────────── + +def gen_vis_frames(path: str, fps: float = 30.0, fft_size: int = 1024, pts: int = 20) -> list: import librosa y, sr = librosa.load(path, sr=22050, mono=True) - hop = int(sr / fps) + hop = max(1, int(sr / fps)) frames = [] for start in range(0, len(y) - fft_size, hop): chunk = y[start:start + fft_size] * np.hanning(fft_size) - spec = np.sqrt(np.abs(np.fft.rfft(chunk)) / fft_size) - half = len(spec) + spec = np.sqrt(np.abs(np.fft.rfft(chunk)) / fft_size) + half = len(spec) cutoff = min(half - 1, 90) + uniform_bw = max(1, cutoff // pts) fp = [] for i in range(pts): - ni = (i + 1) / pts - li = np.log10(ni * 9 + 1) - cb = li * cutoff - bw = max(1, cutoff / pts * li) - sb = max(1, int(cb - bw / 2)) - eb = min(cutoff, int(cb + bw / 2)) - avg = float(np.mean(spec[sb:eb + 1])) if sb <= eb < half else 0 - fp.append(avg * (1 + i / pts * 3.5)) + ni = (i + 1) / pts + li = np.log10(ni * 9 + 1) + cb = int(li * cutoff) + lo = max(1, cb - uniform_bw // 2) + hi = min(cutoff, cb + uniform_bw // 2) + avg = float(np.mean(spec[lo:hi + 1])) if lo <= hi < half else 0.0 + fp.append(avg) # no eqBoost frames.append(fp) - del y; import gc; gc.collect() + del y + import gc; gc.collect() vals = sorted(v for f in frames for v in f if v > 0.001) if vals: p95 = vals[min(int(len(vals) * 0.95), len(vals) - 1)] @@ -389,7 +669,7 @@ def get_vis(path: str): try: with open(cf) as f: return json.load(f) - except: + except Exception: pass if not os.path.isfile(path): return None @@ -403,56 +683,87 @@ def get_vis(path: str): return None -# ═══════════════════════════════════════════════════════════ -# ENDPOINTS -# ═══════════════════════════════════════════════════════════ +# ── Library response helpers ───────────────────────────────────────────────── + +SONG_COLS = ("id,full_path,relative_path,navidrome_id," + "title,artist,album,album_artist,genre," + "year,track_number,disc_number,duration," + "sort_title,sort_artist,sort_album,sort_album_artist," + "cover_art_path,file_size,file_mtime,date_added,date_modified") + +_ALLOWED_SORT = { + "title", "sort_title", "artist", "sort_artist", "album", "sort_album", + "album_artist", "sort_album_artist", "track_number", "disc_number", + "year", "duration", "genre", "date_added", +} + +def song_row_to_dict(row) -> dict: + (song_id, full_path, relative_path, navidrome_id, + title, artist, album, album_artist, genre, + year, track_number, disc_number, duration, + sort_title, sort_artist, sort_album, sort_album_artist, + cover_art_path, file_size, file_mtime, + date_added, date_modified) = row + return { + "id": song_id, + "navidrome_id": navidrome_id, + "title": title, + "artist": artist, + "album": album, + "album_artist": album_artist, + "genre": genre, + "year": year, + "track_number": track_number, + "disc_number": disc_number, + "duration": duration, + "relative_path": relative_path, + "cover_art_url": f"/library/cover-art/{song_id}" if cover_art_path else None, + "sort_title": sort_title, + "sort_artist": sort_artist, + "sort_album": sort_album, + "sort_album_artist": sort_album_artist, + "date_added": date_added, + } + + +# ============================================================================= +# ENDPOINTS - existing (behaviour unchanged) +# ============================================================================= @app.get("/health") async def health(): - pc = 0 - fc = 0 + pc = fc = sc = 0 try: with sqlite3.connect(DB_PATH) as c: pc = c.execute("SELECT COUNT(*) FROM dj_profiles").fetchone()[0] fc = c.execute("SELECT COUNT(*) FROM file_index").fetchone()[0] - except: + sc = c.execute("SELECT COUNT(*) FROM songs").fetchone()[0] + except Exception: pass vc = len(os.listdir(VIS_CACHE_DIR)) if os.path.isdir(VIS_CACHE_DIR) else 0 - return { - "status": "healthy", - "music_dir": MUSIC_DIR, - "profiles": pc, - "file_index": fc, - "vis_cached": vc, - "ws_clients": len(push.connections) - } + return {"status": "healthy", "music_dir": MUSIC_DIR, + "profiles": pc, "file_index": fc, "songs": sc, + "vis_cached": vc, "ws_clients": len(push.connections)} @app.post("/reindex") async def reindex(): - """Rebuild the file index (call after adding/renaming files).""" build_file_index() return {"status": "reindexed"} -# ── Single Metadata Edit ───────────────────────────────── @app.patch("/edit-metadata") async def edit_metadata(update: MetadataUpdate): fp = resolve_path(update.relative_path) if not fp: - decoded = unquote(unquote(update.relative_path)) - raise HTTPException(404, - f"File not found. " - f"raw='{update.relative_path}' | " - f"decoded='{decoded}' | " - f"MUSIC_DIR='{MUSIC_DIR}'") + raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'") try: apply_tags(fp, update) + update_song_in_db(fp) await trigger_scan() await push.broadcast("metadata_updated", { "path": update.relative_path, - "title": update.title or "", - "artist": update.artist or "", + "title": update.title or "", "artist": update.artist or "", "album": update.album or "" }) return {"status": "success", "file": update.relative_path, "resolved": fp} @@ -460,19 +771,16 @@ async def edit_metadata(update: MetadataUpdate): raise HTTPException(500, str(e)) -# ── Batch Metadata Edit ────────────────────────────────── @app.patch("/batch-edit-metadata") async def batch_edit_metadata(update: BatchMetadataUpdate): - """Apply the same tag changes to multiple files at once.""" results = {"succeeded": [], "failed": []} tags = {} - if update.title: tags["title"] = update.title - if update.artist: tags["artist"] = update.artist - if update.album: tags["album"] = update.album + if update.title: tags["title"] = update.title + if update.artist: tags["artist"] = update.artist + if update.album: tags["album"] = update.album if update.album_artist: tags["album_artist"] = update.album_artist - if update.genre: tags["genre"] = update.genre - if update.year: tags["year"] = str(update.year) - + if update.genre: tags["genre"] = update.genre + if update.year: tags["year"] = str(update.year) for rp in update.relative_paths: fp = resolve_path(rp) if not fp: @@ -480,26 +788,22 @@ async def batch_edit_metadata(update: BatchMetadataUpdate): continue try: apply_tags_dict(fp, tags) + update_song_in_db(fp) results["succeeded"].append(rp) except Exception as e: results["failed"].append({"path": rp, "error": str(e)}) - - # Single scan after all edits await trigger_scan() - await push.broadcast("batch_metadata_updated", { - "count": len(results["succeeded"]), - "album": update.album or "" - }) + await push.broadcast("batch_metadata_updated", + {"count": len(results["succeeded"]), "album": update.album or ""}) return results -# ── Single File Upload ──────────────────────────────────── @app.post("/upload-track") async def upload_track( - file: UploadFile = File(...), - title: str = Form(...), - artist: str = Form(...), - album: str = Form(...) + file: UploadFile = File(...), + title: str = Form(...), + artist: str = Form(...), + album: str = Form(...) ): dest = os.path.join(MUSIC_DIR, "uploads") os.makedirs(dest, exist_ok=True) @@ -510,12 +814,11 @@ async def upload_track( u = MetadataUpdate(relative_path=f"uploads/{file.filename}", title=title, artist=artist, album=album) apply_tags(fp, u) + update_song_in_db(fp) profile = analyze(fp) await trigger_scan() - await push.broadcast("track_uploaded", { - "filename": file.filename, - "profile": json.dumps(profile) - }) + await push.broadcast("track_uploaded", + {"filename": file.filename, "profile": json.dumps(profile)}) return {"status": "uploaded", "path": f"uploads/{file.filename}", "profile": profile} except Exception as e: if os.path.exists(fp): @@ -523,120 +826,95 @@ async def upload_track( raise HTTPException(500, str(e)) -# ── Multi-File Upload ───────────────────────────────────── @app.post("/upload-tracks") async def upload_tracks( - files: List[UploadFile] = File(...), - metadata_json: str = Form(...) + files: List[UploadFile] = File(...), + metadata_json: str = Form(...), + cover_art: Optional[UploadFile] = File(None), ): """ Upload multiple audio files with per-track metadata. - - metadata_json is a JSON string containing a list of TrackUploadMeta objects: - [ - {"filename": "01 - Song.mp3", "title": "Song", "artist": "Artist", "album": "Album", "track_number": 1}, - ... - ] - - Each entry's filename must match one of the uploaded files. - Files are saved to MUSIC_DIR/Artist/Album/ with proper directory structure. + Optional cover_art is saved as folder.jpg in the album directory. """ try: meta_list = json.loads(metadata_json) except json.JSONDecodeError: - raise HTTPException(400, "Invalid metadata_json — must be a JSON array") + raise HTTPException(400, "Invalid metadata_json") - # Index metadata by filename - meta_by_name = {} - for m in meta_list: - meta_by_name[m["filename"]] = m - - results = {"uploaded": [], "failed": []} + meta_by_name = {m["filename"]: m for m in meta_list} + results = {"uploaded": [], "failed": []} + album_dir = None for file in files: - meta = meta_by_name.get(file.filename) - if not meta: - # No metadata provided — use filename as title - meta = { - "filename": file.filename, - "title": Path(file.filename).stem, - "artist": "Unknown Artist", - "album": "Uploads" - } - - # Build directory: MUSIC_DIR/Artist/Album/ + meta = meta_by_name.get(file.filename) or { + "filename": file.filename, + "title": Path(file.filename).stem, + "artist": "Unknown Artist", "album": "Uploads" + } artist_name = meta.get("artist", "Unknown Artist") - album_name = meta.get("album", "Uploads") + album_name = meta.get("album", "Uploads") safe_artist = re.sub(r'[<>:"/\\|?*]', '_', artist_name) - safe_album = re.sub(r'[<>:"/\\|?*]', '_', album_name) - dest_dir = os.path.join(MUSIC_DIR, safe_artist, safe_album) + safe_album = re.sub(r'[<>:"/\\|?*]', '_', album_name) + dest_dir = os.path.join(MUSIC_DIR, safe_artist, safe_album) os.makedirs(dest_dir, exist_ok=True) - + album_dir = dest_dir fp = os.path.join(dest_dir, file.filename) try: with open(fp, "wb") as buf: shutil.copyfileobj(file.file, buf) - - # Apply tags tags = { - "title": meta.get("title", Path(file.filename).stem), - "artist": artist_name, - "album": album_name, + "title": meta.get("title", Path(file.filename).stem), + "artist": artist_name, + "album": album_name, + "album_artist": meta.get("album_artist") or artist_name, } - if meta.get("track_number"): - tags["track_number"] = str(meta["track_number"]) - if meta.get("genre"): - tags["genre"] = meta["genre"] - if meta.get("year"): - tags["year"] = str(meta["year"]) - if meta.get("album_artist"): - tags["album_artist"] = meta["album_artist"] - else: - tags["album_artist"] = artist_name - + if meta.get("track_number"): tags["track_number"] = str(meta["track_number"]) + if meta.get("genre"): tags["genre"] = meta["genre"] + if meta.get("year"): tags["year"] = str(meta["year"]) apply_tags_dict(fp, tags) - - # Analyze (non-blocking — catch errors but don't fail the upload) try: analyze(fp) except Exception as e: print(f" Analysis failed for {file.filename}: {e}") - - rel = os.path.relpath(fp, MUSIC_DIR) + update_song_in_db(fp) results["uploaded"].append({ "filename": file.filename, - "path": rel + "path": os.path.relpath(fp, MUSIC_DIR) }) - except Exception as e: if os.path.exists(fp): os.remove(fp) - results["failed"].append({ - "filename": file.filename, - "error": str(e) - }) + results["failed"].append({"filename": file.filename, "error": str(e)}) + + if cover_art and album_dir: + cover_dest = os.path.join(album_dir, "folder.jpg") + try: + with open(cover_dest, "wb") as buf: + shutil.copyfileobj(cover_art.file, buf) + with sqlite3.connect(DB_PATH) as c: + c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?", + (cover_dest, os.path.join(album_dir, "%"))) + print(f" Cover art saved: {cover_dest}", flush=True) + except Exception as e: + print(f" Cover art save failed: {e}", flush=True) - # Single scan after all uploads await trigger_scan() await push.broadcast("tracks_uploaded", { "count": len(results["uploaded"]), "album": meta_list[0].get("album", "") if meta_list else "" }) - return results -# ── Smart DJ ────────────────────────────────────────────── @app.get("/smart-dj/profile") async def get_profile(relative_path: str): fp = resolve_path(relative_path) - if fp: try: with sqlite3.connect(DB_PATH) as c: row = c.execute( - "SELECT bpm, silence_start, silence_end, loudness_lufs " - "FROM dj_profiles WHERE file_path = ?", (fp,) + "SELECT bpm,silence_start,silence_end,loudness_lufs " + "FROM dj_profiles WHERE file_path=?", (fp,) ).fetchone() if row: return {"bpm": row[0], "silence_start": row[1], @@ -644,8 +922,7 @@ async def get_profile(relative_path: str): except sqlite3.OperationalError as e: print(f"DB error: {e}", flush=True) return analyze(fp) - - # File not found by path — try DB search by filename + decoded = relative_path for _ in range(5): nd = unquote(decoded) @@ -656,19 +933,15 @@ async def get_profile(relative_path: str): try: with sqlite3.connect(DB_PATH) as c: rows = c.execute( - "SELECT file_path, bpm, silence_start, silence_end, loudness_lufs " - "FROM dj_profiles WHERE LOWER(file_path) LIKE ?", - (f"%{target}",) + "SELECT file_path,bpm,silence_start,silence_end,loudness_lufs " + "FROM dj_profiles WHERE LOWER(file_path) LIKE ?", (f"%{target}",) ).fetchall() if rows: - print(f" DB fallback: {rows[0][0]}", flush=True) return {"bpm": rows[0][1], "silence_start": rows[0][2], "silence_end": rows[0][3], "loudness_lufs": rows[0][4]} except Exception as e: print(f" DB fallback error: {e}", flush=True) - - raise HTTPException(404, - f"Not found. decoded='{decoded}', MUSIC_DIR='{MUSIC_DIR}'") + raise HTTPException(404, f"Not found. decoded='{decoded}' MUSIC_DIR='{MUSIC_DIR}'") @app.get("/smart-dj/bulk-profiles") @@ -682,20 +955,17 @@ async def bulk_profiles(paths: str = Query(...)): try: with sqlite3.connect(DB_PATH) as c: row = c.execute( - "SELECT bpm, silence_start, silence_end, loudness_lufs " - "FROM dj_profiles WHERE file_path = ?", (fp,) + "SELECT bpm,silence_start,silence_end,loudness_lufs " + "FROM dj_profiles WHERE file_path=?", (fp,) ).fetchone() - if row: - results[rp] = {"bpm": row[0], "silence_start": row[1], - "silence_end": row[2], "loudness_lufs": row[3]} - else: - results[rp] = analyze(fp) - except: + results[rp] = ({"bpm": row[0], "silence_start": row[1], + "silence_end": row[2], "loudness_lufs": row[3]} + if row else analyze(fp)) + except Exception: results[rp] = None return results -# ── Visualizer ──────────────────────────────────────────── @app.get("/visualizer/frames") async def vis_frames(relative_path: str): fp = resolve_path(relative_path) @@ -710,20 +980,18 @@ async def vis_frames(relative_path: str): @app.post("/visualizer/precompute") async def precompute(background_tasks: BackgroundTasks, relative_path: str = ""): def compute_all(): - exts = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav') n = 0 for root, _, files in os.walk(MUSIC_DIR): for f in files: - if f.lower().endswith(exts): + if f.lower().endswith(AUDIO_EXTS): fp = os.path.join(root, f) if not os.path.exists(vis_cache_file(fp)): try: get_vis(fp) n += 1 - except: + except Exception: pass - print(f"✨ Pre-computed {n} vis caches") - + print(f"Pre-computed {n} vis caches") if relative_path: background_tasks.add_task(lambda: get_vis(resolve_path(relative_path) or "")) return {"message": f"Computing: {relative_path}"} @@ -737,14 +1005,224 @@ async def bulk_fix(background_tasks: BackgroundTasks): return {"message": "Scan triggered"} -# ── WebSocket Push ──────────────────────────────────────── +# ============================================================================= +# ENDPOINTS - Phase 1: Library Database +# ============================================================================= + +@app.post("/library/scan") +async def library_scan(background_tasks: BackgroundTasks, full: bool = False): + """Trigger a library rescan. ?full=true forces re-read of every file.""" + background_tasks.add_task(scan_library, full) + return {"message": f"Library scan started (full={full})"} + + +@app.post("/library/sync-navidrome-ids") +async def sync_navidrome(background_tasks: BackgroundTasks): + """Match our songs table to Navidrome IDs so the iOS app can stream.""" + background_tasks.add_task(sync_navidrome_ids_task) + return {"message": "Navidrome ID sync started"} + + +@app.get("/library/songs") +async def library_songs( + page: int = Query(0, ge=0), + per_page: int = Query(100, ge=1, le=500), + sort: str = Query("sort_album,disc_number,track_number"), + album: Optional[str] = Query(None), + artist: Optional[str] = Query(None), + album_artist: Optional[str] = Query(None), + genre: Optional[str] = Query(None), + year: Optional[int] = Query(None), +): + order_parts = [] + for col in sort.split(","): + col = col.strip() + desc = col.startswith("-") + name = col.lstrip("-") + if name in _ALLOWED_SORT: + order_parts.append(f"{name} {'DESC' if desc else 'ASC'}") + order = ", ".join(order_parts) or "sort_album, disc_number, track_number" + + wheres, params = [], [] + if album: wheres.append("album = ?"); params.append(album) + if artist: wheres.append("artist = ?"); params.append(artist) + if album_artist: wheres.append("album_artist = ?"); params.append(album_artist) + if genre: wheres.append("genre = ?"); params.append(genre) + if year: wheres.append("year = ?"); params.append(year) + where = f"WHERE {' AND '.join(wheres)}" if wheres else "" + + with sqlite3.connect(DB_PATH) as c: + total = c.execute(f"SELECT COUNT(*) FROM songs {where}", params).fetchone()[0] + rows = c.execute( + f"SELECT {SONG_COLS} FROM songs {where} ORDER BY {order} LIMIT ? OFFSET ?", + params + [per_page, page * per_page] + ).fetchall() + + return {"total": total, "page": page, "per_page": per_page, + "songs": [song_row_to_dict(r) for r in rows]} + + +@app.get("/library/albums") +async def library_albums( + artist: Optional[str] = Query(None), + album_artist: Optional[str] = Query(None), + genre: Optional[str] = Query(None), +): + wheres, params = [], [] + if artist: wheres.append("artist = ?"); params.append(artist) + if album_artist: wheres.append("album_artist = ?"); params.append(album_artist) + if genre: wheres.append("genre = ?"); params.append(genre) + where = f"WHERE {' AND '.join(wheres)}" if wheres else "" + + with sqlite3.connect(DB_PATH) as c: + rows = c.execute(f""" + SELECT album, album_artist, sort_album, sort_album_artist, + MIN(year) as year, COUNT(*) as track_count, + MAX(cover_art_path) as cover_art_path, MIN(id) as rep_id + FROM songs {where} + GROUP BY album, album_artist + ORDER BY sort_album_artist, sort_album + """, params).fetchall() + + albums = [] + for album, aa, sort_alb, sort_aa, year, tc, cover_path, rep_id in rows: + albums.append({ + "album": album, + "album_artist": aa, + "sort_album": sort_alb, + "sort_album_artist": sort_aa, + "year": year, + "track_count": tc, + "cover_art_url": f"/library/cover-art/{rep_id}" if cover_path else None, + }) + return {"total": len(albums), "albums": albums} + + +@app.get("/library/artists") +async def library_artists(): + with sqlite3.connect(DB_PATH) as c: + rows = c.execute(""" + SELECT artist, sort_artist, + COUNT(*) as track_count, COUNT(DISTINCT album) as album_count + FROM songs GROUP BY artist ORDER BY sort_artist + """).fetchall() + photos = {r[0]: r[1] for r in + c.execute("SELECT artist_name, photo_path FROM artist_photos").fetchall()} + + return {"total": len(rows), "artists": [ + {"artist": artist, + "sort_artist": sort_art, + "track_count": tc, + "album_count": ac, + "photo_url": f"/library/artist-photo/{artist}" if photos.get(artist) else None} + for artist, sort_art, tc, ac in rows + ]} + + +@app.get("/library/search") +async def library_search( + q: str = Query(..., min_length=1), + limit: int = Query(50, ge=1, le=200) +): + term = f"%{q}%" + with sqlite3.connect(DB_PATH) as c: + rows = c.execute(f""" + SELECT {SONG_COLS} FROM songs + WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? OR genre LIKE ? + ORDER BY sort_artist, sort_album, disc_number, track_number + LIMIT ? + """, (term, term, term, term, limit)).fetchall() + return {"total": len(rows), "songs": [song_row_to_dict(r) for r in rows]} + + +@app.get("/library/song/{song_id}") +async def library_song(song_id: str): + with sqlite3.connect(DB_PATH) as c: + row = c.execute(f"SELECT {SONG_COLS} FROM songs WHERE id = ?", (song_id,)).fetchone() + if not row: + raise HTTPException(404, "Song not found") + return song_row_to_dict(row) + + +@app.get("/library/cover-art/{song_id}") +async def library_cover_art(song_id: str): + with sqlite3.connect(DB_PATH) as c: + row = c.execute("SELECT cover_art_path FROM songs WHERE id = ?", (song_id,)).fetchone() + if not row or not row[0] or not os.path.isfile(row[0]): + raise HTTPException(404, "No cover art") + mt = "image/png" if row[0].lower().endswith(".png") else "image/jpeg" + return FileResponse(row[0], media_type=mt) + + +@app.post("/library/cover-art/{song_id}") +async def upload_cover_art(song_id: str, file: UploadFile = File(...)): + """Upload cover art — saves as folder.jpg and updates all songs in that directory.""" + with sqlite3.connect(DB_PATH) as c: + row = c.execute("SELECT full_path FROM songs WHERE id = ?", (song_id,)).fetchone() + if not row: + raise HTTPException(404, "Song not found") + song_dir = os.path.dirname(row[0]) + cover_dest = os.path.join(song_dir, "folder.jpg") + try: + with open(cover_dest, "wb") as buf: + shutil.copyfileobj(file.file, buf) + with sqlite3.connect(DB_PATH) as c: + c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?", + (cover_dest, os.path.join(song_dir, "%"))) + cached = os.path.join(COVER_ART_DIR, f"{song_id}.jpg") + if os.path.isfile(cached): + os.remove(cached) + await push.broadcast("cover_art_updated", {"song_id": song_id}) + return {"status": "saved", "path": cover_dest} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/library/artist-photo") +async def upload_artist_photo( + artist_name: str = Form(...), + file: UploadFile = File(...) +): + """Upload an artist photo.""" + safe = re.sub(r'[<>:"/\\|?*\s]', '_', artist_name) + ext = Path(file.filename).suffix.lower() or ".jpg" + dest = os.path.join(ARTIST_PHOTO_DIR, f"{safe}{ext}") + try: + with open(dest, "wb") as buf: + shutil.copyfileobj(file.file, buf) + with sqlite3.connect(DB_PATH) as c: + c.execute("""INSERT OR REPLACE INTO artist_photos + (artist_name, photo_path, updated_at) VALUES (?,?,CURRENT_TIMESTAMP)""", + (artist_name, dest)) + await push.broadcast("artist_photo_updated", {"artist": artist_name}) + return {"status": "saved", "artist": artist_name} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.get("/library/artist-photo/{artist_name}") +async def get_artist_photo(artist_name: str): + with sqlite3.connect(DB_PATH) as c: + row = c.execute( + "SELECT photo_path FROM artist_photos WHERE artist_name = ?", (artist_name,) + ).fetchone() + if not row or not row[0] or not os.path.isfile(row[0]): + raise HTTPException(404, "No photo") + mt = "image/png" if row[0].lower().endswith(".png") else "image/jpeg" + return FileResponse(row[0], media_type=mt) + + +# ============================================================================= +# WebSocket Push +# ============================================================================= + @app.websocket("/ws/push") async def ws_push(ws: WebSocket): await push.connect(ws) try: while True: data = json.loads(await ws.receive_text()) - act = data.get("action") + act = data.get("action") if act == "ping": await push.send_to(ws, "pong", {"t": str(time.time())}) elif act == "get_profile": @@ -754,14 +1232,14 @@ async def ws_push(ws: WebSocket): try: with sqlite3.connect(DB_PATH) as c: row = c.execute( - "SELECT bpm, silence_start, silence_end, loudness_lufs " - "FROM dj_profiles WHERE file_path = ?", (fp,) + "SELECT bpm,silence_start,silence_end,loudness_lufs " + "FROM dj_profiles WHERE file_path=?", (fp,) ).fetchone() if row: await push.send_to(ws, "profile", { "path": rp, "bpm": str(row[0]), "silence_start": str(row[1]), - "silence_end": str(row[2]), + "silence_end": str(row[2]), "loudness_lufs": str(row[3]) }) else: