Compare commits
9 Commits
c366ca5ce0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a55de3299 | |||
| f3f321511d | |||
| 34fcbe1223 | |||
| f05958ed36 | |||
| aa252487b8 | |||
| ed7034f55b | |||
| 0fbfb6f8ed | |||
| 16b3fdabed | |||
| eeee538fcd |
61
.gitea/workflows/docker-base.yml
Normal file
61
.gitea/workflows/docker-base.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Build Base Docker Image
|
||||||
|
|
||||||
|
# Build base image only when requirements.txt changes or manually triggered
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'backend/requirements.txt'
|
||||||
|
- 'backend/Dockerfile.base'
|
||||||
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.benoitsz.com
|
||||||
|
IMAGE_BASE: audio-classifier-base
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-base:
|
||||||
|
name: Build Base Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BASE }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
|
||||||
|
- name: Build and push base image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile.base
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BASE }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BASE }}:buildcache,mode=max
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
|
- name: Image built successfully
|
||||||
|
run: |
|
||||||
|
echo "✅ Base image built and pushed successfully"
|
||||||
|
echo "📦 Image: ${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BASE }}:latest"
|
||||||
|
echo "⏱️ This image will be used by the main backend builds to speed up CI/CD"
|
||||||
@@ -62,10 +62,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.version.outputs.VERSION }}
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
|
BASE_IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/audio-classifier-base:latest
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BACKEND }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BACKEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
name: Build Frontend Image
|
name: Build Frontend Image
|
||||||
|
|||||||
140
DEPENDENCIES.md
Normal file
140
DEPENDENCIES.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Dépendances du projet
|
||||||
|
|
||||||
|
## Backend Python (requirements.txt)
|
||||||
|
|
||||||
|
### Web Framework
|
||||||
|
- `fastapi==0.109.0` - Framework web moderne
|
||||||
|
- `uvicorn[standard]==0.27.0` - Serveur ASGI
|
||||||
|
- `python-multipart==0.0.6` - Support formulaires multipart
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- `sqlalchemy==2.0.25` - ORM
|
||||||
|
- `psycopg2-binary==2.9.9` - Driver PostgreSQL
|
||||||
|
- `pgvector==0.2.4` - Extension vecteurs PostgreSQL
|
||||||
|
- `alembic==1.13.1` - Migrations de base de données
|
||||||
|
|
||||||
|
### Audio Processing
|
||||||
|
- `librosa==0.10.1` - Analyse audio
|
||||||
|
- `soundfile==0.12.1` - Lecture/écriture fichiers audio
|
||||||
|
- `audioread==3.0.1` - Décodage formats audio
|
||||||
|
- `mutagen==1.47.0` - Métadonnées ID3
|
||||||
|
|
||||||
|
### Machine Learning
|
||||||
|
- `essentia-tensorflow` - Classification genre/mood/instruments (installé via Dockerfile)
|
||||||
|
- `numpy==1.24.3` - Calcul numérique
|
||||||
|
- `scipy==1.11.4` - Calcul scientifique
|
||||||
|
|
||||||
|
### Configuration & Validation
|
||||||
|
- `pydantic==2.5.3` - Validation de données
|
||||||
|
- `pydantic-settings==2.1.0` - Configuration via env vars
|
||||||
|
- `python-dotenv==1.0.0` - Chargement fichier .env
|
||||||
|
- `email-validator==2.1.0` - Validation emails (requis par Pydantic EmailStr)
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `python-jose[cryptography]==3.3.0` - JWT tokens
|
||||||
|
- `passlib[bcrypt]==1.7.4` - Hashing passwords
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
- `aiofiles==23.2.1` - I/O fichiers asynchrones
|
||||||
|
- `httpx==0.26.0` - Client HTTP asynchrone
|
||||||
|
|
||||||
|
## Dépendances Système (Dockerfile)
|
||||||
|
|
||||||
|
### Requis pour le backend
|
||||||
|
```bash
|
||||||
|
apt-get install -y \
|
||||||
|
ffmpeg # Transcodage audio (MP3, etc.)
|
||||||
|
libsndfile1 # Lecture formats audio
|
||||||
|
gcc g++ gfortran # Compilation packages Python
|
||||||
|
libopenblas-dev # Algèbre linéaire optimisée
|
||||||
|
liblapack-dev # Routines algèbre linéaire
|
||||||
|
libfftw3-dev # Transformées de Fourier rapides
|
||||||
|
libavcodec-dev # Codecs audio/vidéo
|
||||||
|
libavformat-dev # Formats conteneurs
|
||||||
|
libavutil-dev # Utilitaires FFmpeg
|
||||||
|
libswresample-dev # Resampling audio
|
||||||
|
libsamplerate0-dev # Conversion taux d'échantillonnage
|
||||||
|
libtag1-dev # Métadonnées audio
|
||||||
|
libchromaprint-dev # Audio fingerprinting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend (package.json)
|
||||||
|
|
||||||
|
### Framework
|
||||||
|
- `next@15.5.6` - Framework React
|
||||||
|
- `react@19.0.0` - Bibliothèque UI
|
||||||
|
- `react-dom@19.0.0` - Rendu React
|
||||||
|
|
||||||
|
### State Management & Data Fetching
|
||||||
|
- `@tanstack/react-query@5.62.11` - Gestion état serveur
|
||||||
|
- `axios@1.7.9` - Client HTTP
|
||||||
|
|
||||||
|
### UI & Styling
|
||||||
|
- `tailwindcss@3.4.17` - Framework CSS utility-first
|
||||||
|
|
||||||
|
### Types
|
||||||
|
- `typescript@5.7.2` - Typage statique
|
||||||
|
- `@types/react@19.0.1`
|
||||||
|
- `@types/node@22.10.1`
|
||||||
|
|
||||||
|
## Modèles Essentia (inclus dans le repo)
|
||||||
|
|
||||||
|
Total: ~28 MB
|
||||||
|
|
||||||
|
- `discogs-effnet-bs64-1.pb` (18 MB) - Modèle d'embedding
|
||||||
|
- `genre_discogs400-discogs-effnet-1.pb` (2 MB) - Classification genre
|
||||||
|
- `genre_discogs400-discogs-effnet-1.json` (15 KB) - Métadonnées genres
|
||||||
|
- `mtg_jamendo_moodtheme-discogs-effnet-1.pb` (2.6 MB) - Classification mood
|
||||||
|
- `mtg_jamendo_instrument-discogs-effnet-1.pb` (2.6 MB) - Classification instruments
|
||||||
|
- `mtg_jamendo_genre-discogs-effnet-1.pb` (2.7 MB) - Classification genre (alternatif)
|
||||||
|
|
||||||
|
## Vérification des dépendances
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python check_dependencies.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Docker
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
docker build -t audio-classifier-backend -f backend/Dockerfile .
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
docker build -t audio-classifier-frontend -f frontend/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes de compatibilité
|
||||||
|
|
||||||
|
- **Python**: 3.9 (requis pour essentia-tensorflow)
|
||||||
|
- **Architecture**: amd64 (meilleure compatibilité Essentia)
|
||||||
|
- **Node.js**: 20+ (pour Next.js 15)
|
||||||
|
- **PostgreSQL**: 16+ avec extension pgvector
|
||||||
|
|
||||||
|
## Installation locale
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install essentia-tensorflow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables d'environnement requises
|
||||||
|
|
||||||
|
Voir `.env.example` pour la liste complète des variables nécessaires.
|
||||||
|
|
||||||
|
### Critiques
|
||||||
|
- `DATABASE_URL` - Connexion PostgreSQL
|
||||||
|
- `ADMIN_EMAIL` - Email admin
|
||||||
|
- `ADMIN_PASSWORD` - Mot de passe admin
|
||||||
|
- `JWT_SECRET_KEY` - Secret pour JWT (générer avec `openssl rand -hex 32`)
|
||||||
136
backend/DOCKER_BUILD.md
Normal file
136
backend/DOCKER_BUILD.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Docker Build Optimization
|
||||||
|
|
||||||
|
Cette configuration utilise une approche en 2 images pour accélérer les builds backend de **15-25 minutes** à **30 secondes - 2 minutes**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Image 1 : Base (`audio-classifier-base`)
|
||||||
|
Contient toutes les dépendances système et Python qui changent rarement :
|
||||||
|
- Python 3.9 + apt packages (ffmpeg, libsndfile, etc.)
|
||||||
|
- numpy, scipy, essentia-tensorflow
|
||||||
|
- Toutes les dépendances de `requirements.txt`
|
||||||
|
|
||||||
|
**Build** : ~15 minutes (1 fois par semaine ou quand `requirements.txt` change)
|
||||||
|
|
||||||
|
### Image 2 : App (`audio-classifier-backend`)
|
||||||
|
Hérite de l'image de base et ajoute uniquement le code applicatif :
|
||||||
|
- Code source (`src/`)
|
||||||
|
- Fichiers de configuration (`alembic.ini`)
|
||||||
|
- Modèles Essentia (`models/`)
|
||||||
|
|
||||||
|
**Build** : ~30 secondes - 2 minutes (à chaque commit)
|
||||||
|
|
||||||
|
## Workflows CI/CD
|
||||||
|
|
||||||
|
### 1. Build de l'image de base (`.gitea/workflows/docker-base.yml`)
|
||||||
|
Se déclenche automatiquement quand :
|
||||||
|
- `backend/requirements.txt` est modifié
|
||||||
|
- `backend/Dockerfile.base` est modifié
|
||||||
|
- Déclenchement manuel via l'interface Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Image produite :
|
||||||
|
git.benoitsz.com/benoit/audio-classifier-base:latest
|
||||||
|
git.benoitsz.com/benoit/audio-classifier-base:sha-<commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build de l'image app (`.gitea/workflows/docker.yml`)
|
||||||
|
Se déclenche à chaque push sur `main` :
|
||||||
|
- Utilise l'image de base comme FROM
|
||||||
|
- Copie uniquement le code source
|
||||||
|
- Build rapide (~30s-2min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Image produite :
|
||||||
|
git.benoitsz.com/benoit/audio-classifier-backend:dev
|
||||||
|
git.benoitsz.com/benoit/audio-classifier-backend:dev-<commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation en local
|
||||||
|
|
||||||
|
### Build de l'image de base
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
docker build -f Dockerfile.base -t audio-classifier-base:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build de l'image app (utilise l'image de base)
|
||||||
|
```bash
|
||||||
|
# Depuis la racine du projet
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE=audio-classifier-base:local \
|
||||||
|
-f backend/Dockerfile \
|
||||||
|
-t audio-classifier-backend:local \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build direct (sans image de base) - pour tests
|
||||||
|
Si tu veux tester un build complet sans dépendre de l'image de base :
|
||||||
|
```bash
|
||||||
|
# Revenir temporairement au Dockerfile original
|
||||||
|
git show HEAD~1:backend/Dockerfile > backend/Dockerfile.monolithic
|
||||||
|
docker build -f backend/Dockerfile.monolithic -t audio-classifier-backend:monolithic .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mise à jour des dépendances
|
||||||
|
|
||||||
|
Quand tu modifies `requirements.txt` :
|
||||||
|
|
||||||
|
1. **Push les changements sur `main`**
|
||||||
|
```bash
|
||||||
|
git add backend/requirements.txt
|
||||||
|
git commit -m "Update dependencies"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Le workflow `docker-base.yml` se déclenche automatiquement**
|
||||||
|
- Build de la nouvelle image de base (~15 min)
|
||||||
|
- Push vers `git.benoitsz.com/benoit/audio-classifier-base:latest`
|
||||||
|
|
||||||
|
3. **Les prochains builds backend utiliseront la nouvelle base**
|
||||||
|
- Builds futurs rapides (~30s-2min)
|
||||||
|
|
||||||
|
## Déclenchement manuel
|
||||||
|
|
||||||
|
Pour rebuild l'image de base manuellement (sans modifier `requirements.txt`) :
|
||||||
|
|
||||||
|
1. Va sur Gitea : `https://git.benoitsz.com/benoit/audio-classifier/actions`
|
||||||
|
2. Sélectionne le workflow "Build Base Docker Image"
|
||||||
|
3. Clique sur "Run workflow"
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Vérifie les builds dans Gitea Actions :
|
||||||
|
- **Base image** : `.gitea/workflows/docker-base.yml`
|
||||||
|
- **App image** : `.gitea/workflows/docker.yml`
|
||||||
|
|
||||||
|
Les logs montrent la durée de build pour chaque étape.
|
||||||
|
|
||||||
|
## Gains de performance attendus
|
||||||
|
|
||||||
|
| Scénario | Avant | Après | Gain |
|
||||||
|
|----------|-------|-------|------|
|
||||||
|
| Build normal (code change) | 15-25 min | 30s-2min | **90-95%** |
|
||||||
|
| Build après update deps | 15-25 min | 15-25 min (base) + 30s-2min (app) | 0% (1ère fois) |
|
||||||
|
| Builds suivants | 15-25 min | 30s-2min | **90-95%** |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erreur "base image not found"
|
||||||
|
L'image de base n'existe pas encore dans le registry. Solutions :
|
||||||
|
1. Trigger le workflow `docker-base.yml` manuellement
|
||||||
|
2. Ou build localement et push :
|
||||||
|
```bash
|
||||||
|
docker build -f backend/Dockerfile.base -t git.benoitsz.com/benoit/audio-classifier-base:latest backend/
|
||||||
|
docker push git.benoitsz.com/benoit/audio-classifier-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build app lent malgré l'image de base
|
||||||
|
Vérifie que le build-arg `BASE_IMAGE` est bien passé :
|
||||||
|
```yaml
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/audio-classifier-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dépendances Python pas à jour dans l'app
|
||||||
|
L'image de base doit être rebuildée. Trigger `docker-base.yml`.
|
||||||
@@ -1,49 +1,12 @@
|
|||||||
# Use amd64 platform for better Essentia compatibility, works with emulation on ARM
|
# Use pre-built base image with all dependencies
|
||||||
FROM --platform=linux/amd64 python:3.9-slim
|
# Base image includes: Python 3.9, system deps, numpy, scipy, essentia-tensorflow, all pip deps
|
||||||
|
# Only rebuild base when requirements.txt changes
|
||||||
|
ARG BASE_IMAGE=git.benoitsz.com/benoit/audio-classifier-base:latest
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
# Install system dependencies
|
# Working directory already set in base image
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
ffmpeg \
|
|
||||||
libsndfile1 \
|
|
||||||
libsndfile1-dev \
|
|
||||||
gcc \
|
|
||||||
g++ \
|
|
||||||
gfortran \
|
|
||||||
libopenblas-dev \
|
|
||||||
liblapack-dev \
|
|
||||||
pkg-config \
|
|
||||||
curl \
|
|
||||||
build-essential \
|
|
||||||
libyaml-dev \
|
|
||||||
libfftw3-dev \
|
|
||||||
libavcodec-dev \
|
|
||||||
libavformat-dev \
|
|
||||||
libavutil-dev \
|
|
||||||
libswresample-dev \
|
|
||||||
libsamplerate0-dev \
|
|
||||||
libtag1-dev \
|
|
||||||
libchromaprint-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Upgrade pip, setuptools, wheel
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
|
|
||||||
|
|
||||||
# Copy requirements
|
|
||||||
COPY backend/requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies in stages for better caching
|
|
||||||
# Using versions compatible with Python 3.9
|
|
||||||
RUN pip install --no-cache-dir numpy==1.24.3
|
|
||||||
RUN pip install --no-cache-dir scipy==1.11.4
|
|
||||||
|
|
||||||
# Install Essentia-TensorFlow - Python 3.9 AMD64 support
|
|
||||||
RUN pip install --no-cache-dir essentia-tensorflow
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY backend/src/ ./src/
|
COPY backend/src/ ./src/
|
||||||
COPY backend/alembic.ini .
|
COPY backend/alembic.ini .
|
||||||
|
|||||||
59
backend/Dockerfile.base
Normal file
59
backend/Dockerfile.base
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Base image for Audio Classifier Backend
|
||||||
|
# This image contains all system dependencies and Python packages
|
||||||
|
# Build this image only when dependencies change (requirements.txt updates)
|
||||||
|
|
||||||
|
# Use amd64 platform for better Essentia compatibility
|
||||||
|
FROM --platform=linux/amd64 python:3.9-slim
|
||||||
|
|
||||||
|
LABEL maintainer="benoit.schw@gmail.com"
|
||||||
|
LABEL description="Base image with all dependencies for Audio Classifier Backend"
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
libsndfile1 \
|
||||||
|
libsndfile1-dev \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
gfortran \
|
||||||
|
libopenblas-dev \
|
||||||
|
liblapack-dev \
|
||||||
|
pkg-config \
|
||||||
|
curl \
|
||||||
|
build-essential \
|
||||||
|
libyaml-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
libavcodec-dev \
|
||||||
|
libavformat-dev \
|
||||||
|
libavutil-dev \
|
||||||
|
libswresample-dev \
|
||||||
|
libsamplerate0-dev \
|
||||||
|
libtag1-dev \
|
||||||
|
libchromaprint-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Upgrade pip, setuptools, wheel
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies in stages for better caching
|
||||||
|
# Using versions compatible with Python 3.9
|
||||||
|
RUN pip install --no-cache-dir numpy==1.24.3
|
||||||
|
RUN pip install --no-cache-dir scipy==1.11.4
|
||||||
|
|
||||||
|
# Install Essentia-TensorFlow - Python 3.9 AMD64 support
|
||||||
|
RUN pip install --no-cache-dir essentia-tensorflow
|
||||||
|
|
||||||
|
# Install remaining dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Verify installations
|
||||||
|
RUN python -c "import essentia.standard; import numpy; import scipy; import fastapi; print('All dependencies installed successfully')"
|
||||||
|
|
||||||
|
# This image is meant to be used as a base
|
||||||
|
# The application code will be copied in the derived Dockerfile
|
||||||
76
backend/check_dependencies.py
Normal file
76
backend/check_dependencies.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check all required dependencies are installed."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def check_import(module_name, package_name=None):
|
||||||
|
"""Try to import a module and report status."""
|
||||||
|
package = package_name or module_name
|
||||||
|
try:
|
||||||
|
__import__(module_name)
|
||||||
|
print(f"✅ {package}")
|
||||||
|
return True
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ {package}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Check all dependencies."""
|
||||||
|
print("🔍 Checking Python dependencies...\n")
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
# Web Framework
|
||||||
|
("fastapi", "fastapi"),
|
||||||
|
("uvicorn", "uvicorn"),
|
||||||
|
("multipart", "python-multipart"),
|
||||||
|
|
||||||
|
# Database
|
||||||
|
("sqlalchemy", "sqlalchemy"),
|
||||||
|
("psycopg2", "psycopg2-binary"),
|
||||||
|
("pgvector.sqlalchemy", "pgvector"),
|
||||||
|
("alembic", "alembic"),
|
||||||
|
|
||||||
|
# Audio Processing
|
||||||
|
("librosa", "librosa"),
|
||||||
|
("soundfile", "soundfile"),
|
||||||
|
("audioread", "audioread"),
|
||||||
|
("mutagen", "mutagen"),
|
||||||
|
|
||||||
|
# Scientific
|
||||||
|
("numpy", "numpy"),
|
||||||
|
("scipy", "scipy"),
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
("pydantic", "pydantic"),
|
||||||
|
("pydantic_settings", "pydantic-settings"),
|
||||||
|
("dotenv", "python-dotenv"),
|
||||||
|
("email_validator", "email-validator"),
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
("jose", "python-jose"),
|
||||||
|
("passlib", "passlib"),
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
("aiofiles", "aiofiles"),
|
||||||
|
("httpx", "httpx"),
|
||||||
|
|
||||||
|
# Essentia (optional)
|
||||||
|
("essentia.standard", "essentia-tensorflow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
for module, package in dependencies:
|
||||||
|
if not check_import(module, package):
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
|
if all_ok:
|
||||||
|
print("✅ All dependencies installed!")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("❌ Some dependencies are missing")
|
||||||
|
print("\nInstall missing dependencies with:")
|
||||||
|
print(" pip install -r requirements.txt")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -26,6 +26,7 @@ scipy==1.11.4
|
|||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
email-validator==2.1.0
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|||||||
# Protected endpoints (auth required for ALL routes)
|
# Protected endpoints (auth required for ALL routes)
|
||||||
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"], dependencies=[Depends(require_auth)])
|
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"], dependencies=[Depends(require_auth)])
|
||||||
app.include_router(search.router, prefix="/api/search", tags=["search"], dependencies=[Depends(require_auth)])
|
app.include_router(search.router, prefix="/api/search", tags=["search"], dependencies=[Depends(require_auth)])
|
||||||
app.include_router(audio.router, prefix="/api/audio", tags=["audio"], dependencies=[Depends(require_auth)])
|
# Audio endpoints handle auth internally (support both header and query param)
|
||||||
|
app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
|
||||||
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"], 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(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(stats.router, prefix="/api/stats", tags=["stats"], dependencies=[Depends(require_auth)])
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Audio streaming and download endpoints."""
|
"""Audio streaming and download endpoints."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request, Query, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from ...models.database import get_db
|
from ...models.database import get_db
|
||||||
from ...models import crud
|
from ...models import crud
|
||||||
from ...core.waveform_generator import get_waveform_data
|
from ...core.waveform_generator import get_waveform_data
|
||||||
|
from ...core.auth import verify_token, require_auth
|
||||||
from ...utils.logging import get_logger
|
from ...utils.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -18,6 +20,7 @@ logger = get_logger(__name__)
|
|||||||
async def stream_audio(
|
async def stream_audio(
|
||||||
track_id: UUID,
|
track_id: UUID,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Stream audio file with range request support.
|
"""Stream audio file with range request support.
|
||||||
@@ -28,6 +31,7 @@ async def stream_audio(
|
|||||||
Args:
|
Args:
|
||||||
track_id: Track UUID
|
track_id: Track UUID
|
||||||
request: HTTP request
|
request: HTTP request
|
||||||
|
token: Optional JWT token for authentication (for <audio> tag compatibility)
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -36,6 +40,15 @@ async def stream_audio(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if track not found or file doesn't exist
|
HTTPException: 404 if track not found or file doesn't exist
|
||||||
"""
|
"""
|
||||||
|
# Verify authentication via query parameter for <audio> tag
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
verify_token(token)
|
||||||
|
|
||||||
track = crud.get_track_by_id(db, track_id)
|
track = crud.get_track_by_id(db, track_id)
|
||||||
|
|
||||||
if not track:
|
if not track:
|
||||||
@@ -79,12 +92,14 @@ async def stream_audio(
|
|||||||
@router.get("/download/{track_id}")
|
@router.get("/download/{track_id}")
|
||||||
async def download_audio(
|
async def download_audio(
|
||||||
track_id: UUID,
|
track_id: UUID,
|
||||||
|
token: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Download audio file.
|
"""Download audio file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
track_id: Track UUID
|
track_id: Track UUID
|
||||||
|
token: Optional JWT token for authentication (for <a> tag compatibility)
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -93,6 +108,15 @@ async def download_audio(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if track not found or file doesn't exist
|
HTTPException: 404 if track not found or file doesn't exist
|
||||||
"""
|
"""
|
||||||
|
# Verify authentication via query parameter for <a> tag
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
verify_token(token)
|
||||||
|
|
||||||
track = crud.get_track_by_id(db, track_id)
|
track = crud.get_track_by_id(db, track_id)
|
||||||
|
|
||||||
if not track:
|
if not track:
|
||||||
@@ -129,6 +153,7 @@ async def get_waveform(
|
|||||||
track_id: UUID,
|
track_id: UUID,
|
||||||
num_peaks: int = 800,
|
num_peaks: int = 800,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Get waveform peak data for visualization.
|
"""Get waveform peak data for visualization.
|
||||||
|
|
||||||
@@ -138,6 +163,7 @@ async def get_waveform(
|
|||||||
track_id: Track UUID
|
track_id: Track UUID
|
||||||
num_peaks: Number of peaks to generate
|
num_peaks: Number of peaks to generate
|
||||||
db: Database session
|
db: Database session
|
||||||
|
current_user: Current authenticated user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Waveform data with peaks and duration
|
Waveform data with peaks and duration
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ async def get_tracks(
|
|||||||
has_vocals: Optional[bool] = None,
|
has_vocals: Optional[bool] = None,
|
||||||
key: Optional[str] = None,
|
key: Optional[str] = None,
|
||||||
instrument: Optional[str] = None,
|
instrument: Optional[str] = None,
|
||||||
|
instruments: Optional[List[str]] = Query(None),
|
||||||
tempo_range: Optional[str] = Query(None, regex="^(slow|medium|fast)$"),
|
tempo_range: Optional[str] = Query(None, regex="^(slow|medium|fast)$"),
|
||||||
sort_by: str = Query("analyzed_at", regex="^(analyzed_at|tempo_bpm|duration_seconds|filename|energy)$"),
|
sort_by: str = Query("analyzed_at", regex="^(analyzed_at|tempo_bpm|duration_seconds|filename|energy)$"),
|
||||||
sort_desc: bool = True,
|
sort_desc: bool = True,
|
||||||
@@ -42,7 +43,8 @@ async def get_tracks(
|
|||||||
energy_max: Maximum energy
|
energy_max: Maximum energy
|
||||||
has_vocals: Filter by vocal presence
|
has_vocals: Filter by vocal presence
|
||||||
key: Filter by musical key
|
key: Filter by musical key
|
||||||
instrument: Filter by instrument
|
instrument: Filter by instrument (deprecated, use instruments)
|
||||||
|
instruments: Filter by multiple instruments (must have ALL)
|
||||||
tempo_range: Filter by tempo range (slow: <100, medium: 100-140, fast: >140)
|
tempo_range: Filter by tempo range (slow: <100, medium: 100-140, fast: >140)
|
||||||
sort_by: Field to sort by
|
sort_by: Field to sort by
|
||||||
sort_desc: Sort descending
|
sort_desc: Sort descending
|
||||||
@@ -61,6 +63,9 @@ async def get_tracks(
|
|||||||
elif tempo_range == "fast":
|
elif tempo_range == "fast":
|
||||||
bpm_min = 140.0 if bpm_min is None else max(bpm_min, 140.0)
|
bpm_min = 140.0 if bpm_min is None else max(bpm_min, 140.0)
|
||||||
|
|
||||||
|
# Use instruments if provided, otherwise fall back to instrument
|
||||||
|
final_instruments = instruments if instruments else ([instrument] if instrument else None)
|
||||||
|
|
||||||
tracks, total = crud.get_tracks(
|
tracks, total = crud.get_tracks(
|
||||||
db=db,
|
db=db,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
@@ -73,7 +78,7 @@ async def get_tracks(
|
|||||||
energy_max=energy_max,
|
energy_max=energy_max,
|
||||||
has_vocals=has_vocals,
|
has_vocals=has_vocals,
|
||||||
key=key,
|
key=key,
|
||||||
instrument=instrument,
|
instruments=final_instruments,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
sort_desc=sort_desc,
|
sort_desc=sort_desc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ def get_tracks(
|
|||||||
energy_max: Optional[float] = None,
|
energy_max: Optional[float] = None,
|
||||||
has_vocals: Optional[bool] = None,
|
has_vocals: Optional[bool] = None,
|
||||||
key: Optional[str] = None,
|
key: Optional[str] = None,
|
||||||
instrument: Optional[str] = None,
|
instruments: Optional[List[str]] = None,
|
||||||
sort_by: str = "analyzed_at",
|
sort_by: str = "analyzed_at",
|
||||||
sort_desc: bool = True,
|
sort_desc: bool = True,
|
||||||
) -> Tuple[List[AudioTrack], int]:
|
) -> Tuple[List[AudioTrack], int]:
|
||||||
@@ -122,7 +122,7 @@ def get_tracks(
|
|||||||
energy_max: Maximum energy (0-1)
|
energy_max: Maximum energy (0-1)
|
||||||
has_vocals: Filter by vocal presence
|
has_vocals: Filter by vocal presence
|
||||||
key: Filter by musical key
|
key: Filter by musical key
|
||||||
instrument: Filter by instrument
|
instruments: Filter by instruments (track must have ALL instruments in the list)
|
||||||
sort_by: Field to sort by
|
sort_by: Field to sort by
|
||||||
sort_desc: Sort descending if True
|
sort_desc: Sort descending if True
|
||||||
|
|
||||||
@@ -168,8 +168,10 @@ def get_tracks(
|
|||||||
if key:
|
if key:
|
||||||
query = query.filter(AudioTrack.key == key)
|
query = query.filter(AudioTrack.key == key)
|
||||||
|
|
||||||
if instrument:
|
if instruments:
|
||||||
query = query.filter(AudioTrack.instruments.any(instrument))
|
# Track must have ALL specified instruments
|
||||||
|
for instrument in instruments:
|
||||||
|
query = query.filter(AudioTrack.instruments.any(instrument))
|
||||||
|
|
||||||
# Get total count before pagination
|
# Get total count before pagination
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ services:
|
|||||||
ANALYSIS_USE_CLAP: ${ANALYSIS_USE_CLAP:-false}
|
ANALYSIS_USE_CLAP: ${ANALYSIS_USE_CLAP:-false}
|
||||||
ANALYSIS_NUM_WORKERS: ${ANALYSIS_NUM_WORKERS:-4}
|
ANALYSIS_NUM_WORKERS: ${ANALYSIS_NUM_WORKERS:-4}
|
||||||
ESSENTIA_MODELS_PATH: /app/models
|
ESSENTIA_MODELS_PATH: /app/models
|
||||||
|
# Authentication
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-secret-key-change-in-production}
|
||||||
|
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-24}
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -91,8 +91,17 @@ export default function Home() {
|
|||||||
setIsScanning(true)
|
setIsScanning(true)
|
||||||
setScanStatus("Démarrage du scan...")
|
setScanStatus("Démarrage du scan...")
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${getApiUrl()}/api/library/scan`, {
|
const response = await fetch(`${getApiUrl()}/api/library/scan`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -104,7 +113,15 @@ export default function Home() {
|
|||||||
// Poll scan status
|
// Poll scan status
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const statusResponse = await fetch(`${getApiUrl()}/api/library/scan/status`)
|
const token = localStorage.getItem('access_token')
|
||||||
|
const pollHeaders: HeadersInit = {}
|
||||||
|
if (token) {
|
||||||
|
pollHeaders['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResponse = await fetch(`${getApiUrl()}/api/library/scan/status`, {
|
||||||
|
headers: pollHeaders,
|
||||||
|
})
|
||||||
const status = await statusResponse.json()
|
const status = await statusResponse.json()
|
||||||
|
|
||||||
if (!status.is_scanning) {
|
if (!status.is_scanning) {
|
||||||
|
|||||||
@@ -79,8 +79,15 @@ export default function AudioPlayer({ track, isPlaying, onPlayingChange }: Audio
|
|||||||
const loadWaveform = async (trackId: string) => {
|
const loadWaveform = async (trackId: string) => {
|
||||||
setIsLoadingWaveform(true)
|
setIsLoadingWaveform(true)
|
||||||
try {
|
try {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const headers: HeadersInit = {}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getApiUrl()}/api/audio/waveform/${trackId}`
|
`${getApiUrl()}/api/audio/waveform/${trackId}`,
|
||||||
|
{ headers }
|
||||||
)
|
)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -159,10 +166,19 @@ export default function AudioPlayer({ track, isPlaying, onPlayingChange }: Audio
|
|||||||
|
|
||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
const getAuthenticatedStreamUrl = (trackId: string) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const baseUrl = `${getApiUrl()}/api/audio/stream/${trackId}`
|
||||||
|
if (token) {
|
||||||
|
return `${baseUrl}?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
return baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 border-t border-gray-300 shadow-lg" style={{ height: '80px' }}>
|
<div className="bg-gray-50 border-t border-gray-300 shadow-lg" style={{ height: '80px' }}>
|
||||||
{/* Hidden audio element */}
|
{/* Hidden audio element */}
|
||||||
{track && <audio ref={audioRef} src={`${getApiUrl()}/api/audio/stream/${track.id}`} />}
|
{track && <audio ref={audioRef} src={getAuthenticatedStreamUrl(track.id)} />}
|
||||||
|
|
||||||
<div className="h-full flex items-center gap-3 px-4">
|
<div className="h-full flex items-center gap-3 px-4">
|
||||||
{/* Play/Pause button */}
|
{/* Play/Pause button */}
|
||||||
@@ -301,7 +317,7 @@ export default function AudioPlayer({ track, isPlaying, onPlayingChange }: Audio
|
|||||||
{/* Download button */}
|
{/* Download button */}
|
||||||
{track && (
|
{track && (
|
||||||
<a
|
<a
|
||||||
href={`${getApiUrl()}/api/audio/download/${track.id}`}
|
href={getAuthenticatedStreamUrl(track.id).replace('/stream/', '/download/')}
|
||||||
download
|
download
|
||||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200 flex-shrink-0"
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-gray-900 transition-colors rounded hover:bg-gray-200 flex-shrink-0"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
|
|||||||
@@ -93,23 +93,40 @@ export default function FilterPanel({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instrument Filter */}
|
{/* Instrument Filter - Multiple Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
||||||
Instrument
|
Instruments
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="space-y-2 max-h-48 overflow-y-auto bg-slate-50 border border-slate-300 rounded-lg p-2">
|
||||||
value={localFilters.instrument || ""}
|
{availableInstruments.length === 0 ? (
|
||||||
onChange={(e) => handleFilterChange("instrument", e.target.value || undefined)}
|
<p className="text-xs text-slate-500 p-2">Aucun instrument disponible</p>
|
||||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
) : (
|
||||||
>
|
availableInstruments.map((instrument) => {
|
||||||
<option value="">Tous les instruments</option>
|
const isSelected = localFilters.instruments?.includes(instrument) || false
|
||||||
{availableInstruments.map((instrument) => (
|
return (
|
||||||
<option key={instrument} value={instrument}>
|
<label
|
||||||
{instrument}
|
key={instrument}
|
||||||
</option>
|
className="flex items-center gap-2 p-2 hover:bg-slate-100 rounded cursor-pointer transition-colors"
|
||||||
))}
|
>
|
||||||
</select>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentInstruments = localFilters.instruments || []
|
||||||
|
const newInstruments = e.target.checked
|
||||||
|
? [...currentInstruments, instrument]
|
||||||
|
: currentInstruments.filter(i => i !== instrument)
|
||||||
|
handleFilterChange("instruments", newInstruments.length > 0 ? newInstruments : undefined)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 text-orange-500 border-slate-300 rounded focus:ring-2 focus:ring-orange-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">{instrument}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Filter */}
|
{/* Key Filter */}
|
||||||
@@ -165,10 +182,16 @@ export default function FilterPanel({
|
|||||||
<span className="font-medium text-slate-800">{localFilters.mood}</span>
|
<span className="font-medium text-slate-800">{localFilters.mood}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{localFilters.instrument && (
|
{localFilters.instruments && localFilters.instruments.length > 0 && (
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="text-xs">
|
||||||
<span className="text-slate-600">Instrument:</span>
|
<span className="text-slate-600">Instruments:</span>
|
||||||
<span className="font-medium text-slate-800">{localFilters.instrument}</span>
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{localFilters.instruments.map((instrument) => (
|
||||||
|
<span key={instrument} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700">
|
||||||
|
{instrument}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{localFilters.key && (
|
{localFilters.key && (
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface FilterParams {
|
|||||||
has_vocals?: boolean
|
has_vocals?: boolean
|
||||||
key?: string
|
key?: string
|
||||||
instrument?: string
|
instrument?: string
|
||||||
|
instruments?: string[] // Multiple instruments filter
|
||||||
tempo_range?: 'slow' | 'medium' | 'fast' // Lent (<100), Moyen (100-140), Rapide (>140)
|
tempo_range?: 'slow' | 'medium' | 'fast' // Lent (<100), Moyen (100-140), Rapide (>140)
|
||||||
sort_by?: 'analyzed_at' | 'tempo_bpm' | 'duration_seconds' | 'filename' | 'energy'
|
sort_by?: 'analyzed_at' | 'tempo_bpm' | 'duration_seconds' | 'filename' | 'energy'
|
||||||
sort_desc?: boolean
|
sort_desc?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user