package services import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" "veza-backend-api/internal/models" ) var ( // ErrExportFormatNotSupported est retourné quand le format d'export n'est pas supporté ErrExportFormatNotSupported = errors.New("export format not supported") // ErrSourceFileNotFound est retourné quand le fichier source n'existe pas ErrSourceFileNotFound = errors.New("source file not found") // ErrFFmpegNotAvailable est retourné quand ffmpeg n'est pas disponible ErrFFmpegNotAvailable = errors.New("ffmpeg not available") // ErrExportFailed est retourné quand l'export échoue ErrExportFailed = errors.New("export failed") ) // TrackExportService gère l'export de tracks en différents formats type TrackExportService struct { exportDir string logger *zap.Logger } // NewTrackExportService crée un nouveau service d'export de tracks func NewTrackExportService(exportDir string, logger *zap.Logger) *TrackExportService { if logger == nil { logger = zap.NewNop() } // Créer le répertoire d'export s'il n'existe pas if err := os.MkdirAll(exportDir, 0755); err != nil { logger.Warn("Failed to create export directory", zap.Error(err)) } return &TrackExportService{ exportDir: exportDir, logger: logger, } } // ExportTrack exporte un track vers le format spécifié // Si le fichier exporté existe déjà, il est retourné directement (cache) // MIGRATION UUID: Completée. TrackID en UUID. func (s *TrackExportService) ExportTrack(ctx context.Context, track *models.Track, format string) (string, error) { // Normaliser le format (minuscules) format = strings.ToLower(format) // Vérifier que le format est supporté if !s.isFormatSupported(format) { return "", ErrExportFormatNotSupported } // Vérifier que le fichier source existe if _, err := os.Stat(track.FilePath); os.IsNotExist(err) { s.logger.Error("Source file not found", zap.String("track_id", track.ID.String()), zap.String("file_path", track.FilePath)) return "", ErrSourceFileNotFound } // Vérifier si le fichier exporté existe déjà (cache) exportPath := s.getExportPath(track.ID, format) if _, err := os.Stat(exportPath); err == nil { s.logger.Info("Using cached export", zap.String("track_id", track.ID.String()), zap.String("format", format), zap.String("export_path", exportPath)) return exportPath, nil } // Si le format source est le même que le format cible, copier le fichier if strings.ToLower(track.Format) == format { return s.copyTrackFile(track, exportPath) } // Convertir avec ffmpeg return s.convertTrack(ctx, track, format, exportPath) } // copyTrackFile copie le fichier source vers le répertoire d'export func (s *TrackExportService) copyTrackFile(track *models.Track, exportPath string) (string, error) { // Créer le répertoire parent si nécessaire if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil { return "", fmt.Errorf("failed to create export directory: %w", err) } // Lire le fichier source sourceData, err := os.ReadFile(track.FilePath) if err != nil { return "", fmt.Errorf("failed to read source file: %w", err) } // Écrire le fichier exporté if err := os.WriteFile(exportPath, sourceData, 0644); err != nil { return "", fmt.Errorf("failed to write export file: %w", err) } s.logger.Info("Track file copied", zap.String("track_id", track.ID.String()), zap.String("export_path", exportPath)) return exportPath, nil } // convertTrack convertit un track vers un format différent en utilisant ffmpeg func (s *TrackExportService) convertTrack(ctx context.Context, track *models.Track, format string, exportPath string) (string, error) { // Vérifier que ffmpeg est disponible if !s.isFFmpegAvailable() { s.logger.Error("FFmpeg not available") return "", ErrFFmpegNotAvailable } // Créer le répertoire parent si nécessaire if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil { return "", fmt.Errorf("failed to create export directory: %w", err) } // Construire la commande ffmpeg codec := s.getCodec(format) bitrate := s.getBitrate(format) quality := s.getQuality(format) args := []string{ "-i", track.FilePath, "-y", // Overwrite output file } // Ajouter les options de codec if codec != "" { args = append(args, "-codec:a", codec) } // Ajouter le bitrate pour MP3 if bitrate != "" { args = append(args, "-b:a", bitrate) } // Ajouter la qualité pour FLAC if quality != "" { args = append(args, "-compression_level", quality) } // Ajouter le fichier de sortie args = append(args, exportPath) // Créer la commande avec timeout cmd := exec.CommandContext(ctx, "ffmpeg", args...) // Capturer stderr pour les logs var stderr strings.Builder cmd.Stderr = &stderr // Exécuter la conversion startTime := time.Now() err := cmd.Run() duration := time.Since(startTime) if err != nil { s.logger.Error("FFmpeg conversion failed", zap.String("track_id", track.ID.String()), zap.String("format", format), zap.String("stderr", stderr.String()), zap.Error(err), zap.Duration("duration", duration)) return "", fmt.Errorf("%w: %v", ErrExportFailed, err) } // Vérifier que le fichier exporté existe if _, err := os.Stat(exportPath); os.IsNotExist(err) { return "", fmt.Errorf("%w: output file was not created", ErrExportFailed) } s.logger.Info("Track exported successfully", zap.String("track_id", track.ID.String()), zap.String("format", format), zap.String("export_path", exportPath), zap.Duration("duration", duration)) return exportPath, nil } // getExportPath retourne le chemin du fichier exporté func (s *TrackExportService) getExportPath(trackID uuid.UUID, format string) string { filename := fmt.Sprintf("%s.%s", trackID.String(), format) return filepath.Join(s.exportDir, filename) } // isFormatSupported vérifie si le format est supporté func (s *TrackExportService) isFormatSupported(format string) bool { supportedFormats := []string{"mp3", "flac", "wav", "ogg", "aac", "m4a"} format = strings.ToLower(format) for _, f := range supportedFormats { if f == format { return true } } return false } // isFFmpegAvailable vérifie si ffmpeg est disponible func (s *TrackExportService) isFFmpegAvailable() bool { cmd := exec.Command("ffmpeg", "-version") if err := cmd.Run(); err != nil { return false } return true } // getCodec retourne le codec audio approprié pour le format func (s *TrackExportService) getCodec(format string) string { switch strings.ToLower(format) { case "mp3": return "libmp3lame" case "flac": return "flac" case "wav": return "pcm_s16le" case "ogg": return "libvorbis" case "aac", "m4a": return "aac" default: return "copy" } } // getBitrate retourne le bitrate approprié pour le format func (s *TrackExportService) getBitrate(format string) string { switch strings.ToLower(format) { case "mp3": return "192k" // Bitrate par défaut pour MP3 case "aac", "m4a": return "128k" // Bitrate par défaut pour AAC default: return "" // Pas de bitrate pour les formats lossless } } // getQuality retourne le niveau de qualité/compression pour le format func (s *TrackExportService) getQuality(format string) string { switch strings.ToLower(format) { case "flac": return "5" // Niveau de compression FLAC (0-8, 5 est un bon compromis) default: return "" // Pas de paramètre de qualité pour les autres formats } } // DeleteExport supprime un fichier exporté du cache func (s *TrackExportService) DeleteExport(trackID uuid.UUID, format string) error { exportPath := s.getExportPath(trackID, format) if err := os.Remove(exportPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete export file: %w", err) } return nil } // DeleteAllExports supprime tous les exports d'un track func (s *TrackExportService) DeleteAllExports(trackID uuid.UUID) error { supportedFormats := []string{"mp3", "flac", "wav", "ogg", "aac", "m4a"} for _, format := range supportedFormats { if err := s.DeleteExport(trackID, format); err != nil { // Log l'erreur mais continue avec les autres formats s.logger.Warn("Failed to delete export", zap.String("track_id", trackID.String()), zap.String("format", format), zap.Error(err)) } } return nil }