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:
parent
89cc015e54
commit
73533bea77
12 changed files with 884 additions and 34 deletions
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
180
apps/web/src/features/player/components/WaveformDisplay.tsx
Normal file
180
apps/web/src/features/player/components/WaveformDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/features/streaming/hooks/useHLSPlayer.ts
Normal file
144
apps/web/src/features/streaming/hooks/useHLSPlayer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
223
veza-backend-api/internal/services/waveform_service.go
Normal file
223
veza-backend-api/internal/services/waveform_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
Loading…
Reference in a new issue