veza/veza-backend-api/internal/services/hls_transcode_service.go

225 lines
6.6 KiB
Go

package services
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/google/uuid"
"veza-backend-api/internal/models"
"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")
// 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) {
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)
}