veza/veza-backend-api/internal/services/track_export_service.go
2026-03-05 23:03:43 +01:00

292 lines
8.5 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"veza-backend-api/internal/models"
"veza-backend-api/internal/utils"
"github.com/google/uuid"
"go.uber.org/zap"
)
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
}
// SECURITY: Validate paths for exec.Command
if !utils.ValidateExecPath(track.FilePath) {
return "", fmt.Errorf("%w: invalid file path", ErrExportFailed)
}
exportPath := s.getExportPath(track.ID, format)
if !utils.ValidateExecPath(exportPath) {
return "", fmt.Errorf("%w: invalid export path", ErrExportFailed)
}
// 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)
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
}