feat(tracks): add BPM field to model and CRUD (E1)

- Backend: BPM and MusicalKey in Track model, UpdateTrack handler
- track_search_service: enable BPM filter (min_bpm, max_bpm)
- Frontend: Track type, UpdateTrackParams, display in TrackDetailPageInfo
- TrackMetadataEditModal: BPM input, edit flow for track creator
- MSW: bpm, musical_key in mock track, correct response envelope
This commit is contained in:
senke 2026-02-20 15:34:00 +01:00
parent fc34f4d064
commit 1620819afd
11 changed files with 225 additions and 32 deletions

View file

@ -38,6 +38,8 @@ export interface UpdateTrackRequest {
album?: string;
genre?: string;
year?: number;
bpm?: number | null;
musical_key?: string | null;
is_public?: boolean;
}

View file

@ -0,0 +1,105 @@
/**
* TrackMetadataEditModal Edit BPM and musical key (E1/E2)
*/
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 toast from '@/utils/toast';
import type { Track } from '../types/track';
const MUSICAL_KEYS = ['C', 'Cm', 'C#', 'C#m', 'D', 'Dm', 'Eb', 'Ebm', 'E', 'Em', 'F', 'Fm', 'F#', 'F#m', 'G', 'Gm', 'Ab', 'Abm', 'A', 'Am', 'Bb', 'Bbm', 'B', 'Bm'];
interface TrackMetadataEditModalProps {
open: boolean;
onClose: () => void;
track: Track;
onUpdated: () => void;
}
export function TrackMetadataEditModal({
open,
onClose,
track,
onUpdated,
}: TrackMetadataEditModalProps) {
const [bpm, setBpm] = useState<string>(() =>
track.bpm != null && track.bpm > 0 ? String(track.bpm) : '',
);
const [musicalKey, setMusicalKey] = useState<string>(
track.musical_key ?? (track as { key?: string }).key ?? '',
);
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 ?? '');
}
}, [open, track]);
const handleSave = async () => {
const bpmNum = bpm.trim() === '' ? null : parseInt(bpm, 10);
if (bpmNum != null && (isNaN(bpmNum) || bpmNum < 0 || bpmNum > 300)) {
toast.error('BPM must be between 0 and 300');
throw new Error('Validation failed');
}
setIsSaving(true);
try {
await updateTrack(track.id, {
bpm: bpmNum ?? undefined,
musical_key: musicalKey.trim() || undefined,
});
toast.success('Metadata updated');
onUpdated();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
throw err;
} finally {
setIsSaving(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
title="Edit metadata"
size="md"
onConfirm={handleSave}
confirmLabel={isSaving ? 'Saving…' : 'Save'}
cancelLabel="Cancel"
showCancel={true}
>
<div className="space-y-4">
<Input
label="BPM"
type="number"
min={0}
max={300}
placeholder="120"
value={bpm}
onChange={(e) => setBpm(e.target.value)}
/>
<div className="space-y-2">
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Key
</label>
<select
value={musicalKey}
onChange={(e) => setMusicalKey(e.target.value)}
className="flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value=""></option>
{MUSICAL_KEYS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
</div>
</div>
</Dialog>
);
}

View file

@ -70,7 +70,10 @@ export function TrackDetailPage(props?: TrackDetailPageProps) {
{/* Info & tabs — stagger delay 2 */}
<div className="lg:col-span-8 space-y-8 animate-stagger-in" style={{ animationDelay: '120ms' }}>
<TrackDetailPageInfo track={track} />
<TrackDetailPageInfo
track={track}
onTrackUpdated={loadTrack}
/>
<TrackDetailPageTabs
track={track}
isShareDialogOpen={isShareDialogOpen}

View file

@ -1,11 +1,15 @@
import { useMemo } from 'react';
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Clock, Disc3, Music2, Radio, Calendar, FileAudio, Gauge, Music, Pencil } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useUser } from '@/features/auth/hooks/useUser';
import { TrackMetadataEditModal } from '../../components/TrackMetadataEditModal';
import { formatDuration } from './utils';
import type { Track } from '../../types/track';
interface TrackDetailPageInfoProps {
track: Track;
onTrackUpdated?: () => void;
}
/* ── Waveform Visualization (placeholder when no server-rendered waveform) ── */
@ -58,7 +62,11 @@ function MetaItem({ icon, label, value }: MetaItemProps) {
/* ── Main component ── */
export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
export function TrackDetailPageInfo({ track, onTrackUpdated }: TrackDetailPageInfoProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const { data: currentUser } = useUser();
const canEdit =
onTrackUpdated && currentUser && String(currentUser.id) === String(track.creator_id);
const waveformPath = track.waveform_path;
const album = track.album;
const year = track.year;
@ -129,6 +137,19 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
{/* Metadata grid */}
<Card variant="glass" className="p-4 bg-muted/20 border-border">
{canEdit && (
<div className="flex justify-end items-center mb-3">
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditModalOpen(true)}
className="text-muted-foreground hover:text-foreground"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit
</Button>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<MetaItem
icon={<Clock className="w-4 h-4" />}
@ -163,6 +184,20 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
value={`${(sampleRate / 1000).toFixed(1)} kHz`}
/>
)}
{track.bpm != null && track.bpm > 0 && (
<MetaItem
icon={<Gauge className="w-4 h-4" />}
label="BPM"
value={track.bpm}
/>
)}
{(track.musical_key ?? (track as { key?: string }).key) && (
<MetaItem
icon={<Music className="w-4 h-4" />}
label="Key"
value={track.musical_key ?? (track as { key?: string }).key ?? ''}
/>
)}
{formattedDate && (
<MetaItem
icon={<Calendar className="w-4 h-4" />}
@ -172,6 +207,15 @@ export function TrackDetailPageInfo({ track }: TrackDetailPageInfoProps) {
)}
</div>
</Card>
{canEdit && (
<TrackMetadataEditModal
open={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
track={track}
onUpdated={onTrackUpdated}
/>
)}
</div>
);
}

View file

@ -186,6 +186,8 @@ export interface UpdateTrackParams {
album?: string;
genre?: string;
year?: number;
bpm?: number | null;
musical_key?: string | null;
is_public?: boolean;
}

View file

@ -44,6 +44,9 @@ export type Track = VezaBackendApiInternalModelsTrack & {
status?: TrackStatus | VezaBackendApiInternalModelsTrackStatus;
// Stream status with specific union type
stream_status?: 'pending' | 'processing' | 'ready' | 'error';
// E1/E2: Metadata from migration 040
bpm?: number | null;
musical_key?: string | null;
};
export interface UploadProgress {

View file

@ -6,6 +6,7 @@ import { http, HttpResponse } from 'msw';
const mockTrack = (overrides: Record<string, unknown> = {}) => ({
id: 'track-1',
creator_id: '1',
title: 'Storybook Track',
artist: 'Test Artist',
duration: 240,
@ -20,6 +21,8 @@ const mockTrack = (overrides: Record<string, unknown> = {}) => ({
comments_count: 5,
is_liked: false,
user: { id: 'user-1', username: 'ArtistUser', avatar_url: 'https://i.pravatar.cc/150?u=artist' },
bpm: 120,
musical_key: 'Cm',
...overrides,
});
@ -127,12 +130,14 @@ export const handlersTracks = [
}),
http.get('*/api/v1/tracks/:id', ({ params }) => {
return HttpResponse.json({
const track = {
...mockTrack({ id: params.id }),
description: 'A test track for Storybook',
bpm: 120,
key: 'Cm',
stats: { plays: 100, likes: 10, downloads: 5, comments: 5 },
};
return HttpResponse.json({
success: true,
data: { track },
});
}),
@ -210,10 +215,18 @@ export const handlersTracks = [
});
}),
http.put('*/api/v1/tracks/:id', ({ params }) => {
http.put('*/api/v1/tracks/:id', async ({ params, request }) => {
const body = (await request.json()) as Record<string, unknown>;
const track = {
...mockTrack({ id: params.id }),
...(body.bpm != null && { bpm: body.bpm }),
...(body.musical_key != null && { musical_key: body.musical_key }),
...(body.title != null && { title: body.title }),
...(body.artist != null && { artist: body.artist }),
};
return HttpResponse.json({
success: true,
track: { id: params.id, title: 'Updated Track Title', artist: 'Updated Artist' },
data: { track },
});
}),

View file

@ -927,12 +927,14 @@ 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"`
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"`
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
@ -978,12 +980,14 @@ func (h *TrackHandler) UpdateTrack(c *gin.Context) {
// Convertir la requête en paramètres de service
params := UpdateTrackParams{
Title: req.Title,
Artist: req.Artist,
Album: req.Album,
Genre: req.Genre,
Year: req.Year,
IsPublic: req.IsPublic,
Title: req.Title,
Artist: req.Artist,
Album: req.Album,
Genre: req.Genre,
Year: req.Year,
BPM: req.BPM,
MusicalKey: req.MusicalKey,
IsPublic: req.IsPublic,
}
// MOD-P1-003: Check if user is admin for ownership bypass

View file

@ -642,12 +642,14 @@ 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"`
IsPublic *bool `json:"is_public"`
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"`
}
// UpdateTrack met à jour les métadonnées d'un track
@ -695,6 +697,15 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
}
updates["year"] = *params.Year
}
if params.BPM != nil {
if *params.BPM < 0 || *params.BPM > 300 {
return nil, fmt.Errorf("bpm must be between 0 and 300")
}
updates["bpm"] = *params.BPM
}
if params.MusicalKey != nil {
updates["musical_key"] = *params.MusicalKey
}
if params.IsPublic != nil {
updates["is_public"] = *params.IsPublic
}

View file

@ -19,6 +19,8 @@ type Track struct {
Duration int `gorm:"not null" json:"duration" db:"duration"` // seconds
Genre string `gorm:"size:100" json:"genre" db:"genre"`
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"`
FilePath string `gorm:"not null;size:500" json:"file_path" db:"file_path"`
FileSize int64 `gorm:"not null" json:"file_size" db:"file_size"` // bytes
Format string `gorm:"size:10" json:"format" db:"format"` // mp3, flac, wav, etc.

View file

@ -78,11 +78,15 @@ func (s *TrackSearchService) SearchTracks(ctx context.Context, params TrackSearc
query = query.Where("duration <= ?", *params.MaxDuration)
}
// BPM filter - Note: BPM field not in current model, skipping for now
// This can be implemented when BPM field is added to the Track model
if params.MinBPM != nil || params.MaxBPM != nil {
// BPM functionality would go here when BPM field is added
// When implemented, should support combined min/max like duration
// BPM filter (E1: BPM in Track model)
if params.MinBPM != nil && params.MaxBPM != nil {
if *params.MinBPM <= *params.MaxBPM {
query = query.Where("bpm >= ? AND bpm <= ?", *params.MinBPM, *params.MaxBPM)
}
} else if params.MinBPM != nil {
query = query.Where("bpm >= ?", *params.MinBPM)
} else if params.MaxBPM != nil {
query = query.Where("bpm <= ?", *params.MaxBPM)
}
// Genre filter (case-insensitive)