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