feat(v0.501): Sprint 2 -- HLS production-ready

- S1-01: Add multi-bitrate streaming profiles (128k, 256k, 320k)
- S1-02: Update master.m3u8 endpoint with 3-tier quality system
- S1-03: Integrate hls.js with ABR + useHLSPlayer hook
- S1-04: Add Cache-Control headers on HLS segments and manifests
- S1-05: Create WaveformService with async generation (FFmpeg + audiowaveform)
- S1-06: Add GET /tracks/:id/waveform endpoint with Redis cache
- S1-07: Create WaveformDisplay component with story
- S1-08: Add 4 Prometheus metrics for streaming monitoring
This commit is contained in:
senke 2026-02-22 18:16:37 +01:00
parent 89cc015e54
commit 73533bea77
12 changed files with 884 additions and 34 deletions

View file

@ -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' },
];

View file

@ -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<typeof WaveformDisplay> = {
title: 'Player/WaveformDisplay',
component: WaveformDisplay,
parameters: {
layout: 'padded',
msw: {
handlers: [
http.get('*/tracks/:trackId/waveform', () => {
return HttpResponse.json(mockWaveformData);
}),
],
},
},
decorators: [
(Story) => (
<div className="w-full max-w-2xl bg-background p-4 rounded-lg">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WaveformDisplay>;
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,
},
};

View file

@ -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<HTMLDivElement>(null);
const [bars, setBars] = useState<number[]>([]);
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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className={cn(
'relative flex items-end gap-px w-full',
onSeek && 'cursor-pointer',
className
)}
style={{ height }}
onClick={handleClick}
role={onSeek ? 'slider' : undefined}
aria-valuenow={onSeek ? Math.round(progress * 100) : undefined}
aria-valuemin={onSeek ? 0 : undefined}
aria-valuemax={onSeek ? 100 : undefined}
aria-label={onSeek ? 'Seek position' : 'Waveform'}
>
{isLoading && bars.length === 0 ? (
<div className="flex items-end gap-px w-full h-full">
{Array.from({ length: barCount || 50 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-muted-foreground/20 animate-pulse rounded-t-sm"
style={{
height: `${30 + Math.random() * 40}%`,
animationDelay: `${i * 20}ms`,
}}
/>
))}
</div>
) : (
bars.map((value, i) => {
const barProgress = i / bars.length;
const isActive = barProgress <= progress;
return (
<div
key={i}
className={cn(
'flex-1 rounded-t-sm transition-colors duration-75',
isActive
? 'bg-primary'
: error
? 'bg-muted-foreground/15'
: 'bg-muted-foreground/30'
)}
style={{
height: `${Math.max(value * 100, 4)}%`,
minWidth: barWidth,
}}
/>
);
})
)}
</div>
);
}

View file

@ -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<string, number> = {
high: 320000,
medium: 256000,
low: 128000,
};
export function useHLSPlayer(
audioRef: React.RefObject<HTMLAudioElement | null>,
trackId: string | null,
streamStatus?: string,
) {
const hlsRef = useRef<Hls | null>(null);
const [state, setState] = useState<HLSPlayerState>({
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,
};
}

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -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
}

View file

@ -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<Registry>,
}
@ -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 {

View file

@ -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(),
}
}

View file

@ -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<Self> {
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]

View file

@ -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<Self> {
vec![Self::streaming_high(), Self::streaming_medium(), Self::streaming_low()]
}
/// Retourne tous les profils standards
pub fn all_defaults() -> Vec<Self> {
vec![Self::hi_res(), Self::high(), Self::medium(), Self::low(), Self::mobile()]