package services import ( "context" "crypto/md5" "encoding/hex" "fmt" "io" "mime/multipart" "os" "path/filepath" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) // ChunkUploadInfo représente les informations sur un upload par chunks // MIGRATION UUID: UserID migré vers uuid.UUID type ChunkUploadInfo struct { UploadID string `json:"upload_id"` UserID uuid.UUID `json:"user_id"` TotalChunks int `json:"total_chunks"` TotalSize int64 `json:"total_size"` Filename string `json:"filename"` Chunks map[int]ChunkInfo `json:"chunks"` // chunk_number -> ChunkInfo ReceivedMD5 string `json:"received_md5,omitempty"` // MD5 du fichier final CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // ChunkInfo représente les informations sur un chunk type ChunkInfo struct { ChunkNumber int `json:"chunk_number"` Size int64 `json:"size"` MD5 string `json:"md5"` FilePath string `json:"file_path"` Received bool `json:"received"` } // UploadState représente l'état d'un upload pour la reprise // MIGRATION UUID: UserID migré vers uuid.UUID type UploadState struct { UploadID string `json:"upload_id"` UserID uuid.UUID `json:"user_id"` TotalChunks int `json:"total_chunks"` TotalSize int64 `json:"total_size"` Filename string `json:"filename"` ChunksReceived []int `json:"chunks_received"` // Liste des numéros de chunks reçus LastChunk int `json:"last_chunk"` // Dernier chunk reçu (0 si aucun) ReceivedCount int `json:"received_count"` // Nombre de chunks reçus Progress int `json:"progress"` // Pourcentage de progression (0-100) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // TrackChunkService gère l'upload par chunks de fichiers audio type TrackChunkService struct { chunksDir string store UploadStateStore logger *zap.Logger cleanupInterval time.Duration maxUploadAge time.Duration } // NewTrackChunkService crée un nouveau service de gestion d'upload par chunks // MIGRATION: Ajout de Redis Client pour le store func NewTrackChunkService(chunksDir string, redisClient *redis.Client, logger *zap.Logger) *TrackChunkService { if chunksDir == "" { chunksDir = "uploads/tracks/chunks" } if logger == nil { logger = zap.NewNop() } // 24h retention for uploads store := NewRedisUploadStore(redisClient, 24*time.Hour) service := &TrackChunkService{ chunksDir: chunksDir, store: store, logger: logger, cleanupInterval: 1 * time.Hour, maxUploadAge: 24 * time.Hour, } // Créer le répertoire de chunks if err := os.MkdirAll(chunksDir, 0755); err != nil { logger.Warn("Failed to create chunks directory", zap.Error(err)) } // Démarrer le nettoyages des FICHIERS orphelins (Garbage Collector) go service.startDiskCleanup() return service } // InitiateChunkedUpload initialise un nouvel upload par chunks func (s *TrackChunkService) InitiateChunkedUpload(userID uuid.UUID, totalChunks int, totalSize int64, filename string) (string, error) { uploadID := uuid.New().String() uploadInfo := &ChunkUploadInfo{ UploadID: uploadID, UserID: userID, TotalChunks: totalChunks, TotalSize: totalSize, Filename: filename, Chunks: make(map[int]ChunkInfo), CreatedAt: time.Now(), UpdatedAt: time.Now(), } // Save to Redis ctx := context.Background() if err := s.store.SetState(ctx, uploadInfo); err != nil { return "", fmt.Errorf("failed to initiate upload: %w", err) } s.logger.Info("Chunked upload initiated", zap.String("upload_id", uploadID), zap.String("user_id", userID.String()), zap.Int("total_chunks", totalChunks), zap.Int64("total_size", totalSize), ) return uploadID, nil } // SaveChunk sauvegarde un chunk reçu func (s *TrackChunkService) SaveChunk(ctx context.Context, uploadID string, chunkNumber int, totalChunks int, fileHeader *multipart.FileHeader) error { // 1. Get State from Redis uploadInfo, err := s.store.GetState(ctx, uploadID) if err != nil { return err } // Use mutex within memory object is Useless in distributed system, // BUT since we just fetched it and will write it back, we rely on Redis being fast. // Optimistic locking (WATCH) would be better but simple GET/SET is acceptable for P0 fix // assuming low contention per user/upload. // Vérifier que le chunk n'a pas déjà été reçu if chunk, exists := uploadInfo.Chunks[chunkNumber]; exists && chunk.Received { return fmt.Errorf("chunk %d already received", chunkNumber) } // Vérifier les paramètres if uploadInfo.TotalChunks != totalChunks { return fmt.Errorf("total chunks mismatch: expected %d, got %d", uploadInfo.TotalChunks, totalChunks) } // Créer le répertoire pour cet upload uploadDir := filepath.Join(s.chunksDir, uploadID) if err := os.MkdirAll(uploadDir, 0755); err != nil { return fmt.Errorf("failed to create upload directory: %w", err) } // Sauvegarder le chunk chunkPath := filepath.Join(uploadDir, fmt.Sprintf("chunk_%d", chunkNumber)) file, err := fileHeader.Open() if err != nil { return fmt.Errorf("failed to open chunk file: %w", err) } defer file.Close() // Créer le fichier de destination destFile, err := os.Create(chunkPath) if err != nil { return fmt.Errorf("failed to create chunk file: %w", err) } defer destFile.Close() // Calculer le MD5 pendant la copie hash := md5.New() multiWriter := io.MultiWriter(destFile, hash) if _, err := io.Copy(multiWriter, file); err != nil { os.Remove(chunkPath) return fmt.Errorf("failed to save chunk: %w", err) } chunkMD5 := hex.EncodeToString(hash.Sum(nil)) // Enregistrer les informations du chunk uploadInfo.Chunks[chunkNumber] = ChunkInfo{ ChunkNumber: chunkNumber, Size: fileHeader.Size, MD5: chunkMD5, FilePath: chunkPath, Received: true, } uploadInfo.UpdatedAt = time.Now() // Update State in Redis if err := s.store.SetState(ctx, uploadInfo); err != nil { return fmt.Errorf("failed to update upload state: %w", err) } s.logger.Info("Chunk saved", zap.String("upload_id", uploadID), zap.Int("chunk_number", chunkNumber), zap.Int64("size", fileHeader.Size), zap.String("md5", chunkMD5), ) return nil } // GetUploadInfo récupère les informations d'un upload func (s *TrackChunkService) GetUploadInfo(uploadID string) (*ChunkUploadInfo, error) { return s.store.GetState(context.Background(), uploadID) } // CompleteChunkedUpload assemble tous les chunks et crée le fichier final func (s *TrackChunkService) CompleteChunkedUpload(ctx context.Context, uploadID string, finalPath string) (string, int64, string, error) { // Get State uploadInfo, err := s.store.GetState(ctx, uploadID) if err != nil { return "", 0, "", err } // Vérifier que tous les chunks ont été reçus if len(uploadInfo.Chunks) != uploadInfo.TotalChunks { return "", 0, "", fmt.Errorf("missing chunks: received %d/%d", len(uploadInfo.Chunks), uploadInfo.TotalChunks) } // Vérifier l'ordre des chunks (1 à totalChunks) for i := 1; i <= uploadInfo.TotalChunks; i++ { chunk, exists := uploadInfo.Chunks[i] if !exists || !chunk.Received { return "", 0, "", fmt.Errorf("chunk %d is missing", i) } } // Créer le répertoire de destination if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { return "", 0, "", fmt.Errorf("failed to create destination directory: %w", err) } // Assembler les chunks finalFile, err := os.Create(finalPath) if err != nil { return "", 0, "", fmt.Errorf("failed to create final file: %w", err) } defer finalFile.Close() hash := md5.New() multiWriter := io.MultiWriter(finalFile, hash) var totalSize int64 // Assembler les chunks dans l'ordre for i := 1; i <= uploadInfo.TotalChunks; i++ { chunk := uploadInfo.Chunks[i] chunkFile, err := os.Open(chunk.FilePath) if err != nil { finalFile.Close() os.Remove(finalPath) return "", 0, "", fmt.Errorf("failed to open chunk %d: %w", i, err) } size, err := io.Copy(multiWriter, chunkFile) chunkFile.Close() if err != nil { finalFile.Close() os.Remove(finalPath) return "", 0, "", fmt.Errorf("failed to write chunk %d: %w", i, err) } totalSize += size } finalMD5 := hex.EncodeToString(hash.Sum(nil)) // Vérifier la taille totale if totalSize != uploadInfo.TotalSize { finalFile.Close() os.Remove(finalPath) return "", 0, "", fmt.Errorf("size mismatch: expected %d, got %d", uploadInfo.TotalSize, totalSize) } // Nettoyer les chunks temporaires uploadDir := filepath.Join(s.chunksDir, uploadID) if err := os.RemoveAll(uploadDir); err != nil { s.logger.Warn("Failed to cleanup chunks", zap.String("upload_id", uploadID), zap.Error(err)) } // Supprimer l'upload de Redis if err := s.store.DeleteState(ctx, uploadID); err != nil { s.logger.Warn("Failed to delete state from Redis", zap.Error(err)) } s.logger.Info("Chunked upload completed", zap.String("upload_id", uploadID), zap.String("final_path", finalPath), zap.Int64("total_size", totalSize), zap.String("md5", finalMD5), ) return uploadInfo.Filename, totalSize, finalMD5, nil } // GetUploadState récupère l'état d'un upload pour permettre la reprise func (s *TrackChunkService) GetUploadState(uploadID string) (*UploadState, error) { uploadInfo, err := s.store.GetState(context.Background(), uploadID) if err != nil { return nil, err } // Compter les chunks reçus et déterminer le dernier chunksReceived := make([]int, 0, len(uploadInfo.Chunks)) lastChunk := 0 receivedCount := 0 for chunkNum, chunk := range uploadInfo.Chunks { if chunk.Received { chunksReceived = append(chunksReceived, chunkNum) if chunkNum > lastChunk { lastChunk = chunkNum } receivedCount++ } } progress := 0 if uploadInfo.TotalChunks > 0 { progress = (receivedCount * 100) / uploadInfo.TotalChunks } return &UploadState{ UploadID: uploadInfo.UploadID, UserID: uploadInfo.UserID, TotalChunks: uploadInfo.TotalChunks, TotalSize: uploadInfo.TotalSize, Filename: uploadInfo.Filename, ChunksReceived: chunksReceived, LastChunk: lastChunk, ReceivedCount: receivedCount, Progress: progress, CreatedAt: uploadInfo.CreatedAt, UpdatedAt: uploadInfo.UpdatedAt, }, nil } // GetUploadProgress retourne la progression d'un upload par chunks func (s *TrackChunkService) GetUploadProgress(uploadID string) (int, int, error) { uploadInfo, err := s.store.GetState(context.Background(), uploadID) if err != nil { return 0, 0, err } receivedChunks := 0 for _, chunk := range uploadInfo.Chunks { if chunk.Received { receivedChunks++ } } progress := (receivedChunks * 100) / uploadInfo.TotalChunks return receivedChunks, progress, nil } // CleanupUpload supprime un upload et ses chunks func (s *TrackChunkService) CleanupUpload(uploadID string) error { // Clean from Redis _ = s.store.DeleteState(context.Background(), uploadID) // Ignore error if already deleted // Clean from Disk uploadDir := filepath.Join(s.chunksDir, uploadID) if err := os.RemoveAll(uploadDir); err != nil { return fmt.Errorf("failed to cleanup chunks: %w", err) } s.logger.Info("Upload cleaned up", zap.String("upload_id", uploadID)) return nil } // startDiskCleanup démarre le nettoyage périodique des FICHIERS orphelins (Garbage Collector) func (s *TrackChunkService) startDiskCleanup() { ticker := time.NewTicker(s.cleanupInterval) defer ticker.Stop() for range ticker.C { s.CleanupOrphanedChunks(context.Background()) } } // CleanupOrphanedChunks scan le disque et supprime les dossiers qui n'ont pas bougé depuis maxUploadAge // Ceci agit comme un Garbage Collector pour les fichiers orphelins func (s *TrackChunkService) CleanupOrphanedChunks(ctx context.Context) { s.logger.Debug("Starting orphaned chunks cleanup") entries, err := os.ReadDir(s.chunksDir) if err != nil { s.logger.Error("Failed to read chunks directory", zap.Error(err)) return } now := time.Now() deletedCount := 0 for _, entry := range entries { if !entry.IsDir() { continue } info, err := entry.Info() if err != nil { continue } // Si le dossier est plus vieux que maxUploadAge if now.Sub(info.ModTime()) > s.maxUploadAge { // On vérifie s'il existe dans Redis (au cas où Redis a été flushé mais pas les fichiers, ou TTL mismatch) // Si Redis n'a plus l'info, on considère que c'est orphelin uploadID := entry.Name() _, err := s.store.GetState(ctx, uploadID) if err != nil { // Upload not in Redis (or error), assume safe to delete if older than 24h path := filepath.Join(s.chunksDir, uploadID) if err := os.RemoveAll(path); err != nil { s.logger.Warn("Failed to delete orphaned chunk folder", zap.String("path", path), zap.Error(err)) } else { deletedCount++ s.logger.Info("Deleted orphaned upload folder", zap.String("upload_id", uploadID)) } } } } if deletedCount > 0 { s.logger.Info("Cleanup completed", zap.Int("deleted_count", deletedCount)) } }