diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index b61ce424c..3df6befa5 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -707,34 +707,36 @@ Implémenter le livestreaming audio basique via Nginx-RTMP (Option A) + backend --- -### v0.10.7 — Collaboration Temps Réel (F481-F488) +### v0.10.7 — Collaboration Temps Réel (F481-F483) -**Statut** : ⏳ TODO +**Statut** : ✅ DONE **Priorité** : P2 **Durée estimée** : 5-6 jours **Prerequisite** : v0.10.6 complète +**Complété le** : 2026-03-10 **Objectif** -Implémenter la collaboration temps réel basique : sessions de co-écoute et partage de projets. +Implémenter la collaboration temps réel basique : sessions de co-écoute, partage de stems et espaces collaboratifs. **Tâches** -- [ ] Sessions de co-écoute synchronisée (F481) +- [x] Sessions de co-écoute synchronisée (F481) - Plusieurs utilisateurs écoutent la même track en sync - - Le host contrôle la lecture + - Le host contrôle la lecture via WebSocket /api/v1/co-listening/ws + - SyncInit, SyncAdjustment, drift < 500ms -- [ ] Partage de projets / stems (F482) - - Upload de stems séparés (kick, snare, bass, etc.) - - Autre utilisateur peut télécharger les stems +- [x] Partage de projets / stems (F482) + - Upload de stems séparés (kick, snare, bass, etc.) — formats wav, aiff, flac + - Liste et téléchargement pour utilisateurs autorisés (owner ou track public) -- [ ] Espace de travail collaboratif partagé (F483) - - Room collaborative avec chat dédié + partage de fichiers - - Accès contrôlé (invitation seulement) +- [x] Espace de travail collaboratif partagé (F483) + - Room collaborative (type "collaborative") avec chat dédié + - Accès par invitation uniquement, max 10 participants **Critères d'acceptation** -- [ ] Co-écoute : synchronisation en moins de 500ms entre participants -- [ ] Partage de stems : tous les formats (wav, aiff, flac) acceptés -- [ ] Room collaborative : max 10 participants simultanés +- [x] Co-écoute : WebSocket dédié, synchronisation < 500ms +- [x] Partage de stems : wav, aiff, flac acceptés +- [x] Room collaborative : max 10 participants simultanés --- @@ -1212,7 +1214,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 : | v0.10.4 | Playlists Collaboratives | P4R | ✅ DONE | 3-4j | v0.10.0 | | v0.10.5 | Notifications Complètes | P4R | ✅ DONE | 2-3j | v0.10.3 | | v0.10.6 | Livestreaming Basique | P4R | ✅ DONE | 5-7j | v0.10.0 | -| v0.10.7 | Collaboration Temps Réel | P4R | ⏳ TODO | 5-6j | v0.10.6 | +| v0.10.7 | Collaboration Temps Réel | P4R | ✅ DONE | 5-6j | v0.10.6 | | v0.10.8 | Portabilité Données RGPD | P4R | ⏳ TODO | 2-3j | v0.10.0 | | v0.11.0 | Analytics Créateur | P5R | ⏳ TODO | 4-5j | v0.10.3 | | v0.11.1 | Analytics Avancés | P5R | ⏳ TODO | 3-4j | v0.11.0 | diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 14d80c27b..9824722ff 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -32,6 +32,7 @@ export { LazyGear, LazyLive, LazyGoLive, + LazyListenTogether, LazyCloud, LazyQueue, LazyDeveloper, diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index cf930a29f..f9d52dad5 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -35,6 +35,7 @@ export { LazyGear, LazyLive, LazyGoLive, + LazyListenTogether, LazyCloud, LazyQueue, LazyDeveloper, diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index a273a1287..de8687462 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -206,6 +206,14 @@ export const LazyGoLive = createLazyComponent( undefined, 'Go Live', ); +export const LazyListenTogether = createLazyComponent( + () => + import('@/features/player/pages/ListenTogetherPage').then((m) => ({ + default: m.ListenTogetherPage, + })), + undefined, + 'Listen Together', +); export const LazyCloud = createLazyComponent( () => import('@/features/cloud/pages/CloudPage'), undefined, diff --git a/apps/web/src/config/env.ts b/apps/web/src/config/env.ts index 752298aab..6a3e2c967 100644 --- a/apps/web/src/config/env.ts +++ b/apps/web/src/config/env.ts @@ -119,6 +119,12 @@ const deriveWSUrl = (): string => { return wsUrl; }; +// v0.10.7 F481: Co-listening WebSocket URL (same origin as chat, different path) +const deriveCoListeningWSUrl = (): string => { + const wsBase = deriveWSUrl(); + return wsBase.replace(/\/ws$/, '/co-listening/ws'); +}; + // HLS base URL: explicit or derived from STREAM_URL (ws://host:port -> http://host:port) // When STREAM_URL is relative (/stream), use '' so HLS URLs are relative (/hls/...) and get proxied const deriveHLSBaseURL = (): string => { @@ -135,6 +141,7 @@ export const env = { DOMAIN: validatedEnv.VITE_DOMAIN, API_URL: validatedEnv.VITE_API_URL, WS_URL: deriveWSUrl(), + CO_LISTENING_WS_URL: deriveCoListeningWSUrl(), STREAM_URL: validatedEnv.VITE_STREAM_URL, HLS_BASE_URL: deriveHLSBaseURL(), UPLOAD_URL: validatedEnv.VITE_UPLOAD_URL, diff --git a/apps/web/src/features/chat/components/CreateRoomDialog.tsx b/apps/web/src/features/chat/components/CreateRoomDialog.tsx index 9909b6e3a..ae2393ddb 100644 --- a/apps/web/src/features/chat/components/CreateRoomDialog.tsx +++ b/apps/web/src/features/chat/components/CreateRoomDialog.tsx @@ -19,7 +19,7 @@ interface CreateRoomDialogProps { export function CreateRoomDialog({ open, onClose }: CreateRoomDialogProps) { const [name, setName] = useState(''); - const [type, setType] = useState<'public' | 'private'>('public'); + const [type, setType] = useState<'public' | 'private' | 'collaborative'>('public'); const [isCreating, setIsCreating] = useState(false); const [validationError, setValidationError] = useState(null); const [mutationError, setMutationError] = useState(null); @@ -148,13 +148,15 @@ export function CreateRoomDialog({ open, onClose }: CreateRoomDialogProps) { options={[ { value: 'public', label: 'Public' }, { value: 'private', label: 'Private' }, + { value: 'collaborative', label: 'Collaborative (invite only, max 10)' }, ]} value={type} onChange={(value) => setType( (Array.isArray(value) ? value[0] : value) as | 'public' - | 'private', + | 'private' + | 'collaborative', ) } name="room-type" diff --git a/apps/web/src/features/player/pages/ListenTogetherPage.tsx b/apps/web/src/features/player/pages/ListenTogetherPage.tsx new file mode 100644 index 000000000..3b3c96cd2 --- /dev/null +++ b/apps/web/src/features/player/pages/ListenTogetherPage.tsx @@ -0,0 +1,95 @@ +/** + * Listen Together page (v0.10.7 F481) + * Join a co-listening session and sync playback + */ +import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { usePlayerStore } from '@/features/player/store/playerStore'; +import { useStreamSync } from '@/features/player/hooks/useStreamSync'; +import { coListeningService } from '@/services/coListeningService'; +import { trackService } from '@/features/tracks/services/trackService'; +import { Button } from '@/components/ui/button'; +import { Users, Loader2 } from 'lucide-react'; + +export function ListenTogetherPage() { + const { sessionId } = useParams<{ sessionId: string }>(); + const [searchParams] = useSearchParams(); + const trackIdFromQuery = searchParams.get('track'); + + const [session, setSession] = useState<{ track_id: string } | null>(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const play = usePlayerStore((s) => s.play); + const { isSynced } = useStreamSync({ + sessionId: sessionId ?? null, + trackId: session?.track_id ?? trackIdFromQuery ?? null, + }); + + useEffect(() => { + if (!sessionId) { + setError('Session ID missing'); + setLoading(false); + return; + } + + coListeningService + .getSession(sessionId) + .then((s) => { + setSession(s); + }) + .catch((err) => { + setError(err?.response?.data?.error?.message ?? 'Session not found or expired'); + }) + .finally(() => setLoading(false)); + }, [sessionId]); + + const trackId = session?.track_id ?? trackIdFromQuery; + + useEffect(() => { + if (!trackId || !session) return; + + trackService + .getTrack(trackId) + .then((track) => { + if (track) play(track); + }) + .catch(() => setError('Could not load track')); + }, [trackId, session, play]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+ + Listening together + {isSynced && ( + + Synced + + )} +
+

+ Playback is synchronized with the host. Use the player below to control playback. +

+
+ ); +} diff --git a/apps/web/src/features/player/services/syncClient.ts b/apps/web/src/features/player/services/syncClient.ts index fa97e02f8..92b0b0296 100644 --- a/apps/web/src/features/player/services/syncClient.ts +++ b/apps/web/src/features/player/services/syncClient.ts @@ -1,4 +1,4 @@ -import { API_URLS } from '@/config/constants'; +import { env } from '@/config/env'; // --- Types --- @@ -63,11 +63,11 @@ export class SyncClient { this.disconnect(); } - // Build WebSocket URL with query params - const wsUrl = new URL(`${API_URLS.WS}/ws`); + // v0.10.7 F481: Co-listening WebSocket at /api/v1/co-listening/ws + const wsUrl = new URL(env.CO_LISTENING_WS_URL); wsUrl.searchParams.append('token', this.config.token); wsUrl.searchParams.append('session_id', this.config.sessionId); - // track_id might be needed if the backend uses it for initial routing/validation + wsUrl.searchParams.append('track_id', this.config.trackId); // but usually session_id is enough for the connection itself. // The previous implementation used stream_id/session_id. // Let's assume the standard WS endpoint. diff --git a/apps/web/src/features/tracks/components/TrackStemsSection.tsx b/apps/web/src/features/tracks/components/TrackStemsSection.tsx new file mode 100644 index 000000000..9bf98792b --- /dev/null +++ b/apps/web/src/features/tracks/components/TrackStemsSection.tsx @@ -0,0 +1,124 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Upload, Download, Loader2, Disc3 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { trackStemService, type TrackStem } from '../services/trackStemService'; +import type { Track } from '../types/track'; + +interface TrackStemsSectionProps { + track: Track; + isCreator: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function TrackStemsSection({ track, isCreator }: TrackStemsSectionProps) { + const queryClient = useQueryClient(); + const { data: stems, isLoading, error } = useQuery({ + queryKey: ['track-stems', track.id], + queryFn: () => trackStemService.listStems(track.id), + }); + + const uploadMutation = useMutation({ + mutationFn: ({ file, name }: { file: File; name?: string }) => + trackStemService.uploadStem(track.id, file, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['track-stems', track.id] }); + }, + }); + + const handleUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.wav,.aiff,.aif,.flac'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + uploadMutation.mutate({ file, name: file.name.replace(/\.[^.]+$/, '') }); + }; + input.click(); + }; + + const handleDownload = async (stem: TrackStem) => { + await trackStemService.downloadStem(track.id, stem.name, stem.format); + }; + + if (isLoading) { + return ( + +
+ + Loading stems... +
+
+ ); + } + + if (error) { + return ( + +

Failed to load stems.

+
+ ); + } + + const hasStems = stems && stems.length > 0; + const showUpload = isCreator; + + return ( + +
+
+
+ +
+

Stems

+
+ {showUpload && ( + + )} +
+ + {!hasStems && !showUpload ? ( +

No stems available.

+ ) : !hasStems ? ( +

Upload stems (WAV, AIFF, FLAC) to share with collaborators.

+ ) : ( +
    + {stems!.map((stem) => ( +
  • + {stem.name} + {stem.format} · {formatBytes(stem.size_bytes)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageCoverAndActions.tsx b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageCoverAndActions.tsx index ac2c27a1a..075f0e8ac 100644 --- a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageCoverAndActions.tsx +++ b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageCoverAndActions.tsx @@ -1,9 +1,17 @@ -import { Music, Play, Pause, ListPlus, Share2 } from 'lucide-react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Music, Play, Pause, ListPlus, Share2, Users, Loader2, Copy, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; +import { Dialog } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { LikeButton } from '../../components/LikeButton'; import { RepostButton } from '../../components/RepostButton'; import { useUser } from '@/features/auth/hooks/useUser'; +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; +import { useToast } from '@/hooks/useToast'; +import { coListeningService, type CoListeningSession } from '@/services/coListeningService'; import type { Track } from '../../types/track'; interface TrackDetailPageCoverAndActionsProps { @@ -23,8 +31,48 @@ export function TrackDetailPageCoverAndActions({ onAddToQueue, onShare, }: TrackDetailPageCoverAndActionsProps) { + const navigate = useNavigate(); const { data: user } = useUser(); + const toast = useToast(); + const { copied: isCopied, copy } = useCopyToClipboard(); const isCreator = track.creator_id === user?.id; + const [listenTogetherLoading, setListenTogetherLoading] = useState(false); + const [listenTogetherModalOpen, setListenTogetherModalOpen] = useState(false); + const [listenTogetherSession, setListenTogetherSession] = useState(null); + + const handleListenTogether = async () => { + setListenTogetherLoading(true); + try { + const session = await coListeningService.createSession(track.id); + setListenTogetherSession(session); + setListenTogetherModalOpen(true); + } catch { + toast.error('Could not create listening session'); + } finally { + setListenTogetherLoading(false); + } + }; + + const shareUrl = listenTogetherSession + ? coListeningService.getShareUrl(listenTogetherSession.id, listenTogetherSession.track_id) + : ''; + + const handleCopyListenTogetherLink = async () => { + if (!shareUrl) return; + const ok = await copy(shareUrl); + if (ok) { + toast.success('Link copied to clipboard'); + } else { + toast.error('Failed to copy link'); + } + }; + + const handleStartListening = () => { + if (!listenTogetherSession) return; + setListenTogetherModalOpen(false); + setListenTogetherSession(null); + navigate(`/listen-together/${listenTogetherSession.id}?track=${listenTogetherSession.track_id}`); + }; const coverUrl = track.cover_art_path; const playCount = track.play_count ?? 0; @@ -115,6 +163,20 @@ export function TrackDetailPageCoverAndActions({ > + @@ -139,6 +201,67 @@ export function TrackDetailPageCoverAndActions({ Likes + + {/* Listen Together modal (v0.10.7 F481) */} + { + setListenTogetherModalOpen(false); + setListenTogetherSession(null); + }} + title="Listen together" + variant="default" + size="md" + footer={ +
+ + +
+ } + showCancel={false} + > +
+

+ Share this link with friends to listen together. Playback will be synchronized. +

+ {shareUrl && ( +
+ +
+ + +
+
+ )} +
+
); } diff --git a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx index 302328673..3aefe476f 100644 --- a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx +++ b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPageTabs.tsx @@ -1,4 +1,4 @@ -import { BarChart3, MessageCircle, Clock3, FileText } from 'lucide-react'; +import { BarChart3, MessageCircle, Clock3, FileText, Disc3 } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { usePlayer } from '@/features/player/hooks/usePlayer'; @@ -7,6 +7,8 @@ import { ShareDialog } from '../../components/ShareDialog'; import { TrackHistory } from '../../components/TrackHistory'; import { TrackStatsDisplay } from '../../components/TrackStatsDisplay'; import { TrackLyricsSection } from '../../components/TrackLyricsSection'; +import { TrackStemsSection } from '../../components/TrackStemsSection'; +import { useUser } from '@/features/auth/hooks/useUser'; import type { Track } from '../../types/track'; const tabTriggerClass = @@ -27,6 +29,8 @@ export function TrackDetailPageTabs({ const { currentTime, currentTrack } = usePlayer(); const currentTimestamp = currentTrack?.id === track.id ? currentTime : undefined; + const { data: user } = useUser(); + const isCreator = track.creator_id === user?.id; return ( <> @@ -51,6 +55,10 @@ export function TrackDetailPageTabs({ Lyrics + + + Stems + @@ -84,6 +92,9 @@ export function TrackDetailPageTabs({ + + + { + const res = await apiClient.get<{ stems: TrackStem[] }>(`/tracks/${trackId}/stems`); + return res.data.stems ?? []; + }, + + async uploadStem(trackId: string, file: File, name?: string): Promise { + const formData = new FormData(); + formData.append('file', file); + if (name) formData.append('name', name); + const res = await apiClient.post<{ stem: TrackStem }>(`/tracks/${trackId}/stems`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: API_TIMEOUTS.UPLOAD, + }); + return res.data.stem; + }, + + async downloadStem(trackId: string, stemName: string, stemFormat?: string): Promise { + const url = `/tracks/${trackId}/stems/${encodeURIComponent(stemName)}/download`; + const res = await apiClient.get(url, { responseType: 'blob' }); + const blob = res.data as Blob; + const ext = stemFormat ? `.${stemFormat.toLowerCase()}` : ''; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${stemName}${ext}`; + a.click(); + URL.revokeObjectURL(a.href); + }, +}; diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index d2de2c6ad..ca29056a9 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -656,6 +656,21 @@ export const handlersMisc = [ }, { status: 201 }); }), + + http.post('*/api/v1/conversations', async ({ request }) => { + const body = (await request.json()) as { name: string; type?: string }; + const id = 'conv-' + Date.now(); + const room = { + id, + name: body.name || 'New Room', + type: body.type || 'public', + participants: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + return HttpResponse.json({ success: true, data: room }, { status: 201 }); + }), + http.get('*/api/v1/conversations', () => { return HttpResponse.json({ success: true, @@ -896,6 +911,71 @@ export const handlersMisc = [ return HttpResponse.json({ success: true, data: { stream } }, { status: 201 }); }), + // Co-listening (v0.10.7 F481) + http.post('*/api/v1/co-listening/sessions', async ({ request }) => { + const body = (await request.json()) as { track_id: string }; + const session = { + id: `session-${Date.now()}`, + host_id: 'user-1', + track_id: body.track_id ?? 'track-1', + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), + }; + return HttpResponse.json({ success: true, data: { session } }, { status: 201 }); + }), + http.get('*/api/v1/co-listening/sessions/:id', ({ params }) => { + return HttpResponse.json({ + success: true, + data: { + session: { + id: params.id, + host_id: 'user-1', + track_id: 'track-1', + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), + }, + }, + }); + }), + http.delete('*/api/v1/co-listening/sessions/:id', () => { + return HttpResponse.json({ success: true, data: { message: 'session ended' } }); + }), + + // Track stems (v0.10.7 F482) + http.get('*/api/v1/tracks/:id/stems', ({ params }) => { + return HttpResponse.json({ + success: true, + data: { + stems: [ + { id: 'stem-1', track_id: params.id, name: 'kick', format: 'WAV', size_bytes: 2457600, created_at: new Date().toISOString() }, + { id: 'stem-2', track_id: params.id, name: 'snare', format: 'WAV', size_bytes: 1890000, created_at: new Date().toISOString() }, + ], + }, + }); + }), + http.post('*/api/v1/tracks/:id/stems', async ({ params, request }) => { + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const name = (formData.get('name') as string) || (file?.name?.replace(/\.[^.]+$/, '') || 'stem'); + if (!file) { + return HttpResponse.json({ success: false, error: 'No file provided' }, { status: 400 }); + } + const stem = { + id: `stem-${Date.now()}`, + track_id: params.id, + name, + format: 'WAV', + size_bytes: file.size, + created_at: new Date().toISOString(), + }; + return HttpResponse.json({ success: true, data: { stem } }, { status: 201 }); + }), + http.get('*/api/v1/tracks/:id/stems/:name/download', () => { + return new HttpResponse(new Blob(['mock stem audio']), { + headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="stem.wav"' }, + }); + }), + // Queue API (v0.102) — in-memory mock for Storybook/tests ...createQueueHandlers(), diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index db23832d6..6316185cb 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -42,6 +42,7 @@ import { LazyGear, LazyLive, LazyGoLive, + LazyListenTogether, LazyCloud, } from '@/components/ui/LazyComponent'; import { PublicRoute } from './PublicRoute'; @@ -128,6 +129,8 @@ export function getProtectedRoutes(): RouteEntry[] { // Live: connected to backend live streams API { path: '/live/go-live', element: wrapProtected() }, { path: '/live', element: wrapProtected() }, + // Co-listening (v0.10.7 F481) + { path: '/listen-together/:sessionId', element: wrapProtected() }, // Cloud: connected to backend cloud storage API { path: '/cloud', element: wrapProtected() }, ]; diff --git a/apps/web/src/services/coListeningService.ts b/apps/web/src/services/coListeningService.ts new file mode 100644 index 000000000..d62ff77cc --- /dev/null +++ b/apps/web/src/services/coListeningService.ts @@ -0,0 +1,42 @@ +/** + * Co-listening service (v0.10.7 F481) + * Create and manage synchronized co-listening sessions + */ + +import { apiClient } from '@/services/api/client'; + +export interface CoListeningSession { + id: string; + host_id: string; + track_id: string; + created_at: string; + expires_at: string; +} + +export const coListeningService = { + async createSession(trackId: string): Promise { + const res = await apiClient.post<{ session: CoListeningSession }>( + '/co-listening/sessions', + { track_id: trackId } + ); + return res.data.session; + }, + + async getSession(sessionId: string): Promise { + const res = await apiClient.get<{ session: CoListeningSession }>( + `/co-listening/sessions/${sessionId}` + ); + return res.data.session; + }, + + async endSession(sessionId: string): Promise { + await apiClient.delete(`/co-listening/sessions/${sessionId}`); + }, + + /** Build shareable URL for a co-listening session */ + getShareUrl(sessionId: string, trackId: string): string { + if (typeof window === 'undefined') return ''; + const base = `${window.location.origin}${import.meta.env.BASE_URL || '/'}`.replace(/\/$/, ''); + return `${base}/listen-together/${sessionId}?track=${trackId}`; + }, +}; diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 3ceadd069..086fb0f5f 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -329,6 +329,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error { // Live Streams Routes r.setupLiveRoutes(v1) + // Co-listening (v0.10.7 F481) + r.setupCoListeningRoutes(v1) + // Cloud Storage Routes (v0.501 C1) r.setupCloudRoutes(v1) diff --git a/veza-backend-api/internal/api/routes_co_listening.go b/veza-backend-api/internal/api/routes_co_listening.go new file mode 100644 index 000000000..351ce8ace --- /dev/null +++ b/veza-backend-api/internal/api/routes_co_listening.go @@ -0,0 +1,40 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/services" + colistening "veza-backend-api/internal/websocket/colistening" +) + +// setupCoListeningRoutes configures co-listening REST and WebSocket (v0.10.7 F481) +func (r *APIRouter) setupCoListeningRoutes(router *gin.RouterGroup) { + colisteningHub := colistening.NewHub(r.logger) + coListeningSvc := services.NewCoListeningService(r.db.GormDB, r.logger) + restHandler := handlers.NewCoListeningHandler(coListeningSvc) + + var jwtValidator handlers.JWTValidator + if r.config != nil && r.config.JWTService != nil { + jwtValidator = r.config.JWTService + } + + wsHandler := handlers.NewCoListeningWebSocketHandler(colisteningHub, coListeningSvc, jwtValidator, r.logger) + + // Protected REST routes + if r.config != nil && r.config.AuthMiddleware != nil { + coList := router.Group("/co-listening") + coList.Use(r.config.AuthMiddleware.RequireAuth()) + r.applyCSRFProtection(coList) + { + coList.POST("/sessions", restHandler.CreateSession) + coList.GET("/sessions/:id", restHandler.GetSession) + coList.DELETE("/sessions/:id", restHandler.EndSession) + } + } + + // WebSocket (validates token in query, no auth middleware) + router.GET("/co-listening/ws", wsHandler.HandleWebSocket) + + r.logger.Info("Co-listening routes registered at /api/v1/co-listening") +} diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index 370e762f0..a891b89f0 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -164,6 +164,17 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) { protected.POST("/:id/play", trackHandler.RecordPlay) + // v0.10.7 F482: Stem sharing + stemUploadDir := uploadDir + if stemUploadDir == "" { + stemUploadDir = "uploads/tracks" + } + stemService := services.NewTrackStemService(r.db.GormDB, stemUploadDir, r.logger) + stemHandler := handlers.NewTrackStemHandler(stemService, trackService, r.logger) + protected.GET("/:id/stems/:name/download", stemHandler.DownloadStem) + protected.POST("/:id/stems", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), stemHandler.UploadStem) + protected.GET("/:id/stems", stemHandler.ListStems) + hlsOutputDir := r.config.UploadDir if hlsOutputDir == "" { hlsOutputDir = "uploads/tracks" diff --git a/veza-backend-api/internal/handlers/co_listening_handler.go b/veza-backend-api/internal/handlers/co_listening_handler.go new file mode 100644 index 000000000..59abae546 --- /dev/null +++ b/veza-backend-api/internal/handlers/co_listening_handler.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/services" +) + +// CoListeningHandler handles REST endpoints for co-listening (v0.10.7 F481) +type CoListeningHandler struct { + svc *services.CoListeningService +} + +// NewCoListeningHandler creates a new handler +func NewCoListeningHandler(svc *services.CoListeningService) *CoListeningHandler { + return &CoListeningHandler{svc: svc} +} + +// CreateSession creates a new co-listening session +// POST /co-listening/sessions +func (h *CoListeningHandler) CreateSession(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + var req struct { + TrackID string `json:"track_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + RespondWithAppError(c, apperrors.NewValidationError("track_id is required")) + return + } + + trackID, err := uuid.Parse(req.TrackID) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid track_id")) + return + } + + session, err := h.svc.CreateSession(c.Request.Context(), userID, trackID) + if err != nil { + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to create session", err)) + return + } + + RespondSuccess(c, http.StatusCreated, gin.H{ + "session": gin.H{ + "id": session.ID, + "host_id": session.HostID, + "track_id": session.TrackID, + "created_at": session.CreatedAt, + "expires_at": session.ExpiresAt, + }, + }) +} + +// GetSession returns a session by ID +// GET /co-listening/sessions/:id +func (h *CoListeningHandler) GetSession(c *gin.Context) { + sessionIDStr := c.Param("id") + if sessionIDStr == "" { + RespondWithAppError(c, apperrors.NewValidationError("session id is required")) + return + } + + sessionID, err := uuid.Parse(sessionIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid session id")) + return + } + + session, err := h.svc.GetSession(c.Request.Context(), sessionID) + if err != nil { + if err == services.ErrCoListeningSessionNotFound || err == services.ErrCoListeningSessionExpired { + RespondWithAppError(c, apperrors.NewNotFoundError("session")) + return + } + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get session", err)) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{ + "session": gin.H{ + "id": session.ID, + "host_id": session.HostID, + "track_id": session.TrackID, + "created_at": session.CreatedAt, + "expires_at": session.ExpiresAt, + }, + }) +} + +// EndSession ends a session (host only) +// DELETE /co-listening/sessions/:id +func (h *CoListeningHandler) EndSession(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + sessionIDStr := c.Param("id") + if sessionIDStr == "" { + RespondWithAppError(c, apperrors.NewValidationError("session id is required")) + return + } + + sessionID, err := uuid.Parse(sessionIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid session id")) + return + } + + if err := h.svc.EndSession(c.Request.Context(), sessionID, userID); err != nil { + if err == services.ErrCoListeningSessionNotFound { + RespondWithAppError(c, apperrors.NewNotFoundError("session")) + return + } + if err == services.ErrCoListeningSessionForbidden { + RespondWithAppError(c, apperrors.NewForbiddenError("only the host can end the session")) + return + } + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to end session", err)) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{"message": "session ended"}) +} diff --git a/veza-backend-api/internal/handlers/co_listening_websocket_handler.go b/veza-backend-api/internal/handlers/co_listening_websocket_handler.go new file mode 100644 index 000000000..1c0374d12 --- /dev/null +++ b/veza-backend-api/internal/handlers/co_listening_websocket_handler.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "context" + "encoding/json" + "time" + + "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" + colistening "veza-backend-api/internal/websocket/colistening" +) + +// CoListeningWebSocketHandler handles WebSocket connections for co-listening (v0.10.7 F481) +type CoListeningWebSocketHandler struct { + hub *colistening.Hub + svc *services.CoListeningService + jwtValidator JWTValidator + logger *zap.Logger +} + +// JWTValidator validates access tokens and returns user ID +type JWTValidator interface { + ValidateToken(tokenString string) (*models.CustomClaims, error) +} + +// NewCoListeningWebSocketHandler creates a new handler +func NewCoListeningWebSocketHandler( + hub *colistening.Hub, + svc *services.CoListeningService, + jwtValidator JWTValidator, + logger *zap.Logger, +) *CoListeningWebSocketHandler { + if logger == nil { + logger = zap.NewNop() + } + return &CoListeningWebSocketHandler{ + hub: hub, + svc: svc, + jwtValidator: jwtValidator, + logger: logger, + } +} + +// HandleWebSocket handles the co-listening WebSocket upgrade +func (h *CoListeningWebSocketHandler) HandleWebSocket(c *gin.Context) { + tokenStr := c.Query("token") + if tokenStr == "" { + RespondWithAppError(c, apperrors.NewUnauthorizedError("missing token")) + return + } + + sessionIDStr := c.Query("session_id") + if sessionIDStr == "" { + RespondWithAppError(c, apperrors.NewValidationError("missing session_id")) + return + } + + trackIDStr := c.Query("track_id") + if trackIDStr == "" { + RespondWithAppError(c, apperrors.NewValidationError("missing track_id")) + return + } + + sessionID, err := uuid.Parse(sessionIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid session_id")) + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid track_id")) + return + } + + claims, err := h.jwtValidator.ValidateToken(tokenStr) + if err != nil { + h.logger.Warn("Invalid token for co-listening", zap.Error(err)) + RespondWithAppError(c, apperrors.NewUnauthorizedError("invalid or expired token")) + return + } + + session, err := h.svc.GetSession(c.Request.Context(), sessionID) + if err != nil { + if err == services.ErrCoListeningSessionNotFound || err == services.ErrCoListeningSessionExpired { + RespondWithAppError(c, apperrors.NewNotFoundError("session not found or expired")) + return + } + RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get session", err)) + return + } + + if session.TrackID != trackID { + RespondWithAppError(c, apperrors.NewValidationError("track_id does not match session")) + return + } + + conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + h.logger.Error("Failed to accept WebSocket", zap.Error(err)) + return + } + + colisteningConn := &colistening.Conn{ + SessionID: sessionID, + TrackID: trackID, + UserID: claims.UserID, + IsHost: session.HostID == claims.UserID, + Send: make(chan []byte, 256), + } + + h.hub.Register(colisteningConn) + defer h.hub.Unregister(colisteningConn) + + ctx := c.Request.Context() + go h.writePump(ctx, conn, colisteningConn) + h.readPump(ctx, conn, colisteningConn) +} + +type syncClientStateMsg struct { + Type string `json:"type"` + PositionMs int64 `json:"position_ms"` + ClientTimestampMs int64 `json:"client_timestamp_ms"` +} + +func (h *CoListeningWebSocketHandler) readPump(ctx context.Context, conn *websocket.Conn, colisteningConn *colistening.Conn) { + defer func() { + close(colisteningConn.Send) + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + _, data, err := conn.Read(ctx) + if err != nil { + return + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + + typeVal, _ := raw["type"] + var typeStr string + _ = json.Unmarshal(typeVal, &typeStr) + + switch typeStr { + case "SyncClientState": + var msg syncClientStateMsg + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + if colisteningConn.IsHost { + h.hub.UpdateHostState(colisteningConn.SessionID, msg.PositionMs, msg.ClientTimestampMs) + } else { + h.hub.UpdateListenerState(colisteningConn, msg.PositionMs, msg.ClientTimestampMs) + } + case "SyncPong": + // No-op for now, could track latency + } + } +} + +func (h *CoListeningWebSocketHandler) writePump(ctx context.Context, conn *websocket.Conn, colisteningConn *colistening.Conn) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-colisteningConn.Send: + if !ok { + conn.Close(websocket.StatusGoingAway, "channel closed") + return + } + if err := conn.Write(ctx, websocket.MessageText, msg); err != nil { + return + } + case <-ticker.C: + // Send SyncPing for keepalive + pingID := uuid.New().String() + pingMsg := map[string]interface{}{ + "type": "SyncPing", + "ping_id": pingID, + "server_timestamp_ms": time.Now().UnixMilli(), + } + data, _ := json.Marshal(pingMsg) + if err := conn.Write(ctx, websocket.MessageText, data); err != nil { + return + } + } + } +} diff --git a/veza-backend-api/internal/handlers/track_stem_handler.go b/veza-backend-api/internal/handlers/track_stem_handler.go new file mode 100644 index 000000000..73f7e8595 --- /dev/null +++ b/veza-backend-api/internal/handlers/track_stem_handler.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "context" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" +) + +// TrackLoader loads track for ownership check (v0.10.7 F482) +type TrackLoader interface { + GetTrackByID(ctx context.Context, trackID uuid.UUID) (*models.Track, error) +} + +// TrackStemHandler handles stem upload/download for tracks (v0.10.7 F482) +type TrackStemHandler struct { + stemService *services.TrackStemService + trackLoader TrackLoader + logger *zap.Logger +} + +// NewTrackStemHandler creates a new TrackStemHandler +func NewTrackStemHandler(stemService *services.TrackStemService, trackLoader TrackLoader, logger *zap.Logger) *TrackStemHandler { + return &TrackStemHandler{stemService: stemService, trackLoader: trackLoader, logger: logger} +} + +func (h *TrackStemHandler) getUserID(c *gin.Context) (uuid.UUID, bool) { + uid, exists := c.Get("user_id") + if !exists { + RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) + return uuid.Nil, false + } + userID, ok := uid.(uuid.UUID) + if !ok || userID == uuid.Nil { + RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) + return uuid.Nil, false + } + return userID, true +} + +// UploadStem POST /tracks/:id/stems +func (h *TrackStemHandler) UploadStem(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) + return + } + + form, err := c.MultipartForm() + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("multipart form required")) + return + } + files := form.File["file"] + if len(files) == 0 { + RespondWithAppError(c, apperrors.NewValidationError("file is required")) + return + } + name := c.PostForm("name") + + stem, err := h.stemService.UploadStem(c.Request.Context(), trackID, userID, files[0], name) + if err != nil { + if err == services.ErrTrackNotFound { + RespondWithAppError(c, apperrors.NewNotFoundError("track")) + return + } + if err == services.ErrTrackStemForbidden { + RespondWithAppError(c, apperrors.NewForbiddenError("forbidden")) + return + } + if err == services.ErrTrackStemInvalidExt { + RespondWithAppError(c, apperrors.NewValidationError("invalid format: only wav, aiff, flac allowed")) + return + } + h.logger.Error("Upload stem failed", zap.Error(err)) + RespondWithAppError(c, apperrors.NewInternalError("failed to upload stem")) + return + } + + RespondSuccess(c, http.StatusCreated, gin.H{ + "stem": gin.H{ + "id": stem.ID, + "track_id": stem.TrackID, + "name": stem.Name, + "format": stem.Format, + "size_bytes": stem.SizeBytes, + "created_at": stem.CreatedAt, + }, + }) +} + +// ListStems GET /tracks/:id/stems +func (h *TrackStemHandler) ListStems(c *gin.Context) { + trackIDStr := c.Param("id") + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) + return + } + + stems, err := h.stemService.ListStems(c.Request.Context(), trackID) + if err != nil { + h.logger.Error("List stems failed", zap.Error(err)) + RespondWithAppError(c, apperrors.NewInternalError("failed to list stems")) + return + } + + list := make([]gin.H, len(stems)) + for i, s := range stems { + list[i] = gin.H{ + "id": s.ID, + "track_id": s.TrackID, + "name": s.Name, + "format": s.Format, + "size_bytes": s.SizeBytes, + "created_at": s.CreatedAt, + } + } + RespondSuccess(c, http.StatusOK, gin.H{"stems": list}) +} + +// DownloadStem GET /tracks/:id/stems/:name/download +func (h *TrackStemHandler) DownloadStem(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid track id")) + return + } + + name := c.Param("name") + if name == "" { + RespondWithAppError(c, apperrors.NewValidationError("stem name is required")) + return + } + + stem, err := h.stemService.GetStem(c.Request.Context(), trackID, name) + if err != nil { + if err == services.ErrTrackStemNotFound { + RespondWithAppError(c, apperrors.NewNotFoundError("stem")) + return + } + RespondWithAppError(c, apperrors.NewInternalError("failed to get stem")) + return + } + + track, err := h.trackLoader.GetTrackByID(c.Request.Context(), trackID) + if err != nil || track == nil { + RespondWithAppError(c, apperrors.NewNotFoundError("track")) + return + } + + if !h.stemService.CanAccessStem(c.Request.Context(), track, userID) { + RespondWithAppError(c, apperrors.NewForbiddenError("forbidden")) + return + } + + if _, err := os.Stat(stem.FilePath); os.IsNotExist(err) { + RespondWithAppError(c, apperrors.NewNotFoundError("stem file not found")) + return + } + + ct := stemContentType(stem.Format) + c.Header("Content-Type", ct) + c.Header("Content-Disposition", `attachment; filename="`+stem.Name+"."+strings.ToLower(stem.Format)+`"`) + c.File(stem.FilePath) +} + +func stemContentType(format string) string { + switch strings.ToUpper(format) { + case "WAV": + return "audio/wav" + case "AIFF": + return "audio/aiff" + case "FLAC": + return "audio/flac" + default: + return "application/octet-stream" + } +} diff --git a/veza-backend-api/internal/models/co_listening_session.go b/veza-backend-api/internal/models/co_listening_session.go new file mode 100644 index 000000000..7dbedbdfd --- /dev/null +++ b/veza-backend-api/internal/models/co_listening_session.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CoListeningSession represents a synchronized co-listening session (v0.10.7 F481) +type CoListeningSession struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + HostID uuid.UUID `gorm:"type:uuid;not null;index" json:"host_id"` + TrackID uuid.UUID `gorm:"type:uuid;not null;index" json:"track_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + + Host User `gorm:"foreignKey:HostID" json:"-"` + Track Track `gorm:"foreignKey:TrackID" json:"-"` +} + +// TableName defines the table name for GORM +func (CoListeningSession) TableName() string { + return "co_listening_sessions" +} + +// BeforeCreate GORM hook to generate UUID if not set +func (s *CoListeningSession) BeforeCreate(tx *gorm.DB) error { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil +} diff --git a/veza-backend-api/internal/models/track_stem.go b/veza-backend-api/internal/models/track_stem.go new file mode 100644 index 000000000..73aa69e1b --- /dev/null +++ b/veza-backend-api/internal/models/track_stem.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// TrackStem represents a stem file (kick, snare, bass, etc.) for a track +// v0.10.7 F482: Stem sharing +type TrackStem struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id" db:"id"` + TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id" db:"track_id"` + Name string `gorm:"size:100;not null" json:"name" db:"name"` + FilePath string `gorm:"size:500;not null" json:"file_path" db:"file_path"` + Format string `gorm:"size:10;not null" json:"format" db:"format"` + SizeBytes int64 `gorm:"not null;default:0" json:"size_bytes" db:"size_bytes"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"` + DeletedAt gorm.DeletedAt `json:"-" db:"deleted_at"` + + Track Track `gorm:"foreignKey:TrackID;constraint:OnDelete:CASCADE" json:"-"` +} + +// TableName defines the table name for GORM +func (TrackStem) TableName() string { + return "track_stems" +} + +// BeforeCreate generates UUID if not set +func (m *TrackStem) BeforeCreate(tx *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + return nil +} diff --git a/veza-backend-api/internal/repositories/room_repository.go b/veza-backend-api/internal/repositories/room_repository.go index b25a5afa6..0924b71fe 100644 --- a/veza-backend-api/internal/repositories/room_repository.go +++ b/veza-backend-api/internal/repositories/room_repository.go @@ -96,6 +96,15 @@ func (r *RoomRepository) GetMembersByRoomID(ctx context.Context, roomID uuid.UUI return members, nil } +// CountMembers returns the number of members in a room +func (r *RoomRepository) CountMembers(ctx context.Context, roomID uuid.UUID) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&models.RoomMember{}). + Where("room_id = ?", roomID). + Count(&count).Error + return count, err +} + // GetMemberRole returns the role of a user in a room, or empty if not a member func (r *RoomRepository) GetMemberRole(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) (string, error) { var m models.RoomMember diff --git a/veza-backend-api/internal/services/co_listening_service.go b/veza-backend-api/internal/services/co_listening_service.go new file mode 100644 index 000000000..8950a0df8 --- /dev/null +++ b/veza-backend-api/internal/services/co_listening_service.go @@ -0,0 +1,84 @@ +package services + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +var ( + ErrCoListeningSessionNotFound = errors.New("co-listening session not found") + ErrCoListeningSessionExpired = errors.New("co-listening session expired") + ErrCoListeningSessionForbidden = errors.New("forbidden: only host can end session") +) + +// CoListeningService manages co-listening sessions (v0.10.7 F481) +type CoListeningService struct { + db *gorm.DB + logger *zap.Logger +} + +// NewCoListeningService creates a new CoListeningService +func NewCoListeningService(db *gorm.DB, logger *zap.Logger) *CoListeningService { + if logger == nil { + logger = zap.NewNop() + } + return &CoListeningService{db: db, logger: logger} +} + +// CreateSession creates a new co-listening session +func (s *CoListeningService) CreateSession(ctx context.Context, hostID, trackID uuid.UUID) (*models.CoListeningSession, error) { + expiresAt := time.Now().Add(4 * time.Hour) + session := &models.CoListeningSession{ + HostID: hostID, + TrackID: trackID, + ExpiresAt: expiresAt, + } + + if err := s.db.WithContext(ctx).Create(session).Error; err != nil { + s.logger.Error("Failed to create co-listening session", zap.Error(err)) + return nil, err + } + + return session, nil +} + +// GetSession returns a session by ID if it exists and is not expired +func (s *CoListeningService) GetSession(ctx context.Context, sessionID uuid.UUID) (*models.CoListeningSession, error) { + var session models.CoListeningSession + if err := s.db.WithContext(ctx).Where("id = ?", sessionID).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrCoListeningSessionNotFound + } + return nil, err + } + + if session.ExpiresAt.Before(time.Now()) { + return nil, ErrCoListeningSessionExpired + } + + return &session, nil +} + +// EndSession deletes a session (host only) +func (s *CoListeningService) EndSession(ctx context.Context, sessionID, userID uuid.UUID) error { + var session models.CoListeningSession + if err := s.db.WithContext(ctx).Where("id = ?", sessionID).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrCoListeningSessionNotFound + } + return err + } + + if session.HostID != userID { + return ErrCoListeningSessionForbidden + } + + return s.db.WithContext(ctx).Delete(&session).Error +} diff --git a/veza-backend-api/internal/services/room_service.go b/veza-backend-api/internal/services/room_service.go index cd5b2d319..2660ff80a 100644 --- a/veza-backend-api/internal/services/room_service.go +++ b/veza-backend-api/internal/services/room_service.go @@ -35,7 +35,7 @@ func NewRoomService(roomRepo *repositories.RoomRepository, messageRepo *reposito type CreateRoomRequest struct { Name string `json:"name" binding:"required,min=1,max=255"` Description *string `json:"description,omitempty"` - Type string `json:"type" binding:"required,oneof=public private direct"` + Type string `json:"type" binding:"required,oneof=public private direct collaborative"` IsPrivate bool `json:"is_private"` } @@ -60,12 +60,16 @@ func (s *RoomService) CreateRoom(ctx context.Context, userID uuid.UUID, req Crea } // Créer la room + isPrivate := req.IsPrivate + if req.Type == "collaborative" { + isPrivate = true // v0.10.7 F483: collaborative rooms are invite-only + } room := &models.Room{ Name: req.Name, Description: "", Type: req.Type, - IsPrivate: req.IsPrivate, - CreatedBy: userID, // Corrected: userID is uuid.UUID, models.Room.CreatedBy is uuid.UUID + IsPrivate: isPrivate, + CreatedBy: userID, } if req.Description != nil { @@ -195,8 +199,20 @@ func (s *RoomService) GetRoom(ctx context.Context, roomID uuid.UUID) (*RoomRespo }, nil } +// MaxCollaborativeMembers v0.10.7 F483 +const MaxCollaborativeMembers = 10 + // AddMember ajoute un membre à une room func (s *RoomService) AddMember(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error { + // v0.10.7 F483: collaborative rooms max 10 participants + room, err := s.roomRepo.GetByID(ctx, roomID) + if err == nil && room.Type == "collaborative" { + count, _ := s.roomRepo.CountMembers(ctx, roomID) + if count >= MaxCollaborativeMembers { + return errors.New("collaborative room has reached maximum of 10 participants") + } + } + member := &models.RoomMember{ RoomID: roomID, UserID: userID, diff --git a/veza-backend-api/internal/services/track_stem_service.go b/veza-backend-api/internal/services/track_stem_service.go new file mode 100644 index 000000000..79e3d067c --- /dev/null +++ b/veza-backend-api/internal/services/track_stem_service.go @@ -0,0 +1,147 @@ +package services + +import ( + "context" + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +var ( + ErrTrackStemNotFound = errors.New("track stem not found") + ErrTrackStemForbidden = errors.New("forbidden: only track owner can manage stems") + ErrTrackStemInvalidExt = errors.New("invalid stem format: only wav, aiff, flac allowed") +) + +// Allowed stem formats (v0.10.7 F482) +var stemFormats = map[string]string{ + ".wav": "WAV", + ".aiff": "AIFF", + ".aif": "AIFF", + ".flac": "FLAC", +} + +// TrackStemService manages stem upload/download for tracks (v0.10.7 F482) +type TrackStemService struct { + db *gorm.DB + uploadDir string + logger *zap.Logger +} + +// NewTrackStemService creates a new TrackStemService +func NewTrackStemService(db *gorm.DB, uploadDir string, logger *zap.Logger) *TrackStemService { + if uploadDir == "" { + uploadDir = "uploads/tracks" + } + stemsDir := filepath.Join(uploadDir, "stems") + return &TrackStemService{db: db, uploadDir: stemsDir, logger: logger} +} + +// stemNameRegex allows alphanumeric, underscore, hyphen +var stemNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// UploadStem saves a stem file and creates a record +func (s *TrackStemService) UploadStem(ctx context.Context, trackID, userID uuid.UUID, fileHeader *multipart.FileHeader, name string) (*models.TrackStem, error) { + // Get track and verify ownership + var track models.Track + if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrTrackNotFound // from services/errors.go + } + return nil, err + } + if track.UserID != userID { + return nil, ErrTrackStemForbidden + } + + // Validate stem name + if name == "" { + name = strings.TrimSuffix(fileHeader.Filename, filepath.Ext(fileHeader.Filename)) + } + if !stemNameRegex.MatchString(name) { + return nil, fmt.Errorf("%w: name must be alphanumeric, underscore or hyphen", ErrTrackStemInvalidExt) + } + + ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) + format, ok := stemFormats[ext] + if !ok { + return nil, ErrTrackStemInvalidExt + } + + // Create stems dir: uploads/tracks/stems/{track_id}/ + trackStemsDir := filepath.Join(s.uploadDir, trackID.String()) + if err := os.MkdirAll(trackStemsDir, 0755); err != nil { + s.logger.Error("Failed to create stems dir", zap.Error(err)) + return nil, err + } + + filename := fmt.Sprintf("%s_%s%s", name, uuid.New().String()[:8], ext) + filePath := filepath.Join(trackStemsDir, filename) + + src, err := fileHeader.Open() + if err != nil { + return nil, err + } + defer src.Close() + + dst, err := os.Create(filePath) + if err != nil { + return nil, err + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + os.Remove(filePath) + return nil, err + } + + stem := &models.TrackStem{ + TrackID: trackID, + Name: name, + FilePath: filePath, + Format: format, + SizeBytes: fileHeader.Size, + } + if err := s.db.WithContext(ctx).Create(stem).Error; err != nil { + os.Remove(filePath) + return nil, err + } + return stem, nil +} + +// ListStems returns stems for a track +func (s *TrackStemService) ListStems(ctx context.Context, trackID uuid.UUID) ([]*models.TrackStem, error) { + var stems []*models.TrackStem + if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Order("name").Find(&stems).Error; err != nil { + return nil, err + } + return stems, nil +} + +// GetStem returns a stem by track_id and name for download +func (s *TrackStemService) GetStem(ctx context.Context, trackID uuid.UUID, name string) (*models.TrackStem, error) { + var stem models.TrackStem + if err := s.db.WithContext(ctx).Where("track_id = ? AND name = ?", trackID, name).First(&stem).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrTrackStemNotFound + } + return nil, err + } + return &stem, nil +} + +// CanAccessStem checks if user can download stem (owner or track is public - v0.10.7 F482) +func (s *TrackStemService) CanAccessStem(ctx context.Context, track *models.Track, userID uuid.UUID) bool { + return track.UserID == userID || track.IsPublic +} diff --git a/veza-backend-api/internal/websocket/colistening/hub.go b/veza-backend-api/internal/websocket/colistening/hub.go new file mode 100644 index 000000000..f629d9c9f --- /dev/null +++ b/veza-backend-api/internal/websocket/colistening/hub.go @@ -0,0 +1,198 @@ +package colistening + +import ( + "encoding/json" + "sync" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Conn represents a WebSocket connection in a co-listening session +type Conn struct { + SessionID uuid.UUID + TrackID uuid.UUID + UserID uuid.UUID + IsHost bool + Send chan []byte + LastState *HostState +} + +// HostState is the authoritative playback state from the host +type HostState struct { + PositionMs int64 `json:"position_ms"` + ClientTimestampMs int64 `json:"client_timestamp_ms"` + ServerTimestampMs int64 `json:"server_timestamp_ms"` +} + +// Hub manages co-listening sessions and their connections +type Hub struct { + sessions map[uuid.UUID]map[*Conn]bool + hostState map[uuid.UUID]*HostState + mu sync.RWMutex + logger *zap.Logger +} + +// NewHub creates a new co-listening hub +func NewHub(logger *zap.Logger) *Hub { + if logger == nil { + logger = zap.NewNop() + } + return &Hub{ + sessions: make(map[uuid.UUID]map[*Conn]bool), + hostState: make(map[uuid.UUID]*HostState), + logger: logger, + } +} + +// Register adds a connection to a session +func (h *Hub) Register(conn *Conn) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.sessions[conn.SessionID] == nil { + h.sessions[conn.SessionID] = make(map[*Conn]bool) + } + h.sessions[conn.SessionID][conn] = true + + if conn.IsHost { + // Send SyncStable to host when they connect + h.sendToConn(conn, map[string]interface{}{ + "type": "SyncStable", + "session_id": conn.SessionID.String(), + }) + } else { + // Send SyncInit to new listener with current host state + if state := h.hostState[conn.SessionID]; state != nil { + h.sendToConn(conn, map[string]interface{}{ + "type": "SyncInit", + "session_id": conn.SessionID.String(), + "track_id": conn.TrackID.String(), + "server_timestamp_ms": state.ServerTimestampMs, + "position_ms": state.PositionMs, + }) + } + } + + h.logger.Info("Co-listening client registered", + zap.String("session_id", conn.SessionID.String()), + zap.String("user_id", conn.UserID.String()), + zap.Bool("is_host", conn.IsHost)) +} + +// Unregister removes a connection +func (h *Hub) Unregister(conn *Conn) { + h.mu.Lock() + defer h.mu.Unlock() + + if conns, ok := h.sessions[conn.SessionID]; ok { + delete(conns, conn) + if len(conns) == 0 { + delete(h.sessions, conn.SessionID) + delete(h.hostState, conn.SessionID) + } + } + + h.logger.Info("Co-listening client unregistered", + zap.String("session_id", conn.SessionID.String()), + zap.String("user_id", conn.UserID.String())) +} + +// UpdateHostState stores the host's state and broadcasts SyncAdjustment to listeners +func (h *Hub) UpdateHostState(sessionID uuid.UUID, positionMs, clientTimestampMs int64) { + h.mu.Lock() + state := &HostState{ + PositionMs: positionMs, + ClientTimestampMs: clientTimestampMs, + ServerTimestampMs: time.Now().UnixMilli(), + } + h.hostState[sessionID] = state + + conns := h.sessions[sessionID] + h.mu.Unlock() + + if conns == nil { + return + } + + for conn := range conns { + if conn.IsHost { + continue + } + if conn.LastState != nil { + driftMs := conn.LastState.PositionMs - state.PositionMs + if driftMs != 0 { + h.sendToConn(conn, map[string]interface{}{ + "type": "SyncAdjustment", + "session_id": sessionID.String(), + "drift_ms": driftMs, + }) + } + } + conn.LastState = &HostState{ + PositionMs: state.PositionMs, + ClientTimestampMs: state.ClientTimestampMs, + ServerTimestampMs: state.ServerTimestampMs, + } + } +} + +// UpdateListenerState stores listener state and sends SyncAdjustment if drifted from host +func (h *Hub) UpdateListenerState(conn *Conn, positionMs, clientTimestampMs int64) { + h.mu.Lock() + conn.LastState = &HostState{ + PositionMs: positionMs, + ClientTimestampMs: clientTimestampMs, + ServerTimestampMs: time.Now().UnixMilli(), + } + + state := h.hostState[conn.SessionID] + h.mu.Unlock() + + if state == nil { + return + } + + driftMs := positionMs - state.PositionMs + if driftMs == 0 { + return + } + + h.sendToConn(conn, map[string]interface{}{ + "type": "SyncAdjustment", + "session_id": conn.SessionID.String(), + "drift_ms": driftMs, + }) +} + +// BroadcastToSession sends a message to all conns in a session except exclude +func (h *Hub) BroadcastToSession(sessionID uuid.UUID, msg map[string]interface{}, exclude *Conn) { + h.mu.RLock() + conns := h.sessions[sessionID] + h.mu.RUnlock() + + if conns == nil { + return + } + + for conn := range conns { + if conn == exclude { + continue + } + h.sendToConn(conn, msg) + } +} + +func (h *Hub) sendToConn(conn *Conn, msg map[string]interface{}) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Warn("Failed to marshal co-listening message", zap.Error(err)) + return + } + select { + case conn.Send <- data: + default: + h.logger.Warn("Co-listening send buffer full, dropping message") + } +} diff --git a/veza-backend-api/migrations/942_create_co_listening_sessions.sql b/veza-backend-api/migrations/942_create_co_listening_sessions.sql new file mode 100644 index 000000000..bbac7d8a7 --- /dev/null +++ b/veza-backend-api/migrations/942_create_co_listening_sessions.sql @@ -0,0 +1,16 @@ +-- 942_create_co_listening_sessions.sql +-- v0.10.7 F481: Co-listening sessions for synchronized playback + +CREATE TABLE IF NOT EXISTS co_listening_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '4 hours') +); + +CREATE INDEX IF NOT EXISTS idx_co_listening_sessions_host_id ON co_listening_sessions(host_id); +CREATE INDEX IF NOT EXISTS idx_co_listening_sessions_track_id ON co_listening_sessions(track_id); +CREATE INDEX IF NOT EXISTS idx_co_listening_sessions_expires_at ON co_listening_sessions(expires_at) WHERE expires_at > NOW(); + +COMMENT ON TABLE co_listening_sessions IS 'v0.10.7 F481: Sessions for synchronized co-listening playback'; diff --git a/veza-backend-api/migrations/942_create_co_listening_sessions_down.sql b/veza-backend-api/migrations/942_create_co_listening_sessions_down.sql new file mode 100644 index 000000000..d469377b1 --- /dev/null +++ b/veza-backend-api/migrations/942_create_co_listening_sessions_down.sql @@ -0,0 +1,7 @@ +-- 942_create_co_listening_sessions_down.sql +-- Rollback co_listening_sessions + +DROP INDEX IF EXISTS idx_co_listening_sessions_expires_at; +DROP INDEX IF EXISTS idx_co_listening_sessions_track_id; +DROP INDEX IF EXISTS idx_co_listening_sessions_host_id; +DROP TABLE IF EXISTS co_listening_sessions; diff --git a/veza-backend-api/migrations/943_create_track_stems.sql b/veza-backend-api/migrations/943_create_track_stems.sql new file mode 100644 index 000000000..f442923f1 --- /dev/null +++ b/veza-backend-api/migrations/943_create_track_stems.sql @@ -0,0 +1,17 @@ +-- 943_create_track_stems.sql +-- v0.10.7 F482: Stem sharing (wav, aiff, flac) + +CREATE TABLE IF NOT EXISTS track_stems ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + file_path VARCHAR(500) NOT NULL, + format VARCHAR(10) NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_track_stems_track_name UNIQUE (track_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_track_stems_track_id ON track_stems(track_id); + +COMMENT ON TABLE track_stems IS 'v0.10.7 F482: Individual stems per track (kick, snare, bass, etc.)'; diff --git a/veza-backend-api/migrations/943_create_track_stems_down.sql b/veza-backend-api/migrations/943_create_track_stems_down.sql new file mode 100644 index 000000000..dba8c9a18 --- /dev/null +++ b/veza-backend-api/migrations/943_create_track_stems_down.sql @@ -0,0 +1,5 @@ +-- 943_create_track_stems_down.sql +-- Rollback for v0.10.7 F482 + +DROP INDEX IF EXISTS idx_track_stems_track_id; +DROP TABLE IF EXISTS track_stems;