Bundled companionapi required for batch processing and smartdj
Bundled companionapi required for batch processing and smartdj.
This commit is contained in:
parent
2f06e9bdab
commit
ef1e80911a
5 changed files with 1342 additions and 0 deletions
34
companion-api/Dockerfile
Normal file
34
companion-api/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Location: /home/pi/docker/navidrome/companion_api/Dockerfile
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# System dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
libsndfile1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python dependencies
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi \
|
||||
"uvicorn[standard]" \
|
||||
mutagen \
|
||||
httpx \
|
||||
python-multipart \
|
||||
librosa \
|
||||
numpy \
|
||||
tqdm \
|
||||
websockets
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Data directories (mounted as volume, created as fallback)
|
||||
RUN mkdir -p /app/data /app/data/vis_cache
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
175
companion-api/diagnose.py
Normal file
175
companion-api/diagnose.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Diagnostic script — run inside the music-companion container:
|
||||
docker exec -it music-companion python diagnose.py
|
||||
|
||||
Checks:
|
||||
1. What MUSIC_DIR points to and what's in it
|
||||
2. What paths are stored in smart_dj.db
|
||||
3. Whether those DB paths actually exist on disk
|
||||
4. Tests resolve_path() with sample paths
|
||||
"""
|
||||
import os, sqlite3, hashlib
|
||||
|
||||
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")
|
||||
|
||||
print("=" * 60)
|
||||
print("COMPANION API DIAGNOSTICS")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Check MUSIC_DIR
|
||||
print(f"\n1. MUSIC_DIR = {MUSIC_DIR}")
|
||||
print(f" Exists: {os.path.isdir(MUSIC_DIR)}")
|
||||
|
||||
if os.path.isdir(MUSIC_DIR):
|
||||
top = os.listdir(MUSIC_DIR)
|
||||
dirs = sorted([d for d in top if os.path.isdir(os.path.join(MUSIC_DIR, d))])
|
||||
files = [f for f in top if os.path.isfile(os.path.join(MUSIC_DIR, f))]
|
||||
print(f" Top-level: {len(dirs)} folders, {len(files)} files")
|
||||
|
||||
# Show first 10 folders
|
||||
for d in dirs[:10]:
|
||||
print(f" 📁 {d}")
|
||||
if len(dirs) > 10:
|
||||
print(f" ... and {len(dirs) - 10} more")
|
||||
|
||||
# Check if there's a nested "music" folder (common misconfiguration)
|
||||
if "music" in dirs:
|
||||
nested = os.path.join(MUSIC_DIR, "music")
|
||||
nested_contents = os.listdir(nested)
|
||||
print(f"\n ⚠️ FOUND nested 'music/' folder inside MUSIC_DIR!")
|
||||
print(f" {nested} has {len(nested_contents)} items")
|
||||
print(f" This usually means docker-compose mounts the parent instead of the music dir")
|
||||
print(f" Your files are probably at /music/music/Artist/Album/song.flac")
|
||||
print(f" Fix: change volume mount from '/home/pi/navidrome:/music' to '/home/pi/navidrome/music:/music'")
|
||||
|
||||
# Count total audio files
|
||||
audio_count = 0
|
||||
sample_paths = []
|
||||
for root, _, fnames in os.walk(MUSIC_DIR):
|
||||
for f in fnames:
|
||||
if f.lower().endswith(('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')):
|
||||
audio_count += 1
|
||||
fp = os.path.join(root, f)
|
||||
rel = os.path.relpath(fp, MUSIC_DIR)
|
||||
if len(sample_paths) < 5:
|
||||
sample_paths.append((fp, rel))
|
||||
|
||||
print(f"\n Total audio files: {audio_count}")
|
||||
print(f"\n Sample file paths (absolute → relative):")
|
||||
for abs_path, rel_path in sample_paths:
|
||||
print(f" ABS: {abs_path}")
|
||||
print(f" REL: {rel_path}")
|
||||
print()
|
||||
else:
|
||||
print(" ❌ MUSIC_DIR does not exist!")
|
||||
|
||||
# 2. Check DB
|
||||
print(f"\n2. DATABASE = {DB_PATH}")
|
||||
print(f" Exists: {os.path.isfile(DB_PATH)}")
|
||||
|
||||
db_paths = []
|
||||
if os.path.isfile(DB_PATH):
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
count = c.execute("SELECT COUNT(*) FROM dj_profiles").fetchone()[0]
|
||||
print(f" Profiles: {count}")
|
||||
|
||||
rows = c.execute("SELECT file_path FROM dj_profiles LIMIT 10").fetchall()
|
||||
db_paths = [r[0] for r in rows]
|
||||
|
||||
print(f"\n Sample DB paths:")
|
||||
for p in db_paths:
|
||||
exists = os.path.isfile(p)
|
||||
icon = "✓" if exists else "✗"
|
||||
print(f" {icon} {p}")
|
||||
if not exists:
|
||||
# Try to find what DOES exist
|
||||
basename = os.path.basename(p)
|
||||
for root, _, fnames in os.walk(MUSIC_DIR):
|
||||
if basename in fnames:
|
||||
actual = os.path.join(root, basename)
|
||||
print(f" → FOUND at: {actual}")
|
||||
break
|
||||
|
||||
# Check how many DB paths actually exist
|
||||
all_paths = c.execute("SELECT file_path FROM dj_profiles").fetchall()
|
||||
existing = sum(1 for (p,) in all_paths if os.path.isfile(p))
|
||||
missing = len(all_paths) - existing
|
||||
print(f"\n DB path health: {existing} exist, {missing} broken")
|
||||
if missing > 0:
|
||||
print(f" ⚠️ {missing} profiles point to files that don't exist!")
|
||||
print(f" This means the DB was built with a different mount path")
|
||||
else:
|
||||
print(" ❌ Database does not exist! Run pre_analyze.py first")
|
||||
|
||||
# 3. Check vis cache
|
||||
print(f"\n3. VIS CACHE = {VIS_CACHE_DIR}")
|
||||
if os.path.isdir(VIS_CACHE_DIR):
|
||||
vis_files = os.listdir(VIS_CACHE_DIR)
|
||||
print(f" Cached: {len(vis_files)} files")
|
||||
else:
|
||||
print(f" Does not exist")
|
||||
|
||||
# 4. Test resolve_path with the sample paths
|
||||
print(f"\n4. RESOLVE_PATH TESTS")
|
||||
print(f" Testing with various path formats...")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
def resolve_path(relative):
|
||||
cleaned = relative.lstrip("/")
|
||||
direct = os.path.join(MUSIC_DIR, cleaned)
|
||||
if os.path.isfile(direct):
|
||||
return ("direct", direct)
|
||||
parts = Path(cleaned).parts
|
||||
for i in range(1, len(parts)):
|
||||
sub = os.path.join(MUSIC_DIR, *parts[i:])
|
||||
if os.path.isfile(sub):
|
||||
return (f"strip-{i}", sub)
|
||||
target = os.path.basename(cleaned)
|
||||
if target:
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
if target in files:
|
||||
return ("filename-search", os.path.join(root, target))
|
||||
return ("FAILED", None)
|
||||
|
||||
if sample_paths:
|
||||
for abs_path, rel_path in sample_paths:
|
||||
# Test various formats the iOS app might send
|
||||
tests = [
|
||||
("exact relative", rel_path),
|
||||
("with music/ prefix", f"music/{rel_path}"),
|
||||
("with leading /", f"/{rel_path}"),
|
||||
("absolute", abs_path),
|
||||
]
|
||||
print(f"\n File: {os.path.basename(rel_path)}")
|
||||
for label, test_path in tests:
|
||||
strategy, result = resolve_path(test_path)
|
||||
icon = "✓" if result else "✗"
|
||||
print(f" {icon} {label}: '{test_path}' → {strategy}")
|
||||
break # Just test one file
|
||||
|
||||
# 5. Summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
if os.path.isdir(MUSIC_DIR):
|
||||
if "music" in [d for d in os.listdir(MUSIC_DIR) if os.path.isdir(os.path.join(MUSIC_DIR, d))]:
|
||||
print("❌ LIKELY ISSUE: Nested 'music' folder detected.")
|
||||
print(" Your docker-compose probably mounts /home/pi/navidrome:/music")
|
||||
print(" but should mount /home/pi/navidrome/music:/music")
|
||||
elif audio_count == 0:
|
||||
print("❌ No audio files found in MUSIC_DIR!")
|
||||
elif db_paths and not os.path.isfile(db_paths[0]):
|
||||
print("❌ DB paths don't match filesystem. The DB was built with")
|
||||
print(" a different mount configuration. Fix mount and re-run:")
|
||||
print(" docker exec -it music-companion python pre_analyze.py --force")
|
||||
else:
|
||||
print("✓ Configuration looks correct")
|
||||
print(f" {audio_count} audio files, {count if os.path.isfile(DB_PATH) else 0} profiles")
|
||||
else:
|
||||
print("❌ MUSIC_DIR doesn't exist — check docker-compose volumes")
|
||||
|
||||
print()
|
||||
45
companion-api/docker-compose.yml
Normal file
45
companion-api/docker-compose.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Location: /home/pi/docker/navidrome/docker-compose.yml
|
||||
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
container_name: navidrome
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANSCHEDULE=1h
|
||||
- ND_BASEURL=/navidrome
|
||||
# ... other env vars ...
|
||||
volumes:
|
||||
# Absolute path to your music
|
||||
- /home/pi/navidrome:/music:ro
|
||||
- ./navidrome_data:/data
|
||||
|
||||
music-companion:
|
||||
build: ./companion_api
|
||||
container_name: music-companion
|
||||
restart: unless-stopped
|
||||
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
|
||||
environment:
|
||||
- MUSIC_DIR=/music
|
||||
- DB_PATH=/app/data/smart_dj.db
|
||||
- VIS_CACHE_DIR=/app/data/vis_cache
|
||||
- NAVIDROME_URL=http://navidrome:4533/navidrome
|
||||
- SUBSONIC_USER=dallas
|
||||
- SUBSONIC_TOKEN=your_token
|
||||
- SUBSONIC_SALT=your_salt
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 1G
|
||||
depends_on:
|
||||
- navidrome
|
||||
786
companion-api/main.py
Normal file
786
companion-api/main.py
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
"""
|
||||
Navidrome Companion API
|
||||
Location: /home/pi/docker/navidrome/companion_api/main.py
|
||||
|
||||
Endpoints:
|
||||
GET /health
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import (FastAPI, HTTPException, UploadFile, File, Form,
|
||||
BackgroundTasks, WebSocket, WebSocketDisconnect, Query)
|
||||
from fastapi.responses import JSONResponse
|
||||
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")
|
||||
|
||||
|
||||
# ── DB ─────────────────────────────────────────────────────
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
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,
|
||||
analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS file_index (
|
||||
basename TEXT,
|
||||
full_path TEXT,
|
||||
title_words TEXT,
|
||||
PRIMARY KEY (basename, full_path))""")
|
||||
|
||||
|
||||
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()}
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Cannot list MUSIC_DIR: {e}", flush=True)
|
||||
build_file_index()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Navidrome Companion API", lifespan=lifespan)
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
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
|
||||
|
||||
class TrackUploadMeta(BaseModel):
|
||||
"""Per-track metadata for multi-file upload."""
|
||||
filename: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
track_number: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
|
||||
|
||||
# ── Push Manager (WebSocket) ──────────────────────────────
|
||||
class PushManager:
|
||||
def __init__(self):
|
||||
self.connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.connections.append(ws)
|
||||
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)")
|
||||
|
||||
async def broadcast(self, event: str, data: dict):
|
||||
msg = json.dumps({"event": event, "data": data})
|
||||
dead = []
|
||||
for ws in self.connections:
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.connections.remove(ws)
|
||||
|
||||
async def send_to(self, ws: WebSocket, event: str, data: dict):
|
||||
await ws.send_text(json.dumps({"event": event, "data": data}))
|
||||
|
||||
push = PushManager()
|
||||
|
||||
|
||||
# ── 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:
|
||||
break
|
||||
decoded = next_decoded
|
||||
|
||||
cleaned = decoded.lstrip("/")
|
||||
|
||||
# Log what we're looking for
|
||||
print(f" resolve_path: '{relative}' → decoded: '{cleaned}'", flush=True)
|
||||
|
||||
direct = os.path.join(MUSIC_DIR, cleaned)
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
|
||||
parts = Path(cleaned).parts
|
||||
for i in range(1, len(parts)):
|
||||
sub = os.path.join(MUSIC_DIR, *parts[i:])
|
||||
if os.path.isfile(sub):
|
||||
return sub
|
||||
|
||||
target = os.path.basename(cleaned)
|
||||
if target:
|
||||
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:
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
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 ────────────────────────────────────────
|
||||
async def trigger_scan():
|
||||
if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]):
|
||||
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"
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(f"{NAVIDROME_URL}/rest/startScan.view", params=params)
|
||||
print(f"Navidrome scan: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Scan failed: {e}")
|
||||
|
||||
|
||||
# ── Metadata ──────────────────────────────────────────────
|
||||
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.album_artist: audio['albumartist'] = u.album_artist
|
||||
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'
|
||||
}
|
||||
for key, tag_name in mapping.items():
|
||||
val = tags.get(key)
|
||||
if val is not None and val != "":
|
||||
audio[tag_name] = str(val)
|
||||
audio.save()
|
||||
|
||||
|
||||
# ── Smart DJ 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)
|
||||
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
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:
|
||||
bpm = float(tempo[0]) if tempo is not None else 0.0
|
||||
|
||||
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"],
|
||||
profile["silence_end"], profile["loudness_lufs"]))
|
||||
return profile
|
||||
|
||||
|
||||
# ── Mitsuha Visualizer Frames ─────────────────────────────
|
||||
def gen_vis_frames(path: str, fps=30.0, fft_size=1024, pts=20) -> list:
|
||||
import librosa
|
||||
y, sr = librosa.load(path, sr=22050, mono=True)
|
||||
hop = 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)
|
||||
cutoff = min(half - 1, 90)
|
||||
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))
|
||||
frames.append(fp)
|
||||
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)]
|
||||
if p95 > 0.001:
|
||||
s = 0.8 / p95
|
||||
frames = [[min(1.0, v * s) for v in f] for f in frames]
|
||||
return frames
|
||||
|
||||
|
||||
def vis_cache_file(path: str) -> str:
|
||||
return os.path.join(VIS_CACHE_DIR, hashlib.md5(path.encode()).hexdigest() + ".json")
|
||||
|
||||
|
||||
def get_vis(path: str):
|
||||
cf = vis_cache_file(path)
|
||||
if os.path.exists(cf):
|
||||
try:
|
||||
with open(cf) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
frames = gen_vis_frames(path)
|
||||
with open(cf, "w") as f:
|
||||
json.dump(frames, f)
|
||||
return frames
|
||||
except Exception as e:
|
||||
print(f"Vis gen failed for {os.path.basename(path)}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ENDPOINTS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
pc = 0
|
||||
fc = 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:
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@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}'")
|
||||
try:
|
||||
apply_tags(fp, update)
|
||||
await trigger_scan()
|
||||
await push.broadcast("metadata_updated", {
|
||||
"path": update.relative_path,
|
||||
"title": update.title or "",
|
||||
"artist": update.artist or "",
|
||||
"album": update.album or ""
|
||||
})
|
||||
return {"status": "success", "file": update.relative_path, "resolved": fp}
|
||||
except Exception as e:
|
||||
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.album_artist: tags["album_artist"] = update.album_artist
|
||||
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:
|
||||
results["failed"].append({"path": rp, "error": "File not found"})
|
||||
continue
|
||||
try:
|
||||
apply_tags_dict(fp, tags)
|
||||
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 ""
|
||||
})
|
||||
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(...)
|
||||
):
|
||||
dest = os.path.join(MUSIC_DIR, "uploads")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
fp = os.path.join(dest, file.filename)
|
||||
with open(fp, "wb") as buf:
|
||||
shutil.copyfileobj(file.file, buf)
|
||||
try:
|
||||
u = MetadataUpdate(relative_path=f"uploads/{file.filename}",
|
||||
title=title, artist=artist, album=album)
|
||||
apply_tags(fp, u)
|
||||
profile = analyze(fp)
|
||||
await trigger_scan()
|
||||
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):
|
||||
os.remove(fp)
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── Multi-File Upload ─────────────────────────────────────
|
||||
@app.post("/upload-tracks")
|
||||
async def upload_tracks(
|
||||
files: List[UploadFile] = File(...),
|
||||
metadata_json: str = Form(...)
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
meta_list = json.loads(metadata_json)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Invalid metadata_json — must be a JSON array")
|
||||
|
||||
# Index metadata by filename
|
||||
meta_by_name = {}
|
||||
for m in meta_list:
|
||||
meta_by_name[m["filename"]] = m
|
||||
|
||||
results = {"uploaded": [], "failed": []}
|
||||
|
||||
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/
|
||||
artist_name = meta.get("artist", "Unknown Artist")
|
||||
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)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
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,
|
||||
}
|
||||
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
|
||||
|
||||
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)
|
||||
results["uploaded"].append({
|
||||
"filename": file.filename,
|
||||
"path": rel
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
results["failed"].append({
|
||||
"filename": file.filename,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# 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,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {"bpm": row[0], "silence_start": row[1],
|
||||
"silence_end": row[2], "loudness_lufs": row[3]}
|
||||
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)
|
||||
if nd == decoded: break
|
||||
decoded = nd
|
||||
target = os.path.basename(decoded).lower()
|
||||
if target:
|
||||
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}",)
|
||||
).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}'")
|
||||
|
||||
|
||||
@app.get("/smart-dj/bulk-profiles")
|
||||
async def bulk_profiles(paths: str = Query(...)):
|
||||
results = {}
|
||||
for rp in (p.strip() for p in paths.split(",") if p.strip()):
|
||||
fp = resolve_path(rp)
|
||||
if not fp:
|
||||
results[rp] = None
|
||||
continue
|
||||
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,)
|
||||
).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] = None
|
||||
return results
|
||||
|
||||
|
||||
# ── Visualizer ────────────────────────────────────────────
|
||||
@app.get("/visualizer/frames")
|
||||
async def vis_frames(relative_path: str):
|
||||
fp = resolve_path(relative_path)
|
||||
if not fp:
|
||||
raise HTTPException(404, "Not found")
|
||||
frames = get_vis(fp)
|
||||
if not frames:
|
||||
raise HTTPException(500, "Generation failed")
|
||||
return {"frame_count": len(frames), "fps": 30.0, "points": 20, "frames": frames}
|
||||
|
||||
|
||||
@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):
|
||||
fp = os.path.join(root, f)
|
||||
if not os.path.exists(vis_cache_file(fp)):
|
||||
try:
|
||||
get_vis(fp)
|
||||
n += 1
|
||||
except:
|
||||
pass
|
||||
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}"}
|
||||
background_tasks.add_task(compute_all)
|
||||
return {"message": "Background vis computation started"}
|
||||
|
||||
|
||||
@app.post("/bulk-fix")
|
||||
async def bulk_fix(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(trigger_scan)
|
||||
return {"message": "Scan triggered"}
|
||||
|
||||
|
||||
# ── 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")
|
||||
if act == "ping":
|
||||
await push.send_to(ws, "pong", {"t": str(time.time())})
|
||||
elif act == "get_profile":
|
||||
rp = data.get("path", "")
|
||||
fp = resolve_path(rp)
|
||||
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,)
|
||||
).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]),
|
||||
"loudness_lufs": str(row[3])
|
||||
})
|
||||
else:
|
||||
await push.send_to(ws, "profile",
|
||||
{"path": rp, "error": "not_analyzed"})
|
||||
except Exception as e:
|
||||
await push.send_to(ws, "error", {"message": str(e)})
|
||||
elif act == "get_vis":
|
||||
rp = data.get("path", "")
|
||||
fp = resolve_path(rp)
|
||||
if fp:
|
||||
frames = get_vis(fp)
|
||||
if frames:
|
||||
await push.send_to(ws, "vis_frames", {
|
||||
"path": rp, "count": str(len(frames)),
|
||||
"fps": "30", "frames": json.dumps(frames)
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
push.disconnect(ws)
|
||||
except Exception as e:
|
||||
print(f"WS error: {e}")
|
||||
push.disconnect(ws)
|
||||
302
companion-api/pre_analyze.py
Normal file
302
companion-api/pre_analyze.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""
|
||||
Pre-Analyzer (crash-safe)
|
||||
Location: /home/pi/docker/navidrome/companion_api/pre_analyze.py
|
||||
|
||||
Each track is analyzed in a subprocess. If librosa OOMs or hangs on a large FLAC,
|
||||
only the child process dies — the parent logs the failure and moves on.
|
||||
|
||||
Usage:
|
||||
docker compose exec music-companion python pre_analyze.py
|
||||
docker compose exec music-companion python pre_analyze.py --force
|
||||
docker compose exec music-companion python pre_analyze.py --dj
|
||||
docker compose exec music-companion python pre_analyze.py --vis
|
||||
docker compose exec music-companion python pre_analyze.py --skip-large 500
|
||||
"""
|
||||
import os, sys, json, hashlib, sqlite3, subprocess, time, warnings, multiprocessing
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
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")
|
||||
SUPPORTED = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')
|
||||
TRACK_TIMEOUT = int(os.getenv("TRACK_TIMEOUT", "180")) # 3 min per track
|
||||
|
||||
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
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,
|
||||
analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
|
||||
|
||||
|
||||
def is_dj_done(path):
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
return c.execute("SELECT 1 FROM dj_profiles WHERE file_path=?", (path,)).fetchone() is not None
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def vis_path(path):
|
||||
return os.path.join(VIS_CACHE_DIR, hashlib.md5(path.encode()).hexdigest() + ".json")
|
||||
|
||||
|
||||
def is_vis_done(path):
|
||||
return os.path.exists(vis_path(path))
|
||||
|
||||
|
||||
def fmt(mb):
|
||||
return f"{mb/1024:.2f} GB" if mb >= 1024 else f"{mb:.1f} MB"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# CHILD PROCESS — runs in isolation, can be killed without
|
||||
# taking down the parent
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _worker(full_path, do_dj, do_vis, result_dict):
|
||||
"""Runs in a child process. Writes results to shared dict."""
|
||||
import re, gc, warnings
|
||||
warnings.filterwarnings("ignore")
|
||||
import numpy as np
|
||||
|
||||
dj_ok = False
|
||||
vis_ok = False
|
||||
error_msg = None
|
||||
|
||||
# ── DJ Analysis ───────────────────────────────────────
|
||||
if do_dj:
|
||||
try:
|
||||
import librosa
|
||||
|
||||
# ffmpeg for silence + loudness (streams, low memory)
|
||||
cmd = ["ffmpeg", "-hide_banner", "-i", full_path,
|
||||
"-af", "silencedetect=noise=-50dB:d=0.5,ebur128",
|
||||
"-f", "null", "-"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
out = r.stderr
|
||||
|
||||
ss = re.findall(r"silence_start: ([\d\.]+)", out)
|
||||
se = re.findall(r"silence_end: ([\d\.]+)", out)
|
||||
lu = re.search(r"I:\s+([\-\d\.]+) LUFS", out)
|
||||
|
||||
# trailing silence start = last silence_start (crossfade trigger)
|
||||
sil_start = float(ss[-1]) if ss else 0.0
|
||||
# leading silence end = first silence_end (skip-to point)
|
||||
sil_end = float(se[0]) if se else 0.0
|
||||
loudness = float(lu.group(1)) if lu else -14.0
|
||||
|
||||
# Sanity checks
|
||||
if sil_end > 10.0:
|
||||
sil_end = 0.0
|
||||
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
|
||||
|
||||
# BPM — load only 30s at low rate
|
||||
y, sr = librosa.load(full_path, sr=22050, duration=30)
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
del y; gc.collect()
|
||||
|
||||
try:
|
||||
bpm = float(tempo)
|
||||
except TypeError:
|
||||
bpm = float(tempo[0]) if tempo is not None else 0.0
|
||||
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO dj_profiles VALUES (?,?,?,?,?,CURRENT_TIMESTAMP)",
|
||||
(full_path, round(bpm,1), round(sil_start,3),
|
||||
round(sil_end,3), round(loudness,1)))
|
||||
dj_ok = True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "ffmpeg timeout (>90s)"
|
||||
except MemoryError:
|
||||
error_msg = "OUT OF MEMORY during DJ analysis"
|
||||
except Exception as e:
|
||||
error_msg = f"DJ: {e}"
|
||||
|
||||
# ── Vis Frames ────────────────────────────────────────
|
||||
if do_vis:
|
||||
cache_file = vis_path(full_path)
|
||||
if os.path.exists(cache_file):
|
||||
vis_ok = False # already cached
|
||||
else:
|
||||
try:
|
||||
import librosa
|
||||
|
||||
# Load at 22050 to save memory
|
||||
y, sr = librosa.load(full_path, sr=22050, mono=True)
|
||||
fps = 30.0; fft_size = 1024; pts = 20
|
||||
hop = 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); cutoff = min(half-1, 90)
|
||||
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))
|
||||
frames.append(fp)
|
||||
|
||||
del y; gc.collect()
|
||||
|
||||
# Normalize
|
||||
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)]
|
||||
if p95 > 0.001:
|
||||
s = 0.8/p95
|
||||
frames = [[min(1.0, v*s) for v in f] for f in frames]
|
||||
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(frames, f)
|
||||
del frames; gc.collect()
|
||||
vis_ok = True
|
||||
|
||||
except MemoryError:
|
||||
error_msg = (error_msg + " | " if error_msg else "") + "OUT OF MEMORY during vis"
|
||||
except Exception as e:
|
||||
error_msg = (error_msg + " | " if error_msg else "") + f"Vis: {e}"
|
||||
|
||||
result_dict["dj"] = dj_ok
|
||||
result_dict["vis"] = vis_ok
|
||||
result_dict["error"] = error_msg
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MAIN — runs workers as subprocesses
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def scan(force=False, dj_only=False, vis_only=False, skip_large_mb=0):
|
||||
init_db()
|
||||
os.makedirs(VIS_CACHE_DIR, exist_ok=True)
|
||||
|
||||
print(f"🔍 Scanning {MUSIC_DIR}...")
|
||||
tracks = []
|
||||
total_bytes = 0
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
for f in files:
|
||||
if f.lower().endswith(SUPPORTED):
|
||||
fp = os.path.join(root, f)
|
||||
tracks.append(fp)
|
||||
total_bytes += os.path.getsize(fp)
|
||||
|
||||
total_mb = total_bytes / 1048576
|
||||
mode = "FORCE" if force else "missing"
|
||||
what = "DJ only" if dj_only else "Vis only" if vis_only else "DJ + Vis"
|
||||
print(f"🚀 {len(tracks)} tracks ({fmt(total_mb)}) — {what} ({mode})")
|
||||
print(f" Timeout: {TRACK_TIMEOUT}s per track")
|
||||
if skip_large_mb:
|
||||
print(f" Skipping files > {skip_large_mb} MB")
|
||||
print(f" Each track runs in a subprocess (crash-safe)")
|
||||
print()
|
||||
|
||||
dj_n = vis_n = skip_n = fail_n = 0
|
||||
t0 = time.time()
|
||||
|
||||
for idx, path in enumerate(tracks):
|
||||
name = os.path.basename(path)
|
||||
size_mb = os.path.getsize(path) / 1048576
|
||||
tag = f"[{idx+1}/{len(tracks)}]"
|
||||
|
||||
# Skip oversized
|
||||
if skip_large_mb and size_mb > skip_large_mb:
|
||||
print(f" {tag} ⏭ SKIP ({fmt(size_mb)} > {skip_large_mb}MB): {name}")
|
||||
skip_n += 1
|
||||
continue
|
||||
|
||||
# Check what's needed
|
||||
need_dj = not vis_only and (force or not is_dj_done(path))
|
||||
need_vis = not dj_only and (force or not is_vis_done(path))
|
||||
|
||||
if not need_dj and not need_vis:
|
||||
skip_n += 1
|
||||
continue
|
||||
|
||||
tasks = []
|
||||
if need_dj: tasks.append("DJ")
|
||||
if need_vis: tasks.append("Vis")
|
||||
|
||||
print(f" {tag} 🎵 {'+'.join(tasks)} ({fmt(size_mb)}): {name}", end="", flush=True)
|
||||
t1 = time.time()
|
||||
|
||||
# Run in subprocess with shared dict for results
|
||||
manager = multiprocessing.Manager()
|
||||
result = manager.dict({"dj": False, "vis": False, "error": None})
|
||||
|
||||
proc = multiprocessing.Process(
|
||||
target=_worker,
|
||||
args=(path, need_dj, need_vis, result)
|
||||
)
|
||||
proc.start()
|
||||
proc.join(timeout=TRACK_TIMEOUT)
|
||||
|
||||
elapsed = time.time() - t1
|
||||
|
||||
if proc.is_alive():
|
||||
# Timed out — kill it
|
||||
proc.kill()
|
||||
proc.join()
|
||||
print(f" ⏰ KILLED after {elapsed:.0f}s (timeout)")
|
||||
fail_n += 1
|
||||
continue
|
||||
|
||||
if proc.exitcode != 0 and proc.exitcode is not None:
|
||||
# Crashed (OOM, segfault, etc.)
|
||||
print(f" 💥 CRASHED (exit code {proc.exitcode}, {elapsed:.1f}s)")
|
||||
fail_n += 1
|
||||
continue
|
||||
|
||||
# Success path
|
||||
err = result.get("error")
|
||||
if result.get("dj"):
|
||||
dj_n += 1
|
||||
if result.get("vis"):
|
||||
vis_n += 1
|
||||
|
||||
if err:
|
||||
print(f" ⚠ {elapsed:.1f}s — {err}")
|
||||
fail_n += 1
|
||||
else:
|
||||
print(f" ✓ {elapsed:.1f}s")
|
||||
|
||||
total_elapsed = time.time() - t0
|
||||
m = int(total_elapsed // 60)
|
||||
s = int(total_elapsed % 60)
|
||||
|
||||
print(f"\n✨ Done in {m}m {s}s")
|
||||
print(f" DJ profiles: {dj_n} new")
|
||||
print(f" Vis frames: {vis_n} new")
|
||||
print(f" Skipped: {skip_n} (already done)")
|
||||
if fail_n:
|
||||
print(f" ⚠ Failed: {fail_n} (see errors above)")
|
||||
print(f" Tip: re-run with --skip-large 200 to skip huge FLACs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
force = "--force" in args
|
||||
dj_only = "--dj" in args
|
||||
vis_only = "--vis" in args
|
||||
|
||||
skip_large = 0
|
||||
if "--skip-large" in args:
|
||||
i = args.index("--skip-large")
|
||||
if i + 1 < len(args):
|
||||
try: skip_large = int(args[i+1])
|
||||
except: pass
|
||||
|
||||
scan(force=force, dj_only=dj_only, vis_only=vis_only, skip_large_mb=skip_large)
|
||||
Loading…
Reference in a new issue