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:
@@ -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,7 +168,9 @@ def get_tracks(
|
|||||||
if key:
|
if key:
|
||||||
query = query.filter(AudioTrack.key == key)
|
query = query.filter(AudioTrack.key == key)
|
||||||
|
|
||||||
if instrument:
|
if instruments:
|
||||||
|
# Track must have ALL specified instruments
|
||||||
|
for instrument in instruments:
|
||||||
query = query.filter(AudioTrack.instruments.any(instrument))
|
query = query.filter(AudioTrack.instruments.any(instrument))
|
||||||
|
|
||||||
# Get total count before pagination
|
# Get total count before pagination
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const isSelected = localFilters.instruments?.includes(instrument) || false
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={instrument}
|
||||||
|
className="flex items-center gap-2 p-2 hover:bg-slate-100 rounded cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">Tous les instruments</option>
|
<input
|
||||||
{availableInstruments.map((instrument) => (
|
type="checkbox"
|
||||||
<option key={instrument} value={instrument}>
|
checked={isSelected}
|
||||||
{instrument}
|
onChange={(e) => {
|
||||||
</option>
|
const currentInstruments = localFilters.instruments || []
|
||||||
))}
|
const newInstruments = e.target.checked
|
||||||
</select>
|
? [...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