veza/veza-backend-api/internal/core/feed/service.go
senke 4a422fc4c3 feat(v0.10.1): Tags & Genres discover - F351-F355
- 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
2026-03-09 01:52:56 +01:00

93 lines
2.7 KiB
Go

package feed
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/core/discover"
"veza-backend-api/internal/models"
)
// Service provides the chronological tracks feed from followed users (v0.10.0 F210)
// v0.10.1: Optional by_genres section from discover service
type Service struct {
db *gorm.DB
logger *zap.Logger
discoverService *discover.Service
}
// NewService creates a new feed service
func NewService(db *gorm.DB, logger *zap.Logger) *Service {
return &Service{db: db, logger: logger}
}
// SetDiscoverService sets the discover service for by_genres section (v0.10.1 F355)
func (s *Service) SetDiscoverService(d *discover.Service) {
s.discoverService = d
}
// GetDiscoverService returns the discover service (for handler access)
func (s *Service) GetDiscoverService() *discover.Service {
return s.discoverService
}
// GetTracksFeed returns tracks from users that the viewer follows, chronological order, cursor pagination.
// Only includes completed, public tracks. Requires userID (authenticated).
func (s *Service) GetTracksFeed(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query := s.db.WithContext(ctx).Model(&models.Track{}).
Joins("INNER JOIN follows ON follows.followed_id = tracks.creator_id AND follows.follower_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) {
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("failed to get feed 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
}