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 }