Compare commits

...

2 Commits

Author SHA1 Message Date
c366ca5ce0 Include Essentia models in repo + optimize CI/CD
All checks were successful
Build and Push Docker Images / Build Backend Image (push) Successful in 13m24s
Build and Push Docker Images / Build Frontend Image (push) Successful in 4m56s
Problème: Les modèles Essentia (28 MB) étaient téléchargés à chaque build CI/CD
- Ralentit les builds (~30 secondes de download)
- Consomme bande passante
- Point de défaillance si serveur Essentia down

Solution:
- Commit les 6 modèles dans backend/models/
- Supprime steps "Download Essentia models" du workflow Gitea
- Retire backend/models/*.pb et *.json du .gitignore

Modèles inclus (~28 MB total):
- discogs-effnet-bs64-1.pb (18 MB) - embedding model
- genre_discogs400-discogs-effnet-1.pb (2 MB) - genre classifier
- genre_discogs400-discogs-effnet-1.json (15 KB) - genre metadata
- mtg_jamendo_moodtheme-discogs-effnet-1.pb (2.6 MB) - mood
- mtg_jamendo_instrument-discogs-effnet-1.pb (2.6 MB) - instruments
- mtg_jamendo_genre-discogs-effnet-1.pb (2.7 MB) - genre alt

Bénéfices:
 Builds CI/CD plus rapides (~30s gagnées)
 Pas de dépendance externe au serveur Essentia
 Versioning des modèles avec le code
 Repo offline-friendly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 10:11:03 +01:00
774cb799a2 Ajout authentification JWT complète (app 100% protégée)
Backend:
- Nouveau module auth.py avec JWT et password handling
- Endpoint /api/auth/login (public)
- Endpoint /api/auth/me (protégé)
- TOUS les endpoints API protégés par require_auth
- Variables env: ADMIN_EMAIL, ADMIN_PASSWORD, JWT_SECRET_KEY
- Dependencies: python-jose, passlib

Frontend:
- Page de login (/login)
- AuthGuard component pour redirection automatique
- Axios interceptor: ajoute JWT token à chaque requête
- Gestion erreur 401: redirect automatique vers /login
- Bouton logout dans header
- Token stocké dans localStorage

Configuration:
- .env.example mis à jour avec variables auth
- Credentials admin configurables via env

Sécurité:
- Aucun endpoint public (sauf /api/auth/login et /health)
- JWT expiration configurable (24h par défaut)
- Password en clair dans env (à améliorer avec hash en prod)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 10:05:36 +01:00
21 changed files with 984 additions and 69 deletions

View File

@@ -17,6 +17,12 @@ ANALYSIS_NUM_WORKERS=4
ESSENTIA_MODELS_PATH=/app/models ESSENTIA_MODELS_PATH=/app/models
AUDIO_LIBRARY_PATH=/path/to/your/audio/library AUDIO_LIBRARY_PATH=/path/to/your/audio/library
# Authentication
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
JWT_SECRET_KEY=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRATION_HOURS=24
# Frontend # Frontend
# API URL accessed by the browser (use port 8001 since backend is mapped to 8001) # API URL accessed by the browser (use port 8001 since backend is mapped to 8001)
# For production on a remote server, set this to your server's public URL # For production on a remote server, set this to your server's public URL

View File

@@ -23,41 +23,6 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download Essentia models
run: |
mkdir -p backend/models
cd backend/models
# Download models from Essentia
echo "Downloading Essentia models..."
# Embedding model (18 MB)
curl -L -o discogs-effnet-bs64-1.pb \
https://essentia.upf.edu/models/feature-extractors/discogs-effnet/discogs-effnet-bs64-1.pb
# Genre classifier (2 MB)
curl -L -o genre_discogs400-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/genre_discogs400/genre_discogs400-discogs-effnet-1.pb
# Genre metadata
curl -L -o genre_discogs400-discogs-effnet-1.json \
https://essentia.upf.edu/models/classification-heads/genre_discogs400/genre_discogs400-discogs-effnet-1.json
# Mood classifier (2.7 MB)
curl -L -o mtg_jamendo_moodtheme-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_moodtheme/mtg_jamendo_moodtheme-discogs-effnet-1.pb
# Instrument classifier (2.6 MB)
curl -L -o mtg_jamendo_instrument-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_instrument/mtg_jamendo_instrument-discogs-effnet-1.pb
# Genre classifier alternative (2.7 MB)
curl -L -o mtg_jamendo_genre-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_genre/mtg_jamendo_genre-discogs-effnet-1.pb
ls -lh
echo "Models downloaded successfully!"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -112,25 +77,6 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download Essentia models (for context)
run: |
mkdir -p backend/models
cd backend/models
# Download models (needed because frontend build context is root)
curl -L -o discogs-effnet-bs64-1.pb \
https://essentia.upf.edu/models/feature-extractors/discogs-effnet/discogs-effnet-bs64-1.pb
curl -L -o genre_discogs400-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/genre_discogs400/genre_discogs400-discogs-effnet-1.pb
curl -L -o genre_discogs400-discogs-effnet-1.json \
https://essentia.upf.edu/models/classification-heads/genre_discogs400/genre_discogs400-discogs-effnet-1.json
curl -L -o mtg_jamendo_moodtheme-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_moodtheme/mtg_jamendo_moodtheme-discogs-effnet-1.pb
curl -L -o mtg_jamendo_instrument-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_instrument/mtg_jamendo_instrument-discogs-effnet-1.pb
curl -L -o mtg_jamendo_genre-discogs-effnet-1.pb \
https://essentia.upf.edu/models/classification-heads/mtg_jamendo_genre/mtg_jamendo_genre-discogs-effnet-1.pb
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

4
.gitignore vendored
View File

@@ -75,10 +75,6 @@ yarn-error.log*
# Docker # Docker
postgres_data/ postgres_data/
# Essentia models (large files, download separately)
backend/models/*.pb
backend/models/*.json
# Audio analysis cache # Audio analysis cache
*.peaks.json *.peaks.json
.audio_cache/ .audio_cache/

Binary file not shown.

View File

@@ -0,0 +1,462 @@
{
"name": "Genre Discogs400",
"type": "Music genre classification",
"link": "https://essentia.upf.edu/models/classification-heads/genre_discogs400/genre_discogs400-discogs-effnet-1.pb",
"version": "1",
"description": "Prediction of 400 music styles in the from the Discogs taxonomy",
"author": "Pablo Alonso",
"email": "pablo.alonso@upf.edu",
"release_date": "2023-05-04",
"framework": "tensorflow",
"framework_version": "2.8.0",
"classes": [
"Blues---Boogie Woogie",
"Blues---Chicago Blues",
"Blues---Country Blues",
"Blues---Delta Blues",
"Blues---Electric Blues",
"Blues---Harmonica Blues",
"Blues---Jump Blues",
"Blues---Louisiana Blues",
"Blues---Modern Electric Blues",
"Blues---Piano Blues",
"Blues---Rhythm & Blues",
"Blues---Texas Blues",
"Brass & Military---Brass Band",
"Brass & Military---Marches",
"Brass & Military---Military",
"Children's---Educational",
"Children's---Nursery Rhymes",
"Children's---Story",
"Classical---Baroque",
"Classical---Choral",
"Classical---Classical",
"Classical---Contemporary",
"Classical---Impressionist",
"Classical---Medieval",
"Classical---Modern",
"Classical---Neo-Classical",
"Classical---Neo-Romantic",
"Classical---Opera",
"Classical---Post-Modern",
"Classical---Renaissance",
"Classical---Romantic",
"Electronic---Abstract",
"Electronic---Acid",
"Electronic---Acid House",
"Electronic---Acid Jazz",
"Electronic---Ambient",
"Electronic---Bassline",
"Electronic---Beatdown",
"Electronic---Berlin-School",
"Electronic---Big Beat",
"Electronic---Bleep",
"Electronic---Breakbeat",
"Electronic---Breakcore",
"Electronic---Breaks",
"Electronic---Broken Beat",
"Electronic---Chillwave",
"Electronic---Chiptune",
"Electronic---Dance-pop",
"Electronic---Dark Ambient",
"Electronic---Darkwave",
"Electronic---Deep House",
"Electronic---Deep Techno",
"Electronic---Disco",
"Electronic---Disco Polo",
"Electronic---Donk",
"Electronic---Downtempo",
"Electronic---Drone",
"Electronic---Drum n Bass",
"Electronic---Dub",
"Electronic---Dub Techno",
"Electronic---Dubstep",
"Electronic---Dungeon Synth",
"Electronic---EBM",
"Electronic---Electro",
"Electronic---Electro House",
"Electronic---Electroclash",
"Electronic---Euro House",
"Electronic---Euro-Disco",
"Electronic---Eurobeat",
"Electronic---Eurodance",
"Electronic---Experimental",
"Electronic---Freestyle",
"Electronic---Future Jazz",
"Electronic---Gabber",
"Electronic---Garage House",
"Electronic---Ghetto",
"Electronic---Ghetto House",
"Electronic---Glitch",
"Electronic---Goa Trance",
"Electronic---Grime",
"Electronic---Halftime",
"Electronic---Hands Up",
"Electronic---Happy Hardcore",
"Electronic---Hard House",
"Electronic---Hard Techno",
"Electronic---Hard Trance",
"Electronic---Hardcore",
"Electronic---Hardstyle",
"Electronic---Hi NRG",
"Electronic---Hip Hop",
"Electronic---Hip-House",
"Electronic---House",
"Electronic---IDM",
"Electronic---Illbient",
"Electronic---Industrial",
"Electronic---Italo House",
"Electronic---Italo-Disco",
"Electronic---Italodance",
"Electronic---Jazzdance",
"Electronic---Juke",
"Electronic---Jumpstyle",
"Electronic---Jungle",
"Electronic---Latin",
"Electronic---Leftfield",
"Electronic---Makina",
"Electronic---Minimal",
"Electronic---Minimal Techno",
"Electronic---Modern Classical",
"Electronic---Musique Concr\u00e8te",
"Electronic---Neofolk",
"Electronic---New Age",
"Electronic---New Beat",
"Electronic---New Wave",
"Electronic---Noise",
"Electronic---Nu-Disco",
"Electronic---Power Electronics",
"Electronic---Progressive Breaks",
"Electronic---Progressive House",
"Electronic---Progressive Trance",
"Electronic---Psy-Trance",
"Electronic---Rhythmic Noise",
"Electronic---Schranz",
"Electronic---Sound Collage",
"Electronic---Speed Garage",
"Electronic---Speedcore",
"Electronic---Synth-pop",
"Electronic---Synthwave",
"Electronic---Tech House",
"Electronic---Tech Trance",
"Electronic---Techno",
"Electronic---Trance",
"Electronic---Tribal",
"Electronic---Tribal House",
"Electronic---Trip Hop",
"Electronic---Tropical House",
"Electronic---UK Garage",
"Electronic---Vaporwave",
"Folk, World, & Country---African",
"Folk, World, & Country---Bluegrass",
"Folk, World, & Country---Cajun",
"Folk, World, & Country---Canzone Napoletana",
"Folk, World, & Country---Catalan Music",
"Folk, World, & Country---Celtic",
"Folk, World, & Country---Country",
"Folk, World, & Country---Fado",
"Folk, World, & Country---Flamenco",
"Folk, World, & Country---Folk",
"Folk, World, & Country---Gospel",
"Folk, World, & Country---Highlife",
"Folk, World, & Country---Hillbilly",
"Folk, World, & Country---Hindustani",
"Folk, World, & Country---Honky Tonk",
"Folk, World, & Country---Indian Classical",
"Folk, World, & Country---La\u00efk\u00f3",
"Folk, World, & Country---Nordic",
"Folk, World, & Country---Pacific",
"Folk, World, & Country---Polka",
"Folk, World, & Country---Ra\u00ef",
"Folk, World, & Country---Romani",
"Folk, World, & Country---Soukous",
"Folk, World, & Country---S\u00e9ga",
"Folk, World, & Country---Volksmusik",
"Folk, World, & Country---Zouk",
"Folk, World, & Country---\u00c9ntekhno",
"Funk / Soul---Afrobeat",
"Funk / Soul---Boogie",
"Funk / Soul---Contemporary R&B",
"Funk / Soul---Disco",
"Funk / Soul---Free Funk",
"Funk / Soul---Funk",
"Funk / Soul---Gospel",
"Funk / Soul---Neo Soul",
"Funk / Soul---New Jack Swing",
"Funk / Soul---P.Funk",
"Funk / Soul---Psychedelic",
"Funk / Soul---Rhythm & Blues",
"Funk / Soul---Soul",
"Funk / Soul---Swingbeat",
"Funk / Soul---UK Street Soul",
"Hip Hop---Bass Music",
"Hip Hop---Boom Bap",
"Hip Hop---Bounce",
"Hip Hop---Britcore",
"Hip Hop---Cloud Rap",
"Hip Hop---Conscious",
"Hip Hop---Crunk",
"Hip Hop---Cut-up/DJ",
"Hip Hop---DJ Battle Tool",
"Hip Hop---Electro",
"Hip Hop---G-Funk",
"Hip Hop---Gangsta",
"Hip Hop---Grime",
"Hip Hop---Hardcore Hip-Hop",
"Hip Hop---Horrorcore",
"Hip Hop---Instrumental",
"Hip Hop---Jazzy Hip-Hop",
"Hip Hop---Miami Bass",
"Hip Hop---Pop Rap",
"Hip Hop---Ragga HipHop",
"Hip Hop---RnB/Swing",
"Hip Hop---Screw",
"Hip Hop---Thug Rap",
"Hip Hop---Trap",
"Hip Hop---Trip Hop",
"Hip Hop---Turntablism",
"Jazz---Afro-Cuban Jazz",
"Jazz---Afrobeat",
"Jazz---Avant-garde Jazz",
"Jazz---Big Band",
"Jazz---Bop",
"Jazz---Bossa Nova",
"Jazz---Contemporary Jazz",
"Jazz---Cool Jazz",
"Jazz---Dixieland",
"Jazz---Easy Listening",
"Jazz---Free Improvisation",
"Jazz---Free Jazz",
"Jazz---Fusion",
"Jazz---Gypsy Jazz",
"Jazz---Hard Bop",
"Jazz---Jazz-Funk",
"Jazz---Jazz-Rock",
"Jazz---Latin Jazz",
"Jazz---Modal",
"Jazz---Post Bop",
"Jazz---Ragtime",
"Jazz---Smooth Jazz",
"Jazz---Soul-Jazz",
"Jazz---Space-Age",
"Jazz---Swing",
"Latin---Afro-Cuban",
"Latin---Bai\u00e3o",
"Latin---Batucada",
"Latin---Beguine",
"Latin---Bolero",
"Latin---Boogaloo",
"Latin---Bossanova",
"Latin---Cha-Cha",
"Latin---Charanga",
"Latin---Compas",
"Latin---Cubano",
"Latin---Cumbia",
"Latin---Descarga",
"Latin---Forr\u00f3",
"Latin---Guaguanc\u00f3",
"Latin---Guajira",
"Latin---Guaracha",
"Latin---MPB",
"Latin---Mambo",
"Latin---Mariachi",
"Latin---Merengue",
"Latin---Norte\u00f1o",
"Latin---Nueva Cancion",
"Latin---Pachanga",
"Latin---Porro",
"Latin---Ranchera",
"Latin---Reggaeton",
"Latin---Rumba",
"Latin---Salsa",
"Latin---Samba",
"Latin---Son",
"Latin---Son Montuno",
"Latin---Tango",
"Latin---Tejano",
"Latin---Vallenato",
"Non-Music---Audiobook",
"Non-Music---Comedy",
"Non-Music---Dialogue",
"Non-Music---Education",
"Non-Music---Field Recording",
"Non-Music---Interview",
"Non-Music---Monolog",
"Non-Music---Poetry",
"Non-Music---Political",
"Non-Music---Promotional",
"Non-Music---Radioplay",
"Non-Music---Religious",
"Non-Music---Spoken Word",
"Pop---Ballad",
"Pop---Bollywood",
"Pop---Bubblegum",
"Pop---Chanson",
"Pop---City Pop",
"Pop---Europop",
"Pop---Indie Pop",
"Pop---J-pop",
"Pop---K-pop",
"Pop---Kay\u014dkyoku",
"Pop---Light Music",
"Pop---Music Hall",
"Pop---Novelty",
"Pop---Parody",
"Pop---Schlager",
"Pop---Vocal",
"Reggae---Calypso",
"Reggae---Dancehall",
"Reggae---Dub",
"Reggae---Lovers Rock",
"Reggae---Ragga",
"Reggae---Reggae",
"Reggae---Reggae-Pop",
"Reggae---Rocksteady",
"Reggae---Roots Reggae",
"Reggae---Ska",
"Reggae---Soca",
"Rock---AOR",
"Rock---Acid Rock",
"Rock---Acoustic",
"Rock---Alternative Rock",
"Rock---Arena Rock",
"Rock---Art Rock",
"Rock---Atmospheric Black Metal",
"Rock---Avantgarde",
"Rock---Beat",
"Rock---Black Metal",
"Rock---Blues Rock",
"Rock---Brit Pop",
"Rock---Classic Rock",
"Rock---Coldwave",
"Rock---Country Rock",
"Rock---Crust",
"Rock---Death Metal",
"Rock---Deathcore",
"Rock---Deathrock",
"Rock---Depressive Black Metal",
"Rock---Doo Wop",
"Rock---Doom Metal",
"Rock---Dream Pop",
"Rock---Emo",
"Rock---Ethereal",
"Rock---Experimental",
"Rock---Folk Metal",
"Rock---Folk Rock",
"Rock---Funeral Doom Metal",
"Rock---Funk Metal",
"Rock---Garage Rock",
"Rock---Glam",
"Rock---Goregrind",
"Rock---Goth Rock",
"Rock---Gothic Metal",
"Rock---Grindcore",
"Rock---Grunge",
"Rock---Hard Rock",
"Rock---Hardcore",
"Rock---Heavy Metal",
"Rock---Indie Rock",
"Rock---Industrial",
"Rock---Krautrock",
"Rock---Lo-Fi",
"Rock---Lounge",
"Rock---Math Rock",
"Rock---Melodic Death Metal",
"Rock---Melodic Hardcore",
"Rock---Metalcore",
"Rock---Mod",
"Rock---Neofolk",
"Rock---New Wave",
"Rock---No Wave",
"Rock---Noise",
"Rock---Noisecore",
"Rock---Nu Metal",
"Rock---Oi",
"Rock---Parody",
"Rock---Pop Punk",
"Rock---Pop Rock",
"Rock---Pornogrind",
"Rock---Post Rock",
"Rock---Post-Hardcore",
"Rock---Post-Metal",
"Rock---Post-Punk",
"Rock---Power Metal",
"Rock---Power Pop",
"Rock---Power Violence",
"Rock---Prog Rock",
"Rock---Progressive Metal",
"Rock---Psychedelic Rock",
"Rock---Psychobilly",
"Rock---Pub Rock",
"Rock---Punk",
"Rock---Rock & Roll",
"Rock---Rockabilly",
"Rock---Shoegaze",
"Rock---Ska",
"Rock---Sludge Metal",
"Rock---Soft Rock",
"Rock---Southern Rock",
"Rock---Space Rock",
"Rock---Speed Metal",
"Rock---Stoner Rock",
"Rock---Surf",
"Rock---Symphonic Rock",
"Rock---Technical Death Metal",
"Rock---Thrash",
"Rock---Twist",
"Rock---Viking Metal",
"Rock---Y\u00e9-Y\u00e9",
"Stage & Screen---Musical",
"Stage & Screen---Score",
"Stage & Screen---Soundtrack",
"Stage & Screen---Theme"
],
"model_types": [
"frozen_model",
"SavedModel",
"onnx"
],
"dataset": {
"name": "Discogs-4M (unreleased)",
"citation": "In-house dataset",
"size": "4M full tracks (3.3M used)",
"metrics": {
"ROC-AUC": 0.95417,
"PR-AUC": 0.20629
}
},
"schema": {
"inputs": [
{
"name": "serving_default_model_Placeholder",
"type": "float",
"shape": [
"batch_size",
1280
]
}
],
"outputs": [
{
"name": "PartitionedCall:0",
"type": "float",
"shape": [
"batch_size",
400
],
"op": "Sigmoid",
"output_purpose": "predictions"
}
]
},
"citation": "@inproceedings{alonso2022music,\n title={Music Representation Learning Based on Editorial Metadata from Discogs},\n author={Alonso-Jim{\\'e}nez, Pablo and Serra, Xavier and Bogdanov, Dmitry},\n booktitle={Conference of the International Society for Music Information Retrieval (ISMIR)},\n year={2022}\n}",
"inference": {
"sample_rate": 16000,
"algorithm": "TensorflowPredict2D",
"embedding_model": {
"algorithm": "TensorflowPredictEffnetDiscogs",
"model_name": "discogs-effnet-bs64-1",
"link": "https://essentia.upf.edu/models/music-style-classification/discogs-effnet/discogs-effnet-bs64-1.pb"
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -27,6 +27,10 @@ pydantic==2.5.3
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-dotenv==1.0.0 python-dotenv==1.0.0
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Utilities # Utilities
aiofiles==23.2.1 aiofiles==23.2.1
httpx==0.26.0 httpx==0.26.0

View File

@@ -1,14 +1,15 @@
"""FastAPI main application.""" """FastAPI main application."""
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from ..utils.config import settings from ..utils.config import settings
from ..utils.logging import setup_logging, get_logger from ..utils.logging import setup_logging, get_logger
from ..models.database import engine, Base from ..models.database import engine, Base
from ..core.auth import require_auth
# Import routes # Import routes
from .routes import tracks, search, audio, analyze, similar, stats, library from .routes import tracks, search, audio, analyze, similar, stats, library, auth
# Setup logging # Setup logging
setup_logging() setup_logging()
@@ -62,13 +63,17 @@ async def health_check():
# Include routers # Include routers
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"]) # Auth endpoints (public - no auth required)
app.include_router(search.router, prefix="/api/search", tags=["search"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"]) # Protected endpoints (auth required for ALL routes)
app.include_router(similar.router, prefix="/api", tags=["similar"]) app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"], dependencies=[Depends(require_auth)])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(search.router, prefix="/api/search", tags=["search"], dependencies=[Depends(require_auth)])
app.include_router(library.router, prefix="/api/library", tags=["library"]) app.include_router(audio.router, prefix="/api/audio", tags=["audio"], dependencies=[Depends(require_auth)])
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"], dependencies=[Depends(require_auth)])
app.include_router(similar.router, prefix="/api", tags=["similar"], dependencies=[Depends(require_auth)])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=[Depends(require_auth)])
app.include_router(library.router, prefix="/api/library", tags=["library"], dependencies=[Depends(require_auth)])
@app.get("/", tags=["root"]) @app.get("/", tags=["root"])

View File

@@ -0,0 +1,82 @@
"""Authentication endpoints."""
from datetime import timedelta
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, EmailStr
from ...core.auth import authenticate_user, create_access_token, get_current_user
from ...utils.config import settings
from ...utils.logging import get_logger
router = APIRouter()
logger = get_logger(__name__)
class LoginRequest(BaseModel):
"""Login request model."""
email: EmailStr
password: str
class LoginResponse(BaseModel):
"""Login response model."""
access_token: str
token_type: str = "bearer"
user: dict
class UserResponse(BaseModel):
"""User response model."""
email: str
role: str
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""Authenticate user and return JWT token.
Args:
request: Login credentials
Returns:
Access token and user info
Raises:
HTTPException: 401 if credentials are invalid
"""
user = authenticate_user(request.email, request.password)
if not user:
logger.warning(f"Failed login attempt for: {request.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(hours=settings.JWT_EXPIRATION_HOURS)
access_token = create_access_token(
data={"sub": user["email"], "role": user["role"]},
expires_delta=access_token_expires
)
logger.info(f"User logged in: {user['email']}")
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: dict = Depends(get_current_user)):
"""Get current authenticated user info.
Args:
current_user: Current user from JWT token
Returns:
User information
"""
return current_user

151
backend/src/core/auth.py Normal file
View File

@@ -0,0 +1,151 @@
"""Authentication utilities."""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..utils.config import settings
from ..utils.logging import get_logger
logger = get_logger(__name__)
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# HTTP Bearer for JWT
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password
Returns:
True if password matches
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token.
Args:
data: Data to encode in token
expires_delta: Token expiration time
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm="HS256")
return encoded_jwt
def verify_token(token: str) -> dict:
"""Verify and decode JWT token.
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
HTTPException: If token is invalid
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
return payload
except JWTError as e:
logger.error(f"Token verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def authenticate_user(email: str, password: str) -> Optional[dict]:
"""Authenticate user with email and password.
Args:
email: User email
password: User password
Returns:
User data if authenticated, None otherwise
"""
# Check against admin credentials from environment
if email == settings.ADMIN_EMAIL and password == settings.ADMIN_PASSWORD:
return {
"email": email,
"role": "admin"
}
return None
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""Get current authenticated user from JWT token.
Args:
credentials: HTTP Bearer credentials
Returns:
User data from token
Raises:
HTTPException: If authentication fails
"""
token = credentials.credentials
payload = verify_token(token)
email: str = payload.get("sub")
if email is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return {
"email": email,
"role": payload.get("role", "user")
}
async def require_auth(current_user: dict = Depends(get_current_user)) -> dict:
"""Dependency to require authentication.
Args:
current_user: Current user from get_current_user
Returns:
Current user data
"""
return current_user

View File

@@ -21,6 +21,12 @@ class Settings(BaseSettings):
ESSENTIA_MODELS_PATH: str = "./models" ESSENTIA_MODELS_PATH: str = "./models"
AUDIO_LIBRARY_PATH: str = "/audio" AUDIO_LIBRARY_PATH: str = "/audio"
# Authentication
ADMIN_EMAIL: str = "admin@example.com"
ADMIN_PASSWORD: str = "changeme"
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_EXPIRATION_HOURS: int = 24
# Application # Application
APP_NAME: str = "Audio Classifier API" APP_NAME: str = "Audio Classifier API"
APP_VERSION: str = "1.0.0" APP_VERSION: str = "1.0.0"

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next"
import { Inter } from "next/font/google" import { Inter } from "next/font/google"
import "./globals.css" import "./globals.css"
import { QueryProvider } from "@/components/providers/QueryProvider" import { QueryProvider } from "@/components/providers/QueryProvider"
import AuthGuard from "@/components/AuthGuard"
import Script from "next/script" import Script from "next/script"
const inter = Inter({ subsets: ["latin"] }) const inter = Inter({ subsets: ["latin"] })
@@ -23,7 +24,9 @@ export default function RootLayout({
</head> </head>
<body className={inter.className}> <body className={inter.className}>
<QueryProvider> <QueryProvider>
<AuthGuard>
{children} {children}
</AuthGuard>
</QueryProvider> </QueryProvider>
</body> </body>
</html> </html>

124
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { getApiUrl } from "@/lib/api"
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
try {
const response = await fetch(`${getApiUrl()}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || "Login failed")
}
const data = await response.json()
// Store token in localStorage
localStorage.setItem("access_token", data.access_token)
localStorage.setItem("user", JSON.stringify(data.user))
// Redirect to home
router.push("/")
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-lg shadow-2xl p-8">
{/* Logo/Title */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Audio Classifier
</h1>
<p className="text-gray-600">Sign in to continue</p>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-md text-sm">
{error}
</div>
)}
{/* Login form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin@example.com"
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:bg-blue-400 disabled:cursor-not-allowed"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
{/* Footer */}
<p className="text-center text-gray-400 text-sm mt-6">
Audio Classifier v1.0.0
</p>
</div>
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { useState, useMemo } from "react" import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getTracks, getApiUrl } from "@/lib/api" import { getTracks, getApiUrl } from "@/lib/api"
import { logout, getUser } from "@/lib/auth"
import type { FilterParams, Track } from "@/lib/types" import type { FilterParams, Track } from "@/lib/types"
import FilterPanel from "@/components/FilterPanel" import FilterPanel from "@/components/FilterPanel"
import AudioPlayer from "@/components/AudioPlayer" import AudioPlayer from "@/components/AudioPlayer"
@@ -160,6 +161,18 @@ export default function Home() {
{tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''} {tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''}
</div> </div>
{/* Logout button */}
<button
onClick={logout}
className="px-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors flex items-center gap-2"
title="Déconnexion"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</button>
{/* Rescan button */} {/* Rescan button */}
<button <button
onClick={handleRescan} onClick={handleRescan}

View File

@@ -0,0 +1,37 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter, usePathname } from "next/navigation"
import { isAuthenticated } from "@/lib/auth"
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
// Skip auth check for login page
if (pathname === "/login") {
setIsChecking(false)
return
}
// Check if user is authenticated
if (!isAuthenticated()) {
router.push("/login")
} else {
setIsChecking(false)
}
}, [pathname, router])
// Show loading while checking auth
if (isChecking && pathname !== "/login") {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-white">Loading...</div>
</div>
)
}
return <>{children}</>
}

View File

@@ -24,12 +24,38 @@ export function getApiUrl(): string {
// Create axios instance dynamically to use runtime config // Create axios instance dynamically to use runtime config
function getApiClient() { function getApiClient() {
return axios.create({ const client = axios.create({
baseURL: getApiUrl(), baseURL: getApiUrl(),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
// Add JWT token to requests if available
client.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
// Handle 401 errors (redirect to login)
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && typeof window !== 'undefined') {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
return client
} }
// Tracks // Tracks

34
frontend/lib/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Authentication utilities
*/
export function getToken(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem("access_token")
}
export function setToken(token: string): void {
localStorage.setItem("access_token", token)
}
export function removeToken(): void {
localStorage.removeItem("access_token")
localStorage.removeItem("user")
}
export function getUser(): any | null {
if (typeof window === "undefined") return null
const user = localStorage.getItem("user")
return user ? JSON.parse(user) : null
}
export function isAuthenticated(): boolean {
return getToken() !== null
}
export function logout(): void {
removeToken()
if (typeof window !== "undefined") {
window.location.href = "/login"
}
}

20
frontend/middleware.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Middleware runs on server, can't access localStorage
// Auth check will be done client-side in layout.tsx
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}