package services import ( "context" "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" "regexp" "strings" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" ) var ( ErrTrackStemNotFound = errors.New("track stem not found") ErrTrackStemForbidden = errors.New("forbidden: only track owner can manage stems") ErrTrackStemInvalidExt = errors.New("invalid stem format: only wav, aiff, flac allowed") ) // Allowed stem formats (v0.10.7 F482) var stemFormats = map[string]string{ ".wav": "WAV", ".aiff": "AIFF", ".aif": "AIFF", ".flac": "FLAC", } // TrackStemService manages stem upload/download for tracks (v0.10.7 F482) type TrackStemService struct { db *gorm.DB uploadDir string logger *zap.Logger } // NewTrackStemService creates a new TrackStemService func NewTrackStemService(db *gorm.DB, uploadDir string, logger *zap.Logger) *TrackStemService { if uploadDir == "" { uploadDir = "uploads/tracks" } stemsDir := filepath.Join(uploadDir, "stems") return &TrackStemService{db: db, uploadDir: stemsDir, logger: logger} } // stemNameRegex allows alphanumeric, underscore, hyphen var stemNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // UploadStem saves a stem file and creates a record func (s *TrackStemService) UploadStem(ctx context.Context, trackID, userID uuid.UUID, fileHeader *multipart.FileHeader, name string) (*models.TrackStem, error) { // Get track and verify ownership var track models.Track if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrTrackNotFound // from services/errors.go } return nil, err } if track.UserID != userID { return nil, ErrTrackStemForbidden } // Validate stem name if name == "" { name = strings.TrimSuffix(fileHeader.Filename, filepath.Ext(fileHeader.Filename)) } if !stemNameRegex.MatchString(name) { return nil, fmt.Errorf("%w: name must be alphanumeric, underscore or hyphen", ErrTrackStemInvalidExt) } ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) format, ok := stemFormats[ext] if !ok { return nil, ErrTrackStemInvalidExt } // Create stems dir: uploads/tracks/stems/{track_id}/ trackStemsDir := filepath.Join(s.uploadDir, trackID.String()) if err := os.MkdirAll(trackStemsDir, 0755); err != nil { s.logger.Error("Failed to create stems dir", zap.Error(err)) return nil, err } filename := fmt.Sprintf("%s_%s%s", name, uuid.New().String()[:8], ext) filePath := filepath.Join(trackStemsDir, filename) src, err := fileHeader.Open() if err != nil { return nil, err } defer src.Close() dst, err := os.Create(filePath) if err != nil { return nil, err } defer dst.Close() if _, err := io.Copy(dst, src); err != nil { os.Remove(filePath) return nil, err } stem := &models.TrackStem{ TrackID: trackID, Name: name, FilePath: filePath, Format: format, SizeBytes: fileHeader.Size, } if err := s.db.WithContext(ctx).Create(stem).Error; err != nil { os.Remove(filePath) return nil, err } return stem, nil } // ListStems returns stems for a track func (s *TrackStemService) ListStems(ctx context.Context, trackID uuid.UUID) ([]*models.TrackStem, error) { var stems []*models.TrackStem if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Order("name").Find(&stems).Error; err != nil { return nil, err } return stems, nil } // GetStem returns a stem by track_id and name for download func (s *TrackStemService) GetStem(ctx context.Context, trackID uuid.UUID, name string) (*models.TrackStem, error) { var stem models.TrackStem if err := s.db.WithContext(ctx).Where("track_id = ? AND name = ?", trackID, name).First(&stem).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrTrackStemNotFound } return nil, err } return &stem, nil } // CanAccessStem checks if user can download stem (owner or track is public - v0.10.7 F482) func (s *TrackStemService) CanAccessStem(ctx context.Context, track *models.Track, userID uuid.UUID) bool { return track.UserID == userID || track.IsPublic }