# Plan d'implémentation v0.703 — Go Live & Streaming Complet ## État des lieux Le module Live Streaming est **partiellement implémenté** : | Feature | Backend | Frontend | Route | MSW | Tests | |---------|---------|----------|-------|-----|-------| | List streams | ✅ GET /live/streams | ✅ LiveView | ✅ /live | ❌ | ❌ | | Get stream | ✅ GET /live/streams/:id | ✅ LiveViewPlayer | ✅ | ❌ | ❌ | | Create stream | ✅ POST /live/streams | ❌ | ❌ | ❌ | ❌ | | Stream key | ❌ field exists, never generated | ❌ | ❌ | ❌ | ❌ | | Update stream | ❌ | ❌ | ❌ | ❌ | ❌ | | Go Live page | ❌ | ❌ | ❌ | ❌ | ❌ | | Live chat | ❌ | ⚠️ mock data | ❌ | ❌ | ❌ | | VOD replays | ❌ | ❌ | ❌ | ❌ | ❌ | | Playback speed | ❌ | ❌ | — | — | ❌ | | Media Session | ❌ | ❌ | — | — | ❌ | **Ce plan ajoute le Go Live complet, le chat live connecté, et les améliorations player.** --- ## Fichiers existants clés - Model : [`live_stream.go`](veza-backend-api/internal/models/live_stream.go) (LiveStream struct, StreamKey field L18) - Service : [`live_stream_service.go`](veza-backend-api/internal/services/live_stream_service.go) (Create L35, SetIsLive L79, UpdateViewerCount L97) - Handler : [`live_stream_handler.go`](veza-backend-api/internal/handlers/live_stream_handler.go) (ListLiveStreams L38, GetLiveStream L55, CreateLiveStream L70) - Repository : [`live_stream_repository.go`](veza-backend-api/internal/repositories/live_stream_repository.go) - Routes : [`routes_live.go`](veza-backend-api/internal/api/routes_live.go) (GET /streams L19, GET /streams/:id L20, POST /streams L26) - Stream events : [`stream_events_handler.go`](veza-backend-api/internal/handlers/stream_events_handler.go) - LiveView : [`LiveView.tsx`](apps/web/src/features/live/pages/live-page/LiveView.tsx) (limitation A6 L4) - LiveViewChat : [`LiveViewChat.tsx`](apps/web/src/features/live/pages/live-page/LiveViewChat.tsx) (mock data) - LiveViewPlayer : [`LiveViewPlayer.tsx`](apps/web/src/features/live/pages/live-page/LiveViewPlayer.tsx) - Navbar : [`Navbar.tsx`](apps/web/src/components/layout/Navbar.tsx) (Go Live toast L223) - Lazy exports : [`lazyExports.ts`](apps/web/src/components/ui/lazy-component/lazyExports.ts) - Routes : [`routeConfig.tsx`](apps/web/src/router/routeConfig.tsx) - Live service : [`liveService.ts`](apps/web/src/services/liveService.ts) - Player hooks : [`apps/web/src/features/player/hooks/`](apps/web/src/features/player/hooks/) - Player audio : [`apps/web/src/features/player/components/audio-player/`](apps/web/src/features/player/components/audio-player/) --- ## Step 1 : Migration + Model update (GL1-01, GL1-02) **Fichier** : `veza-backend-api/migrations/117_live_streams_go_live.sql` (nouveau) ```sql ALTER TABLE live_streams ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS stream_url TEXT DEFAULT '', ADD COLUMN IF NOT EXISTS is_vod BOOLEAN NOT NULL DEFAULT false; UPDATE live_streams SET stream_key = gen_random_uuid()::text WHERE stream_key = '' OR stream_key IS NULL; ALTER TABLE live_streams ALTER COLUMN stream_key SET NOT NULL; ALTER TABLE live_streams ALTER COLUMN stream_key SET DEFAULT gen_random_uuid()::text; CREATE INDEX IF NOT EXISTS idx_live_streams_user_id ON live_streams(user_id); CREATE INDEX IF NOT EXISTS idx_live_streams_is_live ON live_streams(is_live) WHERE is_live = true; CREATE INDEX IF NOT EXISTS idx_live_streams_scheduled ON live_streams(scheduled_at) WHERE scheduled_at IS NOT NULL; ``` **Fichier** : `veza-backend-api/internal/models/live_stream.go` — ajouter 3 champs après `Tags` : ```go ScheduledAt *time.Time `json:"scheduled_at,omitempty" db:"scheduled_at"` StreamURL string `gorm:"type:text;default:''" json:"stream_url,omitempty" db:"stream_url"` IsVOD bool `gorm:"default:false" json:"is_vod" db:"is_vod"` ``` **Commit** : `feat(live): add migration 117 and model fields for Go Live` --- ## Step 2 : Service — Stream key generation, ListByUser (GL1-03, GL1-04) **Fichier** : `veza-backend-api/internal/services/live_stream_service.go` Modifier `Create` pour générer la stream key : ```go func (s *LiveStreamService) Create(ctx context.Context, userID uuid.UUID, stream *models.LiveStream) (*models.LiveStream, error) { if stream.Title == "" { return nil, errors.New("title is required") } stream.UserID = userID stream.StreamKey = uuid.New().String() if stream.StreamerName == "" { stream.StreamerName = "Streamer" } if err := s.repo.Create(ctx, stream); err != nil { return nil, err } return stream, nil } ``` Ajouter `ListByUser` : ```go func (s *LiveStreamService) ListByUser(ctx context.Context, userID uuid.UUID) ([]*models.LiveStream, error) { return s.repo.ListByUserID(ctx, userID) } ``` Ajouter `RegenerateStreamKey` : ```go func (s *LiveStreamService) RegenerateStreamKey(ctx context.Context, id, userID uuid.UUID) (string, error) { existing, err := s.repo.GetByID(ctx, id) if err != nil { return "", err } if existing.UserID != userID { return "", errors.New("stream not found") } existing.StreamKey = uuid.New().String() if err := s.repo.Update(ctx, existing); err != nil { return "", err } return existing.StreamKey, nil } ``` **Commit** : `feat(live): stream key generation, ListByUser, RegenerateStreamKey` --- ## Step 3 : Handler — Endpoints me, me/key, regenerate, PUT (GL1-04 to GL1-07) **Fichier** : `veza-backend-api/internal/handlers/live_stream_handler.go` — ajouter : ```go // GetMyStreams returns the authenticated user's streams (including stream_key) func (h *LiveStreamHandler) GetMyStreams(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } streams, err := h.service.ListByUser(c.Request.Context(), userID) if err != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list streams", err)) return } type streamWithKey struct { *models.LiveStream StreamKey string `json:"stream_key"` } result := make([]streamWithKey, len(streams)) for i, s := range streams { result[i] = streamWithKey{LiveStream: s, StreamKey: s.StreamKey} } RespondSuccess(c, http.StatusOK, gin.H{"streams": result}) } // GetMyStreamKey returns the user's stream key (creates draft if none exist) func (h *LiveStreamHandler) GetMyStreamKey(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } streams, err := h.service.ListByUser(c.Request.Context(), userID) if err != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get streams", err)) return } var streamKey string if len(streams) > 0 { streamKey = streams[0].StreamKey } else { draft := &models.LiveStream{Title: "My Stream"} created, createErr := h.service.Create(c.Request.Context(), userID, draft) if createErr != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create stream", createErr)) return } streamKey = created.StreamKey } RespondSuccess(c, http.StatusOK, gin.H{ "stream_key": streamKey, "rtmp_url": "rtmp://stream.veza.app/live", }) } // RegenerateStreamKey generates a new stream key for the user's first stream func (h *LiveStreamHandler) RegenerateStreamKey(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } streams, err := h.service.ListByUser(c.Request.Context(), userID) if err != nil || len(streams) == 0 { RespondWithAppError(c, apperrors.NewNotFoundError("no stream found")) return } newKey, err := h.service.RegenerateStreamKey(c.Request.Context(), streams[0].ID, userID) if err != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to regenerate key", err)) return } RespondSuccess(c, http.StatusOK, gin.H{ "stream_key": newKey, "rtmp_url": "rtmp://stream.veza.app/live", }) } // UpdateLiveStream updates a live stream's metadata (ownership check) func (h *LiveStreamHandler) UpdateLiveStream(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } id, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("invalid stream ID")) return } var req CreateLiveStreamRequest if appErr := NewCommonHandler(h.logger).BindAndValidateJSON(c, &req); appErr != nil { RespondWithAppError(c, appErr) return } stream := &models.LiveStream{ Title: req.Title, Description: req.Description, Category: req.Category, ThumbnailURL: req.ThumbnailURL, StreamerName: req.StreamerName, Tags: req.Tags, } updated, updateErr := h.service.Update(c.Request.Context(), id, userID, stream) if updateErr != nil { RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update stream", updateErr)) return } RespondSuccess(c, http.StatusOK, gin.H{"stream": updated}) } ``` **Fichier** : `veza-backend-api/internal/api/routes_live.go` — ajouter dans le bloc `protected` : ```go protected.GET("/streams/me", liveStreamHandler.GetMyStreams) protected.GET("/streams/me/key", liveStreamHandler.GetMyStreamKey) protected.POST("/streams/me/key/regenerate", liveStreamHandler.RegenerateStreamKey) protected.PUT("/streams/:id", liveStreamHandler.UpdateLiveStream) ``` **Commit** : `feat(live): add handler endpoints for Go Live (me, key, regenerate, update)` --- ## Step 4 : Tests backend live stream (GL1-10) **Fichier** : `veza-backend-api/internal/services/live_stream_service_test.go` (nouveau) Cas de test (SQLite in-memory, AutoMigrate LiveStream) : - `TestCreate_GeneratesStreamKey` — Create → stream.StreamKey non vide, format UUID - `TestCreate_RequiresTitle` — Create avec title="" → erreur - `TestListByUser_ReturnsOnlyUserStreams` — 2 users, 3 streams → ListByUser retourne seulement les streams de l'user - `TestRegenerateStreamKey_Success` — RegenerateStreamKey → nouveau key != ancien key - `TestRegenerateStreamKey_WrongUser` — RegenerateStreamKey avec mauvais userID → erreur - `TestSetIsLive_SetsStartedAt` — SetIsLive(true) → StartedAt set, EndedAt nil - `TestSetIsLive_SetsEndedAt` — SetIsLive(false) → EndedAt set - `TestUpdateViewerCount_Clamps` — UpdateViewerCount(-100) → viewer_count = 0 (pas négatif) - `TestUpdate_OwnershipCheck` — Update avec mauvais userID → erreur ```go package services import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "veza-backend-api/internal/models" "veza-backend-api/internal/repositories" ) func setupLiveStreamTest(t *testing.T) (*LiveStreamService, *gorm.DB) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.LiveStream{})) repo := repositories.NewLiveStreamRepository(db) svc := NewLiveStreamService(repo) return svc, db } func TestCreate_GeneratesStreamKey(t *testing.T) { svc, _ := setupLiveStreamTest(t) stream := &models.LiveStream{Title: "Test Stream"} created, err := svc.Create(context.Background(), uuid.New(), stream) require.NoError(t, err) assert.NotEmpty(t, created.StreamKey) _, parseErr := uuid.Parse(created.StreamKey) assert.NoError(t, parseErr, "stream key should be valid UUID") } func TestCreate_RequiresTitle(t *testing.T) { svc, _ := setupLiveStreamTest(t) _, err := svc.Create(context.Background(), uuid.New(), &models.LiveStream{}) assert.Error(t, err) } ``` **Commit** : `test(live): add live stream service unit tests` --- ## Step 5 : Frontend GoLivePage + GoLiveView (GL2-01 to GL2-04) **Fichier** : `apps/web/src/features/live/pages/go-live-page/GoLiveView.tsx` (nouveau) ```typescript import { useState } from 'react'; import { Copy, Eye, EyeOff, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import toast from '@/utils/toast'; interface GoLiveViewProps { streamKey: string | null; rtmpUrl: string; onCreateStream: (data: { title: string; description: string; category: string; tags: string[] }) => Promise; onRegenerateKey: () => Promise; isLoading: boolean; } export function GoLiveView({ streamKey, rtmpUrl, onCreateStream, onRegenerateKey, isLoading }: GoLiveViewProps) { const [showKey, setShowKey] = useState(false); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); toast.success('Copied to clipboard'); }; // ... form and stream key display ... } export function GoLiveViewSkeleton() { return
/* skeleton */
; } ``` **Fichier** : `apps/web/src/features/live/pages/GoLivePage.tsx` (nouveau) ```typescript import { useState, useEffect } from 'react'; import { GoLiveView, GoLiveViewSkeleton } from './go-live-page/GoLiveView'; import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; import { liveService } from '@/services/liveService'; export function GoLivePage() { const [streamKey, setStreamKey] = useState(null); const [rtmpUrl, setRtmpUrl] = useState('rtmp://stream.veza.app/live'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { liveService.getMyStreamKey() .then((res) => { setStreamKey(res.stream_key); setRtmpUrl(res.rtmp_url); }) .catch((e) => setError(e instanceof Error ? e : new Error(String(e)))) .finally(() => setLoading(false)); }, []); if (loading) return ; if (error) return window.location.reload()} />; return ( { await liveService.createStream(data); }} onRegenerateKey={async () => { const res = await liveService.regenerateStreamKey(); setStreamKey(res.stream_key); }} isLoading={false} /> ); } ``` **Fichier** : `apps/web/src/components/ui/lazy-component/lazyExports.ts` — ajouter : ```typescript export const LazyGoLive = createLazyComponent( () => import('@/features/live/pages/GoLivePage').then((m) => ({ default: m.GoLivePage })), undefined, 'Go Live', ); ``` **Fichier** : `apps/web/src/router/routeConfig.tsx` — ajouter après `/live` : ```typescript { path: '/live/go-live', element: wrapProtected() }, ``` **Commit** : `feat(live): add GoLivePage, GoLiveView, lazy export, route` --- ## Step 6 : Navbar + liveService + MSW (GL2-05 to GL2-07) **Fichier** : `apps/web/src/components/layout/Navbar.tsx` L217-224 — remplacer le onClick Go Live : ```typescript onClick={() => { onNavigate('go-live'); setShowUserMenu(false); }} ``` **Fichier** : `apps/web/src/services/liveService.ts` — ajouter : ```typescript async getMyStreams(): Promise<{ streams: LiveStream[] }> { const response = await api.get('/live/streams/me'); return response.data.data; }, async getMyStreamKey(): Promise<{ stream_key: string; rtmp_url: string }> { const response = await api.get('/live/streams/me/key'); return response.data.data; }, async regenerateStreamKey(): Promise<{ stream_key: string; rtmp_url: string }> { const response = await api.post('/live/streams/me/key/regenerate'); return response.data.data; }, async updateStream(id: string, data: Partial): Promise { const response = await api.put(`/live/streams/${id}`, data); return response.data.data.stream; }, async createStream(data: { title: string; description: string; category: string; tags: string[] }): Promise { const response = await api.post('/live/streams', data); return response.data.data.stream; }, ``` **Fichier** : `apps/web/src/mocks/handlers-live.ts` (nouveau) ```typescript import { http, HttpResponse } from 'msw'; export const liveHandlers = [ http.get('*/api/v1/live/streams/me/key', () => { return HttpResponse.json({ success: true, data: { stream_key: 'sk-abc123-def456-ghi789', rtmp_url: 'rtmp://stream.veza.app/live', }, }); }), http.get('*/api/v1/live/streams/me', () => { return HttpResponse.json({ success: true, data: { streams: [/* mock stream with key */] }, }); }), http.post('*/api/v1/live/streams/me/key/regenerate', () => { return HttpResponse.json({ success: true, data: { stream_key: 'sk-new-' + Date.now(), rtmp_url: 'rtmp://stream.veza.app/live', }, }); }), http.put('*/api/v1/live/streams/:id', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ success: true, data: { stream: body } }); }), ]; ``` Enregistrer dans `apps/web/src/mocks/handlers.ts` : `import { liveHandlers } from './handlers-live';` et ajouter `...liveHandlers` dans le tableau. **Commit** : `feat(live): wire Navbar Go Live, liveService methods, MSW handlers` --- ## Step 7 : Live Chat WebSocket (GL3-01 to GL3-03) **Fichier** : `apps/web/src/features/live/pages/live-page/LiveViewChat.tsx` — remplacer les données mock par le hook `useChat` existant : - Utiliser `JoinConversation` avec le stream ID comme room identifier - Écouter les messages entrants via le WebSocket hub existant - Afficher les messages en temps réel - Input de message connecté à `SendMessage` **Fichier** : `apps/web/src/features/live/pages/live-page/LiveViewPlayer.tsx` — écouter les events viewer count via WebSocket ou polling GET /live/streams/:id **Commit** : `feat(live): connect LiveViewChat to WebSocket, real-time viewer count` --- ## Step 8 : Player enhancements (GL4-01 to GL4-03) **Fichier** : `apps/web/src/features/player/components/audio-player/` — ajouter un composant `PlaybackSpeedButton` : ```typescript const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; export function PlaybackSpeedButton({ speed, onSpeedChange }: { speed: number; onSpeedChange: (speed: number) => void; }) { const cycleSpeed = () => { const idx = SPEED_OPTIONS.indexOf(speed as typeof SPEED_OPTIONS[number]); const next = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length]; onSpeedChange(next); }; return ( ); } ``` **Fichier** : `apps/web/src/features/player/hooks/useMediaSession.ts` (nouveau) ```typescript import { useEffect } from 'react'; interface MediaSessionActions { onPlay: () => void; onPause: () => void; onPrevious: () => void; onNext: () => void; onSeekBackward: () => void; onSeekForward: () => void; } export function useMediaSession( track: { title: string; artist?: string; coverUrl?: string } | null, actions: MediaSessionActions, ) { useEffect(() => { if (!('mediaSession' in navigator) || !track) return; navigator.mediaSession.metadata = new MediaMetadata({ title: track.title, artist: track.artist ?? 'Unknown', artwork: track.coverUrl ? [{ src: track.coverUrl, sizes: '512x512', type: 'image/png' }] : [], }); navigator.mediaSession.setActionHandler('play', actions.onPlay); navigator.mediaSession.setActionHandler('pause', actions.onPause); navigator.mediaSession.setActionHandler('previoustrack', actions.onPrevious); navigator.mediaSession.setActionHandler('nexttrack', actions.onNext); navigator.mediaSession.setActionHandler('seekbackward', actions.onSeekBackward); navigator.mediaSession.setActionHandler('seekforward', actions.onSeekForward); return () => { navigator.mediaSession.metadata = null; }; }, [track?.title, track?.artist, track?.coverUrl]); } ``` **Commit** : `feat(player): add playback speed control and Media Session API` --- ## Step 9 : Stories GoLiveView (GL2-08) **Fichier** : `apps/web/src/features/live/pages/go-live-page/GoLiveView.stories.tsx` (nouveau) ```typescript import type { Meta, StoryObj } from '@storybook/react'; import { GoLiveView, GoLiveViewSkeleton } from './GoLiveView'; import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; const meta: Meta = { title: 'Features/Live/GoLiveView', component: GoLiveView, }; export default meta; type Story = StoryObj; export const Default: Story = { args: { streamKey: 'sk-abc123-def456-ghi789', rtmpUrl: 'rtmp://stream.veza.app/live', onCreateStream: async () => {}, onRegenerateKey: async () => {}, isLoading: false, }, }; export const Loading: Story = { render: () => , }; export const Error: Story = { name: 'Erreur', render: () => ( window.location.reload()} /> ), }; export const StreamKeyVisible: Story = { args: { ...Default.args, streamKey: 'sk-visible-key-for-testing', }, }; ``` **Commit** : `feat(storybook): add GoLiveView stories (Default, Loading, Error, StreamKeyVisible)` --- ## Step 10 : API Reference + Documentation (QA1) **Fichier** : `docs/API_REFERENCE.md` — ajouter section "Live Streaming" : ```markdown ## Live Streaming ### GET /live/streams List live streams (public). Optional `?is_live=true` filter. ### GET /live/streams/:id Get stream details. ### POST /live/streams Create a new stream (auth required). Body: `{ "title": "...", "description": "..." }`. ### GET /live/streams/me List authenticated user's streams (includes stream_key). Auth required. ### GET /live/streams/me/key Get user's stream key + RTMP URL. Creates draft stream if none exist. Auth required. ### POST /live/streams/me/key/regenerate Regenerate stream key. Auth required. ### PUT /live/streams/:id Update stream metadata (ownership check). Auth required. ``` **Fichier** : `CHANGELOG.md` — ajouter section v0.703 **Fichier** : `docs/PROJECT_STATE.md` — mettre à jour Dernier tag → v0.703, Prochaine version → v0.801, ajouter section v0.703 **Fichier** : `docs/FEATURE_STATUS.md` — mettre à jour limitation Go Live (supprimée), ajouter section "Livré en v0.703" **Commit** : `docs: update API_REFERENCE, CHANGELOG, PROJECT_STATE, FEATURE_STATUS for v0.703` --- ## Step 11 : Rétrospective + Scope Control + placeholder v0.801 **Fichier** : `docs/RETROSPECTIVE_V0703.md` (nouveau) **Fichier** : `docs/V0_801_RELEASE_SCOPE.md` (nouveau, placeholder) **Fichier** : `docs/SCOPE_CONTROL.md` — Référence active → V0_801_RELEASE_SCOPE.md, historique v0.703 taguée **Fichier** : `.cursorrules` — Scope v0.801 **Commit** : `docs: add RETROSPECTIVE_V0703, placeholder V0_801, update SCOPE_CONTROL` --- ## Step 12 : Archive + tag ```bash mv docs/V0_703_RELEASE_SCOPE.md docs/archive/V0_703_RELEASE_SCOPE.md git add . git commit -m "chore(docs): archive V0_703_RELEASE_SCOPE" git tag v0.703 ``` --- ## Dépendances entre steps ```mermaid graph TD S1[Step1 Migration+Model] --> S2[Step2 Service] S2 --> S3[Step3 Handler+Routes] S3 --> S4[Step4 Tests backend] S5[Step5 GoLivePage] --> S6[Step6 Navbar+MSW] S6 --> S7[Step7 LiveChat WS] S8[Step8 Player] --> S9[Step9 Stories] S4 --> S10[Step10 Docs] S7 --> S10 S9 --> S10 S10 --> S11[Step11 Retro] S11 --> S12[Step12 Tag] ``` --- ## Validation finale ```bash cd veza-backend-api && go build ./... && go vet ./... && go test ./... -v cd apps/web && npm run build git tag v0.703 ```