feat(v0.10.6): Livestreaming basique F471-F476
- Backend: callbacks on_publish/on_publish_done, UpdateStreamURL, GetByStreamKey - Nginx-RTMP: config infra, docker-compose service (profil live) - Frontend: stream_url dans LiveStream, HLS.js dans LiveViewPlayer, état Stream terminé - Chat: rate limit send_live_message 1 msg/3s pour rooms live_streams - Env: RTMP_CALLBACK_SECRET, STREAM_HLS_BASE_URL, NGINX_RTMP_HOST - Roadmap v0.10.6 marquée DONE
This commit is contained in:
parent
dd23805401
commit
eb2862092d
16 changed files with 375 additions and 37 deletions
|
|
@ -669,41 +669,41 @@ Implémenter le système de notifications complet, respectueux du temps de l'uti
|
|||
|
||||
### v0.10.6 — Livestreaming Basique (F471-F476)
|
||||
|
||||
**Statut** : ⏳ TODO
|
||||
**Statut** : ✅ DONE
|
||||
**Priorité** : P2
|
||||
**Durée estimée** : 5-7 jours
|
||||
**Prerequisite** : v0.10.0 complète, stream server Rust fonctionnel
|
||||
**Complété le** : 2026-03-10
|
||||
|
||||
**Objectif**
|
||||
Implémenter le livestreaming audio basique via le stream server Rust (RTMP in, HLS out).
|
||||
Implémenter le livestreaming audio basique via Nginx-RTMP (Option A) + backend callbacks + frontend HLS player.
|
||||
|
||||
**Tâches**
|
||||
|
||||
- [ ] Ingest RTMP depuis OBS/équipement audio (F471)
|
||||
- Stream server Rust accepte les connexions RTMP
|
||||
- Authentification via stream key unique par créateur
|
||||
- [x] Ingest RTMP depuis OBS/équipement audio (F471)
|
||||
- Nginx-RTMP sur port 1935, validation stream_key via callbacks backend
|
||||
- on_publish / on_publish_done → POST /api/v1/live/callback/*
|
||||
|
||||
- [ ] Distribution HLS multi-bitrate (F472)
|
||||
- Segmentation HLS (segments de 2 secondes)
|
||||
- Bitrates : 64kbps, 128kbps, 320kbps
|
||||
- Référence : ORIGIN_FEATURES_REGISTRY.md F475
|
||||
- [x] Distribution HLS (F472)
|
||||
- Nginx-RTMP HLS segments 2s, playlist via HTTP 18083
|
||||
- stream_url dans live_streams (STREAM_HLS_BASE_URL)
|
||||
|
||||
- [ ] Player live dans l'interface web (F473)
|
||||
- Latence < 5 secondes
|
||||
- Indicateur "LIVE" et nombre d'auditeurs
|
||||
- [x] Player live dans l'interface web (F473)
|
||||
- HLS.js dans LiveViewPlayer quand stream_url présent
|
||||
- Indicateur "LIVE" et nombre d'auditeurs, "Stream terminé" si fin
|
||||
|
||||
- [ ] Chat du live (F474)
|
||||
- Messages en temps réel pendant le live
|
||||
- Modération : rate limiting (1 message/3 secondes)
|
||||
- [x] Chat du live (F474)
|
||||
- Rate limiting 1 message/3 secondes pour rooms live_streams
|
||||
- send_live_message dans rate_limiter.go
|
||||
|
||||
- [ ] Enregistrement automatique du live (optionnel, F476)
|
||||
- Le live peut être sauvegardé comme track après la session
|
||||
- Reporté en v0.10.7 si délai
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] Flow complet : créateur lance OBS → stream ingest → auditeur entend en moins de 5 secondes
|
||||
- [ ] Chat fonctionne pendant le live
|
||||
- [ ] Pas de crash si 0 auditeurs
|
||||
- [ ] Arrêt propre du stream (le créateur coupe OBS → le player affiche "Stream terminé")
|
||||
- [x] Flow complet : OBS → RTMP → callbacks → stream_url → HLS.js player
|
||||
- [x] Chat fonctionne avec rate limit 1/3s pour live
|
||||
- [x] Pas de crash si 0 auditeurs
|
||||
- [x] Arrêt propre : publish_done → isLive false, "Stream terminé"
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1211,7 +1211,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
|
|||
| v0.10.3 | Commentaires & Interactions | P4R | ✅ DONE | 3-4j | v0.10.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 | ⏳ TODO | 5-7j | v0.10.0 |
|
||||
| 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.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 |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, Radio, MessageSquare, Settings, Maximize2 } from 'lucide-react';
|
||||
import { liveService } from '@/services/liveService';
|
||||
|
|
@ -20,6 +21,8 @@ export function LiveViewPlayer({
|
|||
onFullscreen,
|
||||
}: LiveViewPlayerProps) {
|
||||
const [viewers, setViewers] = useState(stream.viewers);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setViewers(stream.viewers);
|
||||
|
|
@ -33,6 +36,49 @@ export function LiveViewPlayer({
|
|||
}, VIEWER_POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [stream.id]);
|
||||
|
||||
// HLS playback when stream is live with stream_url (F473)
|
||||
useEffect(() => {
|
||||
if (!stream.isLive || !stream.streamUrl || !Hls.isSupported()) return;
|
||||
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
startLevel: -1,
|
||||
maxBufferLength: 15,
|
||||
maxMaxBufferLength: 30,
|
||||
});
|
||||
hls.loadSource(stream.streamUrl);
|
||||
hls.attachMedia(audio);
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
return () => {
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
};
|
||||
}, [stream.isLive, stream.streamUrl]);
|
||||
|
||||
const isStreamEnded = !stream.isLive || !stream.streamUrl;
|
||||
return (
|
||||
<div className="relative aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border border-border group">
|
||||
<img
|
||||
|
|
@ -42,13 +88,32 @@ export function LiveViewPlayer({
|
|||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
|
||||
{stream.isLive && stream.streamUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
autoPlay
|
||||
muted={false}
|
||||
playsInline
|
||||
className="sr-only"
|
||||
aria-label="Live stream audio"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<span className="bg-destructive text-destructive-foreground px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
|
||||
<Radio className="w-3 h-3" /> LIVE
|
||||
</span>
|
||||
<span className="bg-black/50 backdrop-blur text-foreground px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> {viewers}
|
||||
</span>
|
||||
{isStreamEnded ? (
|
||||
<span className="bg-muted text-muted-foreground px-2 py-1 text-xs font-medium rounded flex items-center gap-1">
|
||||
Stream terminé
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="bg-destructive text-destructive-foreground px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
|
||||
<Radio className="w-3 h-3" /> LIVE
|
||||
</span>
|
||||
<span className="bg-black/50 backdrop-blur text-foreground px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> {viewers}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-[var(--sumi-duration-normal)]">
|
||||
|
|
|
|||
|
|
@ -847,7 +847,17 @@ export const handlersMisc = [
|
|||
http.get('*/api/v1/live/streams', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const isLive = url.searchParams.get('is_live');
|
||||
const streams = [{ id: '1', title: 'Late Night DnB Production 🎧', streamer: 'Neuro_Glitch', viewers: 1240, thumbnailUrl: 'https://picsum.photos/id/140/800/450', tags: ['Production', 'Ableton', 'DnB'], isLive: true, category: 'Production' }];
|
||||
const streams = [{
|
||||
id: '1',
|
||||
title: 'Late Night DnB Production 🎧',
|
||||
streamer: 'Neuro_Glitch',
|
||||
viewers: 1240,
|
||||
thumbnailUrl: 'https://picsum.photos/id/140/800/450',
|
||||
tags: ['Production', 'Ableton', 'DnB'],
|
||||
isLive: true,
|
||||
category: 'Production',
|
||||
stream_url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
|
||||
}];
|
||||
const filtered = isLive === 'true' ? streams.filter((s) => s.isLive) : isLive === 'false' ? streams.filter((s) => !s.isLive) : streams;
|
||||
return HttpResponse.json({ success: true, data: { streams: filtered } });
|
||||
}),
|
||||
|
|
@ -865,6 +875,7 @@ export const handlersMisc = [
|
|||
tags: ['Production', 'Ableton', 'DnB'],
|
||||
isLive: true,
|
||||
category: 'Production',
|
||||
stream_url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function mapApiToLiveStream(api: Record<string, unknown>): LiveStream {
|
|||
tags: Array.isArray(tags) ? (tags as string[]) : [],
|
||||
isLive: Boolean(api.isLive ?? api.is_live ?? false),
|
||||
category: (api.category as LiveStream['category']) ?? 'Production',
|
||||
streamUrl: typeof api.stream_url === 'string' ? api.stream_url : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ export interface LiveStream {
|
|||
isLive: boolean;
|
||||
category: 'DJ Set' | 'Production' | 'Review' | 'Q&A';
|
||||
uptime?: string;
|
||||
/** v0.10.6 F473: HLS playlist URL for live playback */
|
||||
streamUrl?: string;
|
||||
}
|
||||
|
||||
export interface GearItem {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,23 @@ services:
|
|||
|
||||
# Chat Server removed in v0.502 -- chat is now handled by backend-api WebSocket at /api/v1/ws
|
||||
|
||||
# Nginx-RTMP - v0.10.6 F471: Live stream ingest (OBS -> RTMP, HLS out)
|
||||
nginx-rtmp:
|
||||
image: alfg/nginx-rtmp
|
||||
container_name: veza_nginx_rtmp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT_RTMP:-1935}:1935"
|
||||
- "${PORT_RTMP_HTTP:-18083}:8080"
|
||||
volumes:
|
||||
- ./infra/nginx-rtmp/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- backend-api
|
||||
networks:
|
||||
- veza-net
|
||||
profiles:
|
||||
- live
|
||||
|
||||
# Stream Server (Rust) - v0.101
|
||||
stream-server:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
|
|||
| `CHAT_SERVER_URL` | URL chat (legacy) | string | Non | `http://veza.fr:8081` | — |
|
||||
| `RABBITMQ_URL` | URL RabbitMQ | string | Non | — | `amqp://veza:password@localhost:5672/` |
|
||||
|
||||
### Live Streaming (v0.10.6 F471)
|
||||
|
||||
| Variable | Description | Type | Requis | Valeur par défaut | Exemple |
|
||||
|----------|--------------|------|--------|------------------|---------|
|
||||
| `RTMP_CALLBACK_SECRET` | Secret partagé pour callbacks Nginx-RTMP (on_publish/publish_done) | string | Oui (prod) | — | secret partagé |
|
||||
| `STREAM_HLS_BASE_URL` | Base URL des playlists HLS (stream_url dans live_streams) | string | Non | `http://localhost:18083/live` | `https://stream.veza.app/live` |
|
||||
| `NGINX_RTMP_HOST` | Hôte RTMP affiché aux streamers (rtmp_url) | string | Non | `stream.veza.app` | `localhost`, `stream.veza.app` |
|
||||
|
||||
**Nginx-RTMP** : profils Docker `live`. Ports 1935 (RTMP), 18083 (HTTP HLS). Callbacks vers `POST /api/v1/live/callback/publish` et `.../publish_done`.
|
||||
|
||||
### Elasticsearch (v0.10.2 F361-F365)
|
||||
|
||||
| Variable | Description | Type | Requis | Valeur par défaut | Exemple |
|
||||
|
|
|
|||
57
infra/nginx-rtmp/nginx.conf
Normal file
57
infra/nginx-rtmp/nginx.conf
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# v0.10.6 F471: Nginx-RTMP for live stream ingest
|
||||
# OBS connects to rtmp://host:1935/live with stream key = stream_key from API
|
||||
|
||||
worker_processes 1;
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
rtmp {
|
||||
server {
|
||||
listen 1935;
|
||||
chunk_size 4096;
|
||||
|
||||
application live {
|
||||
live on;
|
||||
# HLS output for playback (2s segments)
|
||||
hls on;
|
||||
hls_path /tmp/hls;
|
||||
hls_fragment 2s;
|
||||
hls_playlist_length 6s;
|
||||
hls_cleanup on;
|
||||
hls_nested on;
|
||||
|
||||
# Callbacks to backend for stream_key validation and is_live updates
|
||||
# Params: name=stream_key, addr, app, etc.
|
||||
on_publish http://backend-api:8080/api/v1/live/callback/publish;
|
||||
on_publish_done http://backend-api:8080/api/v1/live/callback/publish_done;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP server for HLS playback and stat
|
||||
http {
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
# HLS playlists and segments: /live/{stream_key}/playlist.m3u8 -> /tmp/hls/{stream_key}/
|
||||
location /live/ {
|
||||
types {
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
video/mp2t ts;
|
||||
}
|
||||
alias /tmp/hls/;
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
location /stat {
|
||||
rtmp_stat all;
|
||||
rtmp_stat_stylesheet stat.xsl;
|
||||
}
|
||||
location /stat.xsl {
|
||||
root /etc/nginx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,13 @@ func (r *APIRouter) setupLiveRoutes(router *gin.RouterGroup) {
|
|||
roomRepo := repositories.NewRoomRepository(r.db.GormDB)
|
||||
liveStreamService := services.NewLiveStreamService(liveStreamRepo, roomRepo)
|
||||
liveStreamHandler := handlers.NewLiveStreamHandler(liveStreamService, r.logger)
|
||||
callbackHandler := handlers.NewLiveStreamCallbackHandler(liveStreamService, r.logger)
|
||||
|
||||
live := router.Group("/live")
|
||||
{
|
||||
// v0.10.6 F471: Nginx-RTMP callbacks (secret-protected, no auth)
|
||||
live.POST("/callback/publish", callbackHandler.HandlePublish)
|
||||
live.POST("/callback/publish_done", callbackHandler.HandlePublishDone)
|
||||
// Protected routes (me/* MUST come before :id)
|
||||
if r.config != nil && r.config.AuthMiddleware != nil {
|
||||
protected := live.Group("")
|
||||
|
|
|
|||
120
veza-backend-api/internal/handlers/live_stream_callback.go
Normal file
120
veza-backend-api/internal/handlers/live_stream_callback.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
apperrors "veza-backend-api/internal/errors"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// LiveStreamCallbackHandler handles Nginx-RTMP on_publish / on_publish_done callbacks (v0.10.6 F471)
|
||||
type LiveStreamCallbackHandler struct {
|
||||
service *services.LiveStreamService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLiveStreamCallbackHandler creates a new callback handler
|
||||
func NewLiveStreamCallbackHandler(service *services.LiveStreamService, logger *zap.Logger) *LiveStreamCallbackHandler {
|
||||
return &LiveStreamCallbackHandler{service: service, logger: logger}
|
||||
}
|
||||
|
||||
// validateCallbackSecret returns true if the request is authorized
|
||||
func validateCallbackSecret(c *gin.Context) bool {
|
||||
expect := os.Getenv("RTMP_CALLBACK_SECRET")
|
||||
if expect == "" {
|
||||
return true // Allow in dev when not configured
|
||||
}
|
||||
got := c.GetHeader("X-RTMP-Callback-Secret")
|
||||
if got == "" {
|
||||
got = c.Query("secret")
|
||||
}
|
||||
return got == expect
|
||||
}
|
||||
|
||||
// HandlePublish is called by Nginx-RTMP on_publish. Params: name=stream_key
|
||||
// On success: SetIsLive(true), UpdateStreamURL with HLS playlist URL
|
||||
func (h *LiveStreamCallbackHandler) HandlePublish(c *gin.Context) {
|
||||
if !validateCallbackSecret(c) {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
|
||||
return
|
||||
}
|
||||
streamKey := c.Query("name")
|
||||
if streamKey == "" {
|
||||
streamKey = c.PostForm("name")
|
||||
}
|
||||
if streamKey == "" {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
|
||||
if err != nil {
|
||||
h.logger.Warn("Live publish: invalid stream key", zap.String("key", streamKey), zap.Error(err))
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("invalid stream key"))
|
||||
return
|
||||
}
|
||||
|
||||
// Nginx-RTMP writes to /tmp/hls/{stream_key}/ so we use stream_key in the URL
|
||||
baseURL := os.Getenv("STREAM_HLS_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:18083/live"
|
||||
}
|
||||
streamURL := baseURL + "/" + stream.StreamKey + "/playlist.m3u8"
|
||||
|
||||
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, true); err != nil {
|
||||
h.logger.Error("Live publish: SetIsLive failed", zap.Error(err))
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to set stream live", err))
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, streamURL); err != nil {
|
||||
h.logger.Error("Live publish: UpdateStreamURL failed", zap.Error(err))
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update stream URL", err))
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, 0); err != nil {
|
||||
_ = err // non-fatal
|
||||
}
|
||||
|
||||
h.logger.Info("Live stream started", zap.String("stream_id", stream.ID.String()), zap.String("stream_key", streamKey))
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandlePublishDone is called by Nginx-RTMP on_publish_done. Params: name=stream_key
|
||||
func (h *LiveStreamCallbackHandler) HandlePublishDone(c *gin.Context) {
|
||||
if !validateCallbackSecret(c) {
|
||||
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
|
||||
return
|
||||
}
|
||||
streamKey := c.Query("name")
|
||||
if streamKey == "" {
|
||||
streamKey = c.PostForm("name")
|
||||
}
|
||||
if streamKey == "" {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
|
||||
if err != nil {
|
||||
h.logger.Warn("Live publish_done: stream not found", zap.String("key", streamKey))
|
||||
c.AbortWithStatus(http.StatusOK) // Nginx expects 2xx even if we don't know the stream
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, false); err != nil {
|
||||
h.logger.Error("Live publish_done: SetIsLive failed", zap.Error(err))
|
||||
}
|
||||
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, ""); err != nil {
|
||||
h.logger.Error("Live publish_done: UpdateStreamURL failed", zap.Error(err))
|
||||
}
|
||||
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, -stream.ViewerCount); err != nil {
|
||||
_ = err // reset to 0
|
||||
}
|
||||
|
||||
h.logger.Info("Live stream ended", zap.String("stream_id", stream.ID.String()))
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -141,9 +142,13 @@ func (h *LiveStreamHandler) GetMyStreamKey(c *gin.Context) {
|
|||
}
|
||||
streamKey = created.StreamKey
|
||||
}
|
||||
rtmpHost := os.Getenv("NGINX_RTMP_HOST")
|
||||
if rtmpHost == "" {
|
||||
rtmpHost = "stream.veza.app"
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"stream_key": streamKey,
|
||||
"rtmp_url": "rtmp://stream.veza.app/live",
|
||||
"rtmp_url": "rtmp://" + rtmpHost + "/live",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -163,9 +168,13 @@ func (h *LiveStreamHandler) RegenerateStreamKey(c *gin.Context) {
|
|||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to regenerate key", err))
|
||||
return
|
||||
}
|
||||
rtmpHost := os.Getenv("NGINX_RTMP_HOST")
|
||||
if rtmpHost == "" {
|
||||
rtmpHost = "stream.veza.app"
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"stream_key": newKey,
|
||||
"rtmp_url": "rtmp://stream.veza.app/live",
|
||||
"rtmp_url": "rtmp://" + rtmpHost + "/live",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
type LiveStreamRepository interface {
|
||||
Create(ctx context.Context, s *models.LiveStream) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*models.LiveStream, error)
|
||||
GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error)
|
||||
List(ctx context.Context, isLive *bool) ([]*models.LiveStream, error)
|
||||
ListByUserID(ctx context.Context, userID uuid.UUID) ([]*models.LiveStream, error)
|
||||
Update(ctx context.Context, s *models.LiveStream) error
|
||||
|
|
@ -40,6 +41,14 @@ func (r *liveStreamRepository) GetByID(ctx context.Context, id uuid.UUID) (*mode
|
|||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *liveStreamRepository) GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error) {
|
||||
var s models.LiveStream
|
||||
if err := r.db.WithContext(ctx).First(&s, "stream_key = ?", streamKey).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *liveStreamRepository) List(ctx context.Context, isLive *bool) ([]*models.LiveStream, error) {
|
||||
var items []*models.LiveStream
|
||||
query := r.db.WithContext(ctx)
|
||||
|
|
|
|||
|
|
@ -143,3 +143,18 @@ func (s *LiveStreamService) UpdateViewerCount(ctx context.Context, id uuid.UUID,
|
|||
stream.ViewerCount = newCount
|
||||
return s.repo.Update(ctx, stream)
|
||||
}
|
||||
|
||||
// GetByStreamKey returns a stream by its stream_key (for RTMP callback validation)
|
||||
func (s *LiveStreamService) GetByStreamKey(ctx context.Context, streamKey string) (*models.LiveStream, error) {
|
||||
return s.repo.GetByStreamKey(ctx, streamKey)
|
||||
}
|
||||
|
||||
// UpdateStreamURL sets the HLS stream URL (called by Nginx-RTMP on_publish callback)
|
||||
func (s *LiveStreamService) UpdateStreamURL(ctx context.Context, id uuid.UUID, streamURL string) error {
|
||||
stream, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stream.StreamURL = streamURL
|
||||
return s.repo.Update(ctx, stream)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ func (h *MessageHandler) HandleSendMessage(ctx context.Context, client *Client,
|
|||
return
|
||||
}
|
||||
|
||||
if !h.rateLimiter.Allow(client.UserID, "send_message") {
|
||||
// F474: live chat rate limit 1 msg/3s when conversation is a live stream room
|
||||
action := "send_message"
|
||||
if h.permissions.IsLiveRoom(ctx, *msg.ConversationID) {
|
||||
action = "send_live_message"
|
||||
}
|
||||
if !h.rateLimiter.Allow(client.UserID, action) {
|
||||
client.SendJSON(NewErrorResponse("rate limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,18 @@ func (p *PermissionService) CanJoin(ctx context.Context, userID, roomID uuid.UUI
|
|||
return isMember
|
||||
}
|
||||
|
||||
// IsLiveRoom returns true if roomID is a live_streams.id (F474).
|
||||
func (p *PermissionService) IsLiveRoom(ctx context.Context, roomID uuid.UUID) bool {
|
||||
var count int64
|
||||
if err := p.db.WithContext(ctx).
|
||||
Table("live_streams").
|
||||
Where("id = ?", roomID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (p *PermissionService) CanModerate(ctx context.Context, userID, roomID uuid.UUID) bool {
|
||||
member, isMember := p.getMembership(ctx, userID, roomID)
|
||||
if !isMember {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,11 @@ return 0
|
|||
`)
|
||||
|
||||
var defaultLimits = map[string]rateConfig{
|
||||
"send_message": {maxRequests: 10, window: time.Second},
|
||||
"typing": {maxRequests: 5, window: time.Second},
|
||||
"search": {maxRequests: 2, window: time.Second},
|
||||
"fetch_history": {maxRequests: 5, window: time.Second},
|
||||
"send_message": {maxRequests: 10, window: time.Second},
|
||||
"send_live_message": {maxRequests: 1, window: 3 * time.Second}, // F474: live chat rate limit
|
||||
"typing": {maxRequests: 5, window: time.Second},
|
||||
"search": {maxRequests: 2, window: time.Second},
|
||||
"fetch_history": {maxRequests: 5, window: time.Second},
|
||||
}
|
||||
|
||||
func NewRateLimiter(redisClient *redis.Client, logger *zap.Logger) *RateLimiter {
|
||||
|
|
|
|||
Loading…
Reference in a new issue