feat(release): v0.202 — Lots G, H, F, C, D
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

- Lot G: Recherche avancée (musical_key, tri pertinence, autocomplete, facettes, historique)
- Lot H: Analytics créateur (stats, charts, completion rate, export CSV/JSON)
- Lot F: Seller dashboard (GET /sell/stats, liste produits)
- Lot C: Player (crossfade, gapless preload, PiP)
- Lot D2: Autoplay (GET /tracks/recommendations, section À écouter ensuite)

Backend: GetRecommendations handler, route /tracks/recommendations
Frontend: PlayerQueue recommendations, fix TS errors (GlobalPlayer, AnalyticsViewKpiGrid, etc.)
Docs: FEATURE_STATUS, PROJECT_STATE, CHANGELOG, SCOPE_CONTROL
This commit is contained in:
senke 2026-02-20 18:16:17 +01:00
parent 2424986ebf
commit ede3546f4b
20 changed files with 508 additions and 72 deletions

View file

@ -1,10 +1,10 @@
# Règles de Développement UI - Projet SaaS # Règles de Développement UI - Projet SaaS
## 0. Scope v0.201 (priorité absolue) ## 0. Scope v0.202 (priorité absolue)
- **Référence** : `docs/V0_201_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md` - **Référence** : `docs/V0_202_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
- Avant toute modification : vérifier si le changement est **dans le scope v0.201** - Avant toute modification : vérifier si le changement est **dans le scope v0.202**
- **Autorisé v0.201** : lots E, F, G, H, C, D (Métadonnées, Seller, Recherche avancée, Analytics créateur, Player, Queue) - **Autorisé v0.202** : lots G, H, F, C, D (Recherche avancée, Analytics créateur, Seller, Player, Queue)
- **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité) - **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité)
- En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure. - En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure.

View file

@ -1,5 +1,46 @@
# Changelog - Veza # Changelog - Veza
## [v0.202] - 2026-02-20
### Added
- **Lot G — Recherche avancée**
- Filtre musical_key dans track_search (G1)
- Tri pertinence (relevance) dans SearchService (G2)
- Autocomplete : GET /search/suggestions, dropdown debounced (G3)
- Facettes type (tracks/artistes/playlists/users) dans SearchPage (G4)
- Historique recherche localStorage (G5)
- **Lot H — Analytics créateur**
- GET /analytics/creator/stats, carte Completion Rate (H1)
- GET /analytics/creator/charts, graphiques (H2)
- Taux de complétion intégré dashboard (H3)
- GET /analytics/creator/export CSV/JSON (H4)
- **Lot F — Seller dashboard**
- GET /sell/stats, connexion commerceService (F1)
- Support seller_id=me dans ListProducts (F2)
- **Lot C — Player avancé**
- Crossfade configurable (112 s) depuis Settings (C1)
- Gapless préchargement via preloadTrack (C2)
- PiP (Picture-in-Picture) si supporté (C3)
- **Lot D — Autoplay**
- GET /tracks/recommendations (auth), section « À écouter ensuite » dans PlayerQueue (D2)
### Changed
- SearchPage : onglets type, suggestions dropdown, historique récent
- AnalyticsViewKpiGrid : métrique Completion Rate
- AnalyticsViewChart : graphiques creator
- SettingsPage : slider crossfade
- PlayerQueue : recommandations quand queue vide (authentifié)
- PlayerStore : crossfadeSeconds, préchargement ~5 s avant fin
### Documented
- D1 (queue collaborative) reporté v0.203+
- V0_202_RELEASE_SCOPE.md, FEATURE_STATUS.md, PROJECT_STATE.md mis à jour
---
## [v0.201] - 2026-02-20 ## [v0.201] - 2026-02-20
### Added ### Added

View file

@ -63,7 +63,7 @@ export const Default: Story = {
export const Loading: Story = { export const Loading: Story = {
name: 'Loading', name: 'Loading',
decorators: [ decorators: [
(Story) => { (_Story) => {
usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null }); usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null });
return ( return (
<div className="bg-background min-h-screen p-4 max-w-4xl mx-auto space-y-6"> <div className="bg-background min-h-screen p-4 max-w-4xl mx-auto space-y-6">

View file

@ -202,8 +202,8 @@ export const QueueView: React.FC = () => {
> >
{upNext.map((track, i) => ( {upNext.map((track, i) => (
<QueueSortableItem <QueueSortableItem
key={sortableIds[i]} key={sortableIds[i] ?? track.id}
id={sortableIds[i]} id={sortableIds[i] ?? track.id}
track={track} track={track}
onPlay={playTrack} onPlay={playTrack}
onRemove={() => removeFromQueue(currentIndex + 1 + i)} onRemove={() => removeFromQueue(currentIndex + 1 + i)}

View file

@ -39,15 +39,14 @@ export function useEditProfile() {
first_name: p.first_name || '', first_name: p.first_name || '',
last_name: p.last_name || '', last_name: p.last_name || '',
bio: p.bio || '', bio: p.bio || '',
banner_url: p.banner_url || '', banner_url: (p as { banner_url?: string }).banner_url ?? (p as { banner?: string }).banner ?? '',
location: p.location || '', location: p.location || '',
gender: p.gender || 'Prefer not to say', gender: p.gender || 'Prefer not to say',
birthdate: p.birthdate || '', birthdate: p.birthdate || '',
}); });
if (p.avatar_url) setAvatar(p.avatar_url); if (p.avatar_url) setAvatar(p.avatar_url);
if (p.banner_url) { const bannerUrl = (p as { banner_url?: string }).banner_url ?? (p as { banner?: string }).banner;
setBanner(p.banner_url); if (bannerUrl) setBanner(bannerUrl);
}
} catch (e) { } catch (e) {
logger.error('Failed to load profile settings', { logger.error('Failed to load profile settings', {
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),

View file

@ -49,7 +49,7 @@ export function AnalyticsViewKpiGrid({ stats }: AnalyticsViewKpiGridProps) {
: '—' : '—'
} }
icon={<Target className="w-4 h-4" />} icon={<Target className="w-4 h-4" />}
color="green" color="lime"
/> />
</div> </div>
); );

View file

@ -1,6 +1,5 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PictureInPicture2 } from 'lucide-react';
import { usePlayer } from '@/features/player/hooks/usePlayer'; import { usePlayer } from '@/features/player/hooks/usePlayer';
import { usePictureInPicture } from '@/features/player/hooks/usePictureInPicture'; import { usePictureInPicture } from '@/features/player/hooks/usePictureInPicture';
import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@/features/player/hooks/useKeyboardShortcuts';
@ -50,8 +49,8 @@ export function GlobalPlayer() {
const displayTrack = currentTrack || IDLE_TRACK; const displayTrack = currentTrack || IDLE_TRACK;
const isIdle = !currentTrack; const isIdle = !currentTrack;
const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isPiPSupported } = usePictureInPicture( const { setVideoRef: setPiPVideoRef, togglePiP, isPiPActive, isSupported: isPiPSupported } = usePictureInPicture(
currentTrack?.cover_art_path ?? null, currentTrack?.cover ?? null,
); );
useMediaSession({ useMediaSession({
@ -72,7 +71,7 @@ export function GlobalPlayer() {
className="hidden w-0 h-0" className="hidden w-0 h-0"
muted muted
playsInline playsInline
poster={currentTrack?.cover_art_path ?? undefined} poster={currentTrack?.cover ?? undefined}
/> />
)} )}

View file

@ -1,7 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { useEffect } from 'react';
import { PlayerQueue } from './PlayerQueue'; import { PlayerQueue } from './PlayerQueue';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useEffect } from 'react'; import { useAuthStore } from '@/features/auth/store/authStore';
const meta: Meta<typeof PlayerQueue> = { const meta: Meta<typeof PlayerQueue> = {
title: 'Components/Features/Player/PlayerQueue', title: 'Components/Features/Player/PlayerQueue',
@ -65,6 +66,14 @@ export const Default: Story = {
], ],
}; };
const AuthInitializer = ({ authenticated }: { authenticated: boolean }) => {
useEffect(() => {
useAuthStore.setState({ isAuthenticated: authenticated });
return () => useAuthStore.setState({ isAuthenticated: false });
}, [authenticated]);
return null;
};
export const Empty: Story = { export const Empty: Story = {
args: { args: {
isOpen: true, isOpen: true,
@ -80,3 +89,20 @@ export const Empty: Story = {
), ),
], ],
}; };
export const EmptyWithRecommendations: Story = {
args: {
isOpen: true,
},
decorators: [
(Story) => (
<>
<AuthInitializer authenticated />
<StoreInitializer tracks={[]} />
<div className="h-[600px] w-full relative bg-background overflow-hidden">
<Story />
</div>
</>
),
],
};

View file

@ -1,10 +1,16 @@
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useUIStore } from '@/stores/ui'; import { useUIStore } from '@/stores/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { X, GripVertical, ListMusic } from 'lucide-react'; import { X, GripVertical, ListMusic, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/features/auth/store/authStore';
import { tracksApi } from '@/services/api/tracks';
import { getHLSMasterPlaylistURL } from '@/features/streaming/services/hlsService';
import type { Track as PlayerTrack } from '../types';
import type { Track as ApiTrack } from '@/features/tracks/types/track';
interface PlayerQueueProps { interface PlayerQueueProps {
isOpen: boolean; isOpen: boolean;
@ -13,9 +19,31 @@ interface PlayerQueueProps {
onPlay: (track: any) => void; onPlay: (track: any) => void;
} }
function mapApiTrackToPlayerTrack(t: ApiTrack): PlayerTrack {
const apiTrack = t as ApiTrack & { stream_manifest_url?: string; cover_art_path?: string };
return {
id: t.id,
title: t.title,
artist: t.artist,
duration: t.duration ?? 0,
url: apiTrack.stream_manifest_url ?? getHLSMasterPlaylistURL(t.id),
cover: apiTrack.cover_art_path,
genre: t.genre,
like_count: t.like_count,
};
}
export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) { export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
const { queue, currentIndex, removeFromQueue, clearQueue } = usePlayerStore(); const { queue, currentIndex, removeFromQueue, clearQueue } = usePlayerStore();
const { sidebarOpen } = useUIStore(); const { sidebarOpen } = useUIStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { data: recommendations = [], isLoading: recommendationsLoading } = useQuery({
queryKey: ['track-recommendations'],
queryFn: () => tracksApi.getRecommendations({ limit: 10 }),
enabled: isOpen && isAuthenticated && queue.length === 0,
staleTime: 60_000,
});
const playerTracks = recommendations.map(mapApiTrackToPlayerTrack);
if (!isOpen) return null; if (!isOpen) return null;
@ -56,13 +84,44 @@ export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
{queue.length === 0 ? ( {queue.length === 0 ? (
<EmptyState <div className="flex flex-col gap-4 p-4">
icon={<ListMusic className="w-full h-full" />} {playerTracks.length > 0 ? (
title="Your queue is empty" <>
description="Add tracks to keep the vibe going." <h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
size="sm" <Sparkles className="w-4 h-4 text-primary" />
className="border-0 shadow-none bg-transparent" À écouter ensuite
/> </h4>
<ScrollArea className="max-h-layout-list">
<div className="space-y-1">
{playerTracks.map((track) => (
<div
key={track.id}
className="group flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition-colors cursor-pointer border border-transparent hover:border-white/5"
onClick={() => onPlay(track)}
>
<div className="w-6 text-center text-xs font-mono text-muted-foreground"></div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium truncate text-foreground">{track.title}</h4>
<p className="text-xs text-muted-foreground truncate">{track.artist}</p>
</div>
</div>
))}
</div>
</ScrollArea>
<p className="text-xs text-muted-foreground">Cliquez pour lire</p>
</>
) : recommendationsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">Chargement des suggestions</div>
) : (
<EmptyState
icon={<ListMusic className="w-full h-full" />}
title="Your queue is empty"
description="Add tracks to keep the vibe going."
size="sm"
className="border-0 shadow-none bg-transparent"
/>
)}
</div>
) : ( ) : (
<ScrollArea className="h-full max-h-layout-list"> <ScrollArea className="h-full max-h-layout-list">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">

View file

@ -9,7 +9,7 @@ interface SearchPageDiscoveryProps {
export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps) { export function SearchPageDiscovery({ onQuerySelect }: SearchPageDiscoveryProps) {
const { getHistory, clearHistory } = useSearchHistory(); const { getHistory, clearHistory } = useSearchHistory();
const [refreshKey, setRefreshKey] = useState(0); const [, setRefreshKey] = useState(0);
const history = getHistory(); const history = getHistory();
const handleClear = () => { const handleClear = () => {

View file

@ -300,6 +300,24 @@ export async function uploadTrack(
* @returns La liste des tracks avec les métadonnées de pagination * @returns La liste des tracks avec les métadonnées de pagination
* @throws Error si la requête échoue * @throws Error si la requête échoue
*/ */
/**
* Get personalized track recommendations (D2 autoplay)
* Backend: GET /api/v1/tracks/recommendations (auth required)
*/
export async function getTrackRecommendations(
options?: { limit?: number; seedTrackId?: string },
): Promise<Track[]> {
const limit = Math.min(Math.max(options?.limit ?? 20, 1), 100);
const params = new URLSearchParams({ limit: String(limit) });
if (options?.seedTrackId) {
params.set('seed_track_id', options.seedTrackId);
}
const { data } = await apiClient.get<{ tracks: Track[] }>(
`/tracks/recommendations?${params.toString()}`,
);
return data?.tracks ?? [];
}
export async function getTracks( export async function getTracks(
page: number = 1, page: number = 1,
limit: number = 20, limit: number = 20,

View file

@ -2,4 +2,6 @@ import { setupWorker } from 'msw/browser';
import { handlers } from './handlers'; import { handlers } from './handlers';
// This configures a Service Worker with the given request handlers. // This configures a Service Worker with the given request handlers.
export const worker = setupWorker(...handlers); export const worker = setupWorker(
...(handlers.filter((h): h is NonNullable<typeof h> => Boolean(h))),
);

View file

@ -53,6 +53,21 @@ export const handlersTracks = [
}); });
}), }),
http.get('*/api/v1/tracks/recommendations', ({ request }) => {
const url = new URL(request.url);
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '20', 10), 1), 100);
const tracks = Array.from({ length: Math.min(limit, 5) }, (_, i) =>
mockTrack({
id: `rec-${i + 1}`,
title: `Recommended Track ${i + 1}`,
artist: 'Suggested Artist',
duration: 200 + i * 10,
cover_art_path: 'https://picsum.photos/200',
}),
);
return HttpResponse.json({ success: true, data: { tracks } });
}),
http.get('*/api/v1/tracks/suggested-tags', ({ request }) => { http.get('*/api/v1/tracks/suggested-tags', ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
const genre = url.searchParams.get('genre')?.toLowerCase() || 'default'; const genre = url.searchParams.get('genre')?.toLowerCase() || 'default';

View file

@ -8,6 +8,7 @@
import { import {
uploadTrack, uploadTrack,
getTracks, getTracks,
getTrackRecommendations,
updateTrack, updateTrack,
getTrackStats, getTrackStats,
getTrackHistory, getTrackHistory,
@ -54,6 +55,11 @@ export const tracksApi = {
*/ */
get: getTrack, get: getTrack,
/**
* Get personalized track recommendations (D2 autoplay)
*/
getRecommendations: getTrackRecommendations,
/** /**
* Create/upload a new track * Create/upload a new track
*/ */

View file

@ -1,6 +1,6 @@
# Statut des fonctionnalités — Veza # Statut des fonctionnalités — Veza
**Dernière mise à jour** : février 2026 — v0.201 (Lot E Métadonnées : BPM, key, lyrics, tags) **Dernière mise à jour** : février 2026 — v0.202 livrée
Ce document décrit le statut réel des fonctionnalités par rapport au code. Ce document décrit le statut réel des fonctionnalités par rapport au code.
@ -17,14 +17,14 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code.
| Playlists (CRUD, collaboration) | Oui | Oui | Complet | | Playlists (CRUD, collaboration) | Oui | Oui | Complet |
| Chat WebSocket | Oui | Oui | Complet (Chat Server doit être démarré) | | Chat WebSocket | Oui | Oui | Complet (Chat Server doit être démarré) |
| Dashboard | Oui | Oui | GET /api/v1/dashboard | | Dashboard | Oui | Oui | GET /api/v1/dashboard |
| Recherche | Oui | Oui | GET /api/v1/search unifié + endpoints par ressource | | Recherche | Oui | Oui | GET /search unifié, GET /tracks/search. v0.202 : musical_key, tri pertinence, autocomplete /suggestions, facettes type, historique localStorage |
| Social (feed, posts, groups, follows, blocks) | Oui | Oui | Complet | | Social (feed, posts, groups, follows, blocks) | Oui | Oui | Complet |
| Administration | Oui | Oui | Complet | | Administration | Oui | Oui | Complet |
| Marketplace | Oui | Oui | Complet (Hyperswitch) | | Marketplace | Oui | Oui | Complet (Hyperswitch) |
| Webhooks | Oui | Oui | Complet | | Webhooks | Oui | Oui | Complet |
| Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear | | Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear |
| Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server | | Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server |
| Analytics | Oui | Oui | Routes /api/v1/analytics/* | | Analytics | Oui | Oui | Routes /api/v1/analytics/*. v0.202 : creator stats/charts/export (Lot H) |
| Roles | Oui | Oui | Assign, revoke — flag ROLE_MANAGEMENT | | Roles | Oui | Oui | Assign, revoke — flag ROLE_MANAGEMENT |
| Notifications | Oui | Oui | Création auto follow/like/comment | | Notifications | Oui | Oui | Création auto follow/like/comment |
@ -50,10 +50,29 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code.
| Feature | Limitation | Version cible | | Feature | Limitation | Version cible |
|---------|------------|--------------| |---------|------------|--------------|
| **Go Live** (streaming vidéo) | Non implémenté — toast « coming soon » conservé | v0.703 | | **Go Live** (streaming vidéo) | Non implémenté — toast « coming soon » conservé | v0.703 |
| **Social Trending** (tags tendance) | SocialViewTrending utilise tags statiques ; pas d'API `/social/trending` | v0.103 | | **Social Trending** (tags tendance) | SocialViewTrending utilise tags statiques ; pas d'API `/social/trending` | v0.203 |
| **2FA SMS** | Option « Envoyer par SMS » pendant la vérification 2FA — requiert infra Twilio + users.phone_number | v0.104 | | **2FA SMS** | Option « Envoyer par SMS » pendant la vérification 2FA — requiert infra Twilio + users.phone_number | v0.104 |
| **Passkeys / WebAuthn** | Login sans mot de passe — requiert go-webauthn, table webauthn_credentials, frontend navigator.credentials | v0.104 | | **Passkeys / WebAuthn** | Login sans mot de passe — requiert go-webauthn, table webauthn_credentials, frontend navigator.credentials | v0.104 |
## Livré en v0.202
| Lot | Feature |
|-----|---------|
| G | Recherche avancée (musical_key, tri pertinence, autocomplete, facettes type, historique) |
| H | Analytics créateur (stats, graphiques, taux complétion, export CSV/JSON) |
| F | Seller dashboard (GET /sell/stats, liste produits marketplace) |
| C | Player (crossfade, gapless preload, PiP) |
| D | Autoplay (GET /tracks/recommendations, section « À écouter ensuite » dans PlayerQueue) |
## Prochaines fonctionnalités v0.203+
| Feature | Priorité |
|---------|----------|
| Queue collaborative (D1) | Basse |
| Recherche phonétique, booléenne | v0.203+ |
---
## Projets abandonnés ## Projets abandonnés
| Projet | Statut | | Projet | Statut |

View file

@ -8,10 +8,10 @@
| Élément | Valeur | | Élément | Valeur |
|---------|--------| |---------|--------|
| **Dernier tag** | v0.201 | | **Dernier tag** | v0.202 |
| **Branche release** | `release/v0.201` (taguée) | | **Branche release** | `main` (v0.202 mergée) |
| **Phase** | Phase 2 Contenu — Lot E livré | | **Phase** | Phase 2 Contenu — Lots G, H, F, C, D livrés |
| **Prochaine version** | v0.202 (Lot G, H, F, C, D) | | **Prochaine version** | v0.203 |
--- ---
@ -26,28 +26,25 @@
- ✅ Lot E — Métadonnées : BPM, musical_key, lyrics, tags (E1E4) - ✅ Lot E — Métadonnées : BPM, musical_key, lyrics, tags (E1E4)
- Migrations : 084 track_lyrics, 085 tracks.tags - Migrations : 084 track_lyrics, 085 tracks.tags
### Non livré (reporté v0.202) ### v0.202 (Phase 2 Contenu — Lots G, H, F, C, D)
- Lot G : Recherche avancée (filtres, tri, autocomplete, facettes) - Lot G : Recherche avancée (musical_key, tri pertinence, autocomplete, facettes type, historique)
- Lot H : Analytics créateur (stats, graphiques, export) - Lot H : Analytics créateur (stats, graphiques, taux complétion, export CSV/JSON)
- Lot F : Seller dashboard (stats ventes, liste produits) - Lot F : Seller dashboard (GET /sell/stats, liste produits marketplace)
- Lot C : Player (crossfade, gapless, PiP) - Lot C : Player (crossfade, gapless preload, PiP)
- Lot D : Queue collaborative, Autoplay - Lot D : Autoplay (GET /tracks/recommendations, section « À écouter ensuite »)
--- ---
## 3. Prochaines étapes ## 3. Prochaines étapes
### Immédiat (préparation v0.202) ### Immédiat (préparation v0.203)
1. **Créer la branche** : `release/v0.202` 1. **Créer la branche** : `git checkout -b release/v0.203`
2. **Mettre à jour SCOPE_CONTROL** : référence v0.202 2. **Définir** le scope dans V0_203_RELEASE_SCOPE.md
3. **Lire** [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md) — lots G, H, F, C, D 3. **PR** : `gh pr create --base main --head release/v0.203 --title "Release v0.203"`
### Ordre d'implémentation recommandé (v0.202) ### Cibles v0.203+
1. **Lot G** (Recherche avancée) — filtres, tri, autocomplete, facettes - Queue collaborative (D1)
2. **Lot H** (Analytics créateur) — stats, graphiques, export - Recherche phonétique, booléenne
3. **Lot F** (Seller dashboard) — stats vendeur, liste produits
4. **Lot C** (Player) — crossfade, gapless, PiP
5. **Lot D** (Queue) — autoplay, queue collaborative
--- ---
@ -55,7 +52,7 @@
| Document | Usage | | Document | Usage |
|----------|-------| |----------|-------|
| [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md) | Scope détaillé v0.201 | | [V0_202_RELEASE_SCOPE.md](V0_202_RELEASE_SCOPE.md) | Scope détaillé v0.202 |
| [SCOPE_CONTROL.md](SCOPE_CONTROL.md) | Anti-scope-creep, workflow | | [SCOPE_CONTROL.md](SCOPE_CONTROL.md) | Anti-scope-creep, workflow |
| [FEATURE_STATUS.md](FEATURE_STATUS.md) | Statut des features par domaine | | [FEATURE_STATUS.md](FEATURE_STATUS.md) | Statut des features par domaine |
| [CHANGELOG.md](../CHANGELOG.md) | Historique des versions | | [CHANGELOG.md](../CHANGELOG.md) | Historique des versions |
@ -79,5 +76,4 @@
| Métrique | Valeur | | Métrique | Valeur |
|----------|--------| |----------|--------|
| Features livrées (cumul) | ~270 / 600 | | Features livrées (cumul) | ~345 / 600 |
| Cible v0.201 | ~330 / 600 |

View file

@ -1,19 +1,19 @@
# Contrôle du scope — Anti-scope-creep # Contrôle du scope — Anti-scope-creep
**Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable. **Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable.
**Référence active** : [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md) **Référence active** : [V0_202_RELEASE_SCOPE.md](V0_202_RELEASE_SCOPE.md)
**Version précédente** : [V0_103_RELEASE_SCOPE.md](V0_103_RELEASE_SCOPE.md) **Version précédente** : [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md)
--- ---
## 1. Règle d'or ## 1. Règle d'or
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.201.** > **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.202.**
> Si non → ne pas ajouter. Créer un ticket pour une version ultérieure. > Si non → ne pas ajouter. Créer un ticket pour une version ultérieure.
--- ---
## 2. Pendant la phase v0.201 (jusqu'au tag) ## 2. Pendant la phase v0.202 (jusqu'au tag)
### 2.1 Autorisé ### 2.1 Autorisé
@ -26,7 +26,7 @@
### 2.2 Interdit ### 2.2 Interdit
- **Nouvelles features** hors scope v0.201 - **Nouvelles features** hors scope v0.202
- **Nouvelles routes** ou pages hors scope - **Nouvelles routes** ou pages hors scope
- **Nouvelles dépendances** (sauf correctif sécurité) - **Nouvelles dépendances** (sauf correctif sécurité)
- **Changements de comportement** sur les features HORS SCOPE - **Changements de comportement** sur les features HORS SCOPE
@ -52,7 +52,7 @@
- Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ? - Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ?
2. **Mon changement ajoute-t-il du code ?** 2. **Mon changement ajoute-t-il du code ?**
- Nouvelle route, nouveau composant, nouveau service → **STOP.** Hors scope v0.101. - Nouvelle route, nouveau composant, nouveau service → **STOP.** Hors scope v0.202.
- Correction, refactoring, test → OK si lié à une feature IN SCOPE. - Correction, refactoring, test → OK si lié à une feature IN SCOPE.
3. **Mes tests passent-ils ?** 3. **Mes tests passent-ils ?**
@ -81,7 +81,7 @@ Format : `type(scope): description`
Dans chaque PR, le relecteur doit valider : Dans chaque PR, le relecteur doit valider :
- [ ] Le changement est dans le scope v0.201 (voir [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md)) - [ ] Le changement est dans le scope v0.202 (voir [V0_202_RELEASE_SCOPE.md](V0_202_RELEASE_SCOPE.md))
- [ ] Aucune nouvelle feature ajoutée - [ ] Aucune nouvelle feature ajoutée
- [ ] Aucune régression sur les flows critiques - [ ] Aucune régression sur les flows critiques
- [ ] Les tests passent - [ ] Les tests passent
@ -98,22 +98,22 @@ Une PR sera rejetée si :
--- ---
## 5. Proposer une feature pour APRÈS v0.101 ## 5. Proposer une feature pour APRÈS v0.202
### 5.1 Template ### 5.1 Template
Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec : Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec :
- **Alignement scope** : cocher "Hors scope v0.201 — pour v0.202+" - **Alignement scope** : cocher "Hors scope v0.202 — pour v0.203+"
- **Justification** : pourquoi cette feature est nécessaire - **Justification** : pourquoi cette feature est nécessaire
- **Effort estimé** : S / M / L / XL - **Effort estimé** : S / M / L / XL
- **Dépendances** : quelles features v0.101 doivent être stables avant - **Dépendances** : quelles features v0.202 doivent être stables avant
### 5.2 Workflow ### 5.2 Workflow
1. Créer une issue avec le template 1. Créer une issue avec le template
2. **Ne pas implémenter** tant que v0.201 n'est pas taguée 2. **Ne pas implémenter** tant que v0.202 n'est pas taguée
3. Une fois v0.201 stable, prioriser les issues "v0.202" dans un nouveau document de scope 3. Une fois v0.202 stable, prioriser les issues "v0.203" dans un nouveau document de scope
--- ---
@ -125,7 +125,7 @@ Si une vulnérabilité critique est identifiée :
- Correctif autorisé **immédiatement** - Correctif autorisé **immédiatement**
- Documenter dans la PR - Documenter dans la PR
- Pas besoin d'être dans le scope v0.201 - Pas besoin d'être dans le scope v0.202
### 6.2 Blocage production ### 6.2 Blocage production
@ -140,13 +140,13 @@ Pour tout cas ambigu :
- Ouvrir une issue "Scope clarification" - Ouvrir une issue "Scope clarification"
- Décision documentée dans l'issue - Décision documentée dans l'issue
- Mise à jour de V0_201_RELEASE_SCOPE.md si le scope est étendu (exception rare) - Mise à jour de V0_202_RELEASE_SCOPE.md si le scope est étendu (exception rare)
--- ---
## 7. Après le tag d'une version ## 7. Après le tag d'une version
1. **Créer** le document de scope de la version suivante (ex: `V0_202_RELEASE_SCOPE.md`) 1. **Créer** le document de scope de la version suivante (ex: `V0_203_RELEASE_SCOPE.md`)
2. **Définir** explicitement les nouvelles features autorisées 2. **Définir** explicitement les nouvelles features autorisées
3. **Mettre à jour** la référence active dans ce document (section header) 3. **Mettre à jour** la référence active dans ce document (section header)
4. **Reprendre** ce processus avec le nouveau document de scope 4. **Reprendre** ce processus avec le nouveau document de scope
@ -157,11 +157,12 @@ Pour tout cas ambigu :
- v0.102 : Déblocage Coming Soon, renforcement coeur produit (taguée) - v0.102 : Déblocage Coming Soon, renforcement coeur produit (taguée)
- v0.103 : Complétion Phase 1 Fondation — Auth A1/A4, Profils B1-B3 (taguée) - v0.103 : Complétion Phase 1 Fondation — Auth A1/A4, Profils B1-B3 (taguée)
- v0.201 : Phase 2 Contenu — Lot E Métadonnées (BPM, key, lyrics, tags) — taguée - v0.201 : Phase 2 Contenu — Lot E Métadonnées (BPM, key, lyrics, tags) — taguée
- v0.202 : Phase 2 Contenu — Lots G, H, F, C, D — taguée
--- ---
## 8. Rappel pour les contributeurs ## 8. Rappel pour les contributeurs
- **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification. - **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification.
- **Humains** : Lire [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md) avant de coder. - **Humains** : Lire [V0_202_RELEASE_SCOPE.md](V0_202_RELEASE_SCOPE.md) avant de coder.
- **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder. - **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder.

View file

@ -0,0 +1,201 @@
# Scope v0.202 — Phase 2 Contenu (suite)
**Version cible** : v0.202 (X=2, Y=2)
**Prérequis** : v0.201 taguée et mergée dans main
**Objectif** : Phase 2 Contenu — Recherche avancée, Analytics créateur, Seller dashboard, Player, Queue
**Dernière mise à jour** : 20 février 2026
**Effort estimé** : 5-7 semaines de développement
---
## 1. Principe directeur
> **v0.202 = suite Phase 2 (Contenu) après v0.201.**
>
> Cinq axes principaux :
> 1. **Recherche avancée (Lot G)** : Filtres, tri, autocomplete, facettes, historique
> 2. **Analytics créateur (Lot H)** : Stats, graphiques, export
> 3. **Seller dashboard (Lot F)** : Stats ventes, liste produits
> 4. **Player avancé (Lot C)** : Crossfade, gapless, PiP
> 5. **Queue avancée (Lot D)** : Autoplay, queue collaborative
---
## 2. Contexte — État post v0.201
### 2.1 Livré en v0.201
| Lot | Feature | Statut |
|-----|---------|--------|
| E1 | BPM | ✅ |
| E2 | Musical key | ✅ |
| E3 | Lyrics | ✅ |
| E4 | Tags suggérés | ✅ |
### 2.2 Fondation existante (à enrichir)
| Domaine | Existant | À ajouter v0.202 |
|---------|----------|-------------------|
| **Recherche tracks** | `TrackSearchService`, GET /tracks/search, filtres BPM/durée/genre/format/date, tri (popularity, title, created_at) | musical_key, autocomplete, unification search unifiée, historique |
| **Search unifiée** | GET /search (q, type=track|user|playlist), SearchService | Filtres, tri, pagination |
| **Analytics** | Routes /api/v1/analytics/*, playback_analytics | Route créateur, graphiques, export |
| **Player** | MediaSession, usePlayer | Crossfade, gapless, PiP |
| **Queue** | Queue sync, PlayerQueue | Autoplay, queue collaborative |
---
## 3. Features IN SCOPE v0.202
### 3.1 Lot G — Recherche avancée (priorité haute)
**Objectif** : Enrichir la recherche existante.
**Effort** : L (5-7 jours)
**Référence** : Module 11 (veza_full_features_list)
| # | Feature | Tâche détaillée | Backend | Frontend | Critère de sortie |
|---|---------|-----------------|---------|----------|-------------------|
| G1 | **Filtres recherche** | musical_key manquant ; tags déjà dans TrackSearchParams | Ajouter `musical_key` à TrackSearchParams + handler | Vérifier UI SearchPage/TrackSearch utilise tous les filtres | Filtres appliqués, résultats mis à jour |
| G2 | **Tri avancé** | Tri pertinence, date, popularité, alphabétique | Déjà : sort_by, sort_order. Ajouter "relevance" si full-text | Dropdown tri dans SearchPage | Tri fonctionnel |
| G3 | **Autocomplete** | Suggestions pendant la frappe | Route GET /search/suggestions?q=… (tracks, users, playlists) ou extension GET /search avec limit=5 | Input avec dropdown debounced | Suggestions affichées, clic → recherche |
| G4 | **Recherche par type** | Tracks, artistes, playlists, utilisateurs | SearchService : param `type` existant. Vérifier cohérence | Onglets/facettes dans SearchPage | Résultats par catégorie |
| G5 | **Historique recherche** | Mémoriser les dernières recherches | Optionnel : table search_history. Sinon localStorage | Section "Récent" | Dernières recherches cliquables |
**Fichiers clés** :
- Backend : `track_search_service.go`, `search_service.go`, `search_handlers.go`, `routes_search.go`, `routes_tracks.go`
- Frontend : `TrackSearch.tsx`, `TrackSearchFilters`, `trackSearchService.ts`, `SearchPage`
---
### 3.2 Lot H — Analytics créateur (priorité haute)
**Objectif** : Dashboard analytics pour créateurs.
**Effort** : L (5-7 jours)
**Référence** : Module 12.1
| # | Feature | Tâche détaillée | Backend | Frontend | Critère de sortie |
|---|---------|-----------------|---------|----------|-------------------|
| H1 | **Stats d'écoute** | Plays par track, période, durée moyenne | Agrégation playback_analytics, route GET /analytics/creator/stats | Page /analytics ou section créateur | Chiffres affichés |
| H2 | **Graphiques** | Évolution plays, top tracks | Route GET /analytics/creator/charts | Charts (recharts ou équivalent) | Graphiques lisibles |
| H3 | **Taux de complétion** | % d'écoute complète par track | Calcul backend (plays complètes / total) | Affichage dans dashboard | Métrique visible |
| H4 | **Export données** | Export CSV/JSON des stats | Route GET /analytics/creator/export?format=csv|json | Bouton export | Fichier téléchargé |
**Fichiers clés** :
- Backend : `playback_analytics_handler.go`, `routes`, modèles analytics
- Frontend : Page Analytics, composants charts, export
---
### 3.3 Lot F — Seller dashboard (priorité moyenne)
**Objectif** : Dashboard vendeur fonctionnel.
**Effort** : M (3-4 jours)
**Référence** : Module 7.4
| # | Feature | Tâche détaillée | Backend | Frontend | Critère de sortie |
|---|---------|-----------------|---------|----------|-------------------|
| F1 | **Stats ventes** | Nombre ventes, revenus, période | Route GET /sell/stats ou agrégation Hyperswitch/marketplace | Page /sell | Chiffres affichés |
| F2 | **Liste produits** | Tracks/albums en vente par l'utilisateur | Extension catalogue existant | Liste dans seller dashboard | Produits listés |
---
### 3.4 Lot C — Player avancé (priorité moyenne)
**Objectif** : Améliorer l'expérience d'écoute.
**Effort** : M (2-4 jours)
**Référence** : Module 4.1
| # | Feature | Tâche détaillée | Backend | Frontend | Critère de sortie |
|---|---------|-----------------|---------|----------|-------------------|
| C1 | **Crossfade** | Transition en fondu entre tracks | N/A | Logique player (gain, overlap) | Transition fluide 1-10s configurable |
| C2 | **Gapless playback** | Lecture sans silence entre tracks | N/A | Préchargement audio, Web Audio API ou HTMLMediaElement | Pas de coupure |
| C3 | **PiP** | Picture-in-Picture (si supporté) | N/A | Media Session + PiP API (document.pictureInPictureEnabled) | Fenêtre flottante |
**Fichiers clés** : `GlobalPlayer.tsx`, `AudioPlayer.tsx`, `usePlayer.ts`, `playerStore.ts`
---
### 3.5 Lot D — Queue avancée (priorité basse)
**Objectif** : Queue collaborative et autoplay.
**Effort** : M (3-4 jours)
**Référence** : Module 4.2
| # | Feature | Tâche détaillée | Backend | Frontend | Critère de sortie |
|---|---------|-----------------|---------|----------|-------------------|
| D1 | **Queue collaborative** | Partager queue en session | Modèle session queue partagée, routes queue | UI partage | Plusieurs users, même queue |
| D2 | **Autoplay** | Recommandations quand queue vide | Route GET /recommendations ou extension playlists | Section "À écouter ensuite" | Suggestions, ajout 1 clic |
---
## 4. Récapitulatif par lot
| Lot | Nom | Priorité | Effort | Features |
|-----|-----|----------|--------|----------|
| **G** | Recherche avancée | Haute | L | 5 |
| **H** | Analytics créateur | Haute | L | 4 |
| **F** | Seller dashboard | Moyenne | M | 2 |
| **C** | Player avancé | Moyenne | M | 3 |
| **D** | Queue avancée | Basse | M | 2 |
| | **TOTAL** | | **16-27j** | **16** |
---
## 5. Features HORS SCOPE v0.202
| Feature | Raison | Version cible |
|---------|--------|---------------|
| Recherche phonétique, booléenne | Complexité | v0.203+ |
| Collaborative filtering | Nécessite historique riche | v0.203+ |
| Social Trending API | Dépend recommandations | v0.203+ |
| 2FA SMS, Passkeys | v0.104 dédié | v0.104 |
| Go Live vidéo | v0.703 | v0.703 |
---
## 6. Ordre de livraison recommandé
| Semaine | Lots | Activités |
|---------|------|-----------|
| **S1** | G (Recherche) | G1G5 : filtres musical_key, tri, autocomplete, facettes, historique |
| **S2** | H (Analytics) | H1H4 : stats, graphiques, taux complétion, export |
| **S3** | F (Seller) | F1F2 : stats ventes, liste produits |
| **S4** | C (Player) | C1C3 : crossfade, gapless, PiP |
| **S5** | D (Queue) | D1D2 : autoplay, queue collaborative |
| **S6** | Stabilisation | Tests, docs, polish |
---
## 7. Branche et workflow
- **Branche** : `release/v0.202`
- **Format commit** : `feat(scope): description` (ex: `feat(search): add musical_key filter`)
- **Référence scope** : [SCOPE_CONTROL.md](SCOPE_CONTROL.md) → v0.202
- **PR** : `gh pr create --base main --head release/v0.202 --title "Release v0.202"`
---
## 8. Critères de stabilité v0.202
### 8.1 Build & compilation
- [ ] `go build ./...` — 0 erreur
- [ ] `npm run build` — 0 erreur
- [ ] `npx tsc --noEmit` — 0 erreur
### 8.2 Tests
- [ ] `go test ./...` — pas de régression
- [ ] `npm test -- --run` — pas de régression
- [ ] E2E : recherche, analytics, seller (si applicable)
### 8.3 Documentation
- [ ] FEATURE_STATUS.md mis à jour
- [ ] CHANGELOG v0.202
- [ ] MSW handlers pour nouveaux endpoints
---
## Références
- [V0_201_RELEASE_SCOPE.md](V0_201_RELEASE_SCOPE.md) — Version précédente (Lot E livré)
- [SCOPE_CONTROL.md](SCOPE_CONTROL.md) — Processus anti-scope-creep
- [FEATURE_STATUS.md](FEATURE_STATUS.md) — Statut des features
- [PROJECT_STATE.md](PROJECT_STATE.md) — État actuel du projet

View file

@ -74,6 +74,9 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
notificationService := services.NewNotificationService(r.db, r.logger) notificationService := services.NewNotificationService(r.db, r.logger)
trackHandler.SetNotificationService(notificationService) trackHandler.SetNotificationService(notificationService)
trackRecommendationService := services.NewTrackRecommendationService(r.db.GormDB, r.logger)
trackHandler.SetTrackRecommendationService(trackRecommendationService)
tracks := router.Group("/tracks") tracks := router.Group("/tracks")
{ {
tracks.GET("", trackHandler.ListTracks) tracks.GET("", trackHandler.ListTracks)
@ -91,6 +94,8 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
protected.Use(r.config.AuthMiddleware.RequireAuth()) protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected) r.applyCSRFProtection(protected)
protected.GET("/recommendations", trackHandler.GetRecommendations)
uploadGroup := protected.Group("") uploadGroup := protected.Group("")
uploadGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole()) uploadGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole())
uploadGroup.POST("", trackHandler.UploadTrack) uploadGroup.POST("", trackHandler.UploadTrack)

View file

@ -40,8 +40,9 @@ type TrackHandler struct {
playbackAnalyticsService *services.PlaybackAnalyticsService // BE-API-019: Added for play analytics playbackAnalyticsService *services.PlaybackAnalyticsService // BE-API-019: Added for play analytics
permissionService *services.PermissionService // MOD-P1-003: Added for admin check permissionService *services.PermissionService // MOD-P1-003: Added for admin check
uploadValidator *services.UploadValidator // MOD-P1-001: Added for ClamAV scan before persistence uploadValidator *services.UploadValidator // MOD-P1-001: Added for ClamAV scan before persistence
licenseChecker services.TrackDownloadLicenseChecker // A04: Verify paid track download rights licenseChecker services.TrackDownloadLicenseChecker // A04: Verify paid track download rights
notificationService *services.NotificationService // Phase 2.2: Optional, for like notifications notificationService *services.NotificationService // Phase 2.2: Optional, for like notifications
trackRecommendationService *services.TrackRecommendationService
} }
// NewTrackHandler crée un nouveau handler de tracks // NewTrackHandler crée un nouveau handler de tracks
@ -114,6 +115,11 @@ func (h *TrackHandler) SetNotificationService(notificationService *services.Noti
h.notificationService = notificationService h.notificationService = notificationService
} }
// SetTrackRecommendationService définit le service de recommandations
func (h *TrackHandler) SetTrackRecommendationService(svc *services.TrackRecommendationService) {
h.trackRecommendationService = svc
}
// getUserID récupère l'ID utilisateur du contexte de manière sécurisée (fail-secure) // getUserID récupère l'ID utilisateur du contexte de manière sécurisée (fail-secure)
// MOD-P1-RES-003: Remplace c.MustGet() pour éviter les panics // MOD-P1-RES-003: Remplace c.MustGet() pour éviter les panics
// Retourne false si user_id est absent ou invalide (répond déjà avec 401) // Retourne false si user_id est absent ou invalide (répond déjà avec 401)
@ -880,6 +886,49 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
}) })
} }
// GetRecommendations returns personalized track recommendations (D2 autoplay)
func (h *TrackHandler) GetRecommendations(c *gin.Context) {
if h.trackRecommendationService == nil {
response.InternalServerError(c, "recommendations unavailable")
return
}
var userID uuid.UUID
if uid, exists := c.Get("user_id"); exists {
if parsed, ok := uid.(uuid.UUID); ok {
userID = parsed
}
}
limitStr := c.DefaultQuery("limit", "20")
var limit int
if _, err := fmt.Sscanf(limitStr, "%d", &limit); err != nil || limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
params := services.TrackRecommendationParams{
UserID: userID,
Limit: limit,
}
if seedStr := c.Query("seed_track_id"); seedStr != "" {
if sid, err := uuid.Parse(seedStr); err == nil {
params.SeedTrackID = &sid
}
}
recs, err := h.trackRecommendationService.GetRecommendations(c.Request.Context(), params)
if err != nil {
response.InternalServerError(c, "failed to get recommendations")
return
}
tracks := make([]*models.Track, 0, len(recs))
for _, r := range recs {
if r.Track != nil {
tracks = append(tracks, r.Track)
}
}
response.Success(c, gin.H{"tracks": tracks})
}
// tagSuggestionsByGenre holds static tag suggestions per genre (E4) // tagSuggestionsByGenre holds static tag suggestions per genre (E4)
var tagSuggestionsByGenre = map[string][]string{ var tagSuggestionsByGenre = map[string][]string{
"pop": {"Pop", "Catchy", "Radio", "Mainstream", "Vocal"}, "pop": {"Pop", "Catchy", "Radio", "Mainstream", "Vocal"},