veza/veza-backend-api/internal/services/track_search_service.go

208 lines
6.5 KiB
Go
Raw Normal View History

2025-12-03 19:29:37 +00:00
package services
import (
"context"
"fmt"
"strings"
"time"
"github.com/lib/pq"
"veza-backend-api/internal/database"
2025-12-03 19:29:37 +00:00
"veza-backend-api/internal/models"
"gorm.io/gorm"
2025-12-03 19:29:37 +00:00
)
// TrackSearchParams représente les paramètres de recherche de tracks
type TrackSearchParams struct {
Query string
Tags []string
TagMode string // "AND" or "OR"
MusicalKey *string
2025-12-03 19:29:37 +00:00
MinDuration *int // seconds
MaxDuration *int // seconds
MinBPM *int
MaxBPM *int
Genre *string
Format *string
MinDate *string // ISO date
MaxDate *string // ISO date
Page int
Limit int
SortBy string
SortOrder string
}
// TrackSearchService gère la recherche avancée de tracks
type TrackSearchService struct {
db *gorm.DB
}
// NewTrackSearchService crée un nouveau service de recherche de tracks
func NewTrackSearchService(db *gorm.DB) *TrackSearchService {
return &TrackSearchService{db: db}
}
// NewTrackSearchServiceWithDB crée un service de recherche avec support read replica
func NewTrackSearchServiceWithDB(db *database.Database) *TrackSearchService {
return &TrackSearchService{db: db.ForRead()}
}
2025-12-03 19:29:37 +00:00
// SearchTracks effectue une recherche avancée de tracks avec support de filtres combinés
func (s *TrackSearchService) SearchTracks(ctx context.Context, params TrackSearchParams) ([]*models.Track, int64, error) {
query := s.db.Model(&models.Track{}).Where("is_public = ? AND deleted_at IS NULL", true)
// Full-text search on title, artist, album (v0.203 Lot K: pg_trgm similarity, fallback ILIKE for SQLite)
2025-12-03 19:29:37 +00:00
if params.Query != "" {
q := strings.ToLower(strings.TrimSpace(params.Query))
if q != "" {
searchTerm := "%" + q + "%"
if s.db.Dialector.Name() == "sqlite" {
// SQLite: use LIKE (no pg_trgm)
query = query.Where(
"LOWER(title) LIKE ? OR LOWER(artist) LIKE ? OR LOWER(album) LIKE ?",
searchTerm, searchTerm, searchTerm,
)
} else {
// PostgreSQL: pg_trgm similarity for fuzzy match
query = query.Where(
`(similarity(LOWER(title), ?) > 0.1 OR similarity(LOWER(artist), ?) > 0.1 OR similarity(LOWER(album), ?) > 0.1)`,
q, q, q,
)
}
}
2025-12-03 19:29:37 +00:00
}
// Tag search (tracks.tags pq.StringArray, migration 085)
2025-12-03 19:29:37 +00:00
if len(params.Tags) > 0 {
tagArray := pq.Array(params.Tags)
if params.TagMode == "AND" {
query = query.Where("tags @> ?", tagArray)
} else {
query = query.Where("tags && ?", tagArray)
}
}
// Musical key filter (case-insensitive)
if params.MusicalKey != nil && *params.MusicalKey != "" {
query = query.Where("LOWER(musical_key) = ?", strings.ToLower(strings.TrimSpace(*params.MusicalKey)))
2025-12-03 19:29:37 +00:00
}
// Duration filter (supports combined min/max)
if params.MinDuration != nil && params.MaxDuration != nil {
// Validate that min <= max
if *params.MinDuration <= *params.MaxDuration {
query = query.Where("duration >= ? AND duration <= ?", *params.MinDuration, *params.MaxDuration)
}
} else if params.MinDuration != nil {
query = query.Where("duration >= ?", *params.MinDuration)
} else if params.MaxDuration != nil {
query = query.Where("duration <= ?", *params.MaxDuration)
}
// BPM filter (E1: BPM in Track model)
if params.MinBPM != nil && params.MaxBPM != nil {
if *params.MinBPM <= *params.MaxBPM {
query = query.Where("bpm >= ? AND bpm <= ?", *params.MinBPM, *params.MaxBPM)
}
} else if params.MinBPM != nil {
query = query.Where("bpm >= ?", *params.MinBPM)
} else if params.MaxBPM != nil {
query = query.Where("bpm <= ?", *params.MaxBPM)
2025-12-03 19:29:37 +00:00
}
// Genre filter (case-insensitive)
if params.Genre != nil && *params.Genre != "" {
query = query.Where("LOWER(genre) = ?", strings.ToLower(strings.TrimSpace(*params.Genre)))
}
// Format filter (case-insensitive)
if params.Format != nil && *params.Format != "" {
query = query.Where("LOWER(format) = ?", strings.ToLower(strings.TrimSpace(*params.Format)))
}
// Date range filter (supports combined min/max)
if params.MinDate != nil && *params.MinDate != "" {
minDate, err := time.Parse(time.RFC3339, *params.MinDate)
if err == nil {
query = query.Where("created_at >= ?", minDate)
}
}
if params.MaxDate != nil && *params.MaxDate != "" {
maxDate, err := time.Parse(time.RFC3339, *params.MaxDate)
if err == nil {
query = query.Where("created_at <= ?", maxDate)
}
}
// Count total before pagination
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count tracks: %w", err)
}
// Apply sorting with computed fields
sortOrder := "DESC"
if params.SortOrder == "asc" {
sortOrder = "ASC"
}
sortBy := params.SortBy
if sortBy == "" {
sortBy = "created_at"
}
// Handle different sorting options
switch sortBy {
case "relevance":
// When query is set, similarity() in WHERE already filters; order by play_count as proxy for relevance
// (GORM Order doesn't support placeholders for similarity-based sort)
query = query.Order("play_count DESC")
2025-12-03 19:29:37 +00:00
case "popularity":
// Sort by like_count (popularity)
query = query.Order(fmt.Sprintf("like_count %s", sortOrder))
case "play_count":
// Sort by play_count (total plays)
query = query.Order(fmt.Sprintf("play_count %s", sortOrder))
case "comment_count":
// Sort by number of comments (requires join and count)
query = query.Select("tracks.*, COALESCE(comment_counts.count, 0) as comment_count").
Joins("LEFT JOIN (SELECT track_id, COUNT(*) as count FROM track_comments WHERE deleted_at IS NULL GROUP BY track_id) as comment_counts ON comment_counts.track_id = tracks.id").
Order(fmt.Sprintf("comment_count %s", sortOrder))
case "title":
// Sort by title alphabetically (case-insensitive)
query = query.Order(fmt.Sprintf("LOWER(title) %s", sortOrder))
case "artist":
// Sort by artist alphabetically (case-insensitive)
query = query.Order(fmt.Sprintf("LOWER(artist) %s", sortOrder))
case "created_at", "updated_at", "duration":
// Direct field sorting
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
case "like_count":
// Sort by like_count (same as popularity)
query = query.Order(fmt.Sprintf("like_count %s", sortOrder))
default:
// Default to created_at
query = query.Order(fmt.Sprintf("created_at %s", sortOrder))
}
// Apply pagination
if params.Page < 1 {
params.Page = 1
}
if params.Limit < 1 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100 // Max limit
}
offset := (params.Page - 1) * params.Limit
query = query.Offset(offset).Limit(params.Limit)
var tracks []*models.Track
if err := query.Find(&tracks).Error; err != nil {
return nil, 0, fmt.Errorf("failed to search tracks: %w", err)
}
return tracks, total, nil
}