feat(queue): add backend queue API with CRUD operations

This commit is contained in:
senke 2026-02-19 23:44:44 +01:00
parent dc81f89338
commit 6a53daab59
5 changed files with 347 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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