From 6c47f0760eab446cba543c2c529da2d975a3f625 Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 22 Dec 2025 15:53:26 +0100 Subject: [PATCH] Frontend : filtres --- backend/src/api/routes/tracks.py | 18 ++ backend/src/models/crud.py | 28 +++- frontend/app/page.tsx | 64 ++++++- frontend/components/FilterPanel.tsx | 248 ++++++++++++++++++++++++++++ frontend/lib/types.ts | 3 + 5 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 frontend/components/FilterPanel.tsx diff --git a/backend/src/api/routes/tracks.py b/backend/src/api/routes/tracks.py index 5a31dda..ec516eb 100644 --- a/backend/src/api/routes/tracks.py +++ b/backend/src/api/routes/tracks.py @@ -22,6 +22,9 @@ async def get_tracks( energy_min: Optional[float] = Query(None, ge=0, le=1), energy_max: Optional[float] = Query(None, ge=0, le=1), 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_desc: bool = True, db: Session = Depends(get_db), @@ -38,6 +41,9 @@ async def get_tracks( energy_min: Minimum energy energy_max: Maximum energy 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_desc: Sort descending db: Database session @@ -45,6 +51,16 @@ async def get_tracks( Returns: 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( db=db, skip=skip, @@ -56,6 +72,8 @@ async def get_tracks( energy_min=energy_min, energy_max=energy_max, has_vocals=has_vocals, + key=key, + instrument=instrument, sort_by=sort_by, sort_desc=sort_desc, ) diff --git a/backend/src/models/crud.py b/backend/src/models/crud.py index 6ab0e99..0bc42c5 100644 --- a/backend/src/models/crud.py +++ b/backend/src/models/crud.py @@ -2,7 +2,7 @@ from typing import List, Optional, Dict, Tuple from uuid import UUID from sqlalchemy.orm import Session -from sqlalchemy import or_, and_, func +from sqlalchemy import or_, and_, func, any_ from .schema import AudioTrack from ..core.analyzer import AudioAnalysis @@ -103,6 +103,8 @@ def get_tracks( energy_min: Optional[float] = None, energy_max: Optional[float] = None, has_vocals: Optional[bool] = None, + key: Optional[str] = None, + instrument: Optional[str] = None, sort_by: str = "analyzed_at", sort_desc: bool = True, ) -> Tuple[List[AudioTrack], int]: @@ -112,13 +114,15 @@ def get_tracks( db: Database session skip: Number of records to skip 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 bpm_min: Minimum BPM bpm_max: Maximum BPM energy_min: Minimum energy (0-1) energy_max: Maximum energy (0-1) has_vocals: Filter by vocal presence + key: Filter by musical key + instrument: Filter by instrument sort_by: Field to sort by sort_desc: Sort descending if True @@ -129,10 +133,12 @@ def get_tracks( # Apply filters if genre: + # Match genre category (e.g., "Pop" matches "Pop---Ballad", "Pop---Indie Pop", etc.) query = query.filter( or_( + AudioTrack.genre_primary.like(f"{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( or_( 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: 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 total = query.count() @@ -213,7 +225,7 @@ def search_tracks( search_query = search_query.filter( or_( 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( or_( 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( or_( 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( or_( AudioTrack.mood_primary == ref_track.mood_primary, - AudioTrack.mood_secondary.contains([ref_track.mood_primary]) + AudioTrack.mood_secondary.any(ref_track.mood_primary) ) ) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9c005c6..e02b81e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,9 +1,10 @@ "use client" -import { useState } from "react" +import { useState, useMemo } from "react" import { useQuery } from "@tanstack/react-query" 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"]) 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() + const moods = new Set() + const instruments = new Set() + const keys = new Set() + + 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() { const [filters, setFilters] = useState({}) const [page, setPage] = useState(0) @@ -29,6 +66,16 @@ export default function Home() { 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 (
{/* Header */} @@ -63,6 +110,19 @@ export default function Home() {
)} + {/* Filter Panel */} + { + 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 */}
diff --git a/frontend/components/FilterPanel.tsx b/frontend/components/FilterPanel.tsx new file mode 100644 index 0000000..7776c8b --- /dev/null +++ b/frontend/components/FilterPanel.tsx @@ -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(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 ( +
+
+

Filters

+ {hasActiveFilters && ( + + )} +
+ +
+ {/* Genre Filter */} +
+ + +
+ + {/* Mood Filter */} +
+ + +
+ + {/* Instrument Filter */} +
+ + +
+ + {/* Key Filter */} +
+ + +
+ + {/* Tempo Range Filter */} +
+ + +
+ + {/* Sort By */} +
+ + +
+ + {/* Sort Order */} +
+ + +
+
+ + {/* Active Filters Display */} + {hasActiveFilters && ( +
+ {localFilters.genre && ( + + Genre: {localFilters.genre} + + + )} + {localFilters.mood && ( + + Mood: {localFilters.mood} + + + )} + {localFilters.instrument && ( + + Instrument: {localFilters.instrument} + + + )} + {localFilters.key && ( + + Key: {localFilters.key} + + + )} + {localFilters.tempo_range && ( + + Tempo: {localFilters.tempo_range} + + + )} +
+ )} +
+ ) +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index d3b4614..3626704 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -58,6 +58,9 @@ export interface FilterParams { energy_min?: number energy_max?: number 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_desc?: boolean }