J'ai implémenté une solution complète pour optimiser ton système audio : 1. Backend - Transcodage & Waveforms Nouveau module de transcodage (transcoder.py): Transcodage automatique en MP3 128kbps via FFmpeg Stockage dans dossier transcoded/ Compression ~70-90% selon format source Waveforms pré-calculées (waveform_generator.py): Génération lors du scan (800 points) Stockage JSON dans dossier waveforms/ Chargement instantané Schema BDD mis à jour (schema.py): filepath : fichier original (download) stream_filepath : MP3 128kbps (streaming) waveform_filepath : JSON pré-calculé Scanner amélioré (scanner.py): Transcode automatiquement chaque fichier Pré-calcule la waveform Stocke les 3 chemins en BDD 2. API - Endpoints Endpoint /api/library/scan (library.py): POST pour lancer un scan Tâche en arrière-plan Statut consultable via GET /api/library/scan/status Streaming optimisé (audio.py): Utilise stream_filepath (MP3 128kbps) en priorité Fallback sur fichier original si absent Waveform chargée depuis JSON pré-calculé 3. Frontend - Interface Bouton Rescan (page.tsx): Dans le header à droite Icône qui tourne pendant le scan Affichage progression en temps réel Reload automatique après scan 4. Base de données Migration appliquée (20251223_003_add_stream_waveform_paths.py): ALTER TABLE audio_tracks ADD COLUMN stream_filepath VARCHAR; ALTER TABLE audio_tracks ADD COLUMN waveform_filepath VARCHAR; CREATE INDEX idx_stream_filepath ON audio_tracks (stream_filepath); 🚀 Utilisation Via l'interface web Clique sur le bouton "Rescan" dans le header Le scan démarre automatiquement Tu vois la progression en temps réel La page se recharge automatiquement à la fin Via CLI (dans le container) docker-compose exec backend python -m src.cli.scanner /music 📊 Avantages ✅ Streaming ultra-rapide : MP3 128kbps = ~70-90% plus léger ✅ Waveform instantanée : Pré-calculée, pas de latence ✅ Download qualité : Fichier original préservé ✅ Rescan facile : Bouton dans l'UI ✅ Prêt pour serveur distant : Optimisé pour la bande passante
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo } from "react"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { getTracks } from "@/lib/api"
|
|
import type { FilterParams, Track } from "@/lib/types"
|
|
import FilterPanel from "@/components/FilterPanel"
|
|
import AudioPlayer from "@/components/AudioPlayer"
|
|
|
|
// Helper function to format Discogs genre labels
|
|
function formatGenre(genre: string): { category: string; subgenre: string } {
|
|
const parts = genre.split('---')
|
|
return {
|
|
category: parts[0] || genre,
|
|
subgenre: parts[1] || ''
|
|
}
|
|
}
|
|
|
|
// Extract unique values for filter options
|
|
function extractFilterOptions(tracks: Track[]) {
|
|
const genres = new Set<string>()
|
|
const moods = new Set<string>()
|
|
const instruments = new Set<string>()
|
|
const keys = new Set<string>()
|
|
|
|
tracks.forEach(track => {
|
|
const genreCategory = formatGenre(track.classification.genre.primary).category
|
|
genres.add(genreCategory)
|
|
|
|
if (track.classification.mood.primary) {
|
|
moods.add(track.classification.mood.primary)
|
|
}
|
|
|
|
track.classification.instruments?.forEach(instrument => {
|
|
instruments.add(instrument)
|
|
})
|
|
|
|
if (track.features.key) {
|
|
keys.add(track.features.key)
|
|
}
|
|
})
|
|
|
|
return {
|
|
genres: Array.from(genres).sort(),
|
|
moods: Array.from(moods).sort(),
|
|
instruments: Array.from(instruments).sort(),
|
|
keys: Array.from(keys).sort(),
|
|
}
|
|
}
|
|
|
|
export default function Home() {
|
|
const [filters, setFilters] = useState<FilterParams>({})
|
|
const [page, setPage] = useState(0)
|
|
const [currentTrack, setCurrentTrack] = useState<Track | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [isScanning, setIsScanning] = useState(false)
|
|
const [scanStatus, setScanStatus] = useState<string>("")
|
|
const limit = 25
|
|
|
|
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
|
|
queryKey: ['tracks', filters, page],
|
|
queryFn: () => getTracks({ ...filters, skip: page * limit, limit }),
|
|
})
|
|
|
|
// Filter tracks by search query on client side
|
|
const filteredTracks = useMemo(() => {
|
|
if (!tracksData?.tracks) return []
|
|
if (!searchQuery.trim()) return tracksData.tracks
|
|
|
|
const query = searchQuery.toLowerCase()
|
|
return tracksData.tracks.filter(track =>
|
|
track.filename.toLowerCase().includes(query) ||
|
|
track.metadata?.title?.toLowerCase().includes(query) ||
|
|
track.metadata?.artist?.toLowerCase().includes(query)
|
|
)
|
|
}, [tracksData?.tracks, searchQuery])
|
|
|
|
const filterOptions = useMemo(() => {
|
|
if (!tracksData?.tracks || tracksData.tracks.length === 0) {
|
|
return { genres: [], moods: [], instruments: [], keys: [] }
|
|
}
|
|
return extractFilterOptions(tracksData.tracks)
|
|
}, [tracksData])
|
|
|
|
const totalPages = tracksData ? Math.ceil(tracksData.total / limit) : 0
|
|
|
|
const handleRescan = async () => {
|
|
try {
|
|
setIsScanning(true)
|
|
setScanStatus("Démarrage du scan...")
|
|
|
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/library/scan`, {
|
|
method: 'POST',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Échec du démarrage du scan')
|
|
}
|
|
|
|
setScanStatus("Scan en cours...")
|
|
|
|
// Poll scan status
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const statusResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/library/scan/status`)
|
|
const status = await statusResponse.json()
|
|
|
|
if (!status.is_scanning) {
|
|
clearInterval(pollInterval)
|
|
setScanStatus(`Scan terminé ! ${status.processed} fichiers traités`)
|
|
setIsScanning(false)
|
|
|
|
// Refresh tracks after scan
|
|
window.location.reload()
|
|
} else {
|
|
setScanStatus(`Scan : ${status.processed}/${status.total_files} fichiers (${status.progress}%)`)
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur lors de la vérification du statut:', error)
|
|
}
|
|
}, 2000)
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du rescan:', error)
|
|
setScanStatus("Erreur lors du scan")
|
|
setIsScanning(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
|
{/* Header */}
|
|
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-40">
|
|
<div className="px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-800">Bibliothèque Musicale</h1>
|
|
<p className="text-sm text-slate-600">Gestion intelligente de votre collection audio</p>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
<div className="flex-1 max-w-md ml-8">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher un titre, artiste..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full px-4 py-2 pl-10 bg-slate-50 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
|
/>
|
|
<svg className="absolute left-3 top-2.5 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ml-6 flex items-center gap-3">
|
|
<div className="text-sm text-slate-600">
|
|
{tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''}
|
|
</div>
|
|
|
|
{/* Rescan button */}
|
|
<button
|
|
onClick={handleRescan}
|
|
disabled={isScanning}
|
|
className="px-4 py-2 bg-orange-500 hover:bg-orange-600 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
|
|
title="Rescanner la bibliothèque musicale"
|
|
>
|
|
<svg className={`w-4 h-4 ${isScanning ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
{isScanning ? 'Scan en cours...' : 'Rescan'}
|
|
</button>
|
|
|
|
{/* Scan status */}
|
|
{scanStatus && (
|
|
<div className="text-xs text-slate-600 bg-slate-100 px-3 py-1 rounded">
|
|
{scanStatus}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content with sidebar */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Sidebar */}
|
|
<aside className="w-72 bg-white border-r border-slate-200 overflow-y-auto">
|
|
<div className="p-6">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Filtres</h2>
|
|
<FilterPanel
|
|
filters={filters}
|
|
onFiltersChange={(newFilters) => {
|
|
setFilters(newFilters)
|
|
setPage(0)
|
|
}}
|
|
availableGenres={filterOptions.genres}
|
|
availableMoods={filterOptions.moods}
|
|
availableInstruments={filterOptions.instruments}
|
|
availableKeys={filterOptions.keys}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Tracks list */}
|
|
<main className="flex-1 overflow-y-auto pb-32">
|
|
<div className="p-6">
|
|
{isLoadingTracks ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-slate-600">Chargement...</div>
|
|
</div>
|
|
) : filteredTracks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-slate-500">
|
|
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
|
</svg>
|
|
<p className="text-lg font-medium">Aucune piste trouvée</p>
|
|
<p className="text-sm mt-2">Essayez de modifier vos filtres ou votre recherche</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-1 gap-3">
|
|
{filteredTracks.map((track) => (
|
|
<div
|
|
key={track.id}
|
|
className={`bg-white rounded-lg p-4 border transition-all hover:shadow-md ${
|
|
currentTrack?.id === track.id
|
|
? 'border-orange-500 shadow-md'
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
{/* Play button */}
|
|
<button
|
|
onClick={() => setCurrentTrack(track)}
|
|
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 ? (
|
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Track info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-slate-800 truncate text-base">
|
|
{track.metadata?.title || track.filename}
|
|
</h3>
|
|
{track.metadata?.artist && (
|
|
<p className="text-sm text-slate-600 truncate">{track.metadata.artist}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{/* Genre */}
|
|
{(() => {
|
|
const genre = formatGenre(track.classification.genre.primary)
|
|
return (
|
|
<>
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800">
|
|
{genre.category}
|
|
</span>
|
|
{genre.subgenre && (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-orange-50 text-orange-700">
|
|
{genre.subgenre}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
|
|
{/* Mood */}
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-800">
|
|
{track.classification.mood.primary}
|
|
</span>
|
|
|
|
{/* Key & BPM */}
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
|
{track.features.key}
|
|
</span>
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
|
{Math.round(track.features.tempo_bpm)} BPM
|
|
</span>
|
|
|
|
{/* Duration */}
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
|
|
{Math.floor(track.duration_seconds / 60)}:{String(Math.floor(track.duration_seconds % 60)).padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Instruments */}
|
|
{track.classification.instruments && track.classification.instruments.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
<span className="text-xs text-slate-500">Instruments:</span>
|
|
{track.classification.instruments.slice(0, 5).map((instrument, i) => (
|
|
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700">
|
|
{instrument}
|
|
</span>
|
|
))}
|
|
{track.classification.instruments.length > 5 && (
|
|
<span className="text-xs text-slate-400">+{track.classification.instruments.length - 5}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-8 flex items-center justify-between">
|
|
<button
|
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
← Précédent
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-slate-600">
|
|
Page <span className="font-semibold">{page + 1}</span> sur <span className="font-semibold">{totalPages}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={(page + 1) >= totalPages}
|
|
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Suivant →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
{/* Fixed Audio Player at bottom */}
|
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
|
<AudioPlayer track={currentTrack} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|