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
204 lines
5.9 KiB
Go
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})
|
|
}
|