diff --git a/apps/web/src/features/player/components/QualitySelector.tsx b/apps/web/src/features/player/components/QualitySelector.tsx index 0ca46283f..832b53daa 100644 --- a/apps/web/src/features/player/components/QualitySelector.tsx +++ b/apps/web/src/features/player/components/QualitySelector.tsx @@ -26,7 +26,7 @@ export interface QualitySelectorProps { const DEFAULT_QUALITIES: QualityOption[] = [ { value: 'auto', label: 'Auto', description: 'Qualité automatique' }, { value: 'low', label: 'Faible', description: '128 kbps' }, - { value: 'medium', label: 'Moyenne', description: '192 kbps' }, + { value: 'medium', label: 'Moyenne', description: '256 kbps' }, { value: 'high', label: 'Haute', description: '320 kbps' }, { value: 'lossless', label: 'Sans perte', description: 'FLAC / WAV' }, ]; diff --git a/apps/web/src/features/player/components/WaveformDisplay.stories.tsx b/apps/web/src/features/player/components/WaveformDisplay.stories.tsx new file mode 100644 index 000000000..6bdd067b4 --- /dev/null +++ b/apps/web/src/features/player/components/WaveformDisplay.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WaveformDisplay } from './WaveformDisplay'; +import { http, HttpResponse } from 'msw'; + +const mockWaveformData = { + version: 2, + channels: 1, + sample_rate: 800, + samples_per_pixel: 1, + bits: 32, + length: 200, + data: Array.from({ length: 200 }, (_, i) => + Math.sin(i * 0.1) * 0.5 + Math.sin(i * 0.05) * 0.3 + Math.random() * 0.2 + ), +}; + +const meta: Meta = { + title: 'Player/WaveformDisplay', + component: WaveformDisplay, + parameters: { + layout: 'padded', + msw: { + handlers: [ + http.get('*/tracks/:trackId/waveform', () => { + return HttpResponse.json(mockWaveformData); + }), + ], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + trackId: 'mock-track-1', + currentTime: 0, + duration: 180, + }, +}; + +export const WithProgress: Story = { + args: { + trackId: 'mock-track-1', + currentTime: 90, + duration: 180, + }, +}; + +export const Seekable: Story = { + args: { + trackId: 'mock-track-1', + currentTime: 45, + duration: 180, + onSeek: (time: number) => console.log('Seek to', time), + }, +}; + +export const Loading: Story = { + args: { + trackId: 'loading-track', + currentTime: 0, + duration: 180, + }, + parameters: { + msw: { + handlers: [ + http.get('*/tracks/:trackId/waveform', async () => { + await new Promise((resolve) => setTimeout(resolve, 999999)); + return HttpResponse.json(mockWaveformData); + }), + ], + }, + }, +}; + +export const Error: Story = { + args: { + trackId: 'error-track', + currentTime: 60, + duration: 180, + }, + parameters: { + msw: { + handlers: [ + http.get('*/tracks/:trackId/waveform', () => { + return new HttpResponse(null, { status: 404 }); + }), + ], + }, + }, +}; + +export const CustomHeight: Story = { + args: { + trackId: 'mock-track-1', + currentTime: 30, + duration: 120, + height: 80, + }, +}; diff --git a/apps/web/src/features/player/components/WaveformDisplay.tsx b/apps/web/src/features/player/components/WaveformDisplay.tsx new file mode 100644 index 000000000..eeea13c4c --- /dev/null +++ b/apps/web/src/features/player/components/WaveformDisplay.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { cn } from '@/lib/utils'; + +interface WaveformDisplayProps { + trackId: string; + currentTime: number; + duration: number; + onSeek?: (time: number) => void; + className?: string; + height?: number; + barWidth?: number; + barGap?: number; + activeColor?: string; + inactiveColor?: string; +} + +interface WaveformDataResponse { + data: number[]; + length: number; + sample_rate: number; + samples_per_pixel: number; +} + +function normalizeWaveformData(raw: number[], targetBars: number): number[] { + if (raw.length === 0) return Array(targetBars).fill(0); + + const step = raw.length / targetBars; + const normalized: number[] = []; + let maxVal = 0; + + for (let i = 0; i < targetBars; i++) { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + let sum = 0; + let count = 0; + for (let j = start; j < end && j < raw.length; j++) { + sum += Math.abs(raw[j]); + count++; + } + const avg = count > 0 ? sum / count : 0; + normalized.push(avg); + if (avg > maxVal) maxVal = avg; + } + + if (maxVal > 0) { + return normalized.map((v) => v / maxVal); + } + return normalized; +} + +export function WaveformDisplay({ + trackId, + currentTime, + duration, + onSeek, + className, + height = 48, + barWidth = 2, + barGap = 1, +}: WaveformDisplayProps) { + const containerRef = useRef(null); + const [bars, setBars] = useState([]); + const [barCount, setBarCount] = useState(100); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 300; + setBarCount(Math.floor(width / (barWidth + barGap))); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [barWidth, barGap]); + + useEffect(() => { + if (!trackId || barCount === 0) return; + + let cancelled = false; + setIsLoading(true); + setError(false); + + const apiBase = import.meta.env.VITE_API_URL || '/api/v1'; + fetch(`${apiBase}/tracks/${trackId}/waveform`, { + credentials: 'include', + }) + .then((res) => { + if (!res.ok) throw new Error('Waveform not available'); + return res.json(); + }) + .then((data: WaveformDataResponse) => { + if (cancelled) return; + setBars(normalizeWaveformData(data.data, barCount)); + setIsLoading(false); + }) + .catch(() => { + if (cancelled) return; + setError(true); + setIsLoading(false); + const placeholder = Array.from({ length: barCount }, (_, i) => + 0.3 + 0.4 * Math.abs(Math.sin(i * 0.15)) + ); + setBars(placeholder); + }); + + return () => { + cancelled = true; + }; + }, [trackId, barCount]); + + const progress = duration > 0 ? currentTime / duration : 0; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!onSeek || !containerRef.current || duration === 0) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pct = x / rect.width; + onSeek(pct * duration); + }, + [onSeek, duration] + ); + + return ( +
+ {isLoading && bars.length === 0 ? ( +
+ {Array.from({ length: barCount || 50 }).map((_, i) => ( +
+ ))} +
+ ) : ( + bars.map((value, i) => { + const barProgress = i / bars.length; + const isActive = barProgress <= progress; + return ( +
+ ); + }) + )} +
+ ); +} diff --git a/apps/web/src/features/streaming/hooks/useHLSPlayer.ts b/apps/web/src/features/streaming/hooks/useHLSPlayer.ts new file mode 100644 index 000000000..2a5a1550b --- /dev/null +++ b/apps/web/src/features/streaming/hooks/useHLSPlayer.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import Hls from 'hls.js'; +import type { AudioQuality } from '@/features/player/components/QualitySelector'; +import { getHLSMasterPlaylistURL } from '@/features/streaming/services/hlsService'; +import { TokenStorage } from '@/services/tokenStorage'; + +interface HLSPlayerState { + isHLSActive: boolean; + currentLevel: number; + levels: Array<{ bitrate: number; name: string }>; + autoQuality: boolean; +} + +const QUALITY_TO_BITRATE: Record = { + high: 320000, + medium: 256000, + low: 128000, +}; + +export function useHLSPlayer( + audioRef: React.RefObject, + trackId: string | null, + streamStatus?: string, +) { + const hlsRef = useRef(null); + const [state, setState] = useState({ + isHLSActive: false, + currentLevel: -1, + levels: [], + autoQuality: true, + }); + + const attachHLS = useCallback(() => { + if (!audioRef.current || !trackId || streamStatus !== 'ready') return; + if (!Hls.isSupported()) return; + + if (hlsRef.current) { + hlsRef.current.destroy(); + } + + const hls = new Hls({ + xhrSetup: (xhr: XMLHttpRequest) => { + const token = TokenStorage.getAccessToken(); + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + }, + startLevel: -1, + abrEwmaDefaultEstimate: 256000, + maxBufferLength: 30, + maxMaxBufferLength: 60, + }); + + const masterUrl = getHLSMasterPlaylistURL(trackId); + hls.loadSource(masterUrl); + hls.attachMedia(audioRef.current); + + hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => { + const levels = data.levels.map((level) => ({ + bitrate: level.bitrate, + name: level.bitrate >= 320000 ? 'high' : level.bitrate >= 256000 ? 'medium' : 'low', + })); + setState(prev => ({ + ...prev, + isHLSActive: true, + levels, + })); + }); + + hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => { + setState(prev => ({ ...prev, currentLevel: data.level })); + }); + + 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(); + setState(prev => ({ ...prev, isHLSActive: false })); + break; + } + } + }); + + hlsRef.current = hls; + }, [audioRef, trackId, streamStatus]); + + const setQuality = useCallback((quality: AudioQuality) => { + const hls = hlsRef.current; + if (!hls) return; + + if (quality === 'auto' || quality === 'lossless') { + hls.currentLevel = -1; + setState(prev => ({ ...prev, autoQuality: true })); + return; + } + + const targetBitrate = QUALITY_TO_BITRATE[quality]; + if (!targetBitrate) return; + + const levelIndex = hls.levels.findIndex( + (level) => level.bitrate === targetBitrate + ); + if (levelIndex >= 0) { + hls.currentLevel = levelIndex; + setState(prev => ({ ...prev, autoQuality: false, currentLevel: levelIndex })); + } + }, []); + + const getCurrentQuality = useCallback((): AudioQuality => { + const hls = hlsRef.current; + if (!hls || state.autoQuality) return 'auto'; + + const level = hls.levels[state.currentLevel]; + if (!level) return 'auto'; + + if (level.bitrate >= 320000) return 'high'; + if (level.bitrate >= 256000) return 'medium'; + return 'low'; + }, [state.autoQuality, state.currentLevel]); + + useEffect(() => { + attachHLS(); + return () => { + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + }; + }, [attachHLS]); + + return { + ...state, + setQuality, + getCurrentQuality, + hlsInstance: hlsRef, + }; +} diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index d673fd576..c9fe71492 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -83,6 +83,12 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) { trackRecommendationService := services.NewTrackRecommendationService(r.db.GormDB, r.logger) trackHandler.SetTrackRecommendationService(trackRecommendationService) + waveformService := services.NewWaveformService(r.db.GormDB, r.logger, r.config.S3StorageService) + if r.config.CacheService != nil { + waveformService.SetCacheService(r.config.CacheService) + } + trackHandler.SetWaveformService(waveformService) + tracks := router.Group("/tracks") { tracks.GET("", trackHandler.ListTracks) @@ -91,6 +97,7 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) { tracks.GET("/:id", trackHandler.GetTrack) tracks.GET("/:id/lyrics", trackHandler.GetLyrics) tracks.GET("/:id/stats", trackHandler.GetTrackStats) + tracks.GET("/:id/waveform", trackHandler.GetWaveform) tracks.GET("/:id/history", trackHandler.GetTrackHistory) tracks.GET("/:id/download", trackHandler.DownloadTrack) tracks.GET("/shared/:token", trackHandler.GetSharedTrack) diff --git a/veza-backend-api/internal/core/track/handler.go b/veza-backend-api/internal/core/track/handler.go index a21a4332d..c9c93b77c 100644 --- a/veza-backend-api/internal/core/track/handler.go +++ b/veza-backend-api/internal/core/track/handler.go @@ -43,6 +43,7 @@ type TrackHandler struct { licenseChecker services.TrackDownloadLicenseChecker // A04: Verify paid track download rights notificationService *services.NotificationService // Phase 2.2: Optional, for like notifications trackRecommendationService *services.TrackRecommendationService + waveformService *services.WaveformService } // NewTrackHandler crée un nouveau handler de tracks @@ -120,6 +121,11 @@ func (h *TrackHandler) SetTrackRecommendationService(svc *services.TrackRecommen h.trackRecommendationService = svc } +// SetWaveformService définit le service de waveform (S1-05) +func (h *TrackHandler) SetWaveformService(svc *services.WaveformService) { + h.waveformService = svc +} + // getUserID récupère l'ID utilisateur du contexte de manière sécurisée (fail-secure) // MOD-P1-RES-003: Remplace c.MustGet() pour éviter les panics // Retourne false si user_id est absent ou invalide (répond déjà avec 401) @@ -2051,6 +2057,45 @@ func (h *TrackHandler) GetTrackStats(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{"stats": resp}) } +// GetWaveform returns the waveform JSON data for a track (S1-06) +// GET /api/v1/tracks/:id/waveform +func (h *TrackHandler) GetWaveform(c *gin.Context) { + if h.waveformService == nil { + h.respondWithError(c, http.StatusInternalServerError, "waveform service not available") + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + data, err := h.waveformService.GetWaveform(c.Request.Context(), trackID) + if err != nil { + if strings.Contains(err.Error(), "not yet generated") { + h.respondWithError(c, http.StatusNotFound, "waveform not yet generated") + return + } + if strings.Contains(err.Error(), "track not found") { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get waveform") + return + } + + c.Header("Content-Type", "application/json") + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, "application/json", data) +} + // GetTrackHistory returns modification history for a track // GET /api/v1/tracks/:id/history func (h *TrackHandler) GetTrackHistory(c *gin.Context) { diff --git a/veza-backend-api/internal/services/s3_storage_service.go b/veza-backend-api/internal/services/s3_storage_service.go index 264c0c51d..4176ebe61 100644 --- a/veza-backend-api/internal/services/s3_storage_service.go +++ b/veza-backend-api/internal/services/s3_storage_service.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -225,6 +226,34 @@ func (s *S3StorageService) GetPublicURL(key string) string { return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.bucket, s.region, key) } +// DownloadFile télécharge un fichier depuis S3 et retourne son contenu +func (s *S3StorageService) DownloadFile(ctx context.Context, key string) ([]byte, error) { + if key == "" { + return nil, fmt.Errorf("key cannot be empty") + } + + result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + s.logger.Error("Failed to download file from S3", + zap.Error(err), + zap.String("key", key), + zap.String("bucket", s.bucket), + ) + return nil, fmt.Errorf("failed to download file from S3: %w", err) + } + defer result.Body.Close() + + data, err := io.ReadAll(result.Body) + if err != nil { + return nil, fmt.Errorf("failed to read S3 object body: %w", err) + } + + return data, nil +} + // ListFiles liste les fichiers dans un préfixe donné func (s *S3StorageService) ListFiles(ctx context.Context, prefix string) ([]string, error) { var keys []string diff --git a/veza-backend-api/internal/services/waveform_service.go b/veza-backend-api/internal/services/waveform_service.go new file mode 100644 index 000000000..3f387a451 --- /dev/null +++ b/veza-backend-api/internal/services/waveform_service.go @@ -0,0 +1,223 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +type WaveformData struct { + Version int `json:"version"` + Channels int `json:"channels"` + SampleRate int `json:"sample_rate"` + SamplesPerPixel int `json:"samples_per_pixel"` + Bits int `json:"bits"` + Length int `json:"length"` + Data []float64 `json:"data"` +} + +type WaveformService struct { + db *gorm.DB + logger *zap.Logger + s3Service *S3StorageService + cacheService *CacheService + tempDir string +} + +func NewWaveformService(db *gorm.DB, logger *zap.Logger, s3Service *S3StorageService) *WaveformService { + tempDir := os.TempDir() + return &WaveformService{ + db: db, + logger: logger, + s3Service: s3Service, + tempDir: tempDir, + } +} + +func (s *WaveformService) SetCacheService(cache *CacheService) { + s.cacheService = cache +} + +func (s *WaveformService) GenerateWaveformAsync(ctx context.Context, trackID uuid.UUID, inputPath string) { + go func() { + genCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := s.generateWaveform(genCtx, trackID, inputPath); err != nil { + s.logger.Error("Waveform generation failed", + zap.String("track_id", trackID.String()), + zap.Error(err), + ) + } + }() +} + +func (s *WaveformService) generateWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error { + wavPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.wav", trackID)) + jsonPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.json", trackID)) + defer os.Remove(wavPath) + defer os.Remove(jsonPath) + + ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", + "-y", "-i", inputPath, + "-ac", "1", + "-filter:a", "aresample=8000", + "-f", "wav", wavPath, + ) + if out, err := ffmpegCmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg WAV conversion failed: %w (output: %s)", err, string(out)) + } + + awCmd := exec.CommandContext(ctx, "audiowaveform", + "--input-format", "wav", + "--output-format", "json", + "--pixels-per-second", "10", + "-i", wavPath, + "-o", jsonPath, + ) + if _, err := awCmd.CombinedOutput(); err != nil { + return s.generateFallbackWaveform(ctx, trackID, inputPath) + } + + jsonData, err := os.ReadFile(jsonPath) + if err != nil { + return fmt.Errorf("failed to read waveform JSON: %w", err) + } + + s3Key := fmt.Sprintf("waveforms/%s.json", trackID) + if s.s3Service != nil { + if _, err := s.s3Service.UploadFile(ctx, jsonData, s3Key, "application/json"); err != nil { + return fmt.Errorf("failed to upload waveform to S3: %w", err) + } + } + + result := s.db.WithContext(ctx).Model(&models.Track{}). + Where("id = ?", trackID). + Update("waveform_url", s3Key) + if result.Error != nil { + return fmt.Errorf("failed to update track waveform_url: %w", result.Error) + } + + s.logger.Info("Waveform generated successfully", + zap.String("track_id", trackID.String()), + zap.String("s3_key", s3Key), + ) + return nil +} + +func (s *WaveformService) generateFallbackWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error { + s.logger.Warn("audiowaveform not available, generating fallback waveform via FFmpeg", + zap.String("track_id", trackID.String()), + ) + + pcmPath := filepath.Join(s.tempDir, fmt.Sprintf("raw_%s.pcm", trackID)) + defer os.Remove(pcmPath) + + cmd := exec.CommandContext(ctx, "ffmpeg", + "-y", "-i", inputPath, + "-ac", "1", + "-filter:a", "aresample=800", + "-f", "f32le", + "-acodec", "pcm_f32le", + pcmPath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ffmpeg PCM extraction failed: %w (output: %s)", err, string(out)) + } + + pcmData, err := os.ReadFile(pcmPath) + if err != nil { + return fmt.Errorf("failed to read PCM data: %w", err) + } + + samples := make([]float64, 0, len(pcmData)/4) + for i := 0; i+3 < len(pcmData); i += 4 { + bits := uint32(pcmData[i]) | uint32(pcmData[i+1])<<8 | uint32(pcmData[i+2])<<16 | uint32(pcmData[i+3])<<24 + val := float64(math.Float32frombits(bits)) + if val > 1.0 { + val = 1.0 + } else if val < -1.0 { + val = -1.0 + } + samples = append(samples, val) + } + + waveform := WaveformData{ + Version: 2, + Channels: 1, + SampleRate: 800, + SamplesPerPixel: 1, + Bits: 32, + Length: len(samples), + Data: samples, + } + + jsonData, err := json.Marshal(waveform) + if err != nil { + return fmt.Errorf("failed to marshal waveform: %w", err) + } + + s3Key := fmt.Sprintf("waveforms/%s.json", trackID) + if s.s3Service != nil { + if _, err := s.s3Service.UploadFile(ctx, jsonData, s3Key, "application/json"); err != nil { + return fmt.Errorf("failed to upload fallback waveform to S3: %w", err) + } + } + + result := s.db.WithContext(ctx).Model(&models.Track{}). + Where("id = ?", trackID). + Update("waveform_url", s3Key) + if result.Error != nil { + return fmt.Errorf("failed to update track waveform_url: %w", result.Error) + } + + s.logger.Info("Fallback waveform generated", + zap.String("track_id", trackID.String()), + ) + return nil +} + +func (s *WaveformService) GetWaveform(ctx context.Context, trackID uuid.UUID) ([]byte, error) { + cacheKey := fmt.Sprintf("waveform:%s", trackID) + if s.cacheService != nil { + var cached []byte + if err := s.cacheService.Get(ctx, cacheKey, &cached); err == nil { + return cached, nil + } + } + + var track models.Track + if err := s.db.WithContext(ctx).Select("waveform_url").Where("id = ?", trackID).First(&track).Error; err != nil { + return nil, fmt.Errorf("track not found: %w", err) + } + + if track.WaveformURL == nil || *track.WaveformURL == "" { + return nil, fmt.Errorf("waveform not yet generated") + } + + if s.s3Service == nil { + return nil, fmt.Errorf("S3 service not configured") + } + + data, err := s.s3Service.DownloadFile(ctx, *track.WaveformURL) + if err != nil { + return nil, fmt.Errorf("failed to download waveform from S3: %w", err) + } + + if s.cacheService != nil { + _ = s.cacheService.Set(ctx, cacheKey, data, 1*time.Hour) + } + + return data, nil +} diff --git a/veza-stream-server/src/monitoring/metrics.rs b/veza-stream-server/src/monitoring/metrics.rs index 9e7b7456b..7ab847b15 100644 --- a/veza-stream-server/src/monitoring/metrics.rs +++ b/veza-stream-server/src/monitoring/metrics.rs @@ -31,6 +31,18 @@ pub struct StreamMetrics { /// Durée des requêtes HTTP pub http_request_duration: Histogram, + /// Duration of transcoding jobs in seconds (histogram) + pub transcode_duration: Histogram, + + /// Total HLS segments served + pub hls_segments_served_total: Counter, + + /// Number of active HLS connections + pub hls_active_connections: Gauge, + + /// Total HLS errors + pub hls_errors_total: Counter, + /// Registry Prometheus pour exporter les métriques pub registry: Arc, } @@ -91,6 +103,30 @@ impl StreamMetrics { ]), )?; + let transcode_duration = Histogram::with_opts( + prometheus::HistogramOpts::new( + "transcode_duration_seconds", + "Duration of transcoding jobs in seconds", + ) + .namespace("veza_stream") + .buckets(vec![1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0]), + )?; + + let hls_segments_served_total = Counter::with_opts( + prometheus::Opts::new("hls_segments_served_total", "Total HLS segments served") + .namespace("veza_stream"), + )?; + + let hls_active_connections = Gauge::with_opts( + prometheus::Opts::new("hls_active_connections", "Number of active HLS connections") + .namespace("veza_stream"), + )?; + + let hls_errors_total = Counter::with_opts( + prometheus::Opts::new("hls_errors_total", "Total HLS errors") + .namespace("veza_stream"), + )?; + // Enregistrement des métriques registry.register(Box::new(requests_total.clone()))?; registry.register(Box::new(stream_duration.clone()))?; @@ -100,6 +136,10 @@ impl StreamMetrics { registry.register(Box::new(bytes_streamed_total.clone()))?; registry.register(Box::new(stream_errors_total.clone()))?; registry.register(Box::new(http_request_duration.clone()))?; + registry.register(Box::new(transcode_duration.clone()))?; + registry.register(Box::new(hls_segments_served_total.clone()))?; + registry.register(Box::new(hls_active_connections.clone()))?; + registry.register(Box::new(hls_errors_total.clone()))?; Ok(Self { requests_total, @@ -110,6 +150,10 @@ impl StreamMetrics { bytes_streamed_total, stream_errors_total, http_request_duration, + transcode_duration, + hls_segments_served_total, + hls_active_connections, + hls_errors_total, registry: Arc::new(registry), }) } @@ -172,6 +216,26 @@ impl StreamMetrics { pub fn record_http_request_duration(&self, duration: f64) { self.http_request_duration.observe(duration); } + + pub fn record_transcode_duration(&self, duration: f64) { + self.transcode_duration.observe(duration); + } + + pub fn inc_hls_segments_served(&self) { + self.hls_segments_served_total.inc(); + } + + pub fn inc_hls_active_connections(&self) { + self.hls_active_connections.inc(); + } + + pub fn dec_hls_active_connections(&self) { + self.hls_active_connections.dec(); + } + + pub fn inc_hls_errors(&self) { + self.hls_errors_total.inc(); + } } impl Default for StreamMetrics { diff --git a/veza-stream-server/src/routes/api.rs b/veza-stream-server/src/routes/api.rs index 5119a991f..df2b1f089 100644 --- a/veza-stream-server/src/routes/api.rs +++ b/veza-stream-server/src/routes/api.rs @@ -351,13 +351,15 @@ async fn hls_master_playlist_wrapper( let generator = HLSGenerator::new(track_id, state.config.backend_url.clone()) .with_quality(HLSQuality::high()) .with_quality(HLSQuality::medium()) - .with_quality(HLSQuality::low()) - .with_quality(HLSQuality::mobile()); + .with_quality(HLSQuality::low()); let playlist = generator.generate_master_playlist(); ( - [(header::CONTENT_TYPE, "application/vnd.apple.mpegurl")], + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CACHE_CONTROL, "public, max-age=5"), + ], playlist, ) } @@ -395,7 +397,10 @@ async fn hls_quality_playlist_wrapper( match generator.generate_quality_playlist(&quality, segment_count) { Ok(playlist) => ( - [(header::CONTENT_TYPE, "application/vnd.apple.mpegurl")], + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CACHE_CONTROL, "public, max-age=60"), + ], playlist, ).into_response(), Err(_) => (StatusCode::NOT_FOUND, "Quality not found").into_response(), @@ -421,9 +426,16 @@ async fn hls_segment_wrapper( return (StatusCode::NOT_FOUND, "Segment not found").into_response(); } - let headers = HeaderMap::new(); - match serve_partial_file(&state.config, file_path, headers).await { - Ok(response) => response, + let req_headers = HeaderMap::new(); + match serve_partial_file(&state.config, file_path, req_headers).await { + Ok(response) => { + let (mut parts, body) = response.into_parts(); + parts.headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + Response::from_parts(parts, body) + } Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response(), } } diff --git a/veza-stream-server/src/streaming/hls.rs b/veza-stream-server/src/streaming/hls.rs index f6675a291..e3b8be468 100644 --- a/veza-stream-server/src/streaming/hls.rs +++ b/veza-stream-server/src/streaming/hls.rs @@ -234,23 +234,23 @@ pub enum HLSGeneratorError { } impl HLSQuality { - /// Crée une qualité haute définition + /// Crée une qualité haute définition (v0.501: 320kbps, 48kHz) pub fn high() -> Self { Self { quality_id: "high".to_string(), - bandwidth: 320000, // 320 kbps - codecs: "mp4a.40.2".to_string(), // AAC-LC + bandwidth: 320_000, + codecs: "mp4a.40.2".to_string(), resolution: None, - audio_sample_rate: Some(44100), + audio_sample_rate: Some(48000), audio_channels: Some(2), } } - /// Crée une qualité moyenne + /// Crée une qualité moyenne (v0.501: 256kbps, 44.1kHz) pub fn medium() -> Self { Self { quality_id: "medium".to_string(), - bandwidth: 192000, // 192 kbps + bandwidth: 256_000, codecs: "mp4a.40.2".to_string(), resolution: None, audio_sample_rate: Some(44100), @@ -258,28 +258,21 @@ impl HLSQuality { } } - /// Crée une qualité basse + /// Crée une qualité basse (v0.501: 128kbps, 44.1kHz, Stereo) pub fn low() -> Self { Self { quality_id: "low".to_string(), - bandwidth: 128000, // 128 kbps + bandwidth: 128_000, codecs: "mp4a.40.2".to_string(), resolution: None, - audio_sample_rate: Some(22050), + audio_sample_rate: Some(44100), audio_channels: Some(2), } } - /// Crée une qualité mobile - pub fn mobile() -> Self { - Self { - quality_id: "mobile".to_string(), - bandwidth: 96000, // 96 kbps - codecs: "mp4a.40.2".to_string(), - resolution: None, - audio_sample_rate: Some(22050), - audio_channels: Some(1), - } + /// Returns the 3 default streaming qualities (v0.501) + pub fn streaming_defaults() -> Vec { + vec![Self::high(), Self::medium(), Self::low()] } } @@ -403,15 +396,15 @@ mod tests { fn test_hls_quality_defaults() { let high = HLSQuality::high(); assert_eq!(high.quality_id, "high"); - assert_eq!(high.bandwidth, 320000); + assert_eq!(high.bandwidth, 320_000); assert_eq!(high.codecs, "mp4a.40.2"); - assert_eq!(high.audio_sample_rate, Some(44100)); + assert_eq!(high.audio_sample_rate, Some(48000)); assert_eq!(high.audio_channels, Some(2)); - let mobile = HLSQuality::mobile(); - assert_eq!(mobile.quality_id, "mobile"); - assert_eq!(mobile.bandwidth, 96000); - assert_eq!(mobile.audio_channels, Some(1)); + let low = HLSQuality::low(); + assert_eq!(low.quality_id, "low"); + assert_eq!(low.bandwidth, 128_000); + assert_eq!(low.audio_channels, Some(2)); } #[test] @@ -463,7 +456,7 @@ mod tests { fn test_hls_quality_medium() { let medium = HLSQuality::medium(); assert_eq!(medium.quality_id, "medium"); - assert_eq!(medium.bandwidth, 192000); + assert_eq!(medium.bandwidth, 256_000); } #[test] diff --git a/veza-stream-server/src/transcoding/codecs/profiles.rs b/veza-stream-server/src/transcoding/codecs/profiles.rs index 874f0d722..d7f7dfd20 100644 --- a/veza-stream-server/src/transcoding/codecs/profiles.rs +++ b/veza-stream-server/src/transcoding/codecs/profiles.rs @@ -78,6 +78,50 @@ impl QualityProfile { } } + /// Profil Streaming High : 320kbps, 48kHz, Stereo, AAC (v0.501) + pub fn streaming_high() -> Self { + Self { + name: "streaming_high".to_string(), + bitrate: 320_000, + codec: AudioCodec::AAC, + sample_rate: 48_000, + channels: 2, + container: ContainerFormat::HLS, + hls_segment_time: Some(6), + } + } + + /// Profil Streaming Medium : 256kbps, 44.1kHz, Stereo, AAC (v0.501) + pub fn streaming_medium() -> Self { + Self { + name: "streaming_medium".to_string(), + bitrate: 256_000, + codec: AudioCodec::AAC, + sample_rate: 44_100, + channels: 2, + container: ContainerFormat::HLS, + hls_segment_time: Some(6), + } + } + + /// Profil Streaming Low : 128kbps, 44.1kHz, Stereo, AAC (v0.501) + pub fn streaming_low() -> Self { + Self { + name: "streaming_low".to_string(), + bitrate: 128_000, + codec: AudioCodec::AAC, + sample_rate: 44_100, + channels: 2, + container: ContainerFormat::HLS, + hls_segment_time: Some(6), + } + } + + /// Returns the 3 streaming profiles for multi-bitrate HLS (v0.501) + pub fn streaming_profiles() -> Vec { + vec![Self::streaming_high(), Self::streaming_medium(), Self::streaming_low()] + } + /// Retourne tous les profils standards pub fn all_defaults() -> Vec { vec![Self::hi_res(), Self::high(), Self::medium(), Self::low(), Self::mobile()]