1. Tri aléatoire ✅ Ajout d'un sélecteur de tri dans l'en-tête avec options "Récents" et "Aléatoire" Backend : nouveau paramètre sort_by="random" qui utilise func.random() de PostgreSQL Les résultats seront maintenant mélangés aléatoirement, permettant de découvrir tous les titres de la bibliothèque 2. Correction du bug de recherche textuelle ✅ La recherche charge maintenant jusqu'à 10 000 résultats au lieu de 25 quand une requête est active La recherche filtre ensuite sur TOUS les résultats chargés, pas seulement la page active Cela permet de rechercher dans toute la bibliothèque de résultats filtrés 3. Filtres exclusifs pour instruments ✅ Nouvelle option "Uniquement ces instruments (mode exclusif)" qui apparaît quand des instruments sont sélectionnés Backend : nouveau paramètre instruments_exclusive qui vérifie que le track a EXACTEMENT les instruments sélectionnés (pas d'autres) Frontend : checkbox dans un bandeau orange/ambre pour activer le mode exclusif Les modifications touchent : Backend : crud.py et tracks.py Frontend : types.ts, FilterPanel.tsx et page.tsx
422 lines
18 KiB
TypeScript
422 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo } from "react"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { getTracks, getApiUrl } from "@/lib/api"
|
|
import { logout, getUser } from "@/lib/auth"
|
|
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 [isPlaying, setIsPlaying] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [isScanning, setIsScanning] = useState(false)
|
|
const [scanStatus, setScanStatus] = useState<string>("")
|
|
const [sortBy, setSortBy] = useState<'analyzed_at' | 'random'>('analyzed_at')
|
|
const limit = 25
|
|
|
|
const { data: tracksData, isLoading: isLoadingTracks } = useQuery({
|
|
queryKey: ['tracks', filters, page, sortBy, searchQuery],
|
|
queryFn: () => getTracks({
|
|
...filters,
|
|
skip: page * limit,
|
|
limit: searchQuery ? 10000 : limit, // Load more for search to work across all results
|
|
sort_by: sortBy
|
|
}),
|
|
})
|
|
|
|
// Filter tracks by search query on client side
|
|
const filteredTracks = useMemo(() => {
|
|
if (!tracksData?.tracks) return []
|
|
if (!searchQuery.trim()) {
|
|
// No search query: paginate normally
|
|
return tracksData.tracks.slice(0, limit)
|
|
}
|
|
|
|
// Search across ALL loaded 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, limit])
|
|
|
|
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 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`, {
|
|
method: 'POST',
|
|
headers,
|
|
})
|
|
|
|
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 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()
|
|
|
|
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">
|
|
{/* Sort order toggle */}
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => {
|
|
setSortBy(e.target.value as 'analyzed_at' | 'random')
|
|
setPage(0)
|
|
}}
|
|
className="px-3 py-2 text-sm bg-white border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 text-slate-700"
|
|
>
|
|
<option value="analyzed_at">Récents</option>
|
|
<option value="random">Aléatoire</option>
|
|
</select>
|
|
|
|
<div className="text-sm text-slate-600">
|
|
{tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''}
|
|
</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 */}
|
|
<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={() => {
|
|
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"
|
|
>
|
|
{currentTrack?.id === track.id && isPlaying ? (
|
|
<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}
|
|
isPlaying={isPlaying}
|
|
onPlayingChange={setIsPlaying}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|