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 }