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