- F481: Co-listening sessions (WebSocket sync, ListenTogether page) - F482: Stem sharing (upload/list/download wav,aiff,flac) - F483: Collaborative rooms (type collaborative, max 10, invite-only) - Roadmap: v0.10.7 → DONE
147 lines
4.1 KiB
Go
147 lines
4.1 KiB
Go
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
|
|
}
|