From 4a422fc4c3ebecc854552b0ee2f6fc47e3375b87 Mon Sep 17 00:00:00 2001 From: senke Date: Mon, 9 Mar 2026 01:52:56 +0100 Subject: [PATCH] feat(v0.10.1): Tags & Genres discover - F351-F355 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- VEZA_VERSIONS_ROADMAP.md | 86 ++-- apps/web/src/components/ui/LazyComponent.tsx | 2 + .../src/components/ui/lazy-component/index.ts | 2 + .../ui/lazy-component/lazyExports.ts | 13 + .../features/discover/pages/DiscoverPage.tsx | 207 +++++++++ .../feed/components/SuggestionsWidget.tsx | 70 +++ apps/web/src/features/feed/pages/FeedPage.tsx | 152 +++++++ apps/web/src/mocks/handlers-discover.ts | 80 ++++ apps/web/src/mocks/handlers-misc.ts | 89 +++- apps/web/src/mocks/handlers.ts | 2 + apps/web/src/router/routeConfig.tsx | 4 + apps/web/src/services/discoverService.ts | 113 +++++ apps/web/src/services/feedService.ts | 86 ++++ veza-backend-api/internal/api/router.go | 6 + .../internal/api/routes_discover.go | 31 ++ veza-backend-api/internal/api/routes_feed.go | 23 + .../internal/api/routes_tracks.go | 3 + .../internal/core/discover/handler.go | 187 ++++++++ .../internal/core/discover/service.go | 406 ++++++++++++++++++ .../internal/core/feed/handler.go | 71 +++ .../internal/core/feed/service.go | 93 ++++ .../internal/core/track/service.go | 56 ++- .../internal/core/track/track_crud_handler.go | 24 +- veza-backend-api/internal/models/genre.go | 28 ++ veza-backend-api/internal/models/tag.go | 37 ++ .../internal/models/user_genre_tag_follow.go | 27 ++ .../migrations/126_tags_genres_discover.sql | 96 +++++ 27 files changed, 1939 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/features/discover/pages/DiscoverPage.tsx create mode 100644 apps/web/src/features/feed/components/SuggestionsWidget.tsx create mode 100644 apps/web/src/features/feed/pages/FeedPage.tsx create mode 100644 apps/web/src/mocks/handlers-discover.ts create mode 100644 apps/web/src/services/discoverService.ts create mode 100644 apps/web/src/services/feedService.ts create mode 100644 veza-backend-api/internal/api/routes_discover.go create mode 100644 veza-backend-api/internal/api/routes_feed.go create mode 100644 veza-backend-api/internal/core/discover/handler.go create mode 100644 veza-backend-api/internal/core/discover/service.go create mode 100644 veza-backend-api/internal/core/feed/handler.go create mode 100644 veza-backend-api/internal/core/feed/service.go create mode 100644 veza-backend-api/internal/models/genre.go create mode 100644 veza-backend-api/internal/models/tag.go create mode 100644 veza-backend-api/internal/models/user_genre_tag_follow.go create mode 100644 veza-backend-api/migrations/126_tags_genres_discover.sql diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index a472e7598..4ec3bd7ec 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -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 | diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 614081029..2e5e19aff 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -26,6 +26,8 @@ export { LazyWebhooks, LazyDesignSystemDemo, LazySocial, + LazyFeed, + LazyDiscover, LazyGear, LazyLive, LazyGoLive, diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index 28891bf5a..ff9066cfc 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -29,6 +29,8 @@ export { LazyWebhooks, LazyDesignSystemDemo, LazySocial, + LazyFeed, + LazyDiscover, LazyGear, LazyLive, LazyGoLive, diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index e7c792aa3..190b50965 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -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) => ({ diff --git a/apps/web/src/features/discover/pages/DiscoverPage.tsx b/apps/web/src/features/discover/pages/DiscoverPage.tsx new file mode 100644 index 000000000..64e5d960a --- /dev/null +++ b/apps/web/src/features/discover/pages/DiscoverPage.tsx @@ -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(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 ( + +
+ +

Découvrir

+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ + ); + } + + return ( + +
+
+ {browseGenre || browseTag ? ( + + ) : null} + +

+ {browseGenre + ? `Genre : ${genres?.find((g) => g.slug === browseGenre)?.name ?? browseGenre}` + : browseTag + ? `Tag : ${browseTag}` + : 'Découvrir'} +

+
+ + {showGenreList && genres ? ( +
+

+ Par genre +

+
+ {genres.map((g) => ( + + ))} +
+
+ ) : null} + + {browseGenre || browseTag ? ( + <> + {isLoadingTracks ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : error ? ( + refetch()} /> + ) : ( + <> + +
+ {isFetchingNext && ( + + )} +
+ + )} + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/feed/components/SuggestionsWidget.tsx b/apps/web/src/features/feed/components/SuggestionsWidget.tsx new file mode 100644 index 000000000..23fd7f423 --- /dev/null +++ b/apps/web/src/features/feed/components/SuggestionsWidget.tsx @@ -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 ( +
+

+ + Comptes à suivre +

+
+ +
+
+ ); + } + + const suggestions = data?.suggestions ?? []; + if (suggestions.length === 0) return null; + + return ( +
+

+ + Comptes à suivre +

+
    + {suggestions.map((user) => ( +
  • + + +
    +

    @{user.username}

    +

    + {user.followers_count.toLocaleString()} abonnés +

    +
    + + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/features/feed/pages/FeedPage.tsx b/apps/web/src/features/feed/pages/FeedPage.tsx new file mode 100644 index 000000000..6f96bcd73 --- /dev/null +++ b/apps/web/src/features/feed/pages/FeedPage.tsx @@ -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(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 ( + +
+
+ +

Feed

+
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + refetch()} /> + + ); + } + + return ( + +
+
+
+ +

Feed

+
+ {tracks.length === 0 ? ( +
+ +

+ Suivez des artistes pour voir leurs nouveaux morceaux ici. +

+
+ ) : ( + <> + {byGenres?.items && byGenres.items.length > 0 && ( +
+

+ Nouvelles sorties dans vos genres +

+ +
+ )} +
+

+ {byGenres?.items && byGenres.items.length > 0 + ? 'Artistes suivis' + : 'Feed'} +

+ +
+
+ {isFetchingNextPage && ( + + )} +
+ + )} +
+ +
+
+ ); +} diff --git a/apps/web/src/mocks/handlers-discover.ts b/apps/web/src/mocks/handlers-discover.ts new file mode 100644 index 000000000..8ab245e57 --- /dev/null +++ b/apps/web/src/mocks/handlers-discover.ts @@ -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 } }); + }), +]; diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index 702b4d107..f45fd53e1 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -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, diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index 3e61bae01..e47adf484 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -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, diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index 84b56d3dc..ed0b011d6 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -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() }, { path: '/admin/transfers', element: wrapProtected() }, { path: '/social', element: wrapProtected() }, + { path: '/feed', element: wrapProtected() }, + { path: '/discover', element: wrapProtected() }, { path: '/queue', element: wrapProtected() }, { path: '/developer', element: wrapProtected() }, // Gear: connected to backend inventory API diff --git a/apps/web/src/services/discoverService.ts b/apps/web/src/services/discoverService.ts new file mode 100644 index 000000000..c170267b3 --- /dev/null +++ b/apps/web/src/services/discoverService.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + await apiClient.post(`/discover/genre/${encodeURIComponent(genre)}/follow`); + }, + + unfollowGenre: async (genre: string): Promise => { + await apiClient.delete( + `/discover/genre/${encodeURIComponent(genre)}/follow` + ); + }, + + followTag: async (tag: string): Promise => { + await apiClient.post(`/discover/tag/${encodeURIComponent(tag)}/follow`); + }, + + unfollowTag: async (tag: string): Promise => { + await apiClient.delete( + `/discover/tag/${encodeURIComponent(tag)}/follow` + ); + }, +}; diff --git a/apps/web/src/services/feedService.ts b/apps/web/src/services/feedService.ts new file mode 100644 index 000000000..59e0e57c3 --- /dev/null +++ b/apps/web/src/services/feedService.ts @@ -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 => { + 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; + }, +}; diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index b9eb675a8..25bc78b6f 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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) diff --git a/veza-backend-api/internal/api/routes_discover.go b/veza-backend-api/internal/api/routes_discover.go new file mode 100644 index 000000000..03e9a320c --- /dev/null +++ b/veza-backend-api/internal/api/routes_discover.go @@ -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) + } +} diff --git a/veza-backend-api/internal/api/routes_feed.go b/veza-backend-api/internal/api/routes_feed.go new file mode 100644 index 000000000..78402a0c3 --- /dev/null +++ b/veza-backend-api/internal/api/routes_feed.go @@ -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) + } +} diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index abf188c93..7fb77b17e 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -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 { diff --git a/veza-backend-api/internal/core/discover/handler.go b/veza-backend-api/internal/core/discover/handler.go new file mode 100644 index 000000000..0c61432ab --- /dev/null +++ b/veza-backend-api/internal/core/discover/handler.go @@ -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}) +} diff --git a/veza-backend-api/internal/core/discover/service.go b/veza-backend-api/internal/core/discover/service.go new file mode 100644 index 000000000..1c9395ad5 --- /dev/null +++ b/veza-backend-api/internal/core/discover/service.go @@ -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 +} diff --git a/veza-backend-api/internal/core/feed/handler.go b/veza-backend-api/internal/core/feed/handler.go new file mode 100644 index 000000000..1e559d1e6 --- /dev/null +++ b/veza-backend-api/internal/core/feed/handler.go @@ -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) +} diff --git a/veza-backend-api/internal/core/feed/service.go b/veza-backend-api/internal/core/feed/service.go new file mode 100644 index 000000000..66f7ea900 --- /dev/null +++ b/veza-backend-api/internal/core/feed/service.go @@ -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 +} diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index ba4c00767..024059a76 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -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 } diff --git a/veza-backend-api/internal/core/track/track_crud_handler.go b/veza-backend-api/internal/core/track/track_crud_handler.go index c27589ff3..a6eba66b6 100644 --- a/veza-backend-api/internal/core/track/track_crud_handler.go +++ b/veza-backend-api/internal/core/track/track_crud_handler.go @@ -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, diff --git a/veza-backend-api/internal/models/genre.go b/veza-backend-api/internal/models/genre.go new file mode 100644 index 000000000..42b04dc9d --- /dev/null +++ b/veza-backend-api/internal/models/genre.go @@ -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" } diff --git a/veza-backend-api/internal/models/tag.go b/veza-backend-api/internal/models/tag.go new file mode 100644 index 000000000..e8e17cfbe --- /dev/null +++ b/veza-backend-api/internal/models/tag.go @@ -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" } diff --git a/veza-backend-api/internal/models/user_genre_tag_follow.go b/veza-backend-api/internal/models/user_genre_tag_follow.go new file mode 100644 index 000000000..744405e62 --- /dev/null +++ b/veza-backend-api/internal/models/user_genre_tag_follow.go @@ -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" } diff --git a/veza-backend-api/migrations/126_tags_genres_discover.sql b/veza-backend-api/migrations/126_tags_genres_discover.sql new file mode 100644 index 000000000..006a7d88e --- /dev/null +++ b/veza-backend-api/migrations/126_tags_genres_discover.sql @@ -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);