feat(v0.10.7): Collaboration Temps Réel F481-F483
- F481: Co-listening sessions (WebSocket sync, ListenTogether page) - F482: Stem sharing (upload/list/download wav,aiff,flac) - F483: Collaborative rooms (type collaborative, max 10, invite-only) - Roadmap: v0.10.7 → DONE
This commit is contained in:
parent
eb2862092d
commit
871a0f2a05
32 changed files with 1731 additions and 26 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export {
|
|||
LazyGear,
|
||||
LazyLive,
|
||||
LazyGoLive,
|
||||
LazyListenTogether,
|
||||
LazyCloud,
|
||||
LazyQueue,
|
||||
LazyDeveloper,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export {
|
|||
LazyGear,
|
||||
LazyLive,
|
||||
LazyGoLive,
|
||||
LazyListenTogether,
|
||||
LazyCloud,
|
||||
LazyQueue,
|
||||
LazyDeveloper,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [mutationError, setMutationError] = useState<Error | null>(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"
|
||||
|
|
|
|||
95
apps/web/src/features/player/pages/ListenTogetherPage.tsx
Normal file
95
apps/web/src/features/player/pages/ListenTogetherPage.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="min-h-layout-page flex items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-layout-page flex flex-col items-center justify-center gap-4 p-8">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-layout-page flex flex-col items-center justify-center gap-6 p-8">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="w-6 h-6" />
|
||||
<span>Listening together</span>
|
||||
{isSynced && (
|
||||
<span className="text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-1 rounded">
|
||||
Synced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||
Playback is synchronized with the host. Use the player below to control playback.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
124
apps/web/src/features/tracks/components/TrackStemsSection.tsx
Normal file
124
apps/web/src/features/tracks/components/TrackStemsSection.tsx
Normal file
|
|
@ -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 (
|
||||
<Card variant="glass" className="p-6">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Loading stems...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card variant="glass" className="p-6">
|
||||
<p className="text-destructive">Failed to load stems.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasStems = stems && stems.length > 0;
|
||||
const showUpload = isCreator;
|
||||
|
||||
return (
|
||||
<Card variant="glass" className="p-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex justify-center w-8 h-8 rounded-lg bg-primary/10">
|
||||
<Disc3 className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-heading-3">Stems</h3>
|
||||
</div>
|
||||
{showUpload && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploadMutation.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasStems && !showUpload ? (
|
||||
<p className="text-muted-foreground">No stems available.</p>
|
||||
) : !hasStems ? (
|
||||
<p className="text-muted-foreground">Upload stems (WAV, AIFF, FLAC) to share with collaborators.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{stems!.map((stem) => (
|
||||
<li
|
||||
key={stem.id}
|
||||
className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium truncate">{stem.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{stem.format} · {formatBytes(stem.size_bytes)}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(stem)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<CoListeningSession | null>(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({
|
|||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleListenTogether}
|
||||
disabled={listenTogetherLoading}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-full hover:bg-muted/50"
|
||||
title="Listen together"
|
||||
>
|
||||
{listenTogetherLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Users className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -139,6 +201,67 @@ export function TrackDetailPageCoverAndActions({
|
|||
<span className="text-label">Likes</span>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Listen Together modal (v0.10.7 F481) */}
|
||||
<Dialog
|
||||
open={listenTogetherModalOpen}
|
||||
onClose={() => {
|
||||
setListenTogetherModalOpen(false);
|
||||
setListenTogetherSession(null);
|
||||
}}
|
||||
title="Listen together"
|
||||
variant="default"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setListenTogetherModalOpen(false);
|
||||
setListenTogetherSession(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleStartListening}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Start listening
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
showCancel={false}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Share this link with friends to listen together. Playback will be synchronized.
|
||||
</p>
|
||||
{shareUrl && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Share link</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={shareUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-sm"
|
||||
aria-label="Listen together share URL"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCopyListenTogetherLink}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Copy link"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<FileText className="w-4 h-4" />
|
||||
Lyrics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stems" className={tabTriggerClass}>
|
||||
<Disc3 className="w-4 h-4" />
|
||||
Stems
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="comments" className="animate-fade-in mt-0">
|
||||
|
|
@ -84,6 +92,9 @@ export function TrackDetailPageTabs({
|
|||
<TabsContent value="lyrics" className="animate-fade-in mt-0">
|
||||
<TrackLyricsSection trackId={track.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="stems" className="animate-fade-in mt-0">
|
||||
<TrackStemsSection track={track} isCreator={isCreator} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ShareDialog
|
||||
|
|
|
|||
46
apps/web/src/features/tracks/services/trackStemService.ts
Normal file
46
apps/web/src/features/tracks/services/trackStemService.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Track stems service (v0.10.7 F482)
|
||||
* Upload, list, and download stems for a track
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { API_TIMEOUTS } from '@/services/api/client';
|
||||
|
||||
export interface TrackStem {
|
||||
id: string;
|
||||
track_id: string;
|
||||
name: string;
|
||||
format: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const trackStemService = {
|
||||
async listStems(trackId: string): Promise<TrackStem[]> {
|
||||
const res = await apiClient.get<{ stems: TrackStem[] }>(`/tracks/${trackId}/stems`);
|
||||
return res.data.stems ?? [];
|
||||
},
|
||||
|
||||
async uploadStem(trackId: string, file: File, name?: string): Promise<TrackStem> {
|
||||
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<void> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<LazyGoLive />) },
|
||||
{ path: '/live', element: wrapProtected(<LazyLive />) },
|
||||
// Co-listening (v0.10.7 F481)
|
||||
{ path: '/listen-together/:sessionId', element: wrapProtected(<LazyListenTogether />) },
|
||||
// Cloud: connected to backend cloud storage API
|
||||
{ path: '/cloud', element: wrapProtected(<LazyCloud />) },
|
||||
];
|
||||
|
|
|
|||
42
apps/web/src/services/coListeningService.ts
Normal file
42
apps/web/src/services/coListeningService.ts
Normal file
|
|
@ -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<CoListeningSession> {
|
||||
const res = await apiClient.post<{ session: CoListeningSession }>(
|
||||
'/co-listening/sessions',
|
||||
{ track_id: trackId }
|
||||
);
|
||||
return res.data.session;
|
||||
},
|
||||
|
||||
async getSession(sessionId: string): Promise<CoListeningSession> {
|
||||
const res = await apiClient.get<{ session: CoListeningSession }>(
|
||||
`/co-listening/sessions/${sessionId}`
|
||||
);
|
||||
return res.data.session;
|
||||
},
|
||||
|
||||
async endSession(sessionId: string): Promise<void> {
|
||||
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}`;
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
40
veza-backend-api/internal/api/routes_co_listening.go
Normal file
40
veza-backend-api/internal/api/routes_co_listening.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
132
veza-backend-api/internal/handlers/co_listening_handler.go
Normal file
132
veza-backend-api/internal/handlers/co_listening_handler.go
Normal file
|
|
@ -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"})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
veza-backend-api/internal/handlers/track_stem_handler.go
Normal file
199
veza-backend-api/internal/handlers/track_stem_handler.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
33
veza-backend-api/internal/models/co_listening_session.go
Normal file
33
veza-backend-api/internal/models/co_listening_session.go
Normal file
|
|
@ -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
|
||||
}
|
||||
36
veza-backend-api/internal/models/track_stem.go
Normal file
36
veza-backend-api/internal/models/track_stem.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
84
veza-backend-api/internal/services/co_listening_service.go
Normal file
84
veza-backend-api/internal/services/co_listening_service.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
147
veza-backend-api/internal/services/track_stem_service.go
Normal file
147
veza-backend-api/internal/services/track_stem_service.go
Normal file
|
|
@ -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
|
||||
}
|
||||
198
veza-backend-api/internal/websocket/colistening/hub.go
Normal file
198
veza-backend-api/internal/websocket/colistening/hub.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
17
veza-backend-api/migrations/943_create_track_stems.sql
Normal file
17
veza-backend-api/migrations/943_create_track_stems.sql
Normal file
|
|
@ -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.)';
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue