- Tags déclaratifs (max 10, 30 chars) via track_tags + tags - Genres normalisés (max 3) via track_genres + taxonomy - GET /api/v1/discover/genre/:genre, tag/:tag (browse chrono) - POST/DELETE follow genre/tag - Section feed "Nouvelles sorties dans vos genres" - Track update: SyncTrackTags, SyncTrackGenres via discover service - Frontend: discoverService, FeedPage by_genres, DiscoverPage - Migration 126_tags_genres_discover - MSW handlers for discover
406 lines
13 KiB
Go
406 lines
13 KiB
Go
package discover
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
const (
|
|
MaxTagsPerTrack = 10
|
|
MaxTagLength = 30
|
|
MaxGenresPerTrack = 3
|
|
)
|
|
|
|
// Service v0.10.1 F351-F355: Tags, genres, discover, follow
|
|
type Service struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewService creates a discover service
|
|
func NewService(db *gorm.DB, logger *zap.Logger) *Service {
|
|
return &Service{db: db, logger: logger}
|
|
}
|
|
|
|
// SyncTrackTags syncs track_tags from tag names. Validates max 10, 30 chars each.
|
|
func (s *Service) SyncTrackTags(ctx context.Context, trackID uuid.UUID, tagNames []string) error {
|
|
if len(tagNames) > MaxTagsPerTrack {
|
|
return fmt.Errorf("max %d tags per track", MaxTagsPerTrack)
|
|
}
|
|
normalized := make([]string, 0, len(tagNames))
|
|
seen := make(map[string]bool)
|
|
for _, t := range tagNames {
|
|
name := strings.TrimSpace(strings.ToLower(t))
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
if len(name) > MaxTagLength {
|
|
return fmt.Errorf("tag max %d chars: %q", MaxTagLength, t)
|
|
}
|
|
normalized = append(normalized, name)
|
|
seen[name] = true
|
|
}
|
|
if len(normalized) > MaxTagsPerTrack {
|
|
return fmt.Errorf("max %d tags per track", MaxTagsPerTrack)
|
|
}
|
|
|
|
// Delete existing track_tags and decrement use_count
|
|
var existing []models.TrackTag
|
|
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Find(&existing).Error; err != nil {
|
|
return fmt.Errorf("find existing tags: %w", err)
|
|
}
|
|
for _, tt := range existing {
|
|
s.db.Model(&models.Tag{}).Where("id = ?", tt.TagID).UpdateColumn("use_count", gorm.Expr("use_count - 1"))
|
|
}
|
|
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Delete(&models.TrackTag{}).Error; err != nil {
|
|
return fmt.Errorf("delete track tags: %w", err)
|
|
}
|
|
|
|
// Denormalized array for tracks.tags (backward compat)
|
|
tagsArray := make([]string, 0, len(normalized))
|
|
|
|
for _, name := range normalized {
|
|
var tag models.Tag
|
|
err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error
|
|
if err == gorm.ErrRecordNotFound {
|
|
tag = models.Tag{Name: name}
|
|
if err := s.db.WithContext(ctx).Create(&tag).Error; err != nil {
|
|
return fmt.Errorf("create tag %q: %w", name, err)
|
|
}
|
|
} else if err != nil {
|
|
return fmt.Errorf("find tag %q: %w", name, err)
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&models.TrackTag{TrackID: trackID, TagID: tag.ID}).Error; err != nil {
|
|
return fmt.Errorf("link tag: %w", err)
|
|
}
|
|
s.db.Model(&models.Tag{}).Where("id = ?", tag.ID).UpdateColumn("use_count", gorm.Expr("use_count + 1"))
|
|
tagsArray = append(tagsArray, tag.Name)
|
|
}
|
|
|
|
// Update denormalized tracks.tags
|
|
return s.db.WithContext(ctx).Model(&models.Track{}).Where("id = ?", trackID).
|
|
Update("tags", tagsArray).Error
|
|
}
|
|
|
|
// SyncTrackGenres syncs track_genres from genre slugs. Validates max 3, must exist in taxonomy.
|
|
func (s *Service) SyncTrackGenres(ctx context.Context, trackID uuid.UUID, genreSlugs []string) error {
|
|
if len(genreSlugs) > MaxGenresPerTrack {
|
|
return fmt.Errorf("max %d genres per track", MaxGenresPerTrack)
|
|
}
|
|
normalized := make([]string, 0, len(genreSlugs))
|
|
seen := make(map[string]bool)
|
|
for _, g := range genreSlugs {
|
|
slug := strings.TrimSpace(strings.ToLower(g))
|
|
if slug == "" || seen[slug] {
|
|
continue
|
|
}
|
|
var genre models.Genre
|
|
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&genre).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("unknown genre: %q", slug)
|
|
}
|
|
return fmt.Errorf("find genre: %w", err)
|
|
}
|
|
normalized = append(normalized, genre.Slug)
|
|
seen[slug] = true
|
|
}
|
|
if len(normalized) > MaxGenresPerTrack {
|
|
return fmt.Errorf("max %d genres per track", MaxGenresPerTrack)
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).Delete(&models.TrackGenre{}).Error; err != nil {
|
|
return fmt.Errorf("delete track genres: %w", err)
|
|
}
|
|
for i, slug := range normalized {
|
|
if err := s.db.WithContext(ctx).Create(&models.TrackGenre{
|
|
TrackID: trackID,
|
|
GenreSlug: slug,
|
|
Position: i,
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("link genre: %w", err)
|
|
}
|
|
}
|
|
|
|
// Update legacy tracks.genre (first genre)
|
|
primary := ""
|
|
if len(normalized) > 0 {
|
|
primary = normalized[0]
|
|
}
|
|
return s.db.WithContext(ctx).Model(&models.Track{}).Where("id = ?", trackID).
|
|
Update("genre", primary).Error
|
|
}
|
|
|
|
// ListGenres returns all genres from taxonomy
|
|
func (s *Service) ListGenres(ctx context.Context) ([]*models.Genre, error) {
|
|
var list []*models.Genre
|
|
if err := s.db.WithContext(ctx).Order("name").Find(&list).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// GetTracksByGenre F353: browse by genre, chronological, cursor pagination
|
|
func (s *Service) GetTracksByGenre(ctx context.Context, genreSlug string, limit int, cursor string) ([]*models.Track, string, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
slug := strings.TrimSpace(strings.ToLower(genreSlug))
|
|
var g models.Genre
|
|
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&g).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, "", fmt.Errorf("genre not found: %s", slug)
|
|
}
|
|
return nil, "", err
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&models.Track{}).
|
|
Joins("INNER JOIN track_genres ON track_genres.track_id = tracks.id AND track_genres.genre_slug = ?", g.Slug).
|
|
Where("tracks.status = ?", models.TrackStatusCompleted).
|
|
Where("tracks.is_public = ?", true)
|
|
|
|
var cursorCreatedAt int64
|
|
var cursorID uuid.UUID
|
|
if cursor != "" {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err == nil {
|
|
parts := strings.SplitN(string(decoded), "|", 2)
|
|
if len(parts) == 2 {
|
|
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
|
cursorCreatedAt = ts
|
|
}
|
|
if uid, err := uuid.Parse(parts[1]); err == nil {
|
|
cursorID = uid
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
|
|
query = query.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
|
|
}
|
|
query = query.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1)
|
|
|
|
var tracks []*models.Track
|
|
if err := query.Preload("User").Find(&tracks).Error; err != nil {
|
|
return nil, "", fmt.Errorf("get tracks by genre: %w", err)
|
|
}
|
|
|
|
var nextCursor string
|
|
if len(tracks) > limit {
|
|
last := tracks[limit-1]
|
|
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
|
|
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
|
|
tracks = tracks[:limit]
|
|
}
|
|
return tracks, nextCursor, nil
|
|
}
|
|
|
|
// GetTracksByTag F353: browse by tag, chronological, cursor pagination
|
|
func (s *Service) GetTracksByTag(ctx context.Context, tagName string, limit int, cursor string) ([]*models.Track, string, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
name := strings.TrimSpace(strings.ToLower(tagName))
|
|
var tag models.Tag
|
|
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, "", fmt.Errorf("tag not found: %s", tagName)
|
|
}
|
|
return nil, "", err
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&models.Track{}).
|
|
Joins("INNER JOIN track_tags ON track_tags.track_id = tracks.id AND track_tags.tag_id = ?", tag.ID).
|
|
Where("tracks.status = ?", models.TrackStatusCompleted).
|
|
Where("tracks.is_public = ?", true)
|
|
|
|
var cursorCreatedAt int64
|
|
var cursorID uuid.UUID
|
|
if cursor != "" {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err == nil {
|
|
parts := strings.SplitN(string(decoded), "|", 2)
|
|
if len(parts) == 2 {
|
|
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
|
cursorCreatedAt = ts
|
|
}
|
|
if uid, err := uuid.Parse(parts[1]); err == nil {
|
|
cursorID = uid
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
|
|
query = query.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
|
|
}
|
|
query = query.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1)
|
|
|
|
var tracks []*models.Track
|
|
if err := query.Preload("User").Find(&tracks).Error; err != nil {
|
|
return nil, "", fmt.Errorf("get tracks by tag: %w", err)
|
|
}
|
|
|
|
var nextCursor string
|
|
if len(tracks) > limit {
|
|
last := tracks[limit-1]
|
|
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
|
|
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
|
|
tracks = tracks[:limit]
|
|
}
|
|
return tracks, nextCursor, nil
|
|
}
|
|
|
|
// FollowGenre F354
|
|
func (s *Service) FollowGenre(ctx context.Context, userID uuid.UUID, genreSlug string) error {
|
|
slug := strings.TrimSpace(strings.ToLower(genreSlug))
|
|
var g models.Genre
|
|
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&g).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("genre not found: %s", slug)
|
|
}
|
|
return err
|
|
}
|
|
return s.db.WithContext(ctx).FirstOrCreate(&models.UserGenreFollow{
|
|
UserID: userID,
|
|
GenreSlug: g.Slug,
|
|
}).Error
|
|
}
|
|
|
|
// UnfollowGenre F354
|
|
func (s *Service) UnfollowGenre(ctx context.Context, userID uuid.UUID, genreSlug string) error {
|
|
slug := strings.TrimSpace(strings.ToLower(genreSlug))
|
|
res := s.db.WithContext(ctx).Where("user_id = ? AND genre_slug = ?", userID, slug).
|
|
Delete(&models.UserGenreFollow{})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FollowTag F354
|
|
func (s *Service) FollowTag(ctx context.Context, userID uuid.UUID, tagName string) error {
|
|
name := strings.TrimSpace(strings.ToLower(tagName))
|
|
var tag models.Tag
|
|
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("tag not found: %s", tagName)
|
|
}
|
|
return err
|
|
}
|
|
return s.db.WithContext(ctx).FirstOrCreate(&models.UserTagFollow{
|
|
UserID: userID,
|
|
TagID: tag.ID,
|
|
}).Error
|
|
}
|
|
|
|
// UnfollowTag F354
|
|
func (s *Service) UnfollowTag(ctx context.Context, userID uuid.UUID, tagName string) error {
|
|
name := strings.TrimSpace(strings.ToLower(tagName))
|
|
var tag models.Tag
|
|
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil // idempotent
|
|
}
|
|
return err
|
|
}
|
|
res := s.db.WithContext(ctx).Where("user_id = ? AND tag_id = ?", userID, tag.ID).
|
|
Delete(&models.UserTagFollow{})
|
|
return res.Error
|
|
}
|
|
|
|
// IsFollowingGenre returns true if user follows the genre
|
|
func (s *Service) IsFollowingGenre(ctx context.Context, userID uuid.UUID, genreSlug string) (bool, error) {
|
|
slug := strings.TrimSpace(strings.ToLower(genreSlug))
|
|
var count int64
|
|
err := s.db.WithContext(ctx).Model(&models.UserGenreFollow{}).
|
|
Where("user_id = ? AND genre_slug = ?", userID, slug).Count(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
// IsFollowingTag returns true if user follows the tag
|
|
func (s *Service) IsFollowingTag(ctx context.Context, userID uuid.UUID, tagName string) (bool, error) {
|
|
name := strings.TrimSpace(strings.ToLower(tagName))
|
|
var tag models.Tag
|
|
if err := s.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
|
|
return false, nil
|
|
}
|
|
var count int64
|
|
err := s.db.WithContext(ctx).Model(&models.UserTagFollow{}).
|
|
Where("user_id = ? AND tag_id = ?", userID, tag.ID).Count(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
// GetTracksFromFollowedGenres F355: nouveautés dans les genres suivis
|
|
func (s *Service) GetTracksFromFollowedGenres(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
sub := s.db.WithContext(ctx).Model(&models.Track{}).
|
|
Select("DISTINCT tracks.id").
|
|
Joins("INNER JOIN track_genres ON track_genres.track_id = tracks.id").
|
|
Joins("INNER JOIN user_genre_follows ON user_genre_follows.genre_slug = track_genres.genre_slug AND user_genre_follows.user_id = ?", userID).
|
|
Where("tracks.status = ?", models.TrackStatusCompleted).
|
|
Where("tracks.is_public = ?", true)
|
|
|
|
var cursorCreatedAt int64
|
|
var cursorID uuid.UUID
|
|
if cursor != "" {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err == nil {
|
|
parts := strings.SplitN(string(decoded), "|", 2)
|
|
if len(parts) == 2 {
|
|
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
|
|
cursorCreatedAt = ts
|
|
}
|
|
if uid, err := uuid.Parse(parts[1]); err == nil {
|
|
cursorID = uid
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
|
|
sub = sub.Where("(tracks.created_at, tracks.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
|
|
}
|
|
|
|
var trackIDs []uuid.UUID
|
|
if err := sub.Order("tracks.created_at DESC, tracks.id DESC").Limit(limit + 1).Pluck("tracks.id", &trackIDs).Error; err != nil {
|
|
return nil, "", fmt.Errorf("get track ids: %w", err)
|
|
}
|
|
if len(trackIDs) == 0 {
|
|
return []*models.Track{}, "", nil
|
|
}
|
|
|
|
var tracks []*models.Track
|
|
if err := s.db.WithContext(ctx).Preload("User").Where("id IN ?", trackIDs).
|
|
Order("created_at DESC, id DESC").Find(&tracks).Error; err != nil {
|
|
return nil, "", fmt.Errorf("get tracks: %w", err)
|
|
}
|
|
|
|
var nextCursor string
|
|
if len(tracks) > limit {
|
|
last := tracks[limit-1]
|
|
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
|
|
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
|
|
tracks = tracks[:limit]
|
|
}
|
|
return tracks, nextCursor, nil
|
|
}
|