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

271 lines
7.6 KiB
Go

package services
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// TrackStorageService gère le stockage des fichiers audio
type TrackStorageService struct {
localPath string
useS3 bool
s3Service interface{} // S3Service sera implémenté plus tard (T0224)
logger *zap.Logger
maxRetries int
retryDelay time.Duration
}
// S3Service interface pour le service S3 (à implémenter plus tard)
type S3Service interface {
UploadFile(ctx context.Context, data []byte, key string, contentType string) (string, error)
DeleteFile(ctx context.Context, key string) error
GetPresignedURL(ctx context.Context, key string) (string, error)
}
// NewTrackStorageService crée un nouveau service de stockage de tracks
func NewTrackStorageService(localPath string, useS3 bool, logger *zap.Logger) *TrackStorageService {
if localPath == "" {
localPath = "uploads/tracks"
}
if logger == nil {
logger = zap.NewNop()
}
return &TrackStorageService{
localPath: localPath,
useS3: useS3,
logger: logger,
maxRetries: 3,
retryDelay: time.Second * 2,
}
}
// SetS3Service définit le service S3 (quand il sera disponible)
func (s *TrackStorageService) SetS3Service(s3Service S3Service) {
s.s3Service = s3Service
s.useS3 = s3Service != nil
}
// GetDownloadURL retourne une URL de téléchargement (signée pour S3, relative pour local)
func (s *TrackStorageService) GetDownloadURL(ctx context.Context, filePath string) (string, error) {
if s.useS3 && s.s3Service != nil {
s3Service, ok := s.s3Service.(S3Service)
if !ok {
return "", fmt.Errorf("invalid S3 service type")
}
// On suppose que filePath contient la clé ou l'URL complète.
// Pour simplifier, on considère que filePath est la clé si on utilise S3.
// En réalité, il faudrait extraire la clé de l'URL stockée si nécessaire.
return s3Service.GetPresignedURL(ctx, filePath)
}
// Local storage: retourner le chemin tel quel (relatif)
return filePath, nil
}
// SaveTrack sauvegarde un fichier audio avec structure tracks/{user_id}/{track_id}/{filename}
// MIGRATION UUID: userID migré vers uuid.UUID, trackID reste int64
func (s *TrackStorageService) SaveTrack(ctx context.Context, userID uuid.UUID, trackID int64, fileHeader *multipart.FileHeader) (string, error) {
// Générer nom fichier unique
ext := filepath.Ext(fileHeader.Filename)
filename := fmt.Sprintf("%s%s", uuid.New().String(), ext)
// Chemin: tracks/{user_id}/{trackID}/{filename}
key := fmt.Sprintf("tracks/%s/%d/%s", userID.String(), trackID, filename)
var filePath string
var err error
// Retry logic
for attempt := 0; attempt < s.maxRetries; attempt++ {
if attempt > 0 {
s.logger.Warn("Retrying file upload",
zap.Int("attempt", attempt+1),
zap.String("user_id", userID.String()),
zap.Int64("track_id", trackID),
)
time.Sleep(s.retryDelay * time.Duration(attempt))
}
if s.useS3 && s.s3Service != nil {
filePath, err = s.saveToS3(ctx, fileHeader, key)
} else {
filePath, err = s.saveLocally(fileHeader, key)
}
if err == nil {
s.logger.Info("Track file saved successfully",
zap.String("path", filePath),
zap.String("user_id", userID.String()),
zap.Int64("track_id", trackID),
)
return filePath, nil
}
s.logger.Error("Failed to save track file",
zap.Error(err),
zap.Int("attempt", attempt+1),
zap.String("user_id", userID.String()),
zap.Int64("track_id", trackID),
)
}
return "", fmt.Errorf("failed to save track file after %d attempts: %w", s.maxRetries, err)
}
// saveToS3 sauvegarde le fichier vers S3
func (s *TrackStorageService) saveToS3(ctx context.Context, fileHeader *multipart.FileHeader, key string) (string, error) {
if s.s3Service == nil {
return "", fmt.Errorf("S3 service not configured")
}
// Ouvrir le fichier
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Lire le fichier en bytes
fileBytes := make([]byte, fileHeader.Size)
n, err := io.ReadFull(file, fileBytes)
if err != nil && err != io.ErrUnexpectedEOF {
return "", fmt.Errorf("failed to read file: %w", err)
}
fileBytes = fileBytes[:n]
// Déterminer le Content-Type
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
ext := filepath.Ext(fileHeader.Filename)
contentType = s.getContentTypeFromExtension(ext)
}
// Upload vers S3
s3Service, ok := s.s3Service.(S3Service)
if !ok {
return "", fmt.Errorf("invalid S3 service type")
}
url, err := s3Service.UploadFile(ctx, fileBytes, key, contentType)
if err != nil {
return "", fmt.Errorf("failed to upload to S3: %w", err)
}
return url, nil
}
// saveLocally sauvegarde le fichier localement
func (s *TrackStorageService) saveLocally(fileHeader *multipart.FileHeader, key string) (string, error) {
// Chemin complet local
destPath := filepath.Join(s.localPath, key)
// Créer les répertoires nécessaires
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err)
}
// Ouvrir le fichier source
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Créer le fichier de destination
destFile, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("failed to create file: %w", err)
}
defer destFile.Close()
// Copier le contenu
if _, err := io.Copy(destFile, file); err != nil {
// Nettoyer en cas d'erreur
os.Remove(destPath)
return "", fmt.Errorf("failed to save file: %w", err)
}
// Retourner le chemin relatif pour l'URL
relativePath := fmt.Sprintf("/uploads/%s", key)
return relativePath, nil
}
// DeleteTrack supprime un fichier audio
func (s *TrackStorageService) DeleteTrack(ctx context.Context, userID, trackID int64, filename string) error {
key := fmt.Sprintf("tracks/%d/%d/%s", userID, trackID, filename)
if s.useS3 && s.s3Service != nil {
return s.deleteFromS3(ctx, key)
}
return s.deleteLocally(key)
}
// deleteFromS3 supprime le fichier de S3
func (s *TrackStorageService) deleteFromS3(ctx context.Context, key string) error {
if s.s3Service == nil {
return fmt.Errorf("S3 service not configured")
}
s3Service, ok := s.s3Service.(S3Service)
if !ok {
return fmt.Errorf("invalid S3 service type")
}
if err := s3Service.DeleteFile(ctx, key); err != nil {
return fmt.Errorf("failed to delete from S3: %w", err)
}
return nil
}
// deleteLocally supprime le fichier localement
func (s *TrackStorageService) deleteLocally(key string) error {
destPath := filepath.Join(s.localPath, key)
if err := os.Remove(destPath); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to delete file: %w", err)
}
// Le fichier n'existe pas, considérer comme succès
}
return nil
}
// getContentTypeFromExtension retourne le Content-Type basé sur l'extension
func (s *TrackStorageService) getContentTypeFromExtension(ext string) string {
ext = filepath.Ext(ext)
switch ext {
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".wav":
return "audio/wav"
case ".ogg":
return "audio/ogg"
case ".m4a", ".aac":
return "audio/m4a"
default:
return "application/octet-stream"
}
}
// GenerateTrackKey génère une clé S3 pour un track
func (s *TrackStorageService) GenerateTrackKey(userID, trackID int64, filename string) string {
ext := filepath.Ext(filename)
if ext == "" {
ext = ".mp3" // Par défaut
}
uniqueFilename := fmt.Sprintf("%s%s", uuid.New().String(), ext)
return fmt.Sprintf("tracks/%d/%d/%s", userID, trackID, uniqueFilename)
}