veza/veza-backend-api/internal/core/discover/handler.go
senke ac182d9f35
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
feat(v0.10.4): Playlists collaboratives - F136, F140, F141, F143, F145
Backend:
- F141: GET /discover/playlists/editorial for editorial playlists
- F143: GET /playlists/shared/:token (public, no auth)
- F145: POST /playlists/import (JSON), GET /playlists/:id/export/m3u
- F136: GET /playlists/favoris (creates Favoris playlist if needed)
- Repo: GetFavorisByUserID, service GetOrCreateFavorisPlaylist

Frontend:
- SharedPlaylistPage at /playlists/shared/:token (public route)
- Editorial playlists section in DiscoverPage
- Export M3U in ExportPlaylistButton dropdown
- Import JSON via ImportPlaylistButton (PlaylistListPage)
- Favoris sidebar link, FavorisRedirectPage, AddToFavorisButton on tracks

Roadmap: v0.10.4 marked DONE
2026-03-09 16:49:05 +01:00

204 lines
5.9 KiB
Go

package discover
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/handlers"
)
// Handler v0.10.1 F351-F355: discover by genre/tag, follow genre/tag
type Handler struct {
service *Service
}
// NewHandler creates a discover handler
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// parseLimitCursor parses limit (default 20, max 50) and cursor from query
func parseLimitCursor(c *gin.Context) (limit int, cursor string) {
limitStr := c.DefaultQuery("limit", "20")
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
} else {
limit = 20
}
cursor = c.Query("cursor")
return limit, cursor
}
// GetTracksByGenre GET /api/v1/discover/genre/:genre
func (h *Handler) GetTracksByGenre(c *gin.Context) {
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
limit, cursor := parseLimitCursor(c)
tracks, nextCursor, err := h.service.GetTracksByGenre(c.Request.Context(), genreSlug, limit, cursor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("genre"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get tracks by genre", err))
return
}
resp := gin.H{"items": tracks}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// GetTracksByTag GET /api/v1/discover/tag/:tag
func (h *Handler) GetTracksByTag(c *gin.Context) {
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
limit, cursor := parseLimitCursor(c)
tracks, nextCursor, err := h.service.GetTracksByTag(c.Request.Context(), tagName, limit, cursor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("tag"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get tracks by tag", err))
return
}
resp := gin.H{"items": tracks}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// GetEditorialPlaylists GET /api/v1/discover/playlists/editorial (v0.10.4 F141)
func (h *Handler) GetEditorialPlaylists(c *gin.Context) {
limit, cursor := parseLimitCursor(c)
playlists, nextCursor, err := h.service.GetEditorialPlaylists(c.Request.Context(), limit, cursor)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get editorial playlists", err))
return
}
resp := gin.H{"items": playlists}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// ListGenres GET /api/v1/discover/genres
func (h *Handler) ListGenres(c *gin.Context) {
genres, err := h.service.ListGenres(c.Request.Context())
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to list genres", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"genres": genres})
}
// FollowGenre POST /api/v1/discover/genre/:genre/follow
func (h *Handler) FollowGenre(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
err := h.service.FollowGenre(c.Request.Context(), userID, genreSlug)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("genre"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to follow genre", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": true})
}
// UnfollowGenre DELETE /api/v1/discover/genre/:genre/follow
func (h *Handler) UnfollowGenre(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
genreSlug := strings.TrimSpace(c.Param("genre"))
if genreSlug == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("genre is required"))
return
}
err := h.service.UnfollowGenre(c.Request.Context(), userID, genreSlug)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to unfollow genre", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": false})
}
// FollowTag POST /api/v1/discover/tag/:tag/follow
func (h *Handler) FollowTag(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
err := h.service.FollowTag(c.Request.Context(), userID, tagName)
if err != nil {
if strings.Contains(err.Error(), "not found") {
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("tag"))
return
}
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to follow tag", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": true})
}
// UnfollowTag DELETE /api/v1/discover/tag/:tag/follow
func (h *Handler) UnfollowTag(c *gin.Context) {
userID, ok := handlers.GetUserIDUUID(c)
if !ok {
return
}
tagName := strings.TrimSpace(c.Param("tag"))
if tagName == "" {
handlers.RespondWithAppError(c, apperrors.NewValidationError("tag is required"))
return
}
err := h.service.UnfollowTag(c.Request.Context(), userID, tagName)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to unfollow tag", err))
return
}
handlers.RespondSuccess(c, http.StatusOK, gin.H{"followed": false})
}