veza/veza-backend-api/internal/core/discover/service.go

457 lines
15 KiB
Go
Raw Normal View History

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
}
// GetEditorialPlaylists F141 v0.10.4: playlists curatoriales visibles dans Discover
func (s *Service) GetEditorialPlaylists(ctx context.Context, limit int, cursor string) ([]*models.Playlist, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query := s.db.WithContext(ctx).Model(&models.Playlist{}).
Where("is_editorial = ?", true).
Where("is_public = ?", true).
Where("deleted_at IS NULL")
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("(playlists.created_at, playlists.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("playlists.created_at DESC, playlists.id DESC").Limit(limit + 1)
var playlists []*models.Playlist
if err := query.Preload("User").Find(&playlists).Error; err != nil {
return nil, "", fmt.Errorf("get editorial playlists: %w", err)
}
var nextCursor string
if len(playlists) > limit {
last := playlists[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
playlists = playlists[:limit]
}
return playlists, nextCursor, nil
}
// 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
}