feat(tracks): add suggested tags endpoint and UI (E4)

- Migration 085: tracks.tags TEXT[]
- Track model: Tags pq.StringArray
- GET /tracks/suggested-tags?genre=X&bpm=Y (static suggestions by genre)
- UpdateTrack: support tags
- TrackMetadataEditModal: tags chips + suggestions dropdown
- TrackDetailPageInfo: display tags
- getSuggestedTags, UpdateTrackParams.tags
- MSW: suggested-tags handler, tags in mock track
This commit is contained in:
senke 2026-02-20 15:38:51 +01:00
parent 6b80089706
commit 1977183718
11 changed files with 165 additions and 19 deletions

View file

@ -40,6 +40,7 @@ export interface UpdateTrackRequest {
year?: number;
bpm?: number | null;
musical_key?: string | null;
tags?: string[];
is_public?: boolean;
}

View file

@ -1,11 +1,12 @@
/**
* TrackMetadataEditModal Edit BPM and musical key (E1/E2)
* TrackMetadataEditModal Edit BPM, musical key, tags (E1/E2/E4)
*/
import { useState, useEffect } from 'react';
import { Dialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { updateTrack } from '../services/trackService';
import { X, Plus } from 'lucide-react';
import { updateTrack, getSuggestedTags } from '../services/trackService';
import toast from '@/utils/toast';
import type { Track } from '../types/track';
@ -30,15 +31,28 @@ export function TrackMetadataEditModal({
const [musicalKey, setMusicalKey] = useState<string>(
track.musical_key ?? (track as { key?: string }).key ?? '',
);
const [tags, setTags] = useState<string[]>(() => track.tags ?? []);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open) {
setBpm(track.bpm != null && track.bpm > 0 ? String(track.bpm) : '');
setMusicalKey(track.musical_key ?? (track as { key?: string }).key ?? '');
setTags(track.tags ?? []);
getSuggestedTags({ genre: track.genre ?? undefined, bpm: track.bpm ?? undefined })
.then(setSuggestions)
.catch(() => setSuggestions([]));
}
}, [open, track]);
const addTag = (tag: string) => {
const t = tag.trim();
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
};
const removeTag = (tag: string) => setTags(tags.filter((x) => x !== tag));
const handleSave = async () => {
const bpmNum = bpm.trim() === '' ? null : parseInt(bpm, 10);
if (bpmNum != null && (isNaN(bpmNum) || bpmNum < 0 || bpmNum > 300)) {
@ -50,6 +64,7 @@ export function TrackMetadataEditModal({
await updateTrack(track.id, {
bpm: bpmNum ?? undefined,
musical_key: musicalKey.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
});
toast.success('Metadata updated');
onUpdated();
@ -99,6 +114,48 @@ export function TrackMetadataEditModal({
))}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Tags
</label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 text-primary text-sm"
>
{t}
<button
type="button"
onClick={() => removeTag(t)}
className="hover:bg-primary/20 rounded p-0.5"
aria-label={`Remove ${t}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-1">
{suggestions
.filter((s) => !tags.includes(s))
.slice(0, 6)
.map((s) => (
<Button
key={s}
type="button"
variant="ghost"
size="sm"
onClick={() => addTag(s)}
className="h-7 text-xs"
>
<Plus className="w-3 h-3 mr-1" /> {s}
</Button>
))}
</div>
)}
</div>
</div>
</Dialog>
);

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio, Gauge, Music, Pencil } from 'lucide-react';
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio, Gauge, Music, Pencil, Hash } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useUser } from '@/features/auth/hooks/useUser';
@ -198,6 +198,13 @@ export function TrackDetailPageInfo({ track, onTrackUpdated }: TrackDetailPageIn
value={track.musical_key ?? (track as { key?: string }).key ?? ''}
/>
)}
{track.tags && track.tags.length > 0 && (
<MetaItem
icon={<Hash className="w-4 h-4" />}
label="Tags"
value={track.tags.join(', ')}
/>
)}
{formattedDate && (
<MetaItem
icon={<Calendar className="w-4 h-4" />}

View file

@ -218,6 +218,23 @@ export async function updateLyrics(
return response.data.lyrics;
}
/**
* Récupère les tags suggérés selon le genre
*/
export async function getSuggestedTags(params?: {
genre?: string;
bpm?: number;
}): Promise<string[]> {
const searchParams = new URLSearchParams();
if (params?.genre) searchParams.set('genre', params.genre);
if (params?.bpm != null) searchParams.set('bpm', String(params.bpm));
const qs = searchParams.toString();
const response = await apiClient.get<{ tags: string[] }>(
`/tracks/suggested-tags${qs ? `?${qs}` : ''}`,
);
return response.data.tags;
}
export interface UpdateTrackParams {
title?: string;
description?: string;
@ -227,6 +244,7 @@ export interface UpdateTrackParams {
year?: number;
bpm?: number | null;
musical_key?: string | null;
tags?: string[];
is_public?: boolean;
}

View file

@ -47,6 +47,8 @@ export type Track = VezaBackendApiInternalModelsTrack & {
// E1/E2: Metadata from migration 040
bpm?: number | null;
musical_key?: string | null;
// E4: Tags
tags?: string[];
};
export interface UploadProgress {

View file

@ -23,6 +23,7 @@ const mockTrack = (overrides: Record<string, unknown> = {}) => ({
user: { id: 'user-1', username: 'ArtistUser', avatar_url: 'https://i.pravatar.cc/150?u=artist' },
bpm: 120,
musical_key: 'Cm',
tags: ['Pop', 'Synthwave'],
...overrides,
});
@ -52,6 +53,19 @@ export const handlersTracks = [
});
}),
http.get('*/api/v1/tracks/suggested-tags', ({ request }) => {
const url = new URL(request.url);
const genre = url.searchParams.get('genre')?.toLowerCase() || 'default';
const byGenre: Record<string, string[]> = {
pop: ['Pop', 'Catchy', 'Radio'],
rock: ['Rock', 'Guitar', 'Alternative'],
electronic: ['Electronic', 'Synth', 'EDM', 'Techno'],
default: ['Synthwave', 'Lo-Fi', 'Experimental'],
};
const tags = byGenre[genre] ?? byGenre.default;
return HttpResponse.json({ success: true, data: { tags } });
}),
http.get('*/api/v1/tracks/search', () => {
return HttpResponse.json({
tracks: [
@ -253,6 +267,7 @@ export const handlersTracks = [
...mockTrack({ id: params.id }),
...(body.bpm != null && { bpm: body.bpm }),
...(body.musical_key != null && { musical_key: body.musical_key }),
...(body.tags != null && { tags: body.tags }),
...(body.title != null && { title: body.title }),
...(body.artist != null && { artist: body.artist }),
};

View file

@ -78,6 +78,7 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
{
tracks.GET("", trackHandler.ListTracks)
tracks.GET("/search", trackHandler.SearchTracks)
tracks.GET("/suggested-tags", trackHandler.GetSuggestedTags)
tracks.GET("/:id", trackHandler.GetTrack)
tracks.GET("/:id/lyrics", trackHandler.GetLyrics)
tracks.GET("/:id/stats", trackHandler.GetTrackStats)

View file

@ -880,6 +880,39 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
})
}
// tagSuggestionsByGenre holds static tag suggestions per genre (E4)
var tagSuggestionsByGenre = map[string][]string{
"pop": {"Pop", "Catchy", "Radio", "Mainstream", "Vocal"},
"rock": {"Rock", "Guitar", "Drums", "Alternative", "Indie"},
"electronic": {"Electronic", "Synth", "EDM", "Techno", "House", "Dubstep"},
"hip-hop": {"Hip-Hop", "Rap", "Beats", "Urban", "Trap"},
"jazz": {"Jazz", "Smooth", "Saxophone", "Blues", "Soul"},
"classical": {"Classical", "Orchestral", "Piano", "Strings"},
"ambient": {"Ambient", "Chill", "Cinematic", "Atmospheric"},
"default": {"Synthwave", "Lo-Fi", "Experimental", "Instrumental"},
}
// GetSuggestedTags returns tag suggestions based on genre and BPM (E4)
// @Summary Get Suggested Tags
// @Description Get tag suggestions for a track based on genre and optional BPM
// @Tags Track
// @Produce json
// @Param genre query string false "Genre filter"
// @Param bpm query int false "BPM hint"
// @Success 200 {object} response.APIResponse{data=object{tags=[]string}}
// @Router /tracks/suggested-tags [get]
func (h *TrackHandler) GetSuggestedTags(c *gin.Context) {
genre := strings.ToLower(strings.TrimSpace(c.DefaultQuery("genre", "")))
if genre == "" {
genre = "default"
}
tags, ok := tagSuggestionsByGenre[genre]
if !ok {
tags = tagSuggestionsByGenre["default"]
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"tags": tags})
}
// GetTrack gère la récupération d'un track par son ID
// @Summary Get Track by ID
// @Description Get detailed information about a track
@ -927,14 +960,15 @@ func (h *TrackHandler) GetTrack(c *gin.Context) {
// UpdateTrackRequest représente la requête de mise à jour d'un track
// MOD-P1-002: Added validation tags for systematic input validation
type UpdateTrackRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"`
Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"`
Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"`
Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"`
Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"`
BPM *int `json:"bpm" binding:"omitempty,min=0,max=300" validate:"omitempty,min=0,max=300"`
MusicalKey *string `json:"musical_key" binding:"omitempty,max=10" validate:"omitempty,max=10"`
IsPublic *bool `json:"is_public"`
Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"`
Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"`
Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"`
Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"`
Tags []string `json:"tags"`
Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"`
BPM *int `json:"bpm" binding:"omitempty,min=0,max=300" validate:"omitempty,min=0,max=300"`
MusicalKey *string `json:"musical_key" binding:"omitempty,max=10" validate:"omitempty,max=10"`
IsPublic *bool `json:"is_public"`
}
// UpdateTrack gère la mise à jour d'un track
@ -984,6 +1018,7 @@ func (h *TrackHandler) UpdateTrack(c *gin.Context) {
Artist: req.Artist,
Album: req.Album,
Genre: req.Genre,
Tags: req.Tags,
Year: req.Year,
BPM: req.BPM,
MusicalKey: req.MusicalKey,

View file

@ -642,14 +642,15 @@ func (s *TrackService) GetTrackByID(ctx context.Context, trackID uuid.UUID) (*mo
// UpdateTrackParams représente les paramètres de mise à jour d'un track
type UpdateTrackParams struct {
Title *string `json:"title"`
Artist *string `json:"artist"`
Album *string `json:"album"`
Genre *string `json:"genre"`
Year *int `json:"year"`
BPM *int `json:"bpm"`
MusicalKey *string `json:"musical_key"`
IsPublic *bool `json:"is_public"`
Title *string `json:"title"`
Artist *string `json:"artist"`
Album *string `json:"album"`
Genre *string `json:"genre"`
Tags []string `json:"tags"`
Year *int `json:"year"`
BPM *int `json:"bpm"`
MusicalKey *string `json:"musical_key"`
IsPublic *bool `json:"is_public"`
}
// UpdateTrack met à jour les métadonnées d'un track
@ -691,6 +692,9 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
if params.Genre != nil {
updates["genre"] = *params.Genre
}
if params.Tags != nil {
updates["tags"] = params.Tags
}
if params.Year != nil {
if *params.Year < 0 {
return nil, fmt.Errorf("year cannot be negative")

View file

@ -4,6 +4,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
@ -18,6 +19,7 @@ type Track struct {
Album string `gorm:"size:255" json:"album" db:"album"`
Duration int `gorm:"not null" json:"duration" db:"duration"` // seconds
Genre string `gorm:"size:100" json:"genre" db:"genre"`
Tags pq.StringArray `gorm:"type:text[]" json:"tags,omitempty" db:"tags"`
Year int `gorm:"default:0" json:"year" db:"year"`
BPM *int `gorm:"column:bpm" json:"bpm,omitempty" db:"bpm"`
MusicalKey string `gorm:"size:10" json:"musical_key,omitempty" db:"musical_key"`

View file

@ -0,0 +1,4 @@
-- 085_add_track_tags.sql
-- Add tags array to tracks (E4)
ALTER TABLE public.tracks ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';