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:
parent
6b80089706
commit
1977183718
11 changed files with 165 additions and 19 deletions
|
|
@ -40,6 +40,7 @@ export interface UpdateTrackRequest {
|
|||
year?: number;
|
||||
bpm?: number | null;
|
||||
musical_key?: string | null;
|
||||
tags?: string[];
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
4
veza-backend-api/migrations/085_add_track_tags.sql
Normal file
4
veza-backend-api/migrations/085_add_track_tags.sql
Normal 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 '{}';
|
||||
Loading…
Reference in a new issue