Compare commits

...

2 Commits

Author SHA1 Message Date
04603cd5e9 Fix localhost en prod
Résumé des modifications
J'ai implémenté une configuration runtime pour le frontend qui permet de changer l'URL de l'API sans rebuilder l'image Docker. Voici ce qui a été fait :
📝 Fichiers modifiés :
frontend/Dockerfile - Ajout du script de génération de config au démarrage
frontend/lib/api.ts - Lecture de la config depuis window.__RUNTIME_CONFIG__
frontend/app/layout.tsx - Chargement du script config.js
docker-compose.yml - Utilisation de la variable d'environnement
.env.example - Documentation de la variable
DEPLOYMENT.md - Documentation de la configuration runtime
📄 Fichiers créés :
frontend/generate-config.sh - Script de génération de config
frontend/public/config.js - Fichier de config (placeholder)
frontend/README.md - Documentation du frontend
🚀 Pour résoudre votre problème en production :
Sur votre serveur, modifiez le fichier .env :

# URL publique de l'API (utilisée par le navigateur)
NEXT_PUBLIC_API_URL=https://audioclassifier.benoitsz.com:8001

# CORS doit accepter les requêtes du frontend
CORS_ORIGINS=https://audioclassifier.benoitsz.com,https://audioclassifier.benoitsz.com:3000
Ensuite :

# Pull les dernières modifications
git pull

# Rebuild l'image frontend (une seule fois)
# Soit via Gitea Actions en poussant sur main
# Soit manuellement :
# docker build -t git.benoitsz.com/benoit/audio-classifier-frontend:dev -f frontend/Dockerfile .
# docker push git.benoitsz.com/benoit/audio-classifier-frontend:dev

# Redémarrer les containers
docker-compose down
docker-compose up -d
 Avantages :
 Aucun rebuild nécessaire après le premier déploiement
 Configuration flexible via variables d'environnement
 Compatible avec tous les environnements (dev, staging, prod)
 Testé et fonctionnel localement
Le frontend générera automatiquement le bon fichier de configuration au démarrage du container avec l'URL de votre serveur !
2025-12-23 15:33:52 +01:00
cc2f1d0051 Fix CORS 2025-12-23 14:33:25 +01:00
14 changed files with 207 additions and 125 deletions

View File

@@ -5,7 +5,9 @@ POSTGRES_PASSWORD=audio_password
POSTGRES_DB=audio_classifier POSTGRES_DB=audio_classifier
# Backend API # Backend API
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Use "*" to allow all origins (recommended for development/local deployment)
# Or specify comma-separated URLs for production: http://yourdomain.com,https://yourdomain.com
CORS_ORIGINS=*
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
@@ -16,4 +18,7 @@ ESSENTIA_MODELS_PATH=/app/models
AUDIO_LIBRARY_PATH=/path/to/your/audio/library AUDIO_LIBRARY_PATH=/path/to/your/audio/library
# Frontend # Frontend
NEXT_PUBLIC_API_URL=http://localhost:8000 # 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
# Example: NEXT_PUBLIC_API_URL=http://yourserver.com:8001
NEXT_PUBLIC_API_URL=http://localhost:8001

View File

@@ -13,95 +13,6 @@ env:
IMAGE_FRONTEND: audio-classifier-frontend IMAGE_FRONTEND: audio-classifier-frontend
jobs: jobs:
build-backend:
name: Build Backend Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
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
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: Determine version
id: version
run: |
if [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
echo "VERSION=${GITEA_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "VERSION=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
fi
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_BACKEND }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
type=raw,value=dev,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha,prefix=dev-,format=short,enable=${{ gitea.ref == 'refs/heads/main' }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
build-args: |
VERSION=${{ steps.version.outputs.VERSION }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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
build-frontend: build-frontend:
name: Build Frontend Image name: Build Frontend Image
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -204,13 +204,20 @@ cd Audio-Classifier
# Chemin vers musique # Chemin vers musique
AUDIO_LIBRARY_PATH=/mnt/musique AUDIO_LIBRARY_PATH=/mnt/musique
# Domaine public # URL publique de l'API (IMPORTANT pour le frontend)
CORS_ORIGINS=http://votre-domaine.com,https://votre-domaine.com # Cette URL est utilisée par le navigateur pour accéder à l'API
# Remplacer par votre domaine ou IP publique + port 8001
NEXT_PUBLIC_API_URL=https://votre-serveur.com:8001
# Domaine public pour CORS (doit inclure l'URL du frontend)
CORS_ORIGINS=https://votre-domaine.com,https://votre-domaine.com:3000
# Credentials BDD (sécurisés !) # Credentials BDD (sécurisés !)
POSTGRES_PASSWORD=motdepasse_fort_aleatoire POSTGRES_PASSWORD=motdepasse_fort_aleatoire
``` ```
**Important :** Le frontend utilise maintenant une configuration **runtime**, ce qui signifie que vous pouvez changer `NEXT_PUBLIC_API_URL` dans le fichier `.env` et redémarrer les containers sans avoir à rebuilder les images.
4. **Démarrer** : 4. **Démarrer** :
```bash ```bash
docker-compose up -d docker-compose up -d

View File

@@ -10,7 +10,8 @@ class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://audio_user:audio_password@localhost:5432/audio_classifier" DATABASE_URL: str = "postgresql://audio_user:audio_password@localhost:5432/audio_classifier"
# API Configuration # API Configuration
CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000" # Comma-separated list of allowed origins, or use "*" to allow all
CORS_ORIGINS: str = "*"
API_HOST: str = "0.0.0.0" API_HOST: str = "0.0.0.0"
API_PORT: int = 8000 API_PORT: int = 8000
@@ -33,7 +34,13 @@ class Settings(BaseSettings):
@property @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:
"""Parse CORS origins string to list.""" """Parse CORS origins string to list.
If CORS_ORIGINS is "*", allow all origins.
Otherwise, parse comma-separated list.
"""
if self.CORS_ORIGINS.strip() == "*":
return ["*"]
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]

View File

@@ -31,7 +31,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-audio_user}:${POSTGRES_PASSWORD:-audio_password}@postgres:5432/${POSTGRES_DB:-audio_classifier} DATABASE_URL: postgresql://${POSTGRES_USER:-audio_user}:${POSTGRES_PASSWORD:-audio_password}@postgres:5432/${POSTGRES_DB:-audio_classifier}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} CORS_ORIGINS: ${CORS_ORIGINS:-*}
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

View File

@@ -19,14 +19,14 @@ services:
restart: unless-stopped restart: unless-stopped
backend: backend:
image: git.benoitsz.com/benoit/audio-classifier-backend:latest image: git.benoitsz.com/benoit/audio-classifier-backend:dev
container_name: audio_classifier_api container_name: audio_classifier_api
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-audio_user}:${POSTGRES_PASSWORD:-audio_password}@postgres:5432/${POSTGRES_DB:-audio_classifier} DATABASE_URL: postgresql://${POSTGRES_USER:-audio_user}:${POSTGRES_PASSWORD:-audio_password}@postgres:5432/${POSTGRES_DB:-audio_classifier}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} CORS_ORIGINS: ${CORS_ORIGINS:-*}
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
@@ -38,12 +38,12 @@ services:
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: git.benoitsz.com/benoit/audio-classifier-frontend:latest image: git.benoitsz.com/benoit/audio-classifier-frontend:dev
container_name: audio_classifier_ui container_name: audio_classifier_ui
environment: environment:
# Use localhost:8001 because the browser (client-side) needs to access the API # In production, set NEXT_PUBLIC_API_URL to your server's public URL
# The backend is mapped to port 8001 on the host machine # Example: NEXT_PUBLIC_API_URL=https://yourserver.com:8001
NEXT_PUBLIC_API_URL: http://localhost:8001 NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8001}
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:

View File

@@ -12,15 +12,19 @@ RUN npm ci
# Copy application code # Copy application code
COPY frontend/ . COPY frontend/ .
# Build argument for API URL # Build argument for API URL (used for default build)
ARG NEXT_PUBLIC_API_URL=http://localhost:8001 ARG NEXT_PUBLIC_API_URL=http://localhost:8001
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
# Build the application # Build the application
RUN npm run build RUN npm run build
# Copy runtime config generation script
COPY frontend/generate-config.sh /app/generate-config.sh
RUN chmod +x /app/generate-config.sh
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000
# Start the application # Generate runtime config and start the application
CMD ["npm", "start"] CMD ["/bin/sh", "-c", "/app/generate-config.sh && npm start"]

93
frontend/README.md Normal file
View File

@@ -0,0 +1,93 @@
# Frontend - Audio Classifier
Frontend Next.js pour Audio Classifier avec configuration runtime.
## Configuration Runtime
Le frontend utilise un système de **configuration runtime** qui permet de changer l'URL de l'API sans rebuilder l'image Docker.
### Comment ça fonctionne
1. Au démarrage du container, le script `generate-config.sh` génère un fichier `/app/public/config.js`
2. Ce fichier contient l'URL de l'API basée sur la variable `NEXT_PUBLIC_API_URL`
3. Le fichier est chargé dans le navigateur via `<Script src="/config.js">`
4. Le code API lit la configuration depuis `window.__RUNTIME_CONFIG__.API_URL`
### Développement Local
```bash
# Installer les dépendances
npm install
# Créer un fichier .env.local
echo "NEXT_PUBLIC_API_URL=http://localhost:8001" > .env.local
# Lancer en mode dev
npm run dev
```
### Production avec Docker
```bash
# Build l'image
docker build -t audio-classifier-frontend -f frontend/Dockerfile .
# Lancer avec une URL personnalisée
docker run -p 3000:3000 \
-e NEXT_PUBLIC_API_URL=https://mon-serveur.com:8001 \
audio-classifier-frontend
```
### Docker Compose
```yaml
frontend:
image: audio-classifier-frontend
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8001}
ports:
- "3000:3000"
```
## Structure
```
frontend/
├── app/ # Pages Next.js (App Router)
│ ├── layout.tsx # Layout principal (charge config.js)
│ └── page.tsx # Page d'accueil
├── components/ # Composants React
├── lib/ # Utilitaires
│ ├── api.ts # Client API (lit la config runtime)
│ └── types.ts # Types TypeScript
├── public/ # Fichiers statiques
│ └── config.js # Configuration runtime (généré au démarrage)
├── generate-config.sh # Script de génération de config
└── Dockerfile # Image Docker de production
```
## Variables d'Environnement
- `NEXT_PUBLIC_API_URL` : URL de l'API backend (ex: `https://api.example.com:8001`)
## Troubleshooting
### L'API n'est pas accessible
Vérifiez que :
1. La variable `NEXT_PUBLIC_API_URL` est correctement définie
2. Le fichier `/app/public/config.js` existe dans le container
3. Le navigateur peut accéder à l'URL de l'API (pas de CORS, firewall, etc.)
### Voir la configuration active
Ouvrez la console du navigateur et tapez :
```javascript
console.log(window.__RUNTIME_CONFIG__)
```
### Vérifier la config dans le container
```bash
docker exec audio_classifier_ui cat /app/public/config.js
```

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 Script from "next/script"
const inter = Inter({ subsets: ["latin"] }) const inter = Inter({ subsets: ["latin"] })
@@ -17,6 +18,9 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<head>
<Script src="/config.js" strategy="beforeInteractive" />
</head>
<body className={inter.className}> <body className={inter.className}>
<QueryProvider> <QueryProvider>
{children} {children}

View File

@@ -52,6 +52,7 @@ export default function Home() {
const [filters, setFilters] = useState<FilterParams>({}) const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [currentTrack, setCurrentTrack] = useState<Track | null>(null) const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [isScanning, setIsScanning] = useState(false) const [isScanning, setIsScanning] = useState(false)
const [scanStatus, setScanStatus] = useState<string>("") const [scanStatus, setScanStatus] = useState<string>("")
@@ -233,10 +234,19 @@ export default function Home() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Play button */} {/* Play button */}
<button <button
onClick={() => setCurrentTrack(track)} onClick={() => {
if (currentTrack?.id === track.id) {
// Toggle play/pause for current track
setIsPlaying(!isPlaying)
} else {
// Switch to new track and start playing
setCurrentTrack(track)
setIsPlaying(true)
}
}}
className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-orange-500 hover:bg-orange-600 rounded-full transition-colors shadow-sm" className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-orange-500 hover:bg-orange-600 rounded-full transition-colors shadow-sm"
> >
{currentTrack?.id === track.id ? ( {currentTrack?.id === track.id && isPlaying ? (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/> <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg> </svg>
@@ -347,7 +357,11 @@ export default function Home() {
{/* Fixed Audio Player at bottom */} {/* Fixed Audio Player at bottom */}
<div className="fixed bottom-0 left-0 right-0 z-50"> <div className="fixed bottom-0 left-0 right-0 z-50">
<AudioPlayer track={currentTrack} /> <AudioPlayer
track={currentTrack}
isPlaying={isPlaying}
onPlayingChange={setIsPlaying}
/>
</div> </div>
</div> </div>
) )

View File

@@ -5,10 +5,11 @@ import type { Track } from "@/lib/types"
interface AudioPlayerProps { interface AudioPlayerProps {
track: Track | null track: Track | null
isPlaying: boolean
onPlayingChange: (playing: boolean) => void
} }
export default function AudioPlayer({ track }: AudioPlayerProps) { export default function AudioPlayer({ track, isPlaying, onPlayingChange }: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1) const [volume, setVolume] = useState(1)
@@ -22,7 +23,7 @@ export default function AudioPlayer({ track }: AudioPlayerProps) {
// Load audio and waveform when track changes // Load audio and waveform when track changes
useEffect(() => { useEffect(() => {
if (!track) { if (!track) {
setIsPlaying(false) onPlayingChange(false)
setCurrentTime(0) setCurrentTime(0)
setWaveformPeaks([]) setWaveformPeaks([])
return return
@@ -33,13 +34,13 @@ export default function AudioPlayer({ track }: AudioPlayerProps) {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.load() audioRef.current.load()
// Autoplay when track loads // Autoplay when track loads if isPlaying is true
audioRef.current.play().then(() => { if (isPlaying) {
setIsPlaying(true) audioRef.current.play().catch((error: unknown) => {
}).catch((error: unknown) => { console.error("Autoplay failed:", error)
console.error("Autoplay failed:", error) onPlayingChange(false)
setIsPlaying(false) })
}) }
} }
}, [track?.id]) }, [track?.id])
@@ -54,7 +55,7 @@ export default function AudioPlayer({ track }: AudioPlayerProps) {
setDuration(audio.duration) setDuration(audio.duration)
} }
} }
const handleEnded = () => setIsPlaying(false) const handleEnded = () => onPlayingChange(false)
audio.addEventListener("timeupdate", updateTime) audio.addEventListener("timeupdate", updateTime)
audio.addEventListener("loadedmetadata", updateDuration) audio.addEventListener("loadedmetadata", updateDuration)
@@ -91,15 +92,24 @@ export default function AudioPlayer({ track }: AudioPlayerProps) {
} }
} }
const togglePlay = () => { // Sync playing state with audio element
if (!audioRef.current || !track) return useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) { if (isPlaying) {
audioRef.current.pause() audio.play().catch((error: unknown) => {
console.error("Play failed:", error)
onPlayingChange(false)
})
} else { } else {
audioRef.current.play() audio.pause()
} }
setIsPlaying(!isPlaying) }, [isPlaying, onPlayingChange])
const togglePlay = () => {
if (!audioRef.current || !track) return
onPlayingChange(!isPlaying)
} }
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Generate runtime configuration file
echo "Generating runtime configuration..."
echo "API URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8001}"
cat > /app/public/config.js << EOF
// Runtime configuration generated at container startup
window.__RUNTIME_CONFIG__ = {
API_URL: '${NEXT_PUBLIC_API_URL:-http://localhost:8001}'
};
EOF
echo "Configuration generated successfully!"
cat /app/public/config.js

View File

@@ -14,7 +14,15 @@ import type {
FilterParams, FilterParams,
} from './types' } from './types'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' // Get API URL from runtime config (injected at container startup) or fallback to env var
function getApiUrl(): string {
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__) {
return (window as any).__RUNTIME_CONFIG__.API_URL
}
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
}
const API_BASE_URL = getApiUrl()
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,

View File

@@ -0,0 +1,4 @@
// This file will be overwritten at container startup
window.__RUNTIME_CONFIG__ = {
API_URL: 'http://localhost:8001'
};