Frontend : filtres

This commit is contained in:
2025-12-22 15:53:26 +01:00
parent b0ba1e886c
commit 6c47f0760e
5 changed files with 351 additions and 10 deletions

View File

@@ -22,6 +22,9 @@ async def get_tracks(
energy_min: Optional[float] = Query(None, ge=0, le=1), energy_min: Optional[float] = Query(None, ge=0, le=1),
energy_max: Optional[float] = Query(None, ge=0, le=1), energy_max: Optional[float] = Query(None, ge=0, le=1),
has_vocals: Optional[bool] = None, has_vocals: Optional[bool] = None,
key: Optional[str] = None,
instrument: Optional[str] = None,
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,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -38,6 +41,9 @@ async def get_tracks(
energy_min: Minimum energy energy_min: Minimum energy
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
instrument: Filter by instrument
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
db: Database session db: Database session
@@ -45,6 +51,16 @@ async def get_tracks(
Returns: Returns:
Paginated list of tracks with total count Paginated list of tracks with total count
""" """
# Convert tempo_range to bpm_min/bpm_max
if tempo_range:
if tempo_range == "slow":
bpm_max = 100.0 if bpm_max is None else min(bpm_max, 100.0)
elif tempo_range == "medium":
bpm_min = 100.0 if bpm_min is None else max(bpm_min, 100.0)
bpm_max = 140.0 if bpm_max is None else min(bpm_max, 140.0)
elif tempo_range == "fast":
bpm_min = 140.0 if bpm_min is None else max(bpm_min, 140.0)
tracks, total = crud.get_tracks( tracks, total = crud.get_tracks(
db=db, db=db,
skip=skip, skip=skip,
@@ -56,6 +72,8 @@ async def get_tracks(
energy_min=energy_min, energy_min=energy_min,
energy_max=energy_max, energy_max=energy_max,
has_vocals=has_vocals, has_vocals=has_vocals,
key=key,
instrument=instrument,
sort_by=sort_by, sort_by=sort_by,
sort_desc=sort_desc, sort_desc=sort_desc,
) )

View File

@@ -2,7 +2,7 @@
from typing import List, Optional, Dict, Tuple from typing import List, Optional, Dict, Tuple
from uuid import UUID from uuid import UUID
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func from sqlalchemy import or_, and_, func, any_
from .schema import AudioTrack from .schema import AudioTrack
from ..core.analyzer import AudioAnalysis from ..core.analyzer import AudioAnalysis
@@ -103,6 +103,8 @@ def get_tracks(
energy_min: Optional[float] = None, energy_min: Optional[float] = None,
energy_max: Optional[float] = None, energy_max: Optional[float] = None,
has_vocals: Optional[bool] = None, has_vocals: Optional[bool] = None,
key: Optional[str] = None,
instrument: Optional[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]:
@@ -112,13 +114,15 @@ def get_tracks(
db: Database session db: Database session
skip: Number of records to skip skip: Number of records to skip
limit: Maximum number of records to return limit: Maximum number of records to return
genre: Filter by genre genre: Filter by genre (searches in genre_primary, supports category matching)
mood: Filter by mood mood: Filter by mood
bpm_min: Minimum BPM bpm_min: Minimum BPM
bpm_max: Maximum BPM bpm_max: Maximum BPM
energy_min: Minimum energy (0-1) energy_min: Minimum energy (0-1)
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
instrument: Filter by instrument
sort_by: Field to sort by sort_by: Field to sort by
sort_desc: Sort descending if True sort_desc: Sort descending if True
@@ -129,10 +133,12 @@ def get_tracks(
# Apply filters # Apply filters
if genre: if genre:
# Match genre category (e.g., "Pop" matches "Pop---Ballad", "Pop---Indie Pop", etc.)
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.genre_primary.like(f"{genre}%"),
AudioTrack.genre_primary == genre, AudioTrack.genre_primary == genre,
AudioTrack.genre_secondary.contains([genre]) AudioTrack.genre_secondary.any(genre)
) )
) )
@@ -140,7 +146,7 @@ def get_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.mood_primary == mood, AudioTrack.mood_primary == mood,
AudioTrack.mood_secondary.contains([mood]) AudioTrack.mood_secondary.any(mood)
) )
) )
@@ -159,6 +165,12 @@ def get_tracks(
if has_vocals is not None: if has_vocals is not None:
query = query.filter(AudioTrack.has_vocals == has_vocals) query = query.filter(AudioTrack.has_vocals == has_vocals)
if key:
query = query.filter(AudioTrack.key == key)
if instrument:
query = query.filter(AudioTrack.instruments.any(instrument))
# Get total count before pagination # Get total count before pagination
total = query.count() total = query.count()
@@ -213,7 +225,7 @@ def search_tracks(
search_query = search_query.filter( search_query = search_query.filter(
or_( or_(
AudioTrack.genre_primary == genre, AudioTrack.genre_primary == genre,
AudioTrack.genre_secondary.contains([genre]) AudioTrack.genre_secondary.any(genre)
) )
) )
@@ -221,7 +233,7 @@ def search_tracks(
search_query = search_query.filter( search_query = search_query.filter(
or_( or_(
AudioTrack.mood_primary == mood, AudioTrack.mood_primary == mood,
AudioTrack.mood_secondary.contains([mood]) AudioTrack.mood_secondary.any(mood)
) )
) )
@@ -265,7 +277,7 @@ def get_similar_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.genre_primary == ref_track.genre_primary, AudioTrack.genre_primary == ref_track.genre_primary,
AudioTrack.genre_secondary.contains([ref_track.genre_primary]) AudioTrack.genre_secondary.any(ref_track.genre_primary)
) )
) )
@@ -274,7 +286,7 @@ def get_similar_tracks(
query = query.filter( query = query.filter(
or_( or_(
AudioTrack.mood_primary == ref_track.mood_primary, AudioTrack.mood_primary == ref_track.mood_primary,
AudioTrack.mood_secondary.contains([ref_track.mood_primary]) AudioTrack.mood_secondary.any(ref_track.mood_primary)
) )
) )

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { useState } from "react" import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getTracks, getStats } from "@/lib/api" import { getTracks, getStats } from "@/lib/api"
import type { FilterParams } from "@/lib/types" import type { FilterParams, Track } from "@/lib/types"
import FilterPanel from "@/components/FilterPanel"
// Helper function to format Discogs genre labels (e.g., "Pop---Ballad" -> ["Pop", "Ballad"]) // Helper function to format Discogs genre labels (e.g., "Pop---Ballad" -> ["Pop", "Ballad"])
function formatGenre(genre: string): { category: string; subgenre: string } { function formatGenre(genre: string): { category: string; subgenre: string } {
@@ -14,6 +15,42 @@ function formatGenre(genre: string): { category: string; subgenre: string } {
} }
} }
// 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 => {
// Extract genre category (before "---")
const genreCategory = formatGenre(track.classification.genre.primary).category
genres.add(genreCategory)
// Extract primary mood
if (track.classification.mood.primary) {
moods.add(track.classification.mood.primary)
}
// Extract instruments
track.classification.instruments?.forEach(instrument => {
instruments.add(instrument)
})
// Extract key
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() { export default function Home() {
const [filters, setFilters] = useState<FilterParams>({}) const [filters, setFilters] = useState<FilterParams>({})
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
@@ -29,6 +66,16 @@ export default function Home() {
queryFn: getStats, queryFn: getStats,
}) })
// Extract filter options from displayed tracks
// For better UX, we use the current page's tracks to populate filters
// TODO: Add a dedicated API endpoint to get all unique filter values
const filterOptions = useMemo(() => {
if (!tracksData?.tracks || tracksData.tracks.length === 0) {
return { genres: [], moods: [], instruments: [], keys: [] }
}
return extractFilterOptions(tracksData.tracks)
}, [tracksData])
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
@@ -63,6 +110,19 @@ export default function Home() {
</div> </div>
)} )}
{/* Filter Panel */}
<FilterPanel
filters={filters}
onFiltersChange={(newFilters) => {
setFilters(newFilters)
setPage(0) // Reset to first page when filters change
}}
availableGenres={filterOptions.genres}
availableMoods={filterOptions.moods}
availableInstruments={filterOptions.instruments}
availableKeys={filterOptions.keys}
/>
{/* Tracks List */} {/* Tracks List */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="p-4 border-b"> <div className="p-4 border-b">

View File

@@ -0,0 +1,248 @@
"use client"
import { useState, useEffect } from "react"
import type { FilterParams } from "@/lib/types"
interface FilterPanelProps {
filters: FilterParams
onFiltersChange: (filters: FilterParams) => void
availableGenres: string[]
availableMoods: string[]
availableInstruments: string[]
availableKeys: string[]
}
export default function FilterPanel({
filters,
onFiltersChange,
availableGenres,
availableMoods,
availableInstruments,
availableKeys,
}: FilterPanelProps) {
const [localFilters, setLocalFilters] = useState<FilterParams>(filters)
// Update local filters when parent changes
useEffect(() => {
setLocalFilters(filters)
}, [filters])
const handleFilterChange = (key: keyof FilterParams, value: any) => {
const newFilters = { ...localFilters, [key]: value }
setLocalFilters(newFilters)
onFiltersChange(newFilters)
}
const clearFilters = () => {
const emptyFilters: FilterParams = {}
setLocalFilters(emptyFilters)
onFiltersChange(emptyFilters)
}
const hasActiveFilters = Object.keys(localFilters).length > 0
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900">Filters</h2>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-blue-600 hover:text-blue-800"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Genre Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Genre
</label>
<select
value={localFilters.genre || ""}
onChange={(e) => handleFilterChange("genre", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Genres</option>
{availableGenres.map((genre) => (
<option key={genre} value={genre}>
{genre}
</option>
))}
</select>
</div>
{/* Mood Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Mood
</label>
<select
value={localFilters.mood || ""}
onChange={(e) => handleFilterChange("mood", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Moods</option>
{availableMoods.map((mood) => (
<option key={mood} value={mood}>
{mood}
</option>
))}
</select>
</div>
{/* Instrument Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrument
</label>
<select
value={localFilters.instrument || ""}
onChange={(e) => handleFilterChange("instrument", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Instruments</option>
{availableInstruments.map((instrument) => (
<option key={instrument} value={instrument}>
{instrument}
</option>
))}
</select>
</div>
{/* Key Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Key
</label>
<select
value={localFilters.key || ""}
onChange={(e) => handleFilterChange("key", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Keys</option>
{availableKeys.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
{/* Tempo Range Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tempo
</label>
<select
value={localFilters.tempo_range || ""}
onChange={(e) => handleFilterChange("tempo_range", e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Tempos</option>
<option value="slow">Slow (&lt;100 BPM)</option>
<option value="medium">Medium (100-140 BPM)</option>
<option value="fast">Fast (&gt;140 BPM)</option>
</select>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sort By
</label>
<select
value={localFilters.sort_by || "analyzed_at"}
onChange={(e) => handleFilterChange("sort_by", e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="analyzed_at">Date Added</option>
<option value="filename">Filename</option>
<option value="tempo_bpm">BPM</option>
<option value="duration_seconds">Duration</option>
<option value="energy">Energy</option>
</select>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Order
</label>
<select
value={localFilters.sort_desc ? "desc" : "asc"}
onChange={(e) => handleFilterChange("sort_desc", e.target.value === "desc")}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-4 flex flex-wrap gap-2">
{localFilters.genre && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-blue-100 text-blue-800">
Genre: {localFilters.genre}
<button
onClick={() => handleFilterChange("genre", undefined)}
className="ml-1 hover:text-blue-900"
>
×
</button>
</span>
)}
{localFilters.mood && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-purple-100 text-purple-800">
Mood: {localFilters.mood}
<button
onClick={() => handleFilterChange("mood", undefined)}
className="ml-1 hover:text-purple-900"
>
×
</button>
</span>
)}
{localFilters.instrument && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-green-100 text-green-800">
Instrument: {localFilters.instrument}
<button
onClick={() => handleFilterChange("instrument", undefined)}
className="ml-1 hover:text-green-900"
>
×
</button>
</span>
)}
{localFilters.key && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-yellow-100 text-yellow-800">
Key: {localFilters.key}
<button
onClick={() => handleFilterChange("key", undefined)}
className="ml-1 hover:text-yellow-900"
>
×
</button>
</span>
)}
{localFilters.tempo_range && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs bg-orange-100 text-orange-800">
Tempo: {localFilters.tempo_range}
<button
onClick={() => handleFilterChange("tempo_range", undefined)}
className="ml-1 hover:text-orange-900"
>
×
</button>
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -58,6 +58,9 @@ export interface FilterParams {
energy_min?: number energy_min?: number
energy_max?: number energy_max?: number
has_vocals?: boolean has_vocals?: boolean
key?: string
instrument?: string
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
} }