Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
Stream Server CI / test (push) Failing after 0s
- ORDER BY dynamiques : whitelist explicite, fallback created_at DESC - Login/register soumis au rate limiter global - VERSION sync + check CI - Nettoyage références veza-chat-server - Go 1.24 partout (Dockerfile, workflows) - TODO/FIXME/HACK convertis en issues ou résolus
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
"veza-backend-api/internal/database"
|
|
"veza-backend-api/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TrackSearchParams représente les paramètres de recherche de tracks
|
|
type TrackSearchParams struct {
|
|
Query string
|
|
Tags []string
|
|
TagMode string // "AND" or "OR"
|
|
MusicalKey *string
|
|
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()}
|
|
}
|
|
|
|
// 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 (v0.203 Lot K: boolean operators + pg_trgm similarity)
|
|
if params.Query != "" {
|
|
parsed := ParseSearchQuery(params.Query)
|
|
if parsed.HasBooleanStructure() {
|
|
// Boolean mode: AND, OR, NOT, "exact phrase" — use LIKE for all
|
|
query = applyParsedQuery(query, parsed)
|
|
} else {
|
|
// Simple term: pg_trgm similarity on PostgreSQL, LIKE on SQLite
|
|
q := strings.ToLower(strings.TrimSpace(parsed.SimpleTerm))
|
|
if q == "" {
|
|
q = strings.ToLower(strings.TrimSpace(params.Query))
|
|
}
|
|
if q != "" {
|
|
searchTerm := "%" + q + "%"
|
|
if s.db.Dialector.Name() == "sqlite" {
|
|
query = query.Where(
|
|
"LOWER(title) LIKE ? OR LOWER(artist) LIKE ? OR LOWER(album) LIKE ?",
|
|
searchTerm, searchTerm, searchTerm,
|
|
)
|
|
} else {
|
|
query = query.Where(
|
|
`(similarity(LOWER(title), ?) > 0.1 OR similarity(LOWER(artist), ?) > 0.1 OR similarity(LOWER(album), ?) > 0.1)`,
|
|
q, q, q,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tag search (tracks.tags pq.StringArray, migration 085)
|
|
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)))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 (v0.903: whitelist for SQL injection prevention)
|
|
sortOrder := "DESC"
|
|
if params.SortOrder == "asc" {
|
|
sortOrder = "ASC"
|
|
}
|
|
sortBy := params.SortBy
|
|
if sortBy == "" {
|
|
sortBy = "created_at"
|
|
}
|
|
allowedSortFields := map[string]bool{
|
|
"created_at": true, "updated_at": true, "duration": true,
|
|
"title": true, "artist": true, "popularity": true,
|
|
"play_count": true, "like_count": true, "comment_count": true, "relevance": true,
|
|
}
|
|
if !allowedSortFields[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")
|
|
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
|
|
}
|