2025-12-03 19:29:37 +00:00
package services
import (
"context"
"fmt"
"strings"
"time"
2026-02-20 15:50:30 +00:00
"github.com/lib/pq"
2026-02-14 21:50:23 +00:00
"veza-backend-api/internal/database"
2025-12-03 19:29:37 +00:00
"veza-backend-api/internal/models"
2026-02-14 21:50:23 +00:00
"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"
2026-02-20 15:50:30 +00:00
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 }
}
2026-02-14 21:50:23 +00:00
// 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 )
2026-02-20 17:36:07 +00:00
// 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 != "" {
2026-02-20 17:36:07 +00:00
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
}
2026-02-20 15:50:30 +00:00
// Tag search (tracks.tags pq.StringArray, migration 085)
2025-12-03 19:29:37 +00:00
if len ( params . Tags ) > 0 {
2026-02-20 15:50:30 +00:00
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 )
}
2026-02-20 14:34:00 +00:00
// 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 {
2026-02-20 15:50:49 +00:00
case "relevance" :
2026-02-20 17:36:07 +00:00
// 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)
2026-02-20 15:50:49 +00:00
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
}