Transcodage systématique MP3 128kbps
J'ai implémenté une solution complète pour optimiser ton système audio : 1. Backend - Transcodage & Waveforms Nouveau module de transcodage (transcoder.py): Transcodage automatique en MP3 128kbps via FFmpeg Stockage dans dossier transcoded/ Compression ~70-90% selon format source Waveforms pré-calculées (waveform_generator.py): Génération lors du scan (800 points) Stockage JSON dans dossier waveforms/ Chargement instantané Schema BDD mis à jour (schema.py): filepath : fichier original (download) stream_filepath : MP3 128kbps (streaming) waveform_filepath : JSON pré-calculé Scanner amélioré (scanner.py): Transcode automatiquement chaque fichier Pré-calcule la waveform Stocke les 3 chemins en BDD 2. API - Endpoints Endpoint /api/library/scan (library.py): POST pour lancer un scan Tâche en arrière-plan Statut consultable via GET /api/library/scan/status Streaming optimisé (audio.py): Utilise stream_filepath (MP3 128kbps) en priorité Fallback sur fichier original si absent Waveform chargée depuis JSON pré-calculé 3. Frontend - Interface Bouton Rescan (page.tsx): Dans le header à droite Icône qui tourne pendant le scan Affichage progression en temps réel Reload automatique après scan 4. Base de données Migration appliquée (20251223_003_add_stream_waveform_paths.py): ALTER TABLE audio_tracks ADD COLUMN stream_filepath VARCHAR; ALTER TABLE audio_tracks ADD COLUMN waveform_filepath VARCHAR; CREATE INDEX idx_stream_filepath ON audio_tracks (stream_filepath); 🚀 Utilisation Via l'interface web Clique sur le bouton "Rescan" dans le header Le scan démarre automatiquement Tu vois la progression en temps réel La page se recharge automatiquement à la fin Via CLI (dans le container) docker-compose exec backend python -m src.cli.scanner /music 📊 Avantages ✅ Streaming ultra-rapide : MP3 128kbps = ~70-90% plus léger ✅ Waveform instantanée : Pré-calculée, pas de latence ✅ Download qualité : Fichier original préservé ✅ Rescan facile : Bouton dans l'UI ✅ Prêt pour serveur distant : Optimisé pour la bande passante
This commit is contained in:
@@ -8,7 +8,7 @@ from ..utils.logging import setup_logging, get_logger
|
||||
from ..models.database import engine, Base
|
||||
|
||||
# Import routes
|
||||
from .routes import tracks, search, audio, analyze, similar, stats
|
||||
from .routes import tracks, search, audio, analyze, similar, stats, library
|
||||
|
||||
# Setup logging
|
||||
setup_logging()
|
||||
@@ -68,6 +68,7 @@ app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
|
||||
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"])
|
||||
app.include_router(similar.router, prefix="/api", tags=["similar"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
|
||||
@@ -22,6 +22,9 @@ async def stream_audio(
|
||||
):
|
||||
"""Stream audio file with range request support.
|
||||
|
||||
Uses the transcoded MP3 128kbps file for fast streaming if available,
|
||||
otherwise falls back to the original file.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
request: HTTP request
|
||||
@@ -38,21 +41,29 @@ async def stream_audio(
|
||||
if not track:
|
||||
raise HTTPException(status_code=404, detail="Track not found")
|
||||
|
||||
file_path = Path(track.filepath)
|
||||
# Prefer stream_filepath (transcoded MP3) if available
|
||||
if track.stream_filepath and Path(track.stream_filepath).exists():
|
||||
file_path = Path(track.stream_filepath)
|
||||
media_type = "audio/mpeg"
|
||||
logger.debug(f"Streaming transcoded file: {file_path}")
|
||||
else:
|
||||
# Fallback to original file
|
||||
file_path = Path(track.filepath)
|
||||
|
||||
if not file_path.exists():
|
||||
logger.error(f"File not found: {track.filepath}")
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
if not file_path.exists():
|
||||
logger.error(f"File not found: {track.filepath}")
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
# Determine media type based on format
|
||||
media_types = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
"m4a": "audio/mp4",
|
||||
"ogg": "audio/ogg",
|
||||
}
|
||||
media_type = media_types.get(track.format, "audio/mpeg")
|
||||
# Determine media type based on format
|
||||
media_types = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
"m4a": "audio/mp4",
|
||||
"ogg": "audio/ogg",
|
||||
}
|
||||
media_type = media_types.get(track.format, "audio/mpeg")
|
||||
logger.debug(f"Streaming original file: {file_path}")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
@@ -121,6 +132,8 @@ async def get_waveform(
|
||||
):
|
||||
"""Get waveform peak data for visualization.
|
||||
|
||||
Uses pre-computed waveform if available, otherwise generates on-the-fly.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
num_peaks: Number of peaks to generate
|
||||
@@ -144,7 +157,14 @@ async def get_waveform(
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
try:
|
||||
waveform_data = get_waveform_data(str(file_path), num_peaks=num_peaks)
|
||||
# Use pre-computed waveform if available
|
||||
waveform_cache_path = track.waveform_filepath if track.waveform_filepath else None
|
||||
|
||||
waveform_data = get_waveform_data(
|
||||
str(file_path),
|
||||
num_peaks=num_peaks,
|
||||
waveform_cache_path=waveform_cache_path
|
||||
)
|
||||
return waveform_data
|
||||
|
||||
except Exception as e:
|
||||
|
||||
239
backend/src/api/routes/library.py
Normal file
239
backend/src/api/routes/library.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""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):
|
||||
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:
|
||||
logger.info(f"Already in database, 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 "/music"
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user