Feature: Sélection multiple d'instruments dans les filtres

Frontend:
- FilterPanel: Remplacer select par checkboxes pour instruments
- Zone scrollable (max-height 12rem) pour la liste
- Affichage des instruments sélectionnés dans résumé filtres actifs

Backend:
- API tracks: Nouveau paramètre instruments (List[str])
- Backward compatible avec ancien paramètre instrument
- CRUD: Filtrage AND (track doit avoir TOUS les instruments)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-26 20:10:39 +01:00
parent 34fcbe1223
commit f3f321511d
4 changed files with 55 additions and 24 deletions

View File

@@ -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,
) )

View File

@@ -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()

View File

@@ -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 && (

View File

@@ -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