"""Library management endpoints.""" from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from sqlalchemy.orm import Session from pathlib import Path from typing import Optional import os from ...models.database import get_db from ...models.schema import AudioTrack from ...core.audio_processor import extract_all_features from ...core.essentia_classifier import EssentiaClassifier from ...core.transcoder import AudioTranscoder from ...core.waveform_generator import save_waveform_to_file from ...utils.logging import get_logger from ...utils.config import settings router = APIRouter() logger = get_logger(__name__) # Supported audio formats AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.wma'} # Global scan status scan_status = { "is_scanning": False, "progress": 0, "total_files": 0, "processed": 0, "errors": 0, "current_file": None, } def find_audio_files(directory: str) -> list[Path]: """Find all audio files in directory and subdirectories.""" audio_files = [] directory_path = Path(directory) if not directory_path.exists(): logger.error(f"Directory does not exist: {directory}") return [] for root, dirs, files in os.walk(directory_path): # Skip transcoded and waveforms directories dirs[:] = [d for d in dirs if d not in ['transcoded', 'waveforms', '.transcoded', '.waveforms']] for file in files: file_path = Path(root) / file if file_path.suffix.lower() in AUDIO_EXTENSIONS: audio_files.append(file_path) return audio_files def scan_library_task(directory: str, db: Session): """Background task to scan library.""" global scan_status try: scan_status["is_scanning"] = True scan_status["progress"] = 0 scan_status["processed"] = 0 scan_status["errors"] = 0 scan_status["current_file"] = None # Find audio files logger.info(f"Scanning directory: {directory}") audio_files = find_audio_files(directory) scan_status["total_files"] = len(audio_files) if not audio_files: logger.warning("No audio files found!") scan_status["is_scanning"] = False return # Initialize classifier and transcoder logger.info("Initializing Essentia classifier...") classifier = EssentiaClassifier() logger.info("Initializing audio transcoder...") transcoder = AudioTranscoder() if not transcoder.check_ffmpeg_available(): logger.error("FFmpeg is required for transcoding.") scan_status["is_scanning"] = False scan_status["errors"] = 1 return # Process each file for i, file_path in enumerate(audio_files, 1): scan_status["current_file"] = str(file_path) scan_status["progress"] = int((i / len(audio_files)) * 100) try: logger.info(f"[{i}/{len(audio_files)}] Processing: {file_path.name}") # Check if already in database existing = db.query(AudioTrack).filter( AudioTrack.filepath == str(file_path) ).first() if existing: # Check if needs transcoding/waveform needs_update = False if not existing.stream_filepath or not Path(existing.stream_filepath).exists(): logger.info(f" → Needs transcoding: {file_path.name}") needs_update = True # Transcode to MP3 128kbps stream_path = transcoder.transcode_to_mp3( str(file_path), bitrate="128k", overwrite=False ) if stream_path: existing.stream_filepath = stream_path if not existing.waveform_filepath or not Path(existing.waveform_filepath).exists(): logger.info(f" → Needs waveform: {file_path.name}") needs_update = True # Pre-compute waveform waveform_dir = file_path.parent / "waveforms" waveform_dir.mkdir(parents=True, exist_ok=True) waveform_path = waveform_dir / f"{file_path.stem}.waveform.json" if save_waveform_to_file(str(file_path), str(waveform_path), num_peaks=800): existing.waveform_filepath = str(waveform_path) if needs_update: db.commit() logger.info(f"✓ Updated: {file_path.name}") else: logger.info(f"Already complete, skipping: {file_path.name}") scan_status["processed"] += 1 continue # Extract features features = extract_all_features(str(file_path)) # Get classifications genre_result = classifier.predict_genre(str(file_path)) mood_result = classifier.predict_mood(str(file_path)) instruments = classifier.predict_instruments(str(file_path)) # Transcode to MP3 128kbps logger.info(" → Transcoding to MP3 128kbps...") stream_path = transcoder.transcode_to_mp3( str(file_path), bitrate="128k", overwrite=False ) # Pre-compute waveform logger.info(" → Generating waveform...") waveform_dir = file_path.parent / "waveforms" waveform_dir.mkdir(parents=True, exist_ok=True) waveform_path = waveform_dir / f"{file_path.stem}.waveform.json" waveform_success = save_waveform_to_file( str(file_path), str(waveform_path), num_peaks=800 ) # Create track record track = AudioTrack( filepath=str(file_path), stream_filepath=stream_path, waveform_filepath=str(waveform_path) if waveform_success else None, filename=file_path.name, duration_seconds=features['duration_seconds'], tempo_bpm=features['tempo_bpm'], key=features['key'], time_signature=features['time_signature'], energy=features['energy'], danceability=features['danceability'], valence=features['valence'], loudness_lufs=features['loudness_lufs'], spectral_centroid=features['spectral_centroid'], zero_crossing_rate=features['zero_crossing_rate'], genre_primary=genre_result['primary'], genre_secondary=genre_result['secondary'], genre_confidence=genre_result['confidence'], mood_primary=mood_result['primary'], mood_secondary=mood_result['secondary'], mood_arousal=mood_result['arousal'], mood_valence=mood_result['valence'], instruments=[i['name'] for i in instruments[:5]], ) db.add(track) db.commit() scan_status["processed"] += 1 logger.info(f"✓ Added: {file_path.name}") except Exception as e: logger.error(f"Failed to process {file_path}: {e}") scan_status["errors"] += 1 db.rollback() # Scan complete logger.info("=" * 60) logger.info(f"Scan complete!") logger.info(f" Total files: {len(audio_files)}") logger.info(f" Processed: {scan_status['processed']}") logger.info(f" Errors: {scan_status['errors']}") logger.info("=" * 60) except Exception as e: logger.error(f"Scan failed: {e}") scan_status["errors"] += 1 finally: scan_status["is_scanning"] = False scan_status["current_file"] = None @router.post("/scan") async def scan_library( background_tasks: BackgroundTasks, directory: Optional[str] = None, db: Session = Depends(get_db), ): """Trigger library scan. Args: background_tasks: FastAPI background tasks directory: Directory to scan (defaults to MUSIC_DIR from settings) db: Database session Returns: Scan status Raises: HTTPException: 400 if scan already in progress or directory invalid """ global scan_status if scan_status["is_scanning"]: raise HTTPException( status_code=400, detail="Scan already in progress" ) # Use default music directory if not provided scan_dir = directory if directory else "/audio" if not Path(scan_dir).exists(): raise HTTPException( status_code=400, detail=f"Directory does not exist: {scan_dir}" ) # Start scan in background background_tasks.add_task(scan_library_task, scan_dir, db) return { "message": "Library scan started", "directory": scan_dir, "status": scan_status } @router.get("/scan/status") async def get_scan_status(): """Get current scan status. Returns: Current scan status """ return scan_status