237 lines
7 KiB
Go
237 lines
7 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/utils"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// HLSTranscodeService gère le transcodage HLS des tracks audio
|
|
type HLSTranscodeService struct {
|
|
outputDir string
|
|
bitrates []int
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewHLSTranscodeService crée un nouveau service de transcodage HLS
|
|
func NewHLSTranscodeService(outputDir string, logger *zap.Logger) *HLSTranscodeService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &HLSTranscodeService{
|
|
outputDir: outputDir,
|
|
bitrates: []int{128, 192, 320},
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetBitrates configure les bitrates à utiliser pour le transcodage
|
|
func (s *HLSTranscodeService) SetBitrates(bitrates []int) {
|
|
s.bitrates = bitrates
|
|
}
|
|
|
|
// TranscodeTrack transcodage un track en format HLS avec plusieurs qualités
|
|
func (s *HLSTranscodeService) TranscodeTrack(ctx context.Context, track *models.Track) (*models.HLSStream, error) {
|
|
if track == nil {
|
|
return nil, fmt.Errorf("track cannot be nil")
|
|
}
|
|
|
|
if track.FilePath == "" {
|
|
return nil, fmt.Errorf("track file path is empty")
|
|
}
|
|
|
|
// Vérifier que le fichier source existe
|
|
if _, err := os.Stat(track.FilePath); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("track file does not exist: %s", track.FilePath)
|
|
}
|
|
|
|
trackDir := filepath.Join(s.outputDir, fmt.Sprintf("track_%s", track.ID))
|
|
if err := os.MkdirAll(trackDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create track directory: %w", err)
|
|
}
|
|
|
|
// Cleanup en cas d'erreur
|
|
var cleanupErr error
|
|
defer func() {
|
|
if cleanupErr != nil {
|
|
// Nettoyer en cas d'erreur
|
|
if err := s.cleanupTrackDir(trackDir); err != nil {
|
|
s.logger.Error("Failed to cleanup track directory", zap.Error(err))
|
|
}
|
|
}
|
|
}()
|
|
|
|
var bitrates []int
|
|
for _, bitrate := range s.bitrates {
|
|
if err := s.transcodeBitrate(ctx, track, trackDir, bitrate); err != nil {
|
|
cleanupErr = err
|
|
return nil, fmt.Errorf("failed to transcode bitrate %dk: %w", bitrate, err)
|
|
}
|
|
bitrates = append(bitrates, bitrate)
|
|
s.logger.Info("Transcoded bitrate", zap.Int("bitrate", bitrate), zap.String("track_id", track.ID.String()))
|
|
}
|
|
|
|
playlistURL := filepath.Join(trackDir, "master.m3u8")
|
|
if err := s.generateMasterPlaylist(trackDir, bitrates); err != nil {
|
|
cleanupErr = err
|
|
return nil, fmt.Errorf("failed to generate master playlist: %w", err)
|
|
}
|
|
|
|
segmentsCount, err := s.countSegments(trackDir)
|
|
if err != nil {
|
|
cleanupErr = err
|
|
return nil, fmt.Errorf("failed to count segments: %w", err)
|
|
}
|
|
|
|
return &models.HLSStream{
|
|
TrackID: track.ID,
|
|
PlaylistURL: playlistURL,
|
|
SegmentsCount: segmentsCount,
|
|
Bitrates: models.BitrateList(bitrates),
|
|
Status: models.HLSStatusReady,
|
|
},
|
|
nil
|
|
}
|
|
|
|
// transcodeBitrate transcodage un track pour un bitrate spécifique
|
|
func (s *HLSTranscodeService) transcodeBitrate(ctx context.Context, track *models.Track, outputDir string, bitrate int) error {
|
|
qualityDir := filepath.Join(outputDir, fmt.Sprintf("%dk", bitrate))
|
|
if err := os.MkdirAll(qualityDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create quality directory: %w", err)
|
|
}
|
|
|
|
outputPattern := filepath.Join(qualityDir, "segment_%03d.ts")
|
|
playlistPath := filepath.Join(qualityDir, "playlist.m3u8")
|
|
|
|
// SECURITY: Validate paths for exec.Command
|
|
if !utils.ValidateExecPath(track.FilePath) || !utils.ValidateExecPath(playlistPath) {
|
|
return fmt.Errorf("invalid file path")
|
|
}
|
|
|
|
// Commande ffmpeg pour transcoder en HLS
|
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-i", track.FilePath,
|
|
"-codec:a", "aac",
|
|
"-b:a", fmt.Sprintf("%dk", bitrate),
|
|
"-hls_time", "10",
|
|
"-hls_playlist_type", "vod",
|
|
"-hls_segment_filename", outputPattern,
|
|
"-hls_list_size", "0", // Inclure tous les segments
|
|
"-y", // Overwrite output files
|
|
playlistPath,
|
|
)
|
|
|
|
// Capturer la sortie pour le logging
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
s.logger.Error("FFmpeg transcoding failed",
|
|
zap.Int("bitrate", bitrate),
|
|
zap.String("track_id", track.ID.String()),
|
|
zap.String("output", string(output)),
|
|
zap.Error(err))
|
|
return fmt.Errorf("ffmpeg failed: %w", err)
|
|
}
|
|
|
|
// Vérifier que le fichier playlist a été créé
|
|
if _, err := os.Stat(playlistPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("playlist file was not created: %s", playlistPath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateMasterPlaylist génère le fichier master.m3u8 avec toutes les qualités
|
|
func (s *HLSTranscodeService) generateMasterPlaylist(trackDir string, bitrates []int) error {
|
|
masterPlaylistPath := filepath.Join(trackDir, "master.m3u8")
|
|
|
|
var lines []string
|
|
lines = append(lines, "#EXTM3U")
|
|
lines = append(lines, "#EXT-X-VERSION:3")
|
|
|
|
for _, bitrate := range bitrates {
|
|
qualityDir := fmt.Sprintf("%dk", bitrate)
|
|
playlistPath := filepath.Join(qualityDir, "playlist.m3u8")
|
|
|
|
// Ajouter l'entrée pour cette qualité
|
|
lines = append(lines, fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d000", bitrate))
|
|
lines = append(lines, playlistPath)
|
|
}
|
|
|
|
content := strings.Join(lines, "\n") + "\n"
|
|
|
|
if err := os.WriteFile(masterPlaylistPath, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("failed to write master playlist: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getPlaylistDuration lit la durée totale d'une playlist .m3u8
|
|
func (s *HLSTranscodeService) getPlaylistDuration(playlistPath string) float64 {
|
|
data, err := os.ReadFile(playlistPath)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
var totalDuration float64
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "#EXTINF:") {
|
|
// Format: #EXTINF:10.0,
|
|
parts := strings.Split(line, ":")
|
|
if len(parts) > 1 {
|
|
durationStr := strings.TrimSuffix(parts[1], ",")
|
|
var duration float64
|
|
if _, err := fmt.Sscanf(durationStr, "%f", &duration); err == nil {
|
|
totalDuration += duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return totalDuration
|
|
}
|
|
|
|
// countSegments compte le nombre de segments .ts dans le répertoire du track
|
|
// T0344: Compte les segments dans chaque répertoire de qualité et retourne le maximum
|
|
func (s *HLSTranscodeService) countSegments(trackDir string) (int, error) {
|
|
// Check if track directory exists
|
|
if _, err := os.Stat(trackDir); os.IsNotExist(err) {
|
|
return 0, fmt.Errorf("track directory does not exist: %s", trackDir)
|
|
}
|
|
|
|
count := 0
|
|
for _, bitrate := range s.bitrates {
|
|
qualityDir := filepath.Join(trackDir, fmt.Sprintf("%dk", bitrate))
|
|
files, err := filepath.Glob(filepath.Join(qualityDir, "segment_*.ts"))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to glob segments in %s: %w", qualityDir, err)
|
|
}
|
|
if len(files) > count {
|
|
count = len(files)
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// cleanupTrackDir supprime le répertoire d'un track en cas d'erreur
|
|
func (s *HLSTranscodeService) cleanupTrackDir(trackDir string) error {
|
|
return os.RemoveAll(trackDir)
|
|
}
|
|
|
|
// CleanupTrackDir supprime le répertoire d'un track (méthode publique)
|
|
func (s *HLSTranscodeService) CleanupTrackDir(trackID uuid.UUID) error {
|
|
trackDir := filepath.Join(s.outputDir, fmt.Sprintf("track_%s", trackID))
|
|
return s.cleanupTrackDir(trackDir)
|
|
}
|