diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index bc9645051..d16f308c0 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -293,6 +293,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error { // Inventory / Gear Routes r.setupGearRoutes(v1) + // Queue Routes + r.setupQueueRoutes(v1) + // Live Streams Routes r.setupLiveRoutes(v1) diff --git a/veza-backend-api/internal/api/routes_queue.go b/veza-backend-api/internal/api/routes_queue.go new file mode 100644 index 000000000..2b4ea648e --- /dev/null +++ b/veza-backend-api/internal/api/routes_queue.go @@ -0,0 +1,28 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/services" +) + +// setupQueueRoutes configures the user playback queue routes +func (r *APIRouter) setupQueueRoutes(router *gin.RouterGroup) { + if r.config == nil || r.config.AuthMiddleware == nil { + return + } + queueService := services.NewQueueService(r.db.GormDB, r.logger) + queueHandler := handlers.NewQueueHandler(queueService, r.logger) + + queue := router.Group("/queue") + queue.Use(r.config.AuthMiddleware.RequireAuth()) + r.applyCSRFProtection(queue) + { + queue.GET("", queueHandler.GetQueue) + queue.PUT("", queueHandler.UpdateQueue) + queue.POST("/items", queueHandler.AddQueueItem) + queue.DELETE("/items/:id", queueHandler.RemoveQueueItem) + queue.DELETE("", queueHandler.ClearQueue) + } +} diff --git a/veza-backend-api/internal/handlers/queue_handler.go b/veza-backend-api/internal/handlers/queue_handler.go new file mode 100644 index 000000000..6e960c2fa --- /dev/null +++ b/veza-backend-api/internal/handlers/queue_handler.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/services" +) + +// QueueHandler handles queue HTTP requests +type QueueHandler struct { + queueService *services.QueueService + logger *zap.Logger +} + +// NewQueueHandler creates a new QueueHandler +func NewQueueHandler(queueService *services.QueueService, logger *zap.Logger) *QueueHandler { + return &QueueHandler{queueService: queueService, logger: logger} +} + +// GetQueue returns the user's queue +func (h *QueueHandler) GetQueue(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + q, err := h.queueService.GetOrCreateQueue(c.Request.Context(), userID) + if err != nil { + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get queue", err)) + return + } + RespondSuccess(c, http.StatusOK, gin.H{ + "queue": q, + "items": q.Items, + }) +} + +// UpdateQueue updates the user's queue +func (h *QueueHandler) UpdateQueue(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + var req services.UpdateQueueRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid request body")) + return + } + q, err := h.queueService.UpdateQueue(c.Request.Context(), userID, &req) + if err != nil { + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update queue", err)) + return + } + RespondSuccess(c, http.StatusOK, gin.H{ + "queue": q, + "items": q.Items, + }) +} + +// AddQueueItemRequest represents the request body for adding an item +type AddQueueItemRequest struct { + TrackID uuid.UUID `json:"track_id" binding:"required"` +} + +// AddQueueItem adds a track to the queue +func (h *QueueHandler) AddQueueItem(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + var req AddQueueItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondWithAppError(c, apperrors.NewValidationError("track_id is required")) + return + } + item, err := h.queueService.AddToQueue(c.Request.Context(), userID, req.TrackID) + if err != nil { + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to add to queue", err)) + return + } + RespondSuccess(c, http.StatusCreated, gin.H{"item": item}) +} + +// RemoveQueueItem removes an item from the queue +func (h *QueueHandler) RemoveQueueItem(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid item ID")) + return + } + if err := h.queueService.RemoveFromQueue(c.Request.Context(), userID, id); err != nil { + RespondWithAppError(c, apperrors.NewNotFoundError("queue item not found")) + return + } + RespondSuccess(c, http.StatusOK, gin.H{"message": "item removed"}) +} + +// ClearQueue removes all items from the queue +func (h *QueueHandler) ClearQueue(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + if err := h.queueService.ClearQueue(c.Request.Context(), userID); err != nil { + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to clear queue", err)) + return + } + RespondSuccess(c, http.StatusOK, gin.H{"message": "queue cleared"}) +} diff --git a/veza-backend-api/internal/models/queue.go b/veza-backend-api/internal/models/queue.go new file mode 100644 index 000000000..47dbb8f17 --- /dev/null +++ b/veza-backend-api/internal/models/queue.go @@ -0,0 +1,60 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Queue represents a user's playback queue +type Queue struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"` + CurrentTrackID *uuid.UUID `gorm:"type:uuid" json:"current_track_id,omitempty"` + CurrentPosition int `gorm:"not null;default:0" json:"current_position"` + IsPlaying bool `gorm:"not null;default:false" json:"is_playing"` + Shuffle bool `gorm:"not null;default:false" json:"shuffle"` + RepeatMode string `gorm:"size:20;not null;default:off" json:"repeat_mode"` + Volume int `gorm:"not null;default:100" json:"volume"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Items []QueueItem `gorm:"foreignKey:QueueID;constraint:OnDelete:CASCADE" json:"items,omitempty"` +} + +// TableName defines the table name for GORM +func (Queue) TableName() string { + return "queues" +} + +// BeforeCreate GORM hook to generate UUID if not set +func (q *Queue) BeforeCreate(tx *gorm.DB) error { + if q.ID == uuid.Nil { + q.ID = uuid.New() + } + return nil +} + +// QueueItem represents a track in a queue +type QueueItem struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + QueueID uuid.UUID `gorm:"type:uuid;not null" json:"queue_id"` + TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id"` + Position int `gorm:"not null" json:"position"` + AddedAt time.Time `gorm:"autoCreateTime" json:"added_at"` + + Track Track `gorm:"foreignKey:TrackID" json:"track,omitempty"` +} + +// TableName defines the table name for GORM +func (QueueItem) TableName() string { + return "queue_items" +} + +// BeforeCreate GORM hook to generate UUID if not set +func (qi *QueueItem) BeforeCreate(tx *gorm.DB) error { + if qi.ID == uuid.Nil { + qi.ID = uuid.New() + } + return nil +} diff --git a/veza-backend-api/internal/services/queue_service.go b/veza-backend-api/internal/services/queue_service.go new file mode 100644 index 000000000..89f14cc4e --- /dev/null +++ b/veza-backend-api/internal/services/queue_service.go @@ -0,0 +1,139 @@ +package services + +import ( + "context" + "errors" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +// QueueService handles user playback queue operations +type QueueService struct { + db *gorm.DB + logger *zap.Logger +} + +// NewQueueService creates a new QueueService +func NewQueueService(db *gorm.DB, logger *zap.Logger) *QueueService { + if logger == nil { + logger = zap.NewNop() + } + return &QueueService{db: db, logger: logger} +} + +// GetOrCreateQueue returns the user's queue, creating it if it doesn't exist +func (s *QueueService) GetOrCreateQueue(ctx context.Context, userID uuid.UUID) (*models.Queue, error) { + var q models.Queue + err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&q).Error + if err == nil { + return s.loadQueueWithItems(ctx, &q) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + q = models.Queue{UserID: userID} + if err := s.db.WithContext(ctx).Create(&q).Error; err != nil { + return nil, err + } + return s.loadQueueWithItems(ctx, &q) +} + +func (s *QueueService) loadQueueWithItems(ctx context.Context, q *models.Queue) (*models.Queue, error) { + var items []models.QueueItem + if err := s.db.WithContext(ctx).Where("queue_id = ?", q.ID).Order("position ASC").Preload("Track").Find(&items).Error; err != nil { + return nil, err + } + q.Items = items + return q, nil +} + +// UpdateQueue updates queue metadata (order, current position, etc.) +func (s *QueueService) UpdateQueue(ctx context.Context, userID uuid.UUID, req *UpdateQueueRequest) (*models.Queue, error) { + q, err := s.GetOrCreateQueue(ctx, userID) + if err != nil { + return nil, err + } + if req.CurrentTrackID != nil { + q.CurrentTrackID = req.CurrentTrackID + } + if req.CurrentPosition != nil { + q.CurrentPosition = *req.CurrentPosition + } + if req.IsPlaying != nil { + q.IsPlaying = *req.IsPlaying + } + if req.Shuffle != nil { + q.Shuffle = *req.Shuffle + } + if req.RepeatMode != nil { + q.RepeatMode = *req.RepeatMode + } + if req.Volume != nil { + q.Volume = *req.Volume + } + if req.ItemOrder != nil { + for i, itemID := range req.ItemOrder { + s.db.WithContext(ctx).Model(&models.QueueItem{}).Where("id = ? AND queue_id = ?", itemID, q.ID).Update("position", i) + } + } + if err := s.db.WithContext(ctx).Save(q).Error; err != nil { + return nil, err + } + return s.loadQueueWithItems(ctx, q) +} + +// UpdateQueueRequest represents the request body for updating a queue +type UpdateQueueRequest struct { + CurrentTrackID *uuid.UUID `json:"current_track_id"` + CurrentPosition *int `json:"current_position"` + IsPlaying *bool `json:"is_playing"` + Shuffle *bool `json:"shuffle"` + RepeatMode *string `json:"repeat_mode"` + Volume *int `json:"volume"` + ItemOrder []uuid.UUID `json:"item_order"` +} + +// AddToQueue adds a track to the user's queue +func (s *QueueService) AddToQueue(ctx context.Context, userID uuid.UUID, trackID uuid.UUID) (*models.QueueItem, error) { + q, err := s.GetOrCreateQueue(ctx, userID) + if err != nil { + return nil, err + } + var maxPos int + s.db.WithContext(ctx).Model(&models.QueueItem{}).Where("queue_id = ?", q.ID).Select("COALESCE(MAX(position), -1)").Scan(&maxPos) + item := models.QueueItem{QueueID: q.ID, TrackID: trackID, Position: maxPos + 1} + if err := s.db.WithContext(ctx).Create(&item).Error; err != nil { + return nil, err + } + s.db.WithContext(ctx).Preload("Track").First(&item, item.ID) + return &item, nil +} + +// RemoveFromQueue removes an item from the queue +func (s *QueueService) RemoveFromQueue(ctx context.Context, userID uuid.UUID, itemID uuid.UUID) error { + q, err := s.GetOrCreateQueue(ctx, userID) + if err != nil { + return err + } + result := s.db.WithContext(ctx).Where("id = ? AND queue_id = ?", itemID, q.ID).Delete(&models.QueueItem{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// ClearQueue removes all items from the user's queue +func (s *QueueService) ClearQueue(ctx context.Context, userID uuid.UUID) error { + q, err := s.GetOrCreateQueue(ctx, userID) + if err != nil { + return err + } + return s.db.WithContext(ctx).Where("queue_id = ?", q.ID).Delete(&models.QueueItem{}).Error +}