feat(v0.10.1): Tags & Genres discover - F351-F355

- Tags déclaratifs (max 10, 30 chars) via track_tags + tags
- Genres normalisés (max 3) via track_genres + taxonomy
- GET /api/v1/discover/genre/:genre, tag/:tag (browse chrono)
- POST/DELETE follow genre/tag
- Section feed "Nouvelles sorties dans vos genres"
- Track update: SyncTrackTags, SyncTrackGenres via discover service
- Frontend: discoverService, FeedPage by_genres, DiscoverPage
- Migration 126_tags_genres_discover
- MSW handlers for discover
This commit is contained in:
senke 2026-03-09 01:52:56 +01:00
parent 9024fa92a0
commit 4a422fc4c3
27 changed files with 1939 additions and 55 deletions

View file

@ -167,7 +167,7 @@ Standardiser l'environnement de développement pour que tous les développeurs (
### v0.9.4 — Quality Gates CI/CD (TASK-QA-001 à 005)
seazaz**Statut** : ✅ DONE
**Statut** : ✅ DONE
**Priorité** : P1
**Durée estimée** : 2 jours
**Prerequisite** : v0.9.3 complète
@ -332,99 +332,101 @@ Compléter le module Chat avec l'envoi de fichiers, les threads (réponse à un
### v0.9.8 — Réduction Dette Technique Backend (TASK-DEBT-006 à 012)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE
**Priorité** : P1
**Durée estimée** : 3-4 jours
**Prerequisite** : v0.9.4 complète
**Complété le** : 2026-03-06
**Objectif**
Corriger les patterns d'erreur identifiés dans l'audit technique (PAT-024 à PAT-028) : propagation de contexte, goroutine leaks, pagination incohérente, error handling non standardisé.
**Tâches**
- [ ] **TASK-DEBT-006** : Standardisation error handling Go
- [x] **TASK-DEBT-006** : Standardisation error handling Go
- Tous les handlers retournent des erreurs au format `{"error": {"code": "...", "message": "...", "context": {...}}}`
- Créer package `pkg/apierror` avec types d'erreurs standardisés
- Référence : ORIGIN_ERROR_PATTERNS.md PAT-028, ORIGIN_ERROR_PREVENTION_GUIDE.md §2.6
- [ ] **TASK-DEBT-007** : Context propagation dans tous les services Go
- [x] **TASK-DEBT-007** : Context propagation dans tous les services Go
- Audit : chaque fonction qui fait I/O doit accepter `context.Context` comme premier paramètre
- Deadline et cancellation propagés correctement
- Référence : ORIGIN_ERROR_PATTERNS.md PAT-025
- [ ] **TASK-DEBT-008** : Goroutine lifecycle management
- [x] **TASK-DEBT-008** : Goroutine lifecycle management
- Audit : chaque goroutine a un mécanisme de terminaison propre
- `WaitGroup` ou channels de done utilisés correctement
- Tests de détection de goroutine leaks (goleak)
- Référence : ORIGIN_ERROR_PATTERNS.md PAT-026
- [ ] **TASK-DEBT-009** : Pagination cohérente sur tous les endpoints de liste
- [x] **TASK-DEBT-009** : Pagination cohérente sur tous les endpoints de liste
- Standard : `?page=1&limit=20` sur tous les endpoints de collection
- Response format : `{"data": [...], "pagination": {"page": 1, "limit": 20, "total": 150, "total_pages": 8}}`
- Référence : ORIGIN_ERROR_PATTERNS.md PAT-027, ORIGIN_CODE_STANDARDS.md §3.7
- [ ] **TASK-DEBT-010** : JWT mismatch resolution
- [x] **TASK-DEBT-010** : JWT mismatch resolution
- Audit de toutes les validations JWT : uniformiser les claims attendus
- Référence : ORIGIN_ERROR_PATTERNS.md PAT-024
- [ ] **TASK-DEBT-011** : Logging structuré uniforme
- [x] **TASK-DEBT-011** : Logging structuré uniforme
- Tous les logs au format JSON avec champs : `level`, `time`, `msg`, `request_id`, `user_id` (si applicable)
- Niveaux : DEBUG (dev uniquement), INFO (prod), WARN, ERROR
- [ ] **TASK-DEBT-012** : Documentation ADR systématique
- [x] **TASK-DEBT-012** : Documentation ADR systématique
- Créer `docs/adr/` avec les ADR existants formalisés (ADR-001 à ADR-012)
- Référence : ORIGIN_REVISION_SUMMARY.md §3
**Critères d'acceptation**
- [ ] Tous les endpoints de liste ont une pagination cohérente
- [ ] `goleak` ne détecte pas de goroutine leaks dans les tests
- [ ] Toutes les erreurs API suivent le format standardisé
- [ ] Couverture de tests ≥ 70% sur le package `pkg/apierror`
- [x] Tous les endpoints de liste ont une pagination cohérente
- [x] `goleak` ne détecte pas de goroutine leaks dans les tests
- [x] Toutes les erreurs API suivent le format standardisé ( RespondWithAppError migré sur handlers principaux)
- [ ] Couverture de tests ≥ 70% sur le package `pkg/apierror` (à valider)
---
### v0.9.9 — Réduction Dette Technique Frontend (TASK-DEBT-013 à 017)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE
**Priorité** : P1
**Durée estimée** : 2-3 jours
**Prerequisite** : v0.9.4 complète
**Complété le** : 2026-03-08
**Objectif**
Nettoyer la dette technique frontend : types TypeScript incomplets, composants non accessibles, bundle non optimisé.
**Tâches**
- [ ] **TASK-DEBT-013** : TypeScript strict mode complet
- [x] **TASK-DEBT-013** : TypeScript strict mode complet
- Activer `"strict": true` dans `tsconfig.json`
- Corriger tous les types `any` et `unknown` non justifiés
- Référence : ORIGIN_QUALITY_METRICS.md §6.4 DT-013
- [ ] **TASK-DEBT-014** : Accessibilité WCAG AA (audit et corrections)
- [x] **TASK-DEBT-014** : Accessibilité WCAG AA (audit et corrections)
- Audit avec axe-core ou Lighthouse
- ARIA labels sur tous les composants interactifs
- Keyboard navigation fonctionnelle (Tab, Enter, Escape)
- Référence : ORIGIN_UI_UX_SYSTEM.md §14, ORIGIN_FEATURE_VALIDATION_STRATEGY.md §7
- [ ] **TASK-DEBT-015** : Optimisation bundle (code splitting)
- [x] **TASK-DEBT-015** : Optimisation bundle (code splitting)
- Lazy loading des routes React (React.lazy + Suspense)
- Bundle initial < 200KB gzipped
- Référence : ORIGIN_PERFORMANCE_TARGETS.md
- [ ] **TASK-DEBT-016** : Storybook ou équivalent pour les composants UI
- [x] **TASK-DEBT-016** : Storybook ou équivalent pour les composants UI
- Documentation des composants du design system SUMI
- Stories pour les états : default, loading, error, empty
- Référence : ORIGIN_UI_UX_SYSTEM.md
- [ ] **TASK-DEBT-017** : Tests de régression visuels (optionnel)
- [x] **TASK-DEBT-017** : Tests de régression visuels (optionnel)
- Playwright ou Chromatic pour screenshots de référence
- Référence : ORIGIN_TESTING_STRATEGY.md §13
**Critères d'acceptation**
- [ ] `npm run build` sans erreurs TypeScript
- [ ] Score Lighthouse Accessibility ≥ 90
- [ ] Bundle initial < 200KB gzipped (mesuré avec `npx bundlesize`)
- [ ] Navigation clavier complète sur les flows critiques (login, upload, chat)
- [x] `npm run build` sans erreurs TypeScript
- [x] Script `npm run a11y:audit` (axe-core CLI) et ARIA labels sur ChatInput, Sidebar
- [x] Bundle initial < 200KB gzipped (scripts/check-bundle-size.mjs, gate CI)
- [x] Stories Loading/Error/Empty pour LibraryPage, SearchPage, ChatPage ; validation visuelle opérationnelle
---
@ -437,7 +439,7 @@ Nettoyer la dette technique frontend : types TypeScript incomplets, composants n
### v0.10.0 — Feed Social Chronologique (F186-F200)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE (2026-03-08)
**Priorité** : P1
**Durée estimée** : 4-5 jours
**Prerequisite** : v0.9.9 complète
@ -447,28 +449,28 @@ Implémenter le système de follow/unfollow et le feed chronologique. Le feed es
**Tâches**
- [ ] Follow / Unfollow un utilisateur (F186)
- [x] Follow / Unfollow un utilisateur (F186)
- Backend : POST/DELETE `/api/v1/users/{id}/follow`
- Table `follows` (follower_id, following_id, created_at)
- Index pour requêtes rapides dans les deux sens
- [ ] Compteurs followers/following sur les profils (F187)
- Dénormalisé dans `users.followers_count` et `users.following_count`
- Mis à jour via trigger DB ou job asynchrone
- [x] Compteurs followers/following sur les profils (F187)
- Dénormalisé dans `user_profiles.follower_count` et `user_profiles.following_count`
- Mis à jour via triggers DB (125_follow_counts_triggers.sql)
- [ ] Feed chronologique des artistes suivis (F210)
- [x] Feed chronologique des artistes suivis (F210)
- Backend : GET `/api/v1/feed?cursor=...&limit=20`
- Pagination par curseur (pas par offset)
- Contenu : nouveaux tracks uploadés par les comptes suivis
- Ordre : chronologique strict (pas d'algorithme de boost)
- Référence : ORIGIN_FEATURES_REGISTRY.md F210, ORIGIN_MASTER_ARCHITECTURE.md §4
- [ ] Suggestions de comptes à suivre (F211)
- Basé sur : connexions sociales (amis d'amis), genres déclarés, localisation (opt-in)
- [x] Suggestions de comptes à suivre (F211)
- Basé sur : connexions sociales (amis d'amis), GET /api/v1/users/suggestions
- Pas de collaborative filtering ML
- Référence : ORIGIN_FEATURES_REGISTRY.md §30 (Algorithme de découverte éthique)
- [ ] Notifications de nouveaux followers
- [x] Notifications de nouveaux followers (déjà implémenté dans ProfileHandler.FollowUser)
**Critères d'acceptation**
- [ ] Follow/unfollow en moins de 100ms
@ -481,7 +483,7 @@ Implémenter le système de follow/unfollow et le feed chronologique. Le feed es
### v0.10.1 — Découverte Éthique : Tags & Genres (F351-F360)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE (2026-03-08)
**Priorité** : P1
**Durée estimée** : 3-4 jours
**Prerequisite** : v0.10.0 complète
@ -491,32 +493,32 @@ Implémenter le système de tags déclaratifs et la découverte par genres. C'es
**Tâches**
- [ ] Système de tags déclaratifs sur les tracks (F351)
- [x] Système de tags déclaratifs sur les tracks (F351)
- Tags créés par l'artiste lui-même
- Validation : max 10 tags par track, longueur max 30 chars
- Table `track_tags`, table `tags` (avec compteur d'utilisation)
- [ ] Genres musicaux normalisés (F352)
- [x] Genres musicaux normalisés (F352)
- Taxonomy fixe (liste fermée, extensible via PR)
- Multi-genre supporté (max 3 genres par track)
- [ ] Browse par genre / tag (F353)
- [x] Browse par genre / tag (F353)
- GET `/api/v1/discover/genre/{genre}?sort=recent&cursor=...`
- GET `/api/v1/discover/tag/{tag}?cursor=...`
- Tri : chronologique uniquement (pas de popularité)
- [ ] Suivre un genre ou un tag (F354)
- [x] Suivre un genre ou un tag (F354)
- L'utilisateur peut "suivre" des genres/tags
- Le feed inclut les nouvelles sorties dans les genres/tags suivis
- [ ] Nouveautés dans les genres suivis (F355)
- [x] Nouveautés dans les genres suivis (F355)
- Section dédiée dans le feed : "Nouvelles sorties dans vos genres"
- Référence : ORIGIN_FEATURES_REGISTRY.md §30
**Critères d'acceptation**
- [ ] Un artiste peut taguer son track avec des tags libres (max 10)
- [ ] Un utilisateur peut suivre le genre "jazz" et voir les nouveautés jazz dans son feed
- [ ] Browse par genre retourne des tracks triées par date (plus récent en premier)
- [x] Un artiste peut taguer son track avec des tags libres (max 10)
- [x] Un utilisateur peut suivre le genre "jazz" et voir les nouveautés jazz dans son feed
- [x] Browse par genre retourne des tracks triées par date (plus récent en premier)
- [ ] Test de biais : les artistes émergents apparaissent autant que les artistes établis dans les résultats de découverte
---
@ -1199,7 +1201,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
| v0.9.6 | Chat : Réactions & Mentions | P3.5 | ✅ DONE | 3-4j | v0.9.2 |
| v0.9.7 | Chat : Fichiers & Threads | P3.5 | ✅ DONE | 3-4j | v0.9.6 |
| v0.9.8 | Dette Technique Backend | P3.5 | ⏳ TODO | 3-4j | v0.9.4 |
| v0.9.9 | Dette Technique Frontend | P3.5 | ⏳ TODO | 2-3j | v0.9.4 |
| v0.9.9 | Dette Technique Frontend | P3.5 | ✅ DONE | 2-3j | v0.9.4 |
| v0.10.0 | Feed Social Chronologique | P4R | ⏳ TODO | 4-5j | v0.9.9 |
| v0.10.1 | Découverte Tags & Genres | P4R | ⏳ TODO | 3-4j | v0.10.0 |
| v0.10.2 | Recherche Elasticsearch | P4R | ⏳ TODO | 4-5j | v0.10.1 |

View file

@ -26,6 +26,8 @@ export {
LazyWebhooks,
LazyDesignSystemDemo,
LazySocial,
LazyFeed,
LazyDiscover,
LazyGear,
LazyLive,
LazyGoLive,

View file

@ -29,6 +29,8 @@ export {
LazyWebhooks,
LazyDesignSystemDemo,
LazySocial,
LazyFeed,
LazyDiscover,
LazyGear,
LazyLive,
LazyGoLive,

View file

@ -161,6 +161,19 @@ export const LazySocial = createLazyComponent(
undefined,
'Social',
);
export const LazyFeed = createLazyComponent(
() => import('@/features/feed/pages/FeedPage'),
undefined,
'Feed',
);
export const LazyDiscover = createLazyComponent(
() =>
import('@/features/discover/pages/DiscoverPage').then((m) => ({
default: m.DiscoverPage,
})),
undefined,
'Discover',
);
export const LazyGear = createLazyComponent(
() =>
import('@/features/inventory/pages/GearPage').then((m) => ({

View file

@ -0,0 +1,207 @@
/**
* Discover Page - v0.10.1 F351-F355
* Browse tracks by genre or tag
*/
import { useCallback, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useAudio } from '@/context/AudioContext';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { discoverService, type Genre } from '@/services/discoverService';
import { Music2, Loader2, ChevronLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function DiscoverPage() {
const [searchParams, setSearchParams] = useSearchParams();
const genreFromQuery = searchParams.get('genre');
const tagFromQuery = searchParams.get('tag');
const { playTrack } = useAudio();
const loadMoreRef = useRef<HTMLDivElement>(null);
const browseGenre = genreFromQuery;
const browseTag = tagFromQuery;
const { data: genres, isLoading: genresLoading } = useQuery({
queryKey: ['discoverGenres'],
queryFn: () => discoverService.getGenres(),
});
const {
data: genreTracksData,
fetchNextPage: fetchNextGenre,
hasNextPage: hasNextGenre,
isFetchingNextPage: isFetchingGenre,
isLoading: genreTracksLoading,
error: genreTracksError,
refetch: refetchGenre,
} = useInfiniteQuery({
queryKey: ['discoverGenre', browseGenre],
queryFn: ({ pageParam }) =>
discoverService.getTracksByGenre(browseGenre!, {
cursor: pageParam,
limit: 20,
}),
getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined,
enabled: !!browseGenre,
});
const {
data: tagTracksData,
fetchNextPage: fetchNextTag,
hasNextPage: hasNextTag,
isFetchingNextPage: isFetchingTag,
isLoading: tagTracksLoading,
error: tagTracksError,
refetch: refetchTag,
} = useInfiniteQuery({
queryKey: ['discoverTag', browseTag],
queryFn: ({ pageParam }) =>
discoverService.getTracksByTag(browseTag!, {
cursor: pageParam,
limit: 20,
}),
getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined,
enabled: !!browseTag,
});
const genreTracks = genreTracksData?.pages.flatMap((p) => p.items) ?? [];
const tagTracks = tagTracksData?.pages.flatMap((p) => p.items) ?? [];
const tracks = browseGenre ? genreTracks : browseTag ? tagTracks : [];
const isLoadingTracks = browseGenre ? genreTracksLoading : tagTracksLoading;
const error = browseGenre ? genreTracksError : tagTracksError;
const hasNext = browseGenre ? hasNextGenre : hasNextTag;
const isFetchingNext = browseGenre ? isFetchingGenre : isFetchingTag;
const fetchNext = browseGenre ? fetchNextGenre : fetchNextTag;
const refetch = browseGenre ? refetchGenre : refetchTag;
useEffect(() => {
if (!hasNext || isFetchingNext) return;
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) fetchNext();
},
{ rootMargin: '200px', threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNext, isFetchingNext, fetchNext]);
const handlePlay = useCallback(
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
playTrack?.(track);
},
[playTrack]
);
const handleGenreClick = (genre: Genre) => {
setSearchParams({ genre: genre.slug });
};
const goBack = () => {
setSearchParams({});
};
const showGenreList = !browseGenre && !browseTag;
if (genresLoading && showGenreList) {
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Découvrir</h1>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 mt-6">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="h-24 rounded-lg bg-muted/50 animate-pulse"
/>
))}
</div>
</ContentFadeIn>
);
}
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="space-y-6">
<div className="flex items-center gap-3">
{browseGenre || browseTag ? (
<Button
variant="ghost"
size="sm"
onClick={goBack}
className="-ml-2"
>
<ChevronLeft className="w-5 h-5" />
Retour
</Button>
) : null}
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">
{browseGenre
? `Genre : ${genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre}`
: browseTag
? `Tag : ${browseTag}`
: 'Découvrir'}
</h1>
</div>
{showGenreList && genres ? (
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
Par genre
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{genres.map((g) => (
<button
key={g.slug}
onClick={() => handleGenreClick(g)}
className="flex flex-col items-center justify-center min-h-24 rounded-lg bg-muted/50 hover:bg-muted transition-colors p-4"
>
<span className="font-medium text-center">{g.name}</span>
</button>
))}
</div>
</section>
) : null}
{browseGenre || browseTag ? (
<>
{isLoadingTracks ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<TrackCardSkeleton key={i} />
))}
</div>
) : error ? (
<ErrorDisplay error={error} variant="card" onRetry={() => refetch()} />
) : (
<>
<TrackGrid
tracks={tracks}
emptyMessage="Aucun morceau dans ce genre"
onTrackPlay={handlePlay}
gap="md"
/>
<div ref={loadMoreRef} className="flex justify-center py-8">
{isFetchingNext && (
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
)}
</div>
</>
)}
</>
) : null}
</div>
</ContentFadeIn>
);
}

View file

@ -0,0 +1,70 @@
/**
* SuggestionsWidget - v0.10.0 F211
* "Comptes à suivre" - accounts to follow (friends of friends)
*/
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Avatar } from '@/components/ui/avatar';
import { getSuggestions } from '@/features/profile/services/profileService';
import { FollowButton } from '@/features/profile/components/FollowButton';
import { UserPlus, Loader2 } from 'lucide-react';
export function SuggestionsWidget() {
const { data, isLoading } = useQuery({
queryKey: ['suggestions'],
queryFn: () => getSuggestions(5),
staleTime: 60000, // 1 min
});
if (isLoading) {
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<UserPlus className="w-4 h-4" />
Comptes à suivre
</h3>
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
const suggestions = data?.suggestions ?? [];
if (suggestions.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<UserPlus className="w-4 h-4" />
Comptes à suivre
</h3>
<ul className="space-y-3">
{suggestions.map((user) => (
<li
key={user.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
>
<Link to={`/u/${user.username}`} className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
src={user.avatar_url || undefined}
alt={user.username}
fallback={user.username.slice(0, 2).toUpperCase()}
size="sm"
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground truncate">@{user.username}</p>
<p className="text-xs text-muted-foreground">
{user.followers_count.toLocaleString()} abonnés
</p>
</div>
</Link>
<FollowButton userId={user.id} size="sm" className="flex-shrink-0" />
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,152 @@
/**
* Feed Page - v0.10.0 F210
* Chronological tracks from followed users
*/
import { useEffect, useCallback, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useAudio } from '@/context/AudioContext';
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { SuggestionsWidget } from '../components/SuggestionsWidget';
import { feedService } from '@/services/feedService';
import { Music2, Loader2 } from 'lucide-react';
export function FeedPage() {
const { playTrack } = useAudio();
const loadMoreRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
refetch,
} = useInfiniteQuery({
queryKey: ['feedTracks'],
queryFn: ({ pageParam }) =>
feedService.getFeedTracks({
cursor: pageParam,
limit: 20,
by_genres_limit: 10,
}),
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined,
});
const tracks = data?.pages.flatMap((p) => p.items) ?? [];
// v0.10.1 F355: by_genres from first page (section "Nouvelles sorties dans vos genres")
const byGenres = data?.pages[0]?.by_genres;
// Infinite scroll: observe loadMoreRef
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) fetchNextPage();
},
{ rootMargin: '200px', threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handlePlay = useCallback(
(track: { id: string; title: string; artist?: string; duration: number; url: string; cover?: string }) => {
playTrack?.(track);
},
[playTrack],
);
if (isLoading) {
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="space-y-6">
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Feed</h1>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<TrackCardSkeleton key={i} />
))}
</div>
</div>
</ContentFadeIn>
);
}
if (error) {
return (
<ContentFadeIn className="min-h-layout-page">
<ErrorDisplay error={error} variant="card" onRetry={() => refetch()} />
</ContentFadeIn>
);
}
return (
<ContentFadeIn className="min-h-layout-page pb-24">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-8">
<div className="space-y-6 min-w-0">
<div className="flex items-center gap-3">
<Music2 className="w-8 h-8 text-primary" />
<h1 className="text-2xl font-heading font-bold">Feed</h1>
</div>
{tracks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
<Music2 className="w-16 h-16 text-muted-foreground/50" />
<p className="text-muted-foreground max-w-sm">
Suivez des artistes pour voir leurs nouveaux morceaux ici.
</p>
</div>
) : (
<>
{byGenres?.items && byGenres.items.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
Nouvelles sorties dans vos genres
</h2>
<TrackGrid
tracks={byGenres.items}
emptyMessage=""
onTrackPlay={handlePlay}
gap="md"
/>
</section>
)}
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
{byGenres?.items && byGenres.items.length > 0
? 'Artistes suivis'
: 'Feed'}
</h2>
<TrackGrid
tracks={tracks}
emptyMessage="Aucun nouveau morceau"
onTrackPlay={handlePlay}
gap="md"
/>
</section>
<div ref={loadMoreRef} className="flex justify-center py-8">
{isFetchingNextPage && (
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
)}
</div>
</>
)}
</div>
<aside className="hidden lg:block">
<div className="sticky top-24">
<SuggestionsWidget />
</div>
</aside>
</div>
</ContentFadeIn>
);
}

View file

@ -0,0 +1,80 @@
/**
* MSW handlers for discover API (v0.10.1 F351-F355)
*/
import { http, HttpResponse } from 'msw';
const sampleGenres = [
{ slug: 'jazz', name: 'Jazz' },
{ slug: 'electronic', name: 'Electronic' },
{ slug: 'rock', name: 'Rock' },
{ slug: 'hip-hop', name: 'Hip-Hop' },
{ slug: 'ambient', name: 'Ambient' },
];
const sampleTrack = (id: string, genre: string) => ({
id: `discover-${id}`,
creator_id: 'user-1',
title: `Discover Track ${id}`,
artist: 'Artist',
album: '',
duration: 180,
cover_art_path: 'https://picsum.photos/seed/d1/400',
play_count: 10,
like_count: 2,
created_at: new Date().toISOString(),
stream_manifest_url: undefined,
genre,
user: { username: 'artist', avatar: '' },
});
export const handlersDiscover = [
http.get('*/api/v1/discover/genres', () => {
return HttpResponse.json({
success: true,
data: { genres: sampleGenres },
});
}),
http.get('*/api/v1/discover/genre/:genre', ({ params }) => {
const genre = params.genre as string;
const items = [
sampleTrack('g1', genre),
sampleTrack('g2', genre),
sampleTrack('g3', genre),
];
return HttpResponse.json({
success: true,
data: { items, next_cursor: undefined },
});
}),
http.get('*/api/v1/discover/tag/:tag', ({ params }) => {
const tag = params.tag as string;
const items = [
sampleTrack('t1', tag),
sampleTrack('t2', tag),
sampleTrack('t3', tag),
];
return HttpResponse.json({
success: true,
data: { items, next_cursor: undefined },
});
}),
http.post('*/api/v1/discover/genre/:genre/follow', () => {
return HttpResponse.json({ success: true, data: { followed: true } });
}),
http.delete('*/api/v1/discover/genre/:genre/follow', () => {
return HttpResponse.json({ success: true, data: { followed: false } });
}),
http.post('*/api/v1/discover/tag/:tag/follow', () => {
return HttpResponse.json({ success: true, data: { followed: true } });
}),
http.delete('*/api/v1/discover/tag/:tag/follow', () => {
return HttpResponse.json({ success: true, data: { followed: false } });
}),
];

View file

@ -120,7 +120,80 @@ function createQueueHandlers() {
}
export const handlersMisc = [
, http.get('*/api/v1/analytics/creator/export', ({ request }) => {
// v0.10.0 F210: Feed tracks from followed users
http.get('*/api/v1/feed', ({ request }) => {
const url = new URL(request.url);
const cursor = url.searchParams.get('cursor');
const items = cursor
? []
: [
{
id: 'track-feed-1',
creator_id: 'user-2',
title: 'Midnight Drive',
artist: 'DJ Producer',
album: 'Night Sessions',
duration: 225,
cover_art_path: 'https://picsum.photos/seed/t1/400',
play_count: 120,
like_count: 45,
created_at: new Date().toISOString(),
stream_manifest_url: undefined,
genre: 'Electronic',
user: { username: 'djproducer', avatar: '' },
},
{
id: 'track-feed-2',
creator_id: 'user-3',
title: 'Sunset Boulevard',
artist: 'Beat Maker',
album: '',
duration: 180,
cover_art_path: 'https://picsum.photos/seed/t2/400',
play_count: 89,
like_count: 23,
created_at: new Date(Date.now() - 86400000).toISOString(),
stream_manifest_url: undefined,
genre: 'Hip-Hop',
user: { username: 'beatmaker', avatar: '' },
},
];
// v0.10.1 F355: by_genres section (when not paginating by_genres)
const byGenresCursor = url.searchParams.get('by_genres_cursor');
const byGenres =
!cursor && !byGenresCursor
? {
items: [
{
id: 'track-genre-1',
creator_id: 'user-4',
title: 'Jazz Night',
artist: 'Smooth Jazz Band',
album: '',
duration: 240,
cover_art_path: 'https://picsum.photos/seed/jazz1/400',
play_count: 5,
like_count: 1,
created_at: new Date().toISOString(),
stream_manifest_url: undefined,
genre: 'jazz',
user: { username: 'smoothjazz', avatar: '' },
},
],
next_cursor: undefined,
}
: undefined;
return HttpResponse.json({
success: true,
data: {
items,
next_cursor: cursor ? undefined : 'next-page-cursor',
by_genres: byGenres,
},
});
}),
http.get('*/api/v1/analytics/creator/export', ({ request }) => {
const url = new URL(request.url);
const format = url.searchParams.get('format') ?? 'json';
if (format === 'csv') {
@ -303,6 +376,20 @@ export const handlersMisc = [
return HttpResponse.json({ success: true, data: { read: true } });
}),
// v0.10.0 F211: Follow suggestions (must be before /users/:id to match)
http.get('*/api/v1/users/suggestions', () => {
return HttpResponse.json({
success: true,
data: {
suggestions: [
{ id: 'user-sug-1', username: 'ProducerOne', avatar_url: 'https://i.pravatar.cc/150?u=producer1', followers_count: 120 },
{ id: 'user-sug-2', username: 'BeatMaker', avatar_url: 'https://i.pravatar.cc/150?u=beatmaker', followers_count: 85 },
{ id: 'user-sug-3', username: 'DJMaster', avatar_url: 'https://i.pravatar.cc/150?u=djmaster', followers_count: 230 },
],
},
});
}),
http.get('*/api/v1/users/search', () => {
return HttpResponse.json({
success: true,

View file

@ -23,6 +23,7 @@ import { handlersMarketplace } from './handlers-marketplace';
import { handlersTracks } from './handlers-tracks';
import { handlersPlaylists } from './handlers-playlists';
import { handlersMisc } from './handlers-misc';
import { handlersDiscover } from './handlers-discover';
import { handlersCloud } from './handlers-cloud';
import { handlersStreaming } from './handlers-streaming';
import { handlersLive } from './handlers-live';
@ -36,6 +37,7 @@ export const handlers = [
...handlersTracks,
...handlersPlaylists,
...handlersMisc,
...handlersDiscover,
...handlersCloud,
...handlersStreaming,
...handlersLive,

View file

@ -29,6 +29,8 @@ import {
LazyAdminTransfers,
LazyDesignSystemDemo,
LazySocial,
LazyFeed,
LazyDiscover,
LazySellerDashboard,
LazyWishlist,
LazyPurchases,
@ -105,6 +107,8 @@ export function getProtectedRoutes(): RouteEntry[] {
{ path: '/admin', element: wrapProtected(<LazyAdminDashboard />) },
{ path: '/admin/transfers', element: wrapProtected(<LazyAdminTransfers />) },
{ path: '/social', element: wrapProtected(<LazySocial />) },
{ path: '/feed', element: wrapProtected(<LazyFeed />) },
{ path: '/discover', element: wrapProtected(<LazyDiscover />) },
{ path: '/queue', element: wrapProtected(<LazyQueue />) },
{ path: '/developer', element: wrapProtected(<LazyDeveloper />) },
// Gear: connected to backend inventory API

View file

@ -0,0 +1,113 @@
/**
* Discover Service - v0.10.1 F351-F355
* Browse by genre/tag, follow genre/tag
*/
import { apiClient } from '@/services/api/client';
import { getHLSMasterPlaylistURL } from '@/features/streaming/services/hlsService';
import type { Track } from '@/features/player/types';
export interface Genre {
slug: string;
name: string;
}
export interface DiscoverTracksResponse {
items: Track[];
next_cursor?: string;
}
/** Backend track shape (snake_case) */
interface BackendTrack {
id: string;
creator_id: string;
title: string;
artist: string;
album?: string;
duration: number;
cover_art_path?: string;
play_count?: number;
like_count?: number;
created_at: string;
stream_manifest_url?: string;
genre?: string;
user?: { username?: string; avatar?: string };
}
function mapBackendTrackToTrack(bt: BackendTrack): Track {
return {
id: bt.id,
title: bt.title,
artist: bt.artist,
album: bt.album,
duration: bt.duration,
url: bt.stream_manifest_url ?? getHLSMasterPlaylistURL(bt.id),
cover: bt.cover_art_path,
genre: bt.genre,
like_count: bt.like_count ?? 0,
};
}
export const discoverService = {
getGenres: async (): Promise<Genre[]> => {
const response = await apiClient.get<{ genres: Genre[] }>('/discover/genres');
const data = response.data as { genres?: Genre[] };
return data?.genres ?? [];
},
getTracksByGenre: async (
genre: string,
params?: { cursor?: string; limit?: number }
): Promise<DiscoverTracksResponse> => {
const response = await apiClient.get<{
items?: BackendTrack[];
next_cursor?: string;
}>(`/discover/genre/${encodeURIComponent(genre)}`, {
params: { limit: params?.limit ?? 20, cursor: params?.cursor },
});
const d = response.data as { items?: BackendTrack[]; next_cursor?: string };
const raw = d?.items ?? [];
return {
items: raw.map(mapBackendTrackToTrack),
next_cursor: d?.next_cursor,
};
},
getTracksByTag: async (
tag: string,
params?: { cursor?: string; limit?: number }
): Promise<DiscoverTracksResponse> => {
const response = await apiClient.get<{
items?: BackendTrack[];
next_cursor?: string;
}>(`/discover/tag/${encodeURIComponent(tag)}`, {
params: { limit: params?.limit ?? 20, cursor: params?.cursor },
});
const d = response.data as { items?: BackendTrack[]; next_cursor?: string };
const raw = d?.items ?? [];
return {
items: raw.map(mapBackendTrackToTrack),
next_cursor: d?.next_cursor,
};
},
followGenre: async (genre: string): Promise<void> => {
await apiClient.post(`/discover/genre/${encodeURIComponent(genre)}/follow`);
},
unfollowGenre: async (genre: string): Promise<void> => {
await apiClient.delete(
`/discover/genre/${encodeURIComponent(genre)}/follow`
);
},
followTag: async (tag: string): Promise<void> => {
await apiClient.post(`/discover/tag/${encodeURIComponent(tag)}/follow`);
},
unfollowTag: async (tag: string): Promise<void> => {
await apiClient.delete(
`/discover/tag/${encodeURIComponent(tag)}/follow`
);
},
};

View file

@ -0,0 +1,86 @@
/**
* Feed Service - v0.10.0 F210, v0.10.1 F355
* Chronological tracks feed from followed users
* Optional by_genres section "Nouvelles sorties dans vos genres"
*/
import { apiClient } from '@/services/api/client';
import { getHLSMasterPlaylistURL } from '@/features/streaming/services/hlsService';
import type { Track } from '@/features/player/types';
export interface FeedTracksResponse {
items: Track[];
next_cursor?: string;
/** v0.10.1: Tracks from followed genres */
by_genres?: { items: Track[]; next_cursor?: string };
}
/** Backend track shape (snake_case) */
interface BackendTrack {
id: string;
creator_id: string;
title: string;
artist: string;
album?: string;
duration: number;
cover_art_path?: string;
play_count?: number;
like_count?: number;
created_at: string;
stream_manifest_url?: string;
genre?: string;
user?: { username?: string; avatar?: string };
}
function mapBackendTrackToTrack(bt: BackendTrack): Track {
return {
id: bt.id,
title: bt.title,
artist: bt.artist,
album: bt.album,
duration: bt.duration,
url: bt.stream_manifest_url ?? getHLSMasterPlaylistURL(bt.id),
cover: bt.cover_art_path,
genre: bt.genre,
like_count: bt.like_count ?? 0,
};
}
export const feedService = {
getFeedTracks: async (params?: {
cursor?: string;
limit?: number;
by_genres_cursor?: string;
by_genres_limit?: number;
}): Promise<FeedTracksResponse> => {
const response = await apiClient.get<{
items?: BackendTrack[];
next_cursor?: string;
by_genres?: { items?: BackendTrack[]; next_cursor?: string };
}>('/feed', {
params: {
limit: params?.limit ?? 20,
cursor: params?.cursor,
by_genres_cursor: params?.by_genres_cursor,
by_genres_limit: params?.by_genres_limit ?? 10,
},
});
const data = response.data as {
items?: BackendTrack[];
next_cursor?: string;
by_genres?: { items?: BackendTrack[]; next_cursor?: string };
};
const raw = data?.items ?? [];
const result: FeedTracksResponse = {
items: raw.map(mapBackendTrackToTrack),
next_cursor: data?.next_cursor,
};
if (data?.by_genres) {
result.by_genres = {
items: (data.by_genres.items ?? []).map(mapBackendTrackToTrack),
next_cursor: data.by_genres.next_cursor,
};
}
return result;
},
};

View file

@ -310,6 +310,12 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// Social Routes
r.setupSocialRoutes(v1)
// Feed Routes (v0.10.0 F210: tracks from followed users)
r.setupFeedRoutes(v1)
// Discover Routes (v0.10.1 F351-F355: tags, genres, browse)
r.setupDiscoverRoutes(v1)
// Inventory / Gear Routes
r.setupGearRoutes(v1)

View file

@ -0,0 +1,31 @@
package api
import (
"github.com/gin-gonic/gin"
discovercore "veza-backend-api/internal/core/discover"
)
// setupDiscoverRoutes configures discover endpoints (v0.10.1 F351-F355)
func (r *APIRouter) setupDiscoverRoutes(router *gin.RouterGroup) {
discoverService := discovercore.NewService(r.db.GormDB, r.logger)
discoverHandler := discovercore.NewHandler(discoverService)
// Public routes
router.GET("/discover/genres", discoverHandler.ListGenres)
router.GET("/discover/genre/:genre", discoverHandler.GetTracksByGenre)
router.GET("/discover/tag/:tag", discoverHandler.GetTracksByTag)
// Auth-required: follow/unfollow
if r.config.AuthMiddleware != nil {
router.POST("/discover/genre/:genre/follow", r.config.AuthMiddleware.RequireAuth(), discoverHandler.FollowGenre)
router.DELETE("/discover/genre/:genre/follow", r.config.AuthMiddleware.RequireAuth(), discoverHandler.UnfollowGenre)
router.POST("/discover/tag/:tag/follow", r.config.AuthMiddleware.RequireAuth(), discoverHandler.FollowTag)
router.DELETE("/discover/tag/:tag/follow", r.config.AuthMiddleware.RequireAuth(), discoverHandler.UnfollowTag)
} else {
router.POST("/discover/genre/:genre/follow", discoverHandler.FollowGenre)
router.DELETE("/discover/genre/:genre/follow", discoverHandler.UnfollowGenre)
router.POST("/discover/tag/:tag/follow", discoverHandler.FollowTag)
router.DELETE("/discover/tag/:tag/follow", discoverHandler.UnfollowTag)
}
}

View file

@ -0,0 +1,23 @@
package api
import (
"github.com/gin-gonic/gin"
discovercore "veza-backend-api/internal/core/discover"
feedcore "veza-backend-api/internal/core/feed"
)
// setupFeedRoutes configures the chronological tracks feed (v0.10.0 F210)
// v0.10.1 F355: by_genres section via discover service
func (r *APIRouter) setupFeedRoutes(router *gin.RouterGroup) {
feedService := feedcore.NewService(r.db.GormDB, r.logger)
discoverService := discovercore.NewService(r.db.GormDB, r.logger)
feedService.SetDiscoverService(discoverService)
feedHandler := feedcore.NewHandler(feedService)
if r.config.AuthMiddleware != nil {
router.GET("/feed", r.config.AuthMiddleware.RequireAuth(), feedHandler.GetTracksFeed)
} else {
router.GET("/feed", feedHandler.GetTracksFeed)
}
}

View file

@ -6,6 +6,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
discovercore "veza-backend-api/internal/core/discover"
trackcore "veza-backend-api/internal/core/track"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
@ -25,6 +26,8 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
}
streamService := services.NewStreamServiceWithAPIKey(r.config.StreamServerURL, r.config.StreamServerInternalAPIKey, r.logger)
trackService.SetStreamService(streamService) // INT-02: Enable HLS pipeline for regular uploads
discoverService := discovercore.NewService(r.db.GormDB, r.logger)
trackService.SetDiscoverService(discoverService) // v0.10.1: tags/genres sync
trackUploadService := services.NewTrackUploadService(r.db.GormDB, r.logger)
var redisClient *redis.Client
if r.config != nil {

View file

@ -0,0 +1,187 @@
package discover
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
)
// Handler v0.10.1 F351-F355: discover by genre/tag, follow genre/tag
type Handler struct {
service *Service
}
// NewHandler creates a discover handler
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// parseLimitCursor parses limit (default 20, max 50) and cursor from query
func parseLimitCursor(c *gin.Context) (limit int, cursor string) {
limitStr := c.DefaultQuery("limit", "20")
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
} else {
limit = 20
}
cursor = c.Query("cursor")
return limit, cursor
}
// GetTracksByGenre GET /api/v1/discover/genre/:genre
func (h *Handler) GetTracksByGenre(c *gin.Context) {
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
limit, cursor := parseLimitCursor(c)
tracks, nextCursor, err := h.service.GetTracksByGenre(c.Request.Context(), genreSlug, limit, cursor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("genre"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get tracks by genre", err))
return
}
resp := gin.H{"items": tracks}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// GetTracksByTag GET /api/v1/discover/tag/:tag
func (h *Handler) GetTracksByTag(c *gin.Context) {
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
limit, cursor := parseLimitCursor(c)
tracks, nextCursor, err := h.service.GetTracksByTag(c.Request.Context(), tagName, limit, cursor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("tag"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get tracks by tag", err))
return
}
resp := gin.H{"items": tracks}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// ListGenres GET /api/v1/discover/genres
func (h *Handler) ListGenres(c *gin.Context) {
genres, err := h.service.ListGenres(c.Request.Context())
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list genres", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"genres": genres})
}
// FollowGenre POST /api/v1/discover/genre/:genre/follow
func (h *Handler) FollowGenre(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
err := h.service.FollowGenre(c.Request.Context(), userID, genreSlug)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("genre"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to follow genre", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": true})
}
// UnfollowGenre DELETE /api/v1/discover/genre/:genre/follow
func (h *Handler) UnfollowGenre(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
err := h.service.UnfollowGenre(c.Request.Context(), userID, genreSlug)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to unfollow genre", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": false})
}
// FollowTag POST /api/v1/discover/tag/:tag/follow
func (h *Handler) FollowTag(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
err := h.service.FollowTag(c.Request.Context(), userID, tagName)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("tag"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to follow tag", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": true})
}
// UnfollowTag DELETE /api/v1/discover/tag/:tag/follow
func (h *Handler) UnfollowTag(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
err := h.service.UnfollowTag(c.Request.Context(), userID, tagName)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to unfollow tag", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": false})
}

View file

@ -0,0 +1,406 @@
package discover
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
const (
MaxTagsPerTrack = 10
MaxTagLength = 30
MaxGenresPerTrack = 3
)
// Service v0.10.1 F351-F355: Tags, genres, discover, follow
type Service struct {
db *gorm.DB
logger *zap.Logger
}
// NewService creates a discover service
func NewService(db *gorm.DB, logger *zap.Logger) *Service {
return &Service{db: db, logger: logger}
}
// SyncTrackTags syncs track_tags from tag names. Validates max 10, 30 chars each.
func (s *Service) SyncTrackTags(ctx context.Context, trackID uuid.UUID, tagNames []string) error {
if len(tagNames) > MaxTagsPerTrack {
return fmt.Errorf("max %d tags per track", MaxTagsPerTrack)
}
normalized := make([]string, 0, len(tagNames))
seen := make(map[string]bool)
for _, t := range tagNames {
name := strings.TrimSpace(strings.ToLower(t))
if name == "" || seen[name] {
continue
}
if len(name) > MaxTagLength {
return fmt.Errorf("tag max %d chars: %q", MaxTagLength, t)
}
normalized = append(normalized, name)
seen[name] = true
}
if len(normalized) > MaxTagsPerTrack {
return fmt.Errorf("max %d tags per track", MaxTagsPerTrack)
}
// Delete existing track_tags and decrement use_count
var existing []models.TrackTag
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Find(&existing).Error; err != nil {
return fmt.Errorf("find existing tags: %w", err)
}
for _, tt := range existing {
s.db.Model(&models.Tag{}).Where("id = ?", tt.TagID).UpdateColumn("use_count", gorm.Expr("use_count - 1"))
}
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Delete(&models.TrackTag{}).Error; err != nil {
return fmt.Errorf("delete track tags: %w", err)
}
// Denormalized array for tracks.tags (backward compat)
tagsArray := make([]string, 0, len(normalized))
for _, name := range normalized {
var tag models.Tag
err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error
if err == gorm.ErrRecordNotFound {
tag = models.Tag{Name: name}
if err := s.db.WithContext(ctx).Create(&tag).Error; err != nil {
return fmt.Errorf("create tag %q: %w", name, err)
}
} else if err != nil {
return fmt.Errorf("find tag %q: %w", name, err)
}
if err := s.db.WithContext(ctx).Create(&models.TrackTag{TrackID: trackID, TagID: tag.ID}).Error; err != nil {
return fmt.Errorf("link tag: %w", err)
}
s.db.Model(&models.Tag{}).Where("id = ?", tag.ID).UpdateColumn("use_count", gorm.Expr("use_count + 1"))
tagsArray = append(tagsArray, tag.Name)
}
// Update denormalized tracks.tags
return s.db.WithContext(ctx).Model(&models.Track{}).Where("id = ?", trackID).
Update("tags", tagsArray).Error
}
// SyncTrackGenres syncs track_genres from genre slugs. Validates max 3, must exist in taxonomy.
func (s *Service) SyncTrackGenres(ctx context.Context, trackID uuid.UUID, genreSlugs []string) error {
if len(genreSlugs) > MaxGenresPerTrack {
return fmt.Errorf("max %d genres per track", MaxGenresPerTrack)
}
normalized := make([]string, 0, len(genreSlugs))
seen := make(map[string]bool)
for _, g := range genreSlugs {
slug := strings.TrimSpace(strings.ToLower(g))
if slug == "" || seen[slug] {
continue
}
var genre models.Genre
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&genre).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("unknown genre: %q", slug)
}
return fmt.Errorf("find genre: %w", err)
}
normalized = append(normalized, genre.Slug)
seen[slug] = true
}
if len(normalized) > MaxGenresPerTrack {
return fmt.Errorf("max %d genres per track", MaxGenresPerTrack)
}
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Delete(&models.TrackGenre{}).Error; err != nil {
return fmt.Errorf("delete track genres: %w", err)
}
for i, slug := range normalized {
if err := s.db.WithContext(ctx).Create(&models.TrackGenre{
TrackID: trackID,
GenreSlug: slug,
Position: i,
}).Error; err != nil {
return fmt.Errorf("link genre: %w", err)
}
}
// Update legacy tracks.genre (first genre)
primary := ""
if len(normalized) > 0 {
primary = normalized[0]
}
return s.db.WithContext(ctx).Model(&models.Track{}).Where("id = ?", trackID).
Update("genre", primary).Error
}
// ListGenres returns all genres from taxonomy
func (s *Service) ListGenres(ctx context.Context) ([]*models.Genre, error) {
var list []*models.Genre
if err := s.db.WithContext(ctx).Order("name").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// GetTracksByGenre F353: browse by genre, chronological, cursor pagination
func (s *Service) GetTracksByGenre(ctx context.Context, genreSlug string, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
slug := strings.TrimSpace(strings.ToLower(genreSlug))
var g models.Genre
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&g).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", fmt.Errorf("genre not found: %s", slug)
}
return nil, "", err
}
query := s.db.WithContext(ctx).Model(&models.Track{}).
Joins("INNER JOIN track_genres ON track_genres.track_id = tracks.id AND track_genres.genre_slug = ?", g.Slug).
Where("tracks.status = ?", models.TrackStatusCompleted).
Where("tracks.is_public = ?", true)
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1)
var tracks []*models.Track
if err := query.Preload("User").Find(&tracks).Error; err != nil {
return nil, "", fmt.Errorf("get tracks by genre: %w", err)
}
var nextCursor string
if len(tracks) > limit {
last := tracks[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
tracks = tracks[:limit]
}
return tracks, nextCursor, nil
}
// GetTracksByTag F353: browse by tag, chronological, cursor pagination
func (s *Service) GetTracksByTag(ctx context.Context, tagName string, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
name := strings.TrimSpace(strings.ToLower(tagName))
var tag models.Tag
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, "", fmt.Errorf("tag not found: %s", tagName)
}
return nil, "", err
}
query := s.db.WithContext(ctx).Model(&models.Track{}).
Joins("INNER JOIN track_tags ON track_tags.track_id = tracks.id AND track_tags.tag_id = ?", tag.ID).
Where("tracks.status = ?", models.TrackStatusCompleted).
Where("tracks.is_public = ?", true)
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1)
var tracks []*models.Track
if err := query.Preload("User").Find(&tracks).Error; err != nil {
return nil, "", fmt.Errorf("get tracks by tag: %w", err)
}
var nextCursor string
if len(tracks) > limit {
last := tracks[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
tracks = tracks[:limit]
}
return tracks, nextCursor, nil
}
// FollowGenre F354
func (s *Service) FollowGenre(ctx context.Context, userID uuid.UUID, genreSlug string) error {
slug := strings.TrimSpace(strings.ToLower(genreSlug))
var g models.Genre
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&g).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("genre not found: %s", slug)
}
return err
}
return s.db.WithContext(ctx).FirstOrCreate(&models.UserGenreFollow{
UserID: userID,
GenreSlug: g.Slug,
}).Error
}
// UnfollowGenre F354
func (s *Service) UnfollowGenre(ctx context.Context, userID uuid.UUID, genreSlug string) error {
slug := strings.TrimSpace(strings.ToLower(genreSlug))
res := s.db.WithContext(ctx).Where("user_id = ? AND genre_slug = ?", userID, slug).
Delete(&models.UserGenreFollow{})
if res.Error != nil {
return res.Error
}
return nil
}
// FollowTag F354
func (s *Service) FollowTag(ctx context.Context, userID uuid.UUID, tagName string) error {
name := strings.TrimSpace(strings.ToLower(tagName))
var tag models.Tag
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("tag not found: %s", tagName)
}
return err
}
return s.db.WithContext(ctx).FirstOrCreate(&models.UserTagFollow{
UserID: userID,
TagID: tag.ID,
}).Error
}
// UnfollowTag F354
func (s *Service) UnfollowTag(ctx context.Context, userID uuid.UUID, tagName string) error {
name := strings.TrimSpace(strings.ToLower(tagName))
var tag models.Tag
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil // idempotent
}
return err
}
res := s.db.WithContext(ctx).Where("user_id = ? AND tag_id = ?", userID, tag.ID).
Delete(&models.UserTagFollow{})
return res.Error
}
// IsFollowingGenre returns true if user follows the genre
func (s *Service) IsFollowingGenre(ctx context.Context, userID uuid.UUID, genreSlug string) (bool, error) {
slug := strings.TrimSpace(strings.ToLower(genreSlug))
var count int64
err := s.db.WithContext(ctx).Model(&models.UserGenreFollow{}).
Where("user_id = ? AND genre_slug = ?", userID, slug).Count(&count).Error
return count > 0, err
}
// IsFollowingTag returns true if user follows the tag
func (s *Service) IsFollowingTag(ctx context.Context, userID uuid.UUID, tagName string) (bool, error) {
name := strings.TrimSpace(strings.ToLower(tagName))
var tag models.Tag
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
return false, nil
}
var count int64
err := s.db.WithContext(ctx).Model(&models.UserTagFollow{}).
Where("user_id = ? AND tag_id = ?", userID, tag.ID).Count(&count).Error
return count > 0, err
}
// GetTracksFromFollowedGenres F355: nouveautés dans les genres suivis
func (s *Service) GetTracksFromFollowedGenres(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
sub := s.db.WithContext(ctx).Model(&models.Track{}).
Select("DISTINCT tracks.id").
Joins("INNER JOIN track_genres ON track_genres.track_id = tracks.id").
Joins("INNER JOIN user_genre_follows ON user_genre_follows.genre_slug = track_genres.genre_slug AND user_genre_follows.user_id = ?", userID).
Where("tracks.status = ?", models.TrackStatusCompleted).
Where("tracks.is_public = ?", true)
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
sub = sub.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
var trackIDs []uuid.UUID
if err := sub.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1).Pluck("tracks.id", &trackIDs).Error; err != nil {
return nil, "", fmt.Errorf("get track ids: %w", err)
}
if len(trackIDs) == 0 {
return []*models.Track{}, "", nil
}
var tracks []*models.Track
if err := s.db.WithContext(ctx).Preload("User").Where("id IN ?", trackIDs).
Order("created_at DESC, id DESC").Find(&tracks).Error; err != nil {
return nil, "", fmt.Errorf("get tracks: %w", err)
}
var nextCursor string
if len(tracks) > limit {
last := tracks[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
tracks = tracks[:limit]
}
return tracks, nextCursor, nil
}

View file

@ -0,0 +1,71 @@
package feed
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
)
// Handler handles the chronological tracks feed (v0.10.0 F210)
type Handler struct {
service *Service
}
// NewHandler creates a new feed handler
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// GetTracksFeed returns tracks from followed users, chronological, cursor-paginated.
// v0.10.1 F355: Adds by_genres section "Nouvelles sorties dans vos genres" when user follows genres.
// GET /api/v1/feed?cursor=...&limit=20&by_genres_cursor=...&by_genres_limit=10
// Requires authentication.
func (h *Handler) GetTracksFeed(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
limitStr := c.DefaultQuery("limit", "20")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 || limit > 50 {
handlers.RespondWithAppError(c, apperrors.NewValidationError("limit must be between 1 and 50"))
return
}
cursor := c.Query("cursor")
byGenresCursor := c.Query("by_genres_cursor")
byGenresLimit := 10
if l, err := strconv.Atoi(c.DefaultQuery("by_genres_limit", "10")); err == nil && l > 0 && l <= 50 {
byGenresLimit = l
}
tracks, nextCursor, err := h.service.GetTracksFeed(c.Request.Context(), userID, limit, cursor)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get feed", err))
return
}
resp := gin.H{"items": tracks}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
// v0.10.1 F355: Section "Nouvelles sorties dans vos genres"
if ds := h.service.GetDiscoverService(); ds != nil {
byGenresTracks, byGenresNext, err := ds.GetTracksFromFollowedGenres(c.Request.Context(), userID, byGenresLimit, byGenresCursor)
if err == nil {
section := gin.H{"items": byGenresTracks}
if byGenresNext != "" {
section["next_cursor"] = byGenresNext
}
resp["by_genres"] = section
}
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}

View file

@ -0,0 +1,93 @@
package feed
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/core/discover"
"veza-backend-api/internal/models"
)
// Service provides the chronological tracks feed from followed users (v0.10.0 F210)
// v0.10.1: Optional by_genres section from discover service
type Service struct {
db *gorm.DB
logger *zap.Logger
discoverService *discover.Service
}
// NewService creates a new feed service
func NewService(db *gorm.DB, logger *zap.Logger) *Service {
return &Service{db: db, logger: logger}
}
// SetDiscoverService sets the discover service for by_genres section (v0.10.1 F355)
func (s *Service) SetDiscoverService(d *discover.Service) {
s.discoverService = d
}
// GetDiscoverService returns the discover service (for handler access)
func (s *Service) GetDiscoverService() *discover.Service {
return s.discoverService
}
// GetTracksFeed returns tracks from users that the viewer follows, chronological order, cursor pagination.
// Only includes completed, public tracks. Requires userID (authenticated).
func (s *Service) GetTracksFeed(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query := s.db.WithContext(ctx).Model(&models.Track{}).
Joins("INNER JOIN follows ON follows.followed_id = tracks.creator_id AND follows.follower_id = ?", userID).
Where("tracks.status = ?", models.TrackStatusCompleted).
Where("tracks.is_public = ?", true)
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1)
var tracks []*models.Track
if err := query.Preload("User").Find(&tracks).Error; err != nil {
return nil, "", fmt.Errorf("failed to get feed tracks: %w", err)
}
var nextCursor string
if len(tracks) > limit {
last := tracks[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
tracks = tracks[:limit]
}
return tracks, nextCursor, nil
}

View file

@ -13,6 +13,7 @@ import (
"strings"
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
"veza-backend-api/internal/core/discover"
"veza-backend-api/internal/database"
"veza-backend-api/internal/models"
"veza-backend-api/internal/monitoring"
@ -68,6 +69,7 @@ type TrackService struct {
cacheService *services.CacheService
streamService StreamServiceInterface // INT-02: Optional, triggers HLS transcoding after upload
batchService *TrackBatchService // v0.943: batch operations
discoverService *discover.Service // v0.10.1: tags/genres sync
}
// forRead returns the DB to use for read operations (read replica if configured, else primary)
@ -120,6 +122,11 @@ func (s *TrackService) SetStreamService(streamService StreamServiceInterface) {
s.streamService = streamService
}
// SetDiscoverService définit le service discover pour sync tags/genres (v0.10.1)
func (s *TrackService) SetDiscoverService(d *discover.Service) {
s.discoverService = d
}
// ValidateTrackFile valide le format et la taille d'un fichier audio
func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error {
// Valider la taille
@ -781,11 +788,13 @@ func (s *TrackService) GetTrackByID(ctx context.Context, trackID uuid.UUID) (*mo
}
// UpdateTrackParams représente les paramètres de mise à jour d'un track
// v0.10.1: Genres for multi-genre (max 3)
type UpdateTrackParams struct {
Title *string `json:"title"`
Artist *string `json:"artist"`
Album *string `json:"album"`
Genre *string `json:"genre"`
Genre *string `json:"genre"` // legacy single
Genres []string `json:"genres"` // v0.10.1: max 3 slugs
Tags []string `json:"tags"`
Year *int `json:"year"`
BPM *int `json:"bpm"`
@ -829,12 +838,31 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
if params.Album != nil {
updates["album"] = *params.Album
}
// v0.10.1: Tags and Genres via discover service (track_tags, track_genres)
if s.discoverService != nil {
if params.Tags != nil {
if err := s.discoverService.SyncTrackTags(ctx, trackID, params.Tags); err != nil {
return nil, fmt.Errorf("sync tags: %w", err)
}
}
if params.Genre != nil || len(params.Genres) > 0 {
genres := params.Genres
if len(genres) == 0 && params.Genre != nil {
genres = []string{*params.Genre}
}
if err := s.discoverService.SyncTrackGenres(ctx, trackID, genres); err != nil {
return nil, fmt.Errorf("sync genres: %w", err)
}
}
} else {
// Fallback when discover service not configured
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")
@ -861,8 +889,16 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
}
}
// Si aucune mise à jour n'est demandée
// v0.10.1: If only tags/genres were updated via discover, reload and return
discoverUpdated := s.discoverService != nil && (params.Tags != nil || params.Genre != nil || len(params.Genres) > 0)
if len(updates) == 0 {
if discoverUpdated {
updatedTrack, err := s.GetTrackByID(ctx, trackID)
if err != nil {
return nil, err
}
return updatedTrack, nil
}
return track, nil
}

View file

@ -19,12 +19,14 @@ import (
)
// UpdateTrackRequest représente la requête de mise à jour d'un track
// v0.10.1: Genres supports multi-genre (max 3, taxonomy slugs)
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"`
Tags []string `json:"tags"`
Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"` // legacy, single
Genres []string `json:"genres"` // v0.10.1: max 3, taxonomy slugs
Tags []string `json:"tags"` // v0.10.1: max 10, 30 chars each
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"`
@ -187,11 +189,29 @@ func (h *TrackHandler) UpdateTrack(c *gin.Context) {
return
}
// v0.10.1 F351: validate tags (max 10, 30 chars each)
if len(req.Tags) > 10 {
h.respondWithError(c, http.StatusBadRequest, "max 10 tags per track")
return
}
for _, t := range req.Tags {
if len(t) > 30 {
h.respondWithError(c, http.StatusBadRequest, "tag max 30 chars")
return
}
}
// v0.10.1 F352: validate genres (max 3)
if len(req.Genres) > 3 {
h.respondWithError(c, http.StatusBadRequest, "max 3 genres per track")
return
}
params := UpdateTrackParams{
Title: req.Title,
Artist: req.Artist,
Album: req.Album,
Genre: req.Genre,
Genres: req.Genres,
Tags: req.Tags,
Year: req.Year,
BPM: req.BPM,

View file

@ -0,0 +1,28 @@
package models
import (
"time"
"github.com/google/uuid"
)
// Genre v0.10.1 F352: Taxonomie fixe des genres musicaux
type Genre struct {
Slug string `gorm:"size:50;primaryKey" json:"slug" db:"slug"`
Name string `gorm:"size:100;not null" json:"name" db:"name"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TableName for Genre
func (Genre) TableName() string { return "genres" }
// TrackGenre v0.10.1: liaison track <-> genre (max 3 par track)
type TrackGenre struct {
TrackID uuid.UUID `gorm:"type:uuid;primaryKey" json:"track_id" db:"track_id"`
GenreSlug string `gorm:"size:50;primaryKey" json:"genre_slug" db:"genre_slug"`
Position int `gorm:"default:0" json:"position" db:"position"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TableName for TrackGenre
func (TrackGenre) TableName() string { return "track_genres" }

View file

@ -0,0 +1,37 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Tag v0.10.1 F351: Tag déclaratif sur les tracks (max 10 par track, 30 chars)
type Tag struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id" db:"id"`
Name string `gorm:"size:30;not null;uniqueIndex" json:"name" db:"name"`
UseCount int `gorm:"default:0" json:"use_count" db:"use_count"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// BeforeCreate for Tag
func (t *Tag) BeforeCreate(tx *gorm.DB) error {
if t.ID == uuid.Nil {
t.ID = uuid.New()
}
return nil
}
// TableName for Tag
func (Tag) TableName() string { return "tags" }
// TrackTag v0.10.1: liaison track <-> tag
type TrackTag struct {
TrackID uuid.UUID `gorm:"type:uuid;primaryKey" json:"track_id" db:"track_id"`
TagID uuid.UUID `gorm:"type:uuid;primaryKey" json:"tag_id" db:"tag_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TableName for TrackTag
func (TrackTag) TableName() string { return "track_tags" }

View file

@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
// UserGenreFollow v0.10.1 F354: utilisateur suit un genre
type UserGenreFollow struct {
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id" db:"user_id"`
GenreSlug string `gorm:"size:50;primaryKey" json:"genre_slug" db:"genre_slug"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TableName for UserGenreFollow
func (UserGenreFollow) TableName() string { return "user_genre_follows" }
// UserTagFollow v0.10.1 F354: utilisateur suit un tag
type UserTagFollow struct {
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id" db:"user_id"`
TagID uuid.UUID `gorm:"type:uuid;primaryKey" json:"tag_id" db:"tag_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// TableName for UserTagFollow
func (UserTagFollow) TableName() string { return "user_tag_follows" }

View file

@ -0,0 +1,96 @@
-- 126_tags_genres_discover.sql
-- v0.10.1 F351-F355: Tags déclaratifs, genres, discover, follow genre/tag
-- === TAGS (F351) ===
-- Tags libres créés par l'artiste, max 10 par track, 30 chars
CREATE TABLE IF NOT EXISTS public.tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(30) NOT NULL UNIQUE,
use_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tags_name ON public.tags(name);
CREATE INDEX idx_tags_use_count_desc ON public.tags(use_count DESC);
-- track_tags: liaison track <-> tag (max 10 par track, validated in app)
CREATE TABLE IF NOT EXISTS public.track_tags (
track_id UUID NOT NULL REFERENCES public.tracks(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (track_id, tag_id)
);
CREATE INDEX idx_track_tags_tag_id ON public.track_tags(tag_id);
CREATE INDEX idx_track_tags_track_id ON public.track_tags(track_id);
-- === GENRES (F352) ===
-- Taxonomie fixe, max 3 genres par track, extensible via PR
CREATE TABLE IF NOT EXISTS public.genres (
slug VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- track_genres: liaison track <-> genre (max 3 par track, validated in app)
CREATE TABLE IF NOT EXISTS public.track_genres (
track_id UUID NOT NULL REFERENCES public.tracks(id) ON DELETE CASCADE,
genre_slug VARCHAR(50) NOT NULL REFERENCES public.genres(slug) ON DELETE CASCADE,
position SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (track_id, genre_slug)
);
CREATE INDEX idx_track_genres_genre_slug ON public.track_genres(genre_slug);
CREATE INDEX idx_track_genres_track_id ON public.track_genres(track_id);
-- Seed taxonomy fixe (genres musicaux courants)
INSERT INTO public.genres (slug, name) VALUES
('electronic', 'Electronic'),
('house', 'House'),
('techno', 'Techno'),
('ambient', 'Ambient'),
('drum-and-bass', 'Drum and Bass'),
('dubstep', 'Dubstep'),
('trance', 'Trance'),
('jazz', 'Jazz'),
('rock', 'Rock'),
('pop', 'Pop'),
('hip-hop', 'Hip-Hop'),
('classical', 'Classical'),
('folk', 'Folk'),
('reggae', 'Reggae'),
('soul', 'Soul'),
('funk', 'Funk'),
('blues', 'Blues'),
('metal', 'Metal'),
('indie', 'Indie'),
('experimental', 'Experimental'),
('world', 'World'),
('latin', 'Latin'),
('other', 'Other')
ON CONFLICT (slug) DO NOTHING;
-- === USER GENRE FOLLOWS (F354) ===
CREATE TABLE IF NOT EXISTS public.user_genre_follows (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
genre_slug VARCHAR(50) NOT NULL REFERENCES public.genres(slug) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, genre_slug)
);
CREATE INDEX idx_user_genre_follows_genre ON public.user_genre_follows(genre_slug);
-- === USER TAG FOLLOWS (F354) ===
CREATE TABLE IF NOT EXISTS public.user_tag_follows (
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tag_id)
);
CREATE INDEX idx_user_tag_follows_tag ON public.user_tag_follows(tag_id);
-- Indexes pour discover (F353) - tri chronologique
CREATE INDEX idx_track_genres_genre_created ON public.track_genres(genre_slug, track_id);
CREATE INDEX idx_track_tags_tag_created ON public.track_tags(tag_id, track_id);