291 lines
8.5 KiB
Go
291 lines
8.5 KiB
Go
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"
|
|
"veza-backend-api/internal/utils"
|
|
)
|
|
|
|
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
|
|
}
|