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:
parent
9024fa92a0
commit
4a422fc4c3
27 changed files with 1939 additions and 55 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export {
|
|||
LazyWebhooks,
|
||||
LazyDesignSystemDemo,
|
||||
LazySocial,
|
||||
LazyFeed,
|
||||
LazyDiscover,
|
||||
LazyGear,
|
||||
LazyLive,
|
||||
LazyGoLive,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export {
|
|||
LazyWebhooks,
|
||||
LazyDesignSystemDemo,
|
||||
LazySocial,
|
||||
LazyFeed,
|
||||
LazyDiscover,
|
||||
LazyGear,
|
||||
LazyLive,
|
||||
LazyGoLive,
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
207
apps/web/src/features/discover/pages/DiscoverPage.tsx
Normal file
207
apps/web/src/features/discover/pages/DiscoverPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/features/feed/components/SuggestionsWidget.tsx
Normal file
70
apps/web/src/features/feed/components/SuggestionsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/web/src/features/feed/pages/FeedPage.tsx
Normal file
152
apps/web/src/features/feed/pages/FeedPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/mocks/handlers-discover.ts
Normal file
80
apps/web/src/mocks/handlers-discover.ts
Normal 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 } });
|
||||
}),
|
||||
];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
113
apps/web/src/services/discoverService.ts
Normal file
113
apps/web/src/services/discoverService.ts
Normal 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`
|
||||
);
|
||||
},
|
||||
};
|
||||
86
apps/web/src/services/feedService.ts
Normal file
86
apps/web/src/services/feedService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
31
veza-backend-api/internal/api/routes_discover.go
Normal file
31
veza-backend-api/internal/api/routes_discover.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
veza-backend-api/internal/api/routes_feed.go
Normal file
23
veza-backend-api/internal/api/routes_feed.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
187
veza-backend-api/internal/core/discover/handler.go
Normal file
187
veza-backend-api/internal/core/discover/handler.go
Normal 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})
|
||||
}
|
||||
406
veza-backend-api/internal/core/discover/service.go
Normal file
406
veza-backend-api/internal/core/discover/service.go
Normal 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
|
||||
}
|
||||
71
veza-backend-api/internal/core/feed/handler.go
Normal file
71
veza-backend-api/internal/core/feed/handler.go
Normal 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)
|
||||
}
|
||||
93
veza-backend-api/internal/core/feed/service.go
Normal file
93
veza-backend-api/internal/core/feed/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -65,9 +66,10 @@ type TrackService struct {
|
|||
logger *zap.Logger
|
||||
uploadDir string
|
||||
maxFileSize int64
|
||||
cacheService *services.CacheService
|
||||
streamService StreamServiceInterface // INT-02: Optional, triggers HLS transcoding after upload
|
||||
batchService *TrackBatchService // v0.943: batch operations
|
||||
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,11 +838,30 @@ func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userI
|
|||
if params.Album != nil {
|
||||
updates["album"] = *params.Album
|
||||
}
|
||||
if params.Genre != nil {
|
||||
updates["genre"] = *params.Genre
|
||||
}
|
||||
if params.Tags != nil {
|
||||
updates["tags"] = params.Tags
|
||||
// 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 {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
28
veza-backend-api/internal/models/genre.go
Normal file
28
veza-backend-api/internal/models/genre.go
Normal 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" }
|
||||
37
veza-backend-api/internal/models/tag.go
Normal file
37
veza-backend-api/internal/models/tag.go
Normal 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" }
|
||||
27
veza-backend-api/internal/models/user_genre_tag_follow.go
Normal file
27
veza-backend-api/internal/models/user_genre_tag_follow.go
Normal 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" }
|
||||
96
veza-backend-api/migrations/126_tags_genres_discover.sql
Normal file
96
veza-backend-api/migrations/126_tags_genres_discover.sql
Normal 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);
|
||||
Loading…
Reference in a new issue