veza/veza-backend-api/internal/services/track_search_service.go
senke 1620819afd feat(tracks): add BPM field to model and CRUD (E1)
- Backend: BPM and MusicalKey in Track model, UpdateTrack handler
- track_search_service: enable BPM filter (min_bpm, max_bpm)
- Frontend: Track type, UpdateTrackParams, display in TrackDetailPageInfo
- TrackMetadataEditModal: BPM input, edit flow for track creator
- MSW: bpm, musical_key in mock track, correct response envelope
2026-02-20 15:34:00 +01:00

181 lines
5.6 KiB
Go

package services
import (
"context"
"fmt"
"strings"
"time"
"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"
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 on title, artist, album
if params.Query != "" {
searchTerm := "%" + strings.ToLower(params.Query) + "%"
query = query.Where(
"LOWER(title) LIKE ? OR LOWER(artist) LIKE ? OR LOWER(album) LIKE ?",
searchTerm, searchTerm, searchTerm,
)
}
// Tag search - Note: Tags field not in current model, skipping for now
// This can be implemented when tags are added to the Track model
if len(params.Tags) > 0 {
// Tags functionality would go here when Tags field is added
// For now, we'll skip tag filtering
}
// 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
sortOrder := "DESC"
if params.SortOrder == "asc" {
sortOrder = "ASC"
}
sortBy := params.SortBy
if sortBy == "" {
sortBy = "created_at"
}
// Handle different sorting options
switch sortBy {
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
}