From 477b7ff6a2c6235740b4bc3c7990da0cc95b410e Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 21 Mar 2026 12:53:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(M002):=20smart=20playlists=20=E2=80=94=20r?= =?UTF-8?q?ule=20engine,=20editor=20UI,=20sidebar=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovered from orphaned worktree commits (complete-milestone failed to merge). Backend: - Migration 9: is_smart + smart_rules columns on playlists table - smartplaylist package: parameterized WHERE clause builder, field whitelist, genre subquery - playlist.Service: Create/Update/Evaluate/Preview/GetRules smart playlist methods - 65 tests (49 rule engine + 15 service + 1 migration) Frontend: - yj-combobox: reusable typeable dropdown with keyboard nav, ARIA, blur-race fix - smart-playlist-editor: row-based rule builder with live preview - smart-playlist-details: evaluate, refresh, play, shuffle, edit rules - Sidebar: filter icon, Smart badge, create button, routing - Queue snapshot on play/shuffle --- backend/database/database.go | 57 + backend/database/database_test.go | 182 ++ backend/database/sql/schemas/playlists.sql | 2 + backend/database/sql/sqlcgen/models.go | 10 +- backend/database/sql/sqlcgen/playlists.sql.go | 12 +- backend/playlist/playlist.go | 294 +++ backend/playlist/smart_test.go | 665 +++++++ backend/smartplaylist/smartplaylist.go | 597 ++++++ backend/smartplaylist/smartplaylist_test.go | 1608 +++++++++++++++++ frontend/index.ts | 15 + frontend/src/components/combobox/combobox.ts | 303 ++++ .../components/playlist-view/playlist-view.ts | 117 +- .../smart-playlist-details.ts | 564 ++++++ .../smart-playlist-editor.ts | 909 ++++++++++ frontend/wailsjs/go/models.ts | 2 + frontend/wailsjs/go/playlist/Service.d.ts | 11 + frontend/wailsjs/go/playlist/Service.js | 20 + 17 files changed, 5342 insertions(+), 26 deletions(-) create mode 100644 backend/playlist/smart_test.go create mode 100644 backend/smartplaylist/smartplaylist.go create mode 100644 backend/smartplaylist/smartplaylist_test.go create mode 100644 frontend/src/components/combobox/combobox.ts create mode 100644 frontend/src/components/smart-playlist-details/smart-playlist-details.ts create mode 100644 frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts diff --git a/backend/database/database.go b/backend/database/database.go index 00d48ee..f532c04 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -332,6 +332,15 @@ func runMigrations( } } + // Migration 9: add smart playlist columns to playlists. + if version < 9 { + if err := migration9SmartPlaylists( + ctx, db, logger, + ); err != nil { + return err + } + } + return nil } @@ -1154,6 +1163,54 @@ func migration8ContentlessDelete( return nil } +// migration9SmartPlaylists adds the is_smart and smart_rules +// columns to the playlists table for smart playlist support. +func migration9SmartPlaylists( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 9: smart playlist columns", + ) + + if _, err := db.ExecContext(ctx, + `ALTER TABLE playlists + ADD COLUMN is_smart INTEGER NOT NULL DEFAULT 0`, + ); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 9: could not add is_smart column: %w", + err, + ) + } + } + + if _, err := db.ExecContext(ctx, + `ALTER TABLE playlists + ADD COLUMN smart_rules TEXT`, + ); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 9: could not add smart_rules column: %w", + err, + ) + } + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 9", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 9: %w", err, + ) + } + + logger.Info("migration 9 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { diff --git a/backend/database/database_test.go b/backend/database/database_test.go index 09ab4fe..4e944c9 100644 --- a/backend/database/database_test.go +++ b/backend/database/database_test.go @@ -587,3 +587,185 @@ func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) { ) } } + +// --------------------------------------------------------------------------- +// Migration 9 integration tests +// --------------------------------------------------------------------------- + +func TestMigration9SmartPlaylistColumns(t *testing.T) { + t.Parallel() + + db := NewTestDB(t) + + // Verify user_version >= 9. + var version int + + verRows, err := db.QueryContext("PRAGMA user_version") + if err != nil { + t.Fatalf("PRAGMA user_version: %v", err) + } + + if !verRows.Next() { + _ = verRows.Close() + + t.Fatal("PRAGMA user_version: no row returned") + } + + if err := verRows.Scan(&version); err != nil { + _ = verRows.Close() + + t.Fatalf("scan user_version: %v", err) + } + + _ = verRows.Close() + + if version < 9 { + t.Errorf("user_version = %d, want >= 9", version) + } + + // Verify playlists table has is_smart and smart_rules columns. + hasSmart := false + hasRules := false + + ptRows, err := db.QueryContext( + "PRAGMA table_info(playlists)", + ) + if err != nil { + t.Fatalf("PRAGMA table_info(playlists): %v", err) + } + + for ptRows.Next() { + var ( + cid int64 + name string + colType string + notNull int64 + dfltValue sql.NullString + pk int64 + ) + + if err := ptRows.Scan( + &cid, &name, &colType, ¬Null, &dfltValue, &pk, + ); err != nil { + _ = ptRows.Close() + + t.Fatalf("scan playlists table_info: %v", err) + } + + if name == "is_smart" { + hasSmart = true + } + + if name == "smart_rules" { + hasRules = true + } + } + + _ = ptRows.Close() + + if !hasSmart { + t.Error("playlists missing is_smart column") + } + + if !hasRules { + t.Error("playlists missing smart_rules column") + } + + // Insert a smart playlist with rules. + rulesJSON := `{"rules":[{"field":"genre","operator":"is","value":"Rock"}]}` + + _, err = db.ExecContext( + "INSERT INTO playlists (name, is_smart, smart_rules) VALUES (?, 1, ?)", + "Rock Songs", rulesJSON, + ) + if err != nil { + t.Fatalf("insert smart playlist: %v", err) + } + + // Read it back and verify. + rows, err := db.QueryContext( + "SELECT is_smart, smart_rules FROM playlists WHERE name = ?", + "Rock Songs", + ) + if err != nil { + t.Fatalf("query smart playlist: %v", err) + } + + if !rows.Next() { + _ = rows.Close() + + t.Fatal("smart playlist not found") + } + + var ( + isSmart int64 + smartRules sql.NullString + ) + + if err := rows.Scan(&isSmart, &smartRules); err != nil { + _ = rows.Close() + + t.Fatalf("scan smart playlist: %v", err) + } + + _ = rows.Close() + + if isSmart != 1 { + t.Errorf("is_smart = %d, want 1", isSmart) + } + + if !smartRules.Valid || smartRules.String != rulesJSON { + t.Errorf( + "smart_rules = %q, want %q", + smartRules.String, rulesJSON, + ) + } + + // Insert a regular playlist (default is_smart) and verify + // it defaults to 0. + _, err = db.ExecContext( + "INSERT INTO playlists (name) VALUES (?)", + "Regular Playlist", + ) + if err != nil { + t.Fatalf("insert regular playlist: %v", err) + } + + regRows, err := db.QueryContext( + "SELECT is_smart, smart_rules FROM playlists WHERE name = ?", + "Regular Playlist", + ) + if err != nil { + t.Fatalf("query regular playlist: %v", err) + } + + if !regRows.Next() { + _ = regRows.Close() + + t.Fatal("regular playlist not found") + } + + var ( + regSmart int64 + regRules sql.NullString + ) + + if err := regRows.Scan(®Smart, ®Rules); err != nil { + _ = regRows.Close() + + t.Fatalf("scan regular playlist: %v", err) + } + + _ = regRows.Close() + + if regSmart != 0 { + t.Errorf("regular playlist is_smart = %d, want 0", regSmart) + } + + if regRules.Valid { + t.Errorf( + "regular playlist smart_rules should be NULL, got %q", + regRules.String, + ) + } +} diff --git a/backend/database/sql/schemas/playlists.sql b/backend/database/sql/schemas/playlists.sql index 8d7947b..337e7e8 100644 --- a/backend/database/sql/schemas/playlists.sql +++ b/backend/database/sql/schemas/playlists.sql @@ -1,6 +1,8 @@ CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, + is_smart INTEGER NOT NULL DEFAULT 0, + smart_rules TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index 3d5e4b4..405c8a6 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -73,10 +73,12 @@ type PlayerState struct { } type Playlist struct { - ID int64 - Name string - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + Name string + IsSmart int64 + SmartRules sql.NullString + CreatedAt time.Time + UpdatedAt time.Time } type PlaylistTrack struct { diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index fd62ecb..a6d94db 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64, const createPlaylist = `-- name: CreatePlaylist :one INSERT INTO playlists (name) VALUES (?) -RETURNING id, name, created_at, updated_at +RETURNING id, name, is_smart, smart_rules, created_at, updated_at ` func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) { @@ -91,6 +91,8 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er err := row.Scan( &i.ID, &i.Name, + &i.IsSmart, + &i.SmartRules, &i.CreatedAt, &i.UpdatedAt, ) @@ -192,7 +194,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl } const getAllPlaylists = `-- name: GetAllPlaylists :many -SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC +SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC ` func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) { @@ -207,6 +209,8 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) { if err := rows.Scan( &i.ID, &i.Name, + &i.IsSmart, + &i.SmartRules, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -236,7 +240,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i } const getPlaylist = `-- name: GetPlaylist :one -SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1 +SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1 ` func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) { @@ -245,6 +249,8 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) { err := row.Scan( &i.ID, &i.Name, + &i.IsSmart, + &i.SmartRules, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 4f695a7..2d1bc94 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -21,6 +21,8 @@ import ( "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" + "yellowjacket/backend/library" + "yellowjacket/backend/smartplaylist" "yellowjacket/backend/system" ) @@ -48,6 +50,7 @@ type Summary struct { Name string `json:"Name"` CreatedAt string `json:"CreatedAt"` UpdatedAt string `json:"UpdatedAt"` + IsSmart bool `json:"IsSmart"` } // Track represents a track within a playlist, including its @@ -185,6 +188,7 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) { Name: p.Name, CreatedAt: p.CreatedAt.Format(time.RFC3339), UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + IsSmart: p.IsSmart != 0, }) } @@ -264,6 +268,7 @@ func (s *Service) GetAllPlaylistsWithTracks() ( Name: p.Name, CreatedAt: p.CreatedAt.Format(time.RFC3339), UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + IsSmart: p.IsSmart != 0, }, Tracks: tracks, }) @@ -480,6 +485,7 @@ func (s *Service) CreatePlaylist( Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + IsSmart: created.IsSmart != 0, }) return Summary{ @@ -487,6 +493,7 @@ func (s *Service) CreatePlaylist( Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + IsSmart: created.IsSmart != 0, }, nil } @@ -648,6 +655,7 @@ func (s *Service) CreatePlaylistWithTracks( Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + IsSmart: created.IsSmart != 0, } s.logger.Info( @@ -938,6 +946,7 @@ func (s *Service) ImportPlaylist( Name: playlistName, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), + IsSmart: created.IsSmart != 0, } s.emitEvent(events.PlaylistCreated, summary) @@ -2327,3 +2336,288 @@ func sortCandidatesByScore(candidates []CandidateTrack) { }, ) } + +// ================================================================= +// Smart playlist methods +// ================================================================= + +// errNotSmartPlaylist is returned when an operation that requires +// a smart playlist is performed on a regular playlist or a +// non-existent playlist. +var errNotSmartPlaylist = errors.New( + "playlist not found or is not a smart playlist", +) + +// errNoRowReturned is returned when an INSERT ... RETURNING +// query does not return the expected row. +var errNoRowReturned = errors.New( + "no row returned from insert", +) + +// CreateSmartPlaylist creates a new smart playlist with the given +// name and JSON rule set. The rules are validated before storage. +func (s *Service) CreateSmartPlaylist( + name, rulesJSON string, +) (Summary, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return Summary{}, errEmptyName + } + + // Validate rules JSON before storing. + if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil { + return Summary{}, fmt.Errorf( + "invalid smart playlist rules: %w", err, + ) + } + + // SAFETY: Hand-crafted INSERT for smart playlist with + // is_smart and smart_rules columns not yet in sqlc schema. + // All values are parameterized. + rows, err := s.db.QueryContext( + `INSERT INTO playlists (name, is_smart, smart_rules) + VALUES (?, 1, ?) + RETURNING id, name, created_at, updated_at`, + trimmed, rulesJSON, + ) + if err != nil { + s.logger.Error( + "Failed to create smart playlist", + "name", trimmed, "err", err, + ) + + return Summary{}, fmt.Errorf( + "failed to create smart playlist: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return Summary{}, fmt.Errorf( + "failed to create smart playlist: %w", + errNoRowReturned, + ) + } + + var ( + id int64 + retName string + createdAt string + updatedAt string + ) + + if err := rows.Scan( + &id, &retName, &createdAt, &updatedAt, + ); err != nil { + s.logger.Error( + "Failed to create smart playlist", + "name", trimmed, "err", err, + ) + + return Summary{}, fmt.Errorf( + "failed to create smart playlist: %w", err, + ) + } + + s.logger.Info( + "Smart playlist created", + "id", id, "name", retName, + ) + + summary := Summary{ + ID: id, + Name: retName, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + IsSmart: true, + } + + s.emitEvent(events.PlaylistCreated, summary) + + return summary, nil +} + +// UpdateSmartPlaylistRules updates the rule set for an existing +// smart playlist. Returns an error if the playlist does not exist +// or is not a smart playlist. +func (s *Service) UpdateSmartPlaylistRules( + playlistID int64, + rulesJSON string, +) error { + // Validate rules JSON before storing. + if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil { + return fmt.Errorf( + "invalid smart playlist rules: %w", err, + ) + } + + // SAFETY: Hand-crafted UPDATE for smart_rules column not + // yet in sqlc schema. All values are parameterized. + // Only updates rows where is_smart = 1. + result, err := s.db.ExecContext( + `UPDATE playlists + SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND is_smart = 1`, + rulesJSON, playlistID, + ) + if err != nil { + s.logger.Error( + "Failed to update smart playlist rules", + "playlistId", playlistID, "err", err, + ) + + return fmt.Errorf( + "failed to update smart playlist rules: %w", err, + ) + } + + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf( + "could not check rows affected: %w", err, + ) + } + + if affected == 0 { + return errNotSmartPlaylist + } + + s.logger.Info( + "Smart playlist rules updated", + "playlistId", playlistID, + ) + + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// EvaluateSmartPlaylist loads the rule set for a smart playlist +// from the database and evaluates it against the track library, +// returning the matching tracks. +func (s *Service) EvaluateSmartPlaylist( + playlistID int64, +) ([]library.Track, error) { + // SAFETY: Hand-crafted SELECT for smart_rules column not + // yet in sqlc schema. Parameterized by playlist ID. + rows, err := s.db.QueryContext( + `SELECT smart_rules FROM playlists + WHERE id = ? AND is_smart = 1`, + playlistID, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to load smart playlist rules: %w", err, + ) + } + + if !rows.Next() { + _ = rows.Close() + + return nil, errNotSmartPlaylist + } + + var rulesJSON string + + if err := rows.Scan(&rulesJSON); err != nil { + _ = rows.Close() + + return nil, fmt.Errorf( + "failed to load smart playlist rules: %w", err, + ) + } + + // Close rows before calling Evaluate, which opens its own + // query. With MaxOpenConns=1 (test DBs), a deferred close + // would deadlock. + _ = rows.Close() + + ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON) + if err != nil { + return nil, fmt.Errorf( + "corrupt smart playlist rules for id %d: %w", + playlistID, err, + ) + } + + tracks, err := smartplaylist.Evaluate(s.db, ruleSet) + if err != nil { + return nil, fmt.Errorf( + "smart playlist evaluation failed for id %d: %w", + playlistID, err, + ) + } + + return tracks, nil +} + +// PreviewSmartPlaylist evaluates a rule set from raw JSON without +// requiring a saved playlist. This powers live preview in the rule +// editor — the frontend sends rules as they are being edited and +// receives matching tracks immediately. +func (s *Service) PreviewSmartPlaylist( + rulesJSON string, +) ([]library.Track, error) { + ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON) + if err != nil { + return nil, fmt.Errorf( + "invalid smart playlist rules: %w", err, + ) + } + + tracks, err := smartplaylist.Evaluate(s.db, ruleSet) + if err != nil { + return nil, fmt.Errorf( + "smart playlist preview failed: %w", err, + ) + } + + s.logger.Info( + "Smart playlist preview evaluated", + "trackCount", len(tracks), + ) + + return tracks, nil +} + +// GetSmartPlaylistRules returns the raw JSON rule string for an +// existing smart playlist. This is used when the user opens the +// rule editor for an existing smart playlist. +func (s *Service) GetSmartPlaylistRules( + playlistID int64, +) (string, error) { + // SAFETY: Hand-crafted SELECT for smart_rules column not + // yet in sqlc schema. Parameterized by playlist ID. + rows, err := s.db.QueryContext( + `SELECT smart_rules FROM playlists + WHERE id = ? AND is_smart = 1`, + playlistID, + ) + if err != nil { + return "", fmt.Errorf( + "failed to load smart playlist rules: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return "", errNotSmartPlaylist + } + + var rulesJSON string + + if err := rows.Scan(&rulesJSON); err != nil { + return "", fmt.Errorf( + "failed to scan smart playlist rules: %w", err, + ) + } + + s.logger.Info( + "Smart playlist rules loaded", + "playlistId", playlistID, + ) + + return rulesJSON, nil +} diff --git a/backend/playlist/smart_test.go b/backend/playlist/smart_test.go new file mode 100644 index 0000000..cee6704 --- /dev/null +++ b/backend/playlist/smart_test.go @@ -0,0 +1,665 @@ +package playlist + +import ( + "encoding/json" + "log/slog" + "testing" + + "yellowjacket/backend/database" + "yellowjacket/backend/smartplaylist" +) + +// --------------------------------------------------------------------------- +// Test helpers — seed data +// --------------------------------------------------------------------------- + +// seedSmartTestTracks inserts a minimal set of tracks with the full +// FK chain required for smart playlist evaluation tests. +// +// ID 1: "Electric Song" by "Band A" album "Album One" (2020) genre=Rock duration=300000ms +// ID 2: "Acoustic Vibes" by "Band B" album "Album Two" (2015) genre=Jazz duration=240000ms +// ID 3: "Heavy Metal" by "Band A" album "Album One" (2020) genre=Metal duration=420000ms +func seedSmartTestTracks(t *testing.T, db *database.DB) { + t.Helper() + + type track struct { + id int64 + filePath string + title string + artist string + album string + year int64 + genre string + lenMs int64 + } + + tracks := []track{ + { + 1, "/music/band_a/electric.mp3", + "Electric Song", "Band A", "Album One", + 2020, "Rock", 300000, + }, + { + 2, "/music/band_b/acoustic.flac", + "Acoustic Vibes", "Band B", "Album Two", + 2015, "Jazz", 240000, + }, + { + 3, "/music/band_a/heavy.mp3", + "Heavy Metal", "Band A", "Album One", + 2020, "Metal", 420000, + }, + } + + // Build unique sets for artist_credit and release_groups. + artistMap := map[string]int64{} + albumMap := map[string]int64{} + + var artistID, albumID int64 + + for _, tr := range tracks { + if _, ok := artistMap[tr.artist]; !ok { + artistID++ + artistMap[tr.artist] = artistID + } + + if _, ok := albumMap[tr.album]; !ok { + albumID++ + albumMap[tr.album] = albumID + } + } + + // Insert artist_credit rows. + for text, id := range artistMap { + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (?, ?)", + id, text, + ) + if err != nil { + t.Fatalf("insert artist_credit %q: %v", text, err) + } + } + + // Insert release_groups. + for name, id := range albumMap { + _, err := db.ExecContext( + "INSERT INTO release_groups (id, name) VALUES (?, ?)", + id, name, + ) + if err != nil { + t.Fatalf("insert release_group %q: %v", name, err) + } + } + + // Insert genres. + genreMap := map[string]int64{} + + var genreID int64 + + for _, tr := range tracks { + if _, ok := genreMap[tr.genre]; !ok { + genreID++ + genreMap[tr.genre] = genreID + + _, err := db.ExecContext( + "INSERT INTO genres (id, name) VALUES (?, ?)", + genreID, tr.genre, + ) + if err != nil { + t.Fatalf("insert genre %q: %v", tr.genre, err) + } + } + } + + // Insert tracks with full FK chain. + for _, tr := range tracks { + acID := artistMap[tr.artist] + rgID := albumMap[tr.album] + + // Insert recording. + _, err := db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id, year) "+ + "VALUES (?, ?, ?, ?)", + tr.id, tr.title, acID, tr.year, + ) + if err != nil { + t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err) + } + + // Insert audio_file. + _, err = db.ExecContext( + "INSERT INTO audio_files (id, file_path, "+ + "length_milliseconds, file_type_id, recording_id, "+ + "sample_rate, bit_depth, channels, bitrate, file_size) "+ + "VALUES (?, ?, ?, 0, ?, 44100, 16, 2, 320000, 5000000)", + tr.id, tr.filePath, tr.lenMs, tr.id, + ) + if err != nil { + t.Fatalf("insert audio_file %d: %v", tr.id, err) + } + + // Link recording to release_group. + _, err = db.ExecContext( + "INSERT INTO release_group_recordings "+ + "(release_group_id, recording_id) VALUES (?, ?)", + rgID, tr.id, + ) + if err != nil { + t.Fatalf("insert release_group_recordings %d→%d: %v", + rgID, tr.id, err) + } + + // Insert recording_genres link. + gID := genreMap[tr.genre] + + _, err = db.ExecContext( + "INSERT INTO recording_genres "+ + "(recording_id, genre_id) VALUES (?, ?)", + tr.id, gID, + ) + if err != nil { + t.Fatalf("insert recording_genres %d→%d: %v", + tr.id, gID, err) + } + } +} + +// newTestService constructs a playlist.Service with only the +// fields needed for smart playlist operations (db and logger). +func newTestService(t *testing.T, db *database.DB) *Service { + t.Helper() + + return &Service{ + db: db, + logger: slog.Default(), + } +} + +// makeRulesJSON is a helper that marshals rules into a valid JSON +// string for use in tests. +func makeRulesJSON(t *testing.T, rules smartplaylist.RuleSet) string { + t.Helper() + + data, err := json.Marshal(rules) + if err != nil { + t.Fatalf("could not marshal rules: %v", err) + } + + return string(data) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestSmartPlaylistCreateAndEvaluate(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "artist", Operator: "is", Value: "Band A"}, + }, + }) + + // Create smart playlist. + summary, err := svc.CreateSmartPlaylist("My Smart PL", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + if summary.Name != "My Smart PL" { + t.Errorf("Name = %q, want %q", summary.Name, "My Smart PL") + } + + if summary.ID <= 0 { + t.Errorf("ID = %d, want > 0", summary.ID) + } + + if summary.CreatedAt == "" { + t.Error("CreatedAt is empty") + } + + if summary.UpdatedAt == "" { + t.Error("UpdatedAt is empty") + } + + // Evaluate the smart playlist. + tracks, err := svc.EvaluateSmartPlaylist(summary.ID) + if err != nil { + t.Fatalf("EvaluateSmartPlaylist failed: %v", err) + } + + // Band A has tracks 1 and 3. + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + + // Verify tracks belong to Band A. + for _, tr := range tracks { + if tr.ArtistName != "Band A" { + t.Errorf("track %q has artist %q, want Band A", + tr.TrackName, tr.ArtistName) + } + } +} + +func TestSmartPlaylistUpdateRules(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + // Create with artist filter for Band A (2 tracks). + initialRules := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "artist", Operator: "is", Value: "Band A"}, + }, + }) + + summary, err := svc.CreateSmartPlaylist("Update Test", initialRules) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + // Update to artist = Band B (1 track). + newRules := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "artist", Operator: "is", Value: "Band B"}, + }, + }) + + if err := svc.UpdateSmartPlaylistRules(summary.ID, newRules); err != nil { + t.Fatalf("UpdateSmartPlaylistRules failed: %v", err) + } + + // Evaluate — should now return only Band B tracks. + tracks, err := svc.EvaluateSmartPlaylist(summary.ID) + if err != nil { + t.Fatalf("EvaluateSmartPlaylist failed: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].ArtistName != "Band B" { + t.Errorf("artist = %q, want Band B", tracks[0].ArtistName) + } +} + +func TestSmartPlaylistCreateInvalidJSON(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + _, err := svc.CreateSmartPlaylist("Bad", "not json") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestSmartPlaylistCreateEmptyName(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "title", Operator: "contains", Value: "test"}, + }, + }) + + _, err := svc.CreateSmartPlaylist("", rulesJSON) + if err == nil { + t.Fatal("expected error for empty name, got nil") + } +} + +func TestSmartPlaylistEvaluateNonSmartPlaylist(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Create a regular playlist via direct SQL. + // SAFETY: Test-only insert for regular playlist. + rows, err := db.QueryContext( + `INSERT INTO playlists (name) VALUES (?) + RETURNING id`, + "Regular PL", + ) + if err != nil { + t.Fatalf("insert regular playlist: %v", err) + } + + var regularID int64 + if rows.Next() { + if err := rows.Scan(®ularID); err != nil { + t.Fatalf("scan regular playlist id: %v", err) + } + } + + _ = rows.Close() + + // Evaluate should fail — not a smart playlist. + _, err = svc.EvaluateSmartPlaylist(regularID) + if err == nil { + t.Fatal("expected error evaluating non-smart playlist, got nil") + } +} + +func TestSmartPlaylistEvaluateNonExistent(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Evaluate a playlist ID that doesn't exist. + _, err := svc.EvaluateSmartPlaylist(99999) + if err == nil { + t.Fatal("expected error evaluating non-existent playlist, got nil") + } +} + +func TestSmartPlaylistUpdateNonSmartPlaylist(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Create a regular playlist. + rows, err := db.QueryContext( + `INSERT INTO playlists (name) VALUES (?) + RETURNING id`, + "Regular PL", + ) + if err != nil { + t.Fatalf("insert regular playlist: %v", err) + } + + var regularID int64 + if rows.Next() { + if err := rows.Scan(®ularID); err != nil { + t.Fatalf("scan regular playlist id: %v", err) + } + } + + _ = rows.Close() + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "title", Operator: "contains", Value: "test"}, + }, + }) + + // Update should fail — not a smart playlist. + err = svc.UpdateSmartPlaylistRules(regularID, rulesJSON) + if err == nil { + t.Fatal("expected error updating non-smart playlist, got nil") + } +} + +func TestSmartPlaylistUpdateInvalidJSON(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Create a real smart playlist first. + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "title", Operator: "contains", Value: "test"}, + }, + }) + + summary, err := svc.CreateSmartPlaylist("Valid PL", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + // Update with invalid JSON. + err = svc.UpdateSmartPlaylistRules(summary.ID, "bad json") + if err == nil { + t.Fatal("expected error for invalid JSON update, got nil") + } +} + +func TestSmartPlaylistGenreEvaluation(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "genre", Operator: "is", Value: "Rock"}, + }, + }) + + summary, err := svc.CreateSmartPlaylist("Genre Test", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + tracks, err := svc.EvaluateSmartPlaylist(summary.ID) + if err != nil { + t.Fatalf("EvaluateSmartPlaylist failed: %v", err) + } + + // Only track 1 ("Electric Song") has genre exactly "Rock". + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].TrackName != "Electric Song" { + t.Errorf("track = %q, want Electric Song", tracks[0].TrackName) + } +} + +func TestSmartPlaylistYearNumericFilter(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "year", Operator: "greater_than", Value: "2019"}, + }, + }) + + summary, err := svc.CreateSmartPlaylist("Year Test", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + tracks, err := svc.EvaluateSmartPlaylist(summary.ID) + if err != nil { + t.Fatalf("EvaluateSmartPlaylist failed: %v", err) + } + + // Tracks 1 and 3 have year=2020, track 2 has year=2015. + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + + for _, tr := range tracks { + if tr.Year <= 2019 { + t.Errorf("track %q has year %d, want > 2019", + tr.TrackName, tr.Year) + } + } +} + +// --------------------------------------------------------------------------- +// Preview and GetRules tests +// --------------------------------------------------------------------------- + +func TestSmartPlaylistPreview(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "artist", Operator: "is", Value: "Band A"}, + }, + }) + + // Create and evaluate via saved playlist for comparison. + summary, err := svc.CreateSmartPlaylist("Preview Compare", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + savedTracks, err := svc.EvaluateSmartPlaylist(summary.ID) + if err != nil { + t.Fatalf("EvaluateSmartPlaylist failed: %v", err) + } + + // Preview with same rules — should return same tracks. + previewTracks, err := svc.PreviewSmartPlaylist(rulesJSON) + if err != nil { + t.Fatalf("PreviewSmartPlaylist failed: %v", err) + } + + if len(previewTracks) != len(savedTracks) { + t.Fatalf( + "preview returned %d tracks, saved returned %d", + len(previewTracks), len(savedTracks), + ) + } + + // Verify all preview tracks are Band A. + for _, tr := range previewTracks { + if tr.ArtistName != "Band A" { + t.Errorf( + "preview track %q has artist %q, want Band A", + tr.TrackName, tr.ArtistName, + ) + } + } +} + +func TestSmartPlaylistPreviewInvalidRules(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + _, err := svc.PreviewSmartPlaylist("not valid json") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestSmartPlaylistGetRules(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartTestTracks(t, db) + + svc := newTestService(t, db) + + rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ + Rules: []smartplaylist.Rule{ + {Field: "genre", Operator: "is", Value: "Rock"}, + }, + }) + + summary, err := svc.CreateSmartPlaylist("Get Rules Test", rulesJSON) + if err != nil { + t.Fatalf("CreateSmartPlaylist failed: %v", err) + } + + got, err := svc.GetSmartPlaylistRules(summary.ID) + if err != nil { + t.Fatalf("GetSmartPlaylistRules failed: %v", err) + } + + if got != rulesJSON { + t.Errorf( + "GetSmartPlaylistRules = %q, want %q", + got, rulesJSON, + ) + } +} + +func TestSmartPlaylistGetRulesNotFound(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + _, err := svc.GetSmartPlaylistRules(99999) + if err == nil { + t.Fatal( + "expected error for non-existent playlist, got nil", + ) + } + + if err.Error() != errNotSmartPlaylist.Error() { + t.Errorf( + "error = %q, want %q", + err.Error(), errNotSmartPlaylist.Error(), + ) + } +} + +func TestSmartPlaylistGetRulesRegularPlaylist(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + svc := newTestService(t, db) + + // Create a regular playlist via direct SQL. + // SAFETY: Test-only insert for regular playlist. + rows, err := db.QueryContext( + `INSERT INTO playlists (name) VALUES (?) + RETURNING id`, + "Regular PL For GetRules", + ) + if err != nil { + t.Fatalf("insert regular playlist: %v", err) + } + + var regularID int64 + if rows.Next() { + if err := rows.Scan(®ularID); err != nil { + t.Fatalf("scan regular playlist id: %v", err) + } + } + + _ = rows.Close() + + // GetSmartPlaylistRules should fail — not a smart playlist. + _, err = svc.GetSmartPlaylistRules(regularID) + if err == nil { + t.Fatal( + "expected error for regular playlist, got nil", + ) + } + + if err.Error() != errNotSmartPlaylist.Error() { + t.Errorf( + "error = %q, want %q", + err.Error(), errNotSmartPlaylist.Error(), + ) + } +} diff --git a/backend/smartplaylist/smartplaylist.go b/backend/smartplaylist/smartplaylist.go new file mode 100644 index 0000000..0b78294 --- /dev/null +++ b/backend/smartplaylist/smartplaylist.go @@ -0,0 +1,597 @@ +// Package smartplaylist builds parameterized SQL WHERE clauses from +// JSON rule definitions and evaluates them against the track_metadata +// view. Field names are whitelisted; values are always parameterized. +package smartplaylist + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "yellowjacket/backend/database" + "yellowjacket/backend/library" +) + +// Sentinel errors for rule validation. +var ( + errInvalidField = errors.New("invalid field: not in allowed field list") + errInvalidOperator = errors.New("invalid operator for field type") + errEmptyIsAnyOf = errors.New("is_any_of requires at least one value") + errBetweenCount = errors.New("between requires exactly 2 values") + errBetweenFormat = errors.New("between value must be \"min,max\" or [\"min\",\"max\"]") + errUnsupportedOp = errors.New("unsupported operator") + errInvalidSortField = errors.New("invalid sort field: not in allowed field list") + errNotNumeric = errors.New("value must be numeric") +) + +// Rule represents a single filter condition for a smart playlist. +type Rule struct { + Field string `json:"field"` + Operator string `json:"operator"` + Value string `json:"value"` +} + +// RuleSet holds the complete filter configuration for a smart +// playlist, including optional sort and limit. +type RuleSet struct { + Rules []Rule `json:"rules"` + Limit int `json:"limit,omitempty"` + SortField string `json:"sort_field,omitempty"` + SortDir string `json:"sort_dir,omitempty"` +} + +// fieldMap maps user-facing rule field names to track_metadata column +// names. Field names MUST come from this map — never interpolated +// from user input. +var fieldMap = map[string]string{ + "title": "title", + "artist": "artist_name", + "album": "album", + "genre": "genre", + "year": "year", + "composer": "composer", + "file_type": "file_type", + "duration": "length_milliseconds", + "sample_rate": "sample_rate", + "bit_depth": "bit_depth", + "channels": "channels", + "bitrate": "bitrate", + "file_size": "file_size", + "library": "library_id", + "track_number": "track_number", + "disc_number": "disc_number", +} + +// numericFields identifies fields that accept numeric operators. +var numericFields = map[string]bool{ + "year": true, + "duration": true, + "sample_rate": true, + "bit_depth": true, + "channels": true, + "bitrate": true, + "file_size": true, + "library": true, + "track_number": true, + "disc_number": true, +} + +// textOperators are valid operators for text fields. +var textOperators = map[string]bool{ + "is": true, + "is_not": true, + "contains": true, + "does_not_contain": true, + "starts_with": true, + "ends_with": true, + "is_any_of": true, +} + +// numericOperators are valid operators for numeric fields. +var numericOperators = map[string]bool{ + "is": true, + "is_not": true, + "greater_than": true, + "less_than": true, + "between": true, +} + +// genreExactOps require a subquery against recording_genres JOIN +// genres instead of matching the concatenated genre column. +var genreExactOps = map[string]bool{ + "is": true, + "is_not": true, + "is_any_of": true, +} + +// genreDelimiter matches the GROUP_CONCAT delimiter in +// track_metadata_view.sql. +const genreDelimiter = "||" + +// BuildWhereClause builds a parameterized SQL WHERE clause from a +// slice of rules. It is a pure function — no database access needed. +// Returns the clause (without the leading "WHERE"), the parameter +// args, and any validation error. +func BuildWhereClause(rules []Rule) (string, []any, error) { + if len(rules) == 0 { + return "", nil, nil + } + + conditions := make([]string, 0, len(rules)) + args := make([]any, 0, len(rules)) + + for _, rule := range rules { + col, ok := fieldMap[rule.Field] + if !ok { + return "", nil, fmt.Errorf( + "%w: %q", errInvalidField, rule.Field, + ) + } + + isNumeric := numericFields[rule.Field] + + if err := validateOperator(rule.Operator, isNumeric); err != nil { + return "", nil, fmt.Errorf( + "field %q: %w", rule.Field, err, + ) + } + + // Genre exact-match operators use a subquery. + if rule.Field == "genre" && genreExactOps[rule.Operator] { + cond, condArgs, err := buildGenreSubquery(rule) + if err != nil { + return "", nil, err + } + + conditions = append(conditions, cond) + args = append(args, condArgs...) + + continue + } + + cond, condArgs, err := buildCondition(col, rule, isNumeric) + if err != nil { + return "", nil, err + } + + conditions = append(conditions, cond) + args = append(args, condArgs...) + } + + return strings.Join(conditions, " AND "), args, nil +} + +// validateOperator checks that the operator is valid for the field +// type. +func validateOperator(op string, isNumeric bool) error { + if isNumeric { + if !numericOperators[op] { + return fmt.Errorf( + "%w: %q for numeric field", errInvalidOperator, op, + ) + } + } else { + if !textOperators[op] { + return fmt.Errorf( + "%w: %q for text field", errInvalidOperator, op, + ) + } + } + + return nil +} + +// buildGenreSubquery generates a subquery condition against +// recording_genres JOIN genres for exact genre matching. +func buildGenreSubquery(rule Rule) (string, []any, error) { + subquery := `af.id IN ( + SELECT rg_sub.recording_id FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE ` + + switch rule.Operator { + case "is": + return subquery + "g.name = ?)", []any{rule.Value}, nil + + case "is_not": + return `af.id NOT IN ( + SELECT rg_sub.recording_id FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE g.name = ?)`, []any{rule.Value}, nil + + case "is_any_of": + var values []string + + if err := json.Unmarshal( + []byte(rule.Value), &values, + ); err != nil { + return "", nil, fmt.Errorf( + "field %q: is_any_of value must be a JSON "+ + "string array: %w", + rule.Field, err, + ) + } + + if len(values) == 0 { + return "", nil, fmt.Errorf( + "field %q: %w", rule.Field, errEmptyIsAnyOf, + ) + } + + placeholders := make([]string, len(values)) + condArgs := make([]any, len(values)) + + for i, v := range values { + placeholders[i] = "?" + condArgs[i] = v + } + + return subquery + "g.name IN (" + + strings.Join(placeholders, ", ") + "))", condArgs, nil + + default: + return "", nil, fmt.Errorf( + "%w: %q", errUnsupportedOp, rule.Operator, + ) + } +} + +// buildCondition generates a single SQL condition for a non-genre- +// subquery rule. +func buildCondition( + col string, rule Rule, isNumeric bool, +) (string, []any, error) { + switch rule.Operator { + case "is": + if isNumeric { + v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value) + if err != nil { + return "", nil, err + } + + return col + " = ?", []any{v}, nil + } + + return col + " = ?", []any{rule.Value}, nil + + case "is_not": + if isNumeric { + v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value) + if err != nil { + return "", nil, err + } + + return col + " != ?", []any{v}, nil + } + + return col + " != ?", []any{rule.Value}, nil + + case "contains": + return col + " LIKE ?", + []any{"%" + rule.Value + "%"}, nil + + case "does_not_contain": + return col + " NOT LIKE ?", + []any{"%" + rule.Value + "%"}, nil + + case "starts_with": + return col + " LIKE ?", + []any{rule.Value + "%"}, nil + + case "ends_with": + return col + " LIKE ?", + []any{"%" + rule.Value}, nil + + case "is_any_of": + var values []string + + if err := json.Unmarshal( + []byte(rule.Value), &values, + ); err != nil { + return "", nil, fmt.Errorf( + "field %q: is_any_of value must be a JSON "+ + "string array: %w", + rule.Field, err, + ) + } + + if len(values) == 0 { + return "", nil, fmt.Errorf( + "field %q: %w", rule.Field, errEmptyIsAnyOf, + ) + } + + placeholders := make([]string, len(values)) + condArgs := make([]any, len(values)) + + for i, v := range values { + placeholders[i] = "?" + condArgs[i] = v + } + + return col + " IN (" + + strings.Join(placeholders, ", ") + ")", condArgs, nil + + case "greater_than": + v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value) + if err != nil { + return "", nil, err + } + + return col + " > ?", []any{v}, nil + + case "less_than": + v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value) + if err != nil { + return "", nil, err + } + + return col + " < ?", []any{v}, nil + + case "between": + lo, hi, err := parseBetweenValue(rule.Field, rule.Value) + if err != nil { + return "", nil, err + } + + return col + " BETWEEN ? AND ?", + []any{lo, hi}, nil + + default: + return "", nil, fmt.Errorf( + "%w: %q", errUnsupportedOp, rule.Operator, + ) + } +} + +// parseNumericValue converts a string value to int64 for numeric +// field comparisons. +func parseNumericValue(field, op, value string) (int64, error) { + v, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf( + "field %q operator %q: %w: %w", + field, op, errNotNumeric, err, + ) + } + + return v, nil +} + +// parseBetweenValue parses "min,max" or JSON ["min","max"] into two +// integer values. +func parseBetweenValue( + field, value string, +) (int64, int64, error) { + // Try JSON array first. + var arr []string + + if err := json.Unmarshal([]byte(value), &arr); err == nil { + if len(arr) != 2 { + return 0, 0, fmt.Errorf( + "field %q: %w: got %d", + field, errBetweenCount, len(arr), + ) + } + + lo, err := strconv.ParseInt(arr[0], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf( + "field %q between lo: %w: %w", + field, errNotNumeric, err, + ) + } + + hi, err := strconv.ParseInt(arr[1], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf( + "field %q between hi: %w: %w", + field, errNotNumeric, err, + ) + } + + return lo, hi, nil + } + + // Fall back to comma-separated. + parts := strings.SplitN(value, ",", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf( + "field %q: %w", field, errBetweenFormat, + ) + } + + lo, err := strconv.ParseInt( + strings.TrimSpace(parts[0]), 10, 64, + ) + if err != nil { + return 0, 0, fmt.Errorf( + "field %q between lo: %w: %w", + field, errNotNumeric, err, + ) + } + + hi, err := strconv.ParseInt( + strings.TrimSpace(parts[1]), 10, 64, + ) + if err != nil { + return 0, 0, fmt.Errorf( + "field %q between hi: %w: %w", + field, errNotNumeric, err, + ) + } + + return lo, hi, nil +} + +// Evaluate runs the rule set against the track_metadata view and +// returns matching tracks. +func Evaluate( + db *database.DB, ruleSet RuleSet, +) ([]library.Track, error) { + where, args, err := BuildWhereClause(ruleSet.Rules) + if err != nil { + return nil, fmt.Errorf( + "smart playlist rule error: %w", err, + ) + } + + // SAFETY: Dynamic WHERE clause built from whitelisted field + // names and parameterized values only. Sort field is validated + // against fieldMap. No user-supplied strings are interpolated. + query := `SELECT + file_path, + length_milliseconds, + title, + artist_name, + track_number, + disc_number, + album, + genre, + year, + composer, + file_type, + sample_rate, + bit_depth, + channels, + bitrate, + file_size + FROM track_metadata af` + + if where != "" { + query += "\nWHERE " + where + } + + // Sort. + if ruleSet.SortField != "" { + if ruleSet.SortField == "random" { + query += "\nORDER BY RANDOM()" + } else { + sortCol, ok := fieldMap[ruleSet.SortField] + if !ok { + return nil, fmt.Errorf( + "%w: %q", errInvalidSortField, + ruleSet.SortField, + ) + } + + dir := "ASC" + if strings.EqualFold(ruleSet.SortDir, "DESC") { + dir = "DESC" + } + + query += "\nORDER BY " + sortCol + " " + dir + } + } + + // Limit. + if ruleSet.Limit > 0 { + query += "\nLIMIT ?" + + args = append(args, ruleSet.Limit) + } + + rows, err := db.QueryContext(query, args...) + if err != nil { + return nil, fmt.Errorf( + "smart playlist query failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + return scanTracks(rows) +} + +// scanTracks reads all rows from a query result into a Track slice. +func scanTracks(rows *sql.Rows) ([]library.Track, error) { + var tracks []library.Track + + for rows.Next() { + var ( + filePath string + lengthMs int64 + title string + artistName string + trackNumber sql.NullInt64 + discNumber sql.NullInt64 + album string + genre string + year int64 + composer string + fileType string + sampleRate int64 + bitDepth int64 + channels int64 + bitrate int64 + fileSize int64 + ) + + if err := rows.Scan( + &filePath, &lengthMs, &title, &artistName, + &trackNumber, &discNumber, + &album, &genre, &year, &composer, &fileType, + &sampleRate, &bitDepth, &channels, + &bitrate, &fileSize, + ); err != nil { + return nil, fmt.Errorf( + "could not scan smart playlist row: %w", err, + ) + } + + tracks = append(tracks, library.Track{ + TrackName: title, + ArtistName: artistName, + TrackLength: strconv.FormatInt(lengthMs, 10), + FilePath: filePath, + TrackNumber: trackNumber.Int64, + DiscNumber: discNumber.Int64, + Album: album, + Genre: splitGenres(genre), + Year: year, + Composer: composer, + FileType: fileType, + SampleRate: sampleRate, + BitDepth: bitDepth, + Channels: channels, + Bitrate: bitrate, + FileSize: fileSize, + }) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "smart playlist row iteration error: %w", err, + ) + } + + return tracks, nil +} + +// ParseRuleSet parses a JSON string into a validated RuleSet. +func ParseRuleSet(jsonStr string) (RuleSet, error) { + var rs RuleSet + + if err := json.Unmarshal( + []byte(jsonStr), &rs, + ); err != nil { + return RuleSet{}, fmt.Errorf( + "invalid smart playlist rules JSON: %w", err, + ) + } + + return rs, nil +} + +// splitGenres splits a GROUP_CONCAT genre string into individual +// genre names. An empty string returns nil. +func splitGenres(concatenated string) []string { + if concatenated == "" { + return nil + } + + return strings.Split(concatenated, genreDelimiter) +} diff --git a/backend/smartplaylist/smartplaylist_test.go b/backend/smartplaylist/smartplaylist_test.go new file mode 100644 index 0000000..b805335 --- /dev/null +++ b/backend/smartplaylist/smartplaylist_test.go @@ -0,0 +1,1608 @@ +package smartplaylist + +import ( + "strings" + "testing" + + "yellowjacket/backend/database" +) + +// --------------------------------------------------------------------------- +// Test helpers — seed data +// --------------------------------------------------------------------------- + +// seedSmartPlaylistData inserts 8 tracks with the full FK chain +// required for smart playlist evaluation tests. Extends the +// seedSearchData pattern from search_test.go with a multi-genre +// track (ID 8) that has both "Rock" and "Alternative" genres. +// +// Track list: +// +// ID 1: "Bohemian Rhapsody" by "Queen" album "A Night at the Opera" (1975) genre=Rock +// ID 2: "Halo" by "Beyoncé" album "Lemonade" (2008) genre=Pop +// ID 3: "Back in Black" by "AC/DC" album "Back in Black" (1980) genre=Hard Rock +// ID 4: "Comfortably Numb" by "Pink Floyd" album "The Dark Side" (1979) genre=Progressive Rock +// ID 5: "Another One" by "Queen" album "The Game" (1980) genre=Funk Rock +// ID 6: "Thunderstruck" by "AC/DC" album "The Razors Edge" (1990) genre=Hard Rock +// ID 7: "No One Knows" by "QOTSA" album "Rated R" (2000) genre=Stoner Rock +// ID 8: "Under the Bridge" by "RHCP" album "Blood Sugar" (1991) genre=Rock+Alternative (multi-genre) +func seedSmartPlaylistData(t *testing.T, db *database.DB) { + t.Helper() + + type track struct { + id int64 + filePath string + title string + artist string + album string + trackNum *int64 + discNum *int64 + year int64 + genres []string // supports multi-genre + composer string + lenMs int64 + ftID int64 + sr int64 + bd int64 + ch int64 + br int64 + fsize int64 + } + + intPtr := func(v int64) *int64 { return &v } + + tracks := []track{ + { + 1, "/music/queen/bohemian_rhapsody.mp3", + "Bohemian Rhapsody", "Queen", + "A Night at the Opera", intPtr(11), intPtr(1), + 1975, + []string{"Rock"}, + "Freddie Mercury", + 354000, 0, 44100, 16, 2, 320000, 8500000, + }, + { + 2, "/music/beyonce/halo.flac", + "Halo", "Beyoncé", "Lemonade", + intPtr(1), intPtr(1), + 2008, + []string{"Pop"}, + "Ryan Tedder", + 261000, 1, 96000, 24, 2, 1411000, 42000000, + }, + { + 3, "/music/acdc/back_in_black.mp3", + "Back in Black", "AC/DC", "Back in Black", + intPtr(1), intPtr(1), + 1980, + []string{"Hard Rock"}, + "Angus Young", + 255000, 0, 44100, 16, 2, 320000, 6100000, + }, + { + 4, "/music/pinkfloyd/comfortably_numb.flac", + "Comfortably Numb", "Pink Floyd", "The Dark Side", + intPtr(6), intPtr(1), + 1979, + []string{"Progressive Rock"}, + "David Gilmour", + 382000, 1, 96000, 24, 2, 1411000, 54000000, + }, + { + 5, "/music/queen/another_one.mp3", + "Another One Bites the Dust", "Queen", "The Game", + intPtr(3), intPtr(1), + 1980, + []string{"Funk Rock"}, + "John Deacon", + 215000, 0, 44100, 16, 2, 320000, 5200000, + }, + { + 6, "/music/acdc/thunderstruck.mp3", + "Thunderstruck", "AC/DC", "The Razors Edge", + intPtr(1), intPtr(1), + 1990, + []string{"Hard Rock"}, + "Angus Young", + 292000, 0, 44100, 16, 2, 320000, 7000000, + }, + { + 7, "/music/qotsa/no_one_knows.mp3", + "No One Knows", "QOTSA", "Rated R", + intPtr(1), intPtr(1), + 2000, + []string{"Stoner Rock"}, + "Josh Homme", + 310000, 0, 44100, 16, 2, 320000, 7400000, + }, + { + 8, "/music/rhcp/under_the_bridge.mp3", + "Under the Bridge", "RHCP", "Blood Sugar", + intPtr(2), intPtr(1), + 1991, + []string{"Rock", "Alternative"}, + "Anthony Kiedis", + 264000, 0, 44100, 16, 2, 320000, 6300000, + }, + } + + // Build unique sets for artist_credit and release_groups. + artistMap := map[string]int64{} + albumMap := map[string]int64{} + + var artistID, albumID int64 + + for _, tr := range tracks { + if _, ok := artistMap[tr.artist]; !ok { + artistID++ + artistMap[tr.artist] = artistID + } + + if _, ok := albumMap[tr.album]; !ok { + albumID++ + albumMap[tr.album] = albumID + } + } + + // Insert artist_credit rows. + for text, id := range artistMap { + _, err := db.ExecContext( + "INSERT INTO artist_credit (id, text) VALUES (?, ?)", + id, text, + ) + if err != nil { + t.Fatalf("insert artist_credit %q: %v", text, err) + } + } + + // Insert release_groups. + for name, id := range albumMap { + _, err := db.ExecContext( + "INSERT INTO release_groups (id, name) VALUES (?, ?)", + id, name, + ) + if err != nil { + t.Fatalf("insert release_group %q: %v", name, err) + } + } + + // Insert genres. + genreMap := map[string]int64{} + + var genreID int64 + + for _, tr := range tracks { + for _, g := range tr.genres { + if _, ok := genreMap[g]; !ok { + genreID++ + genreMap[g] = genreID + + _, err := db.ExecContext( + "INSERT INTO genres (id, name) VALUES (?, ?)", + genreID, g, + ) + if err != nil { + t.Fatalf("insert genre %q: %v", g, err) + } + } + } + } + + // Insert tracks with full FK chain. + for _, tr := range tracks { + acID := artistMap[tr.artist] + rgID := albumMap[tr.album] + + // Insert recording. + _, err := db.ExecContext( + "INSERT INTO recordings (id, name, artist_credit_id, "+ + "track_number, disc_number, year, composer) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?)", + tr.id, tr.title, acID, tr.trackNum, tr.discNum, + tr.year, tr.composer, + ) + if err != nil { + t.Fatalf("insert recording %d %q: %v", + tr.id, tr.title, err) + } + + // Insert audio_files. + _, err = db.ExecContext( + "INSERT INTO audio_files (id, file_path, "+ + "length_milliseconds, file_type_id, recording_id, "+ + "sample_rate, bit_depth, channels, bitrate, file_size) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id, + tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, + ) + if err != nil { + t.Fatalf("insert audio_file %d: %v", tr.id, err) + } + + // Link recording to release_group. + _, err = db.ExecContext( + "INSERT INTO release_group_recordings "+ + "(release_group_id, recording_id, track_number, disc_number) "+ + "VALUES (?, ?, ?, ?)", + rgID, tr.id, tr.trackNum, tr.discNum, + ) + if err != nil { + t.Fatalf("insert release_group_recordings %d→%d: %v", + rgID, tr.id, err) + } + + // Insert recording_genres links (supports multi-genre). + for _, g := range tr.genres { + gID := genreMap[g] + + _, err = db.ExecContext( + "INSERT INTO recording_genres "+ + "(recording_id, genre_id) VALUES (?, ?)", + tr.id, gID, + ) + if err != nil { + t.Fatalf( + "insert recording_genres %d→%d: %v", + tr.id, gID, err, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// BuildWhereClause tests (pure — no DB needed) +// --------------------------------------------------------------------------- + +func TestBuildWhereClause_TextIs(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "artist", Operator: "is", Value: "Queen"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "artist_name = ?" { + t.Errorf("clause = %q, want %q", clause, "artist_name = ?") + } + + if len(args) != 1 || args[0] != "Queen" { + t.Errorf("args = %v, want [Queen]", args) + } +} + +func TestBuildWhereClause_TextIsNot(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "artist", Operator: "is_not", Value: "Queen"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "artist_name != ?" { + t.Errorf("clause = %q, want %q", + clause, "artist_name != ?") + } + + if len(args) != 1 || args[0] != "Queen" { + t.Errorf("args = %v, want [Queen]", args) + } +} + +func TestBuildWhereClause_TextContains(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "title", Operator: "contains", Value: "Black"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "title LIKE ?" { + t.Errorf("clause = %q, want %q", clause, "title LIKE ?") + } + + if len(args) != 1 || args[0] != "%Black%" { + t.Errorf("args = %v, want [%%Black%%]", args) + } +} + +func TestBuildWhereClause_TextDoesNotContain(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + { + Field: "title", Operator: "does_not_contain", + Value: "Black", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "title NOT LIKE ?" { + t.Errorf("clause = %q, want %q", + clause, "title NOT LIKE ?") + } + + if len(args) != 1 || args[0] != "%Black%" { + t.Errorf("args = %v, want [%%Black%%]", args) + } +} + +func TestBuildWhereClause_TextStartsWith(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "title", Operator: "starts_with", Value: "Back"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "title LIKE ?" { + t.Errorf("clause = %q, want %q", clause, "title LIKE ?") + } + + if len(args) != 1 || args[0] != "Back%" { + t.Errorf("args = %v, want [Back%%]", args) + } +} + +func TestBuildWhereClause_TextEndsWith(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "title", Operator: "ends_with", Value: "Black"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "title LIKE ?" { + t.Errorf("clause = %q, want %q", clause, "title LIKE ?") + } + + if len(args) != 1 || args[0] != "%Black" { + t.Errorf("args = %v, want [%%Black]", args) + } +} + +func TestBuildWhereClause_TextIsAnyOf(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + { + Field: "artist", Operator: "is_any_of", + Value: `["Queen","AC/DC"]`, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "artist_name IN (?, ?)" { + t.Errorf("clause = %q, want %q", + clause, "artist_name IN (?, ?)") + } + + if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" { + t.Errorf("args = %v, want [Queen AC/DC]", args) + } +} + +func TestBuildWhereClause_NumericIs(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "year", Operator: "is", Value: "1980"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year = ?" { + t.Errorf("clause = %q, want %q", clause, "year = ?") + } + + if len(args) != 1 || args[0] != int64(1980) { + t.Errorf("args = %v, want [1980]", args) + } +} + +func TestBuildWhereClause_NumericIsNot(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "year", Operator: "is_not", Value: "1980"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year != ?" { + t.Errorf("clause = %q, want %q", clause, "year != ?") + } + + if len(args) != 1 || args[0] != int64(1980) { + t.Errorf("args = %v, want [1980]", args) + } +} + +func TestBuildWhereClause_NumericGreaterThan(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "year", Operator: "greater_than", Value: "2000"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year > ?" { + t.Errorf("clause = %q, want %q", clause, "year > ?") + } + + if len(args) != 1 || args[0] != int64(2000) { + t.Errorf("args = %v, want [2000]", args) + } +} + +func TestBuildWhereClause_NumericLessThan(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "year", Operator: "less_than", Value: "1980"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year < ?" { + t.Errorf("clause = %q, want %q", clause, "year < ?") + } + + if len(args) != 1 || args[0] != int64(1980) { + t.Errorf("args = %v, want [1980]", args) + } +} + +func TestBuildWhereClause_NumericBetween(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + { + Field: "year", Operator: "between", + Value: "1975,1985", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year BETWEEN ? AND ?" { + t.Errorf("clause = %q, want %q", + clause, "year BETWEEN ? AND ?") + } + + if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) { + t.Errorf("args = %v, want [1975 1985]", args) + } +} + +func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + { + Field: "year", Operator: "between", + Value: `["1975","1985"]`, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "year BETWEEN ? AND ?" { + t.Errorf("clause = %q, want %q", + clause, "year BETWEEN ? AND ?") + } + + if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) { + t.Errorf("args = %v, want [1975 1985]", args) + } +} + +func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "genre", Operator: "is", Value: "Rock"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Must use subquery, NOT "genre = ?" + if strings.Contains(clause, "genre =") { + t.Errorf( + "genre 'is' should use subquery, not direct column match: %q", + clause, + ) + } + + if !strings.Contains(clause, "recording_genres") { + t.Errorf("genre 'is' should reference recording_genres: %q", + clause) + } + + if !strings.Contains(clause, "g.name = ?") { + t.Errorf("genre 'is' should have g.name = ?: %q", clause) + } + + if len(args) != 1 || args[0] != "Rock" { + t.Errorf("args = %v, want [Rock]", args) + } +} + +func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "genre", Operator: "is_not", Value: "Rock"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(clause, "NOT IN") { + t.Errorf("genre 'is_not' should use NOT IN: %q", clause) + } + + if !strings.Contains(clause, "recording_genres") { + t.Errorf( + "genre 'is_not' should reference recording_genres: %q", + clause, + ) + } + + if len(args) != 1 || args[0] != "Rock" { + t.Errorf("args = %v, want [Rock]", args) + } +} + +func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + { + Field: "genre", Operator: "is_any_of", + Value: `["Rock","Pop"]`, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(clause, "recording_genres") { + t.Errorf( + "genre 'is_any_of' should reference recording_genres: %q", + clause, + ) + } + + if !strings.Contains(clause, "g.name IN (?, ?)") { + t.Errorf( + "genre 'is_any_of' should have g.name IN (?, ?): %q", + clause, + ) + } + + if len(args) != 2 || args[0] != "Rock" || args[1] != "Pop" { + t.Errorf("args = %v, want [Rock Pop]", args) + } +} + +func TestBuildWhereClause_GenreContainsUsesLIKE(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "genre", Operator: "contains", Value: "Rock"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // contains on genre should use LIKE on the concatenated column, + // NOT a subquery. + if strings.Contains(clause, "recording_genres") { + t.Errorf( + "genre 'contains' should use LIKE, not subquery: %q", + clause, + ) + } + + if clause != "genre LIKE ?" { + t.Errorf("clause = %q, want %q", clause, "genre LIKE ?") + } + + if len(args) != 1 || args[0] != "%Rock%" { + t.Errorf("args = %v, want [%%Rock%%]", args) + } +} + +func TestBuildWhereClause_MultipleRulesAND(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "artist", Operator: "is", Value: "Queen"}, + {Field: "year", Operator: "greater_than", Value: "1975"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "artist_name = ? AND year > ?" { + t.Errorf("clause = %q, want %q", + clause, "artist_name = ? AND year > ?") + } + + if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) { + t.Errorf("args = %v, want [Queen 1975]", args) + } +} + +func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause([]Rule{ + {Field: "genre", Operator: "contains", Value: "Rock"}, + { + Field: "genre", Operator: "does_not_contain", + Value: "Punk", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "genre LIKE ? AND genre NOT LIKE ?" { + t.Errorf("clause = %q, want %q", + clause, + "genre LIKE ? AND genre NOT LIKE ?") + } + + if len(args) != 2 || + args[0] != "%Rock%" || args[1] != "%Punk%" { + t.Errorf("args = %v, want [%%Rock%% %%Punk%%]", args) + } +} + +func TestBuildWhereClause_EmptyRules(t *testing.T) { + t.Parallel() + + clause, args, err := BuildWhereClause(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clause != "" { + t.Errorf("clause = %q, want empty string", clause) + } + + if args != nil { + t.Errorf("args = %v, want nil", args) + } +} + +func TestBuildWhereClause_InvalidField(t *testing.T) { + t.Parallel() + + _, _, err := BuildWhereClause([]Rule{ + { + Field: "nonexistent", Operator: "is", + Value: "anything", + }, + }) + if err == nil { + t.Fatal("expected error for invalid field, got nil") + } + + if !strings.Contains(err.Error(), "invalid field") { + t.Errorf( + "error should mention 'invalid field': %v", err, + ) + } + + if !strings.Contains(err.Error(), "nonexistent") { + t.Errorf( + "error should include the field name: %v", err, + ) + } +} + +func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) { + t.Parallel() + + _, _, err := BuildWhereClause([]Rule{ + {Field: "year", Operator: "contains", Value: "1980"}, + }) + if err == nil { + t.Fatal( + "expected error for text operator on numeric field", + ) + } + + if !strings.Contains(err.Error(), "invalid operator") { + t.Errorf( + "error should mention 'invalid operator': %v", err, + ) + } +} + +func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) { + t.Parallel() + + _, _, err := BuildWhereClause([]Rule{ + { + Field: "artist", Operator: "greater_than", + Value: "Queen", + }, + }) + if err == nil { + t.Fatal( + "expected error for numeric operator on text field", + ) + } + + if !strings.Contains(err.Error(), "invalid operator") { + t.Errorf( + "error should mention 'invalid operator': %v", err, + ) + } +} + +// --------------------------------------------------------------------------- +// Evaluate tests (with DB) +// --------------------------------------------------------------------------- + +func TestEvaluate_TextIs(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + {Field: "artist", Operator: "is", Value: "Queen"}, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + + for _, tr := range tracks { + if tr.ArtistName != "Queen" { + t.Errorf( + "track %q has artist %q, want Queen", + tr.TrackName, tr.ArtistName, + ) + } + } +} + +func TestEvaluate_TextContains(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "contains", + Value: "Black", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].TrackName != "Back in Black" { + t.Errorf("got %q, want %q", + tracks[0].TrackName, "Back in Black") + } +} + +func TestEvaluate_TextStartsWith(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "starts_with", + Value: "Back", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].TrackName != "Back in Black" { + t.Errorf("got %q, want %q", + tracks[0].TrackName, "Back in Black") + } +} + +func TestEvaluate_TextEndsWith(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "ends_with", + Value: "Numb", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].TrackName != "Comfortably Numb" { + t.Errorf("got %q, want %q", + tracks[0].TrackName, "Comfortably Numb") + } +} + +func TestEvaluate_TextDoesNotContain(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "does_not_contain", + Value: "the", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // "the" appears in: "Another One Bites the Dust", + // "Under the Bridge". The rest should be returned. + for _, tr := range tracks { + if strings.Contains( + strings.ToLower(tr.TrackName), "the") { + t.Errorf( + "track %q should not contain 'the'", + tr.TrackName, + ) + } + } +} + +func TestEvaluate_TextIsAnyOf(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "artist", Operator: "is_any_of", + Value: `["Queen","AC/DC"]`, + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 4 { + t.Fatalf("got %d tracks, want 4 (2 Queen + 2 AC/DC)", + len(tracks)) + } + + for _, tr := range tracks { + if tr.ArtistName != "Queen" && + tr.ArtistName != "AC/DC" { + t.Errorf( + "unexpected artist %q", tr.ArtistName, + ) + } + } +} + +func TestEvaluate_NumericGreaterThan(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "year", Operator: "greater_than", + Value: "2000", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // Only "Halo" (2008) has year > 2000. + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + if tracks[0].TrackName != "Halo" { + t.Errorf("got %q, want Halo", tracks[0].TrackName) + } +} + +func TestEvaluate_NumericLessThan(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "year", Operator: "less_than", + Value: "1980", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // Bohemian Rhapsody (1975) and Comfortably Numb (1979) + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + + for _, tr := range tracks { + if tr.Year >= 1980 { + t.Errorf("track %q year=%d should be < 1980", + tr.TrackName, tr.Year) + } + } +} + +func TestEvaluate_NumericBetween(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "year", Operator: "between", + Value: "1975,1985", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // 1975: Bohemian Rhapsody, 1979: Comfortably Numb, + // 1980: Back in Black, 1980: Another One Bites the Dust + if len(tracks) != 4 { + t.Fatalf("got %d tracks, want 4", len(tracks)) + } + + for _, tr := range tracks { + if tr.Year < 1975 || tr.Year > 1985 { + t.Errorf( + "track %q year=%d outside 1975-1985", + tr.TrackName, tr.Year, + ) + } + } +} + +func TestEvaluate_GenreIs_MultiGenreTrack(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // Genre "is Rock" must match track 8 (Rock+Alternative) and + // track 1 (Rock). This proves the subquery works correctly + // with multi-genre tracks. + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + {Field: "genre", Operator: "is", Value: "Rock"}, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 2 { + names := make([]string, len(tracks)) + for i, tr := range tracks { + names[i] = tr.TrackName + } + + t.Fatalf( + "genre 'is Rock' got %d tracks %v, want 2 "+ + "(Bohemian Rhapsody + Under the Bridge)", + len(tracks), names, + ) + } + + foundBR := false + foundUTB := false + + for _, tr := range tracks { + if tr.TrackName == "Bohemian Rhapsody" { + foundBR = true + } + + if tr.TrackName == "Under the Bridge" { + foundUTB = true + } + } + + if !foundBR { + t.Error("genre 'is Rock' missing Bohemian Rhapsody") + } + + if !foundUTB { + t.Error( + "genre 'is Rock' missing Under the Bridge " + + "(multi-genre track)", + ) + } +} + +func TestEvaluate_GenreIsNot_MultiGenreTrack(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // Genre "is_not Rock" must NOT return tracks 1 or 8. + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "genre", Operator: "is_not", + Value: "Rock", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + for _, tr := range tracks { + if tr.TrackName == "Bohemian Rhapsody" || + tr.TrackName == "Under the Bridge" { + t.Errorf( + "genre 'is_not Rock' should not return %q", + tr.TrackName, + ) + } + } + + // Should return the other 6 tracks. + if len(tracks) != 6 { + t.Errorf("got %d tracks, want 6", len(tracks)) + } +} + +func TestEvaluate_GenreContains(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // "contains Rock" on the concatenated genre column should match + // any track that has "Rock" anywhere in its genre string. + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "genre", Operator: "contains", + Value: "Rock", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // Rock (1,8), Hard Rock (3,6), Progressive Rock (4), + // Funk Rock (5), Stoner Rock (7) = 7 tracks + if len(tracks) != 7 { + names := make([]string, len(tracks)) + for i, tr := range tracks { + names[i] = tr.TrackName + } + + t.Fatalf( + "genre 'contains Rock' got %d tracks %v, want 7", + len(tracks), names, + ) + } +} + +func TestEvaluate_MultipleRulesAND(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // genre contains "Rock" AND year > 1970 AND year < 1981 + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "genre", Operator: "contains", + Value: "Rock", + }, + { + Field: "year", Operator: "greater_than", + Value: "1970", + }, + { + Field: "year", Operator: "less_than", + Value: "1981", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + // Bohemian Rhapsody (1975, Rock), Comfortably Numb (1979, Progressive Rock), + // Back in Black (1980, Hard Rock), Another One (1980, Funk Rock) + if len(tracks) != 4 { + names := make([]string, len(tracks)) + for i, tr := range tracks { + names[i] = tr.TrackName + } + + t.Fatalf("got %d tracks %v, want 4", len(tracks), names) + } +} + +func TestEvaluate_EmptyRulesReturnsAll(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 8 { + t.Fatalf("got %d tracks, want 8 (all seeded)", + len(tracks)) + } +} + +func TestEvaluate_Limit(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{Limit: 3}) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 3 { + t.Fatalf("got %d tracks, want 3 (limited)", + len(tracks)) + } +} + +func TestEvaluate_SortByYearASC(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + SortField: "year", + SortDir: "ASC", + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) < 2 { + t.Fatalf("got %d tracks, want >= 2", len(tracks)) + } + + for i := 1; i < len(tracks); i++ { + if tracks[i].Year < tracks[i-1].Year { + t.Errorf( + "sort ASC violated: track[%d].Year=%d < "+ + "track[%d].Year=%d", + i, tracks[i].Year, i-1, tracks[i-1].Year, + ) + } + } +} + +func TestEvaluate_SortByYearDESC(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + SortField: "year", + SortDir: "DESC", + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) < 2 { + t.Fatalf("got %d tracks, want >= 2", len(tracks)) + } + + for i := 1; i < len(tracks); i++ { + if tracks[i].Year > tracks[i-1].Year { + t.Errorf( + "sort DESC violated: track[%d].Year=%d > "+ + "track[%d].Year=%d", + i, tracks[i].Year, i-1, tracks[i-1].Year, + ) + } + } +} + +func TestEvaluate_SortByRandom(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // Just verify it doesn't error. + tracks, err := Evaluate(db, RuleSet{ + SortField: "random", + }) + if err != nil { + t.Fatalf("Evaluate with random sort: %v", err) + } + + if len(tracks) != 8 { + t.Fatalf("got %d tracks, want 8", len(tracks)) + } +} + +func TestEvaluate_InvalidField(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + _, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + {Field: "bogus", Operator: "is", Value: "x"}, + }, + }) + if err == nil { + t.Fatal("expected error for invalid field, got nil") + } + + if !strings.Contains(err.Error(), "invalid field") { + t.Errorf("error should mention 'invalid field': %v", + err) + } +} + +// --------------------------------------------------------------------------- +// SQL injection tests +// --------------------------------------------------------------------------- + +func TestSQLInjection_FieldName(t *testing.T) { + t.Parallel() + + _, _, err := BuildWhereClause([]Rule{ + { + Field: "title; DROP TABLE playlists", + Operator: "is", Value: "x", + }, + }) + if err == nil { + t.Fatal( + "expected error for injected field name, got nil", + ) + } + + if !strings.Contains(err.Error(), "invalid field") { + t.Errorf("error should mention 'invalid field': %v", + err) + } +} + +func TestSQLInjection_Value(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // Value with SQL injection — should produce no error (safe + // parameterization) and return 0 results. + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "is", + Value: "'; DROP TABLE playlists; --", + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error with injection value: %v", + err) + } + + if len(tracks) != 0 { + t.Errorf("got %d tracks, want 0", len(tracks)) + } +} + +// --------------------------------------------------------------------------- +// ParseRuleSet tests +// --------------------------------------------------------------------------- + +func TestParseRuleSet_Valid(t *testing.T) { + t.Parallel() + + input := `{ + "rules": [ + {"field": "artist", "operator": "is", "value": "Queen"} + ], + "limit": 50, + "sort_field": "year", + "sort_dir": "DESC" + }` + + rs, err := ParseRuleSet(input) + if err != nil { + t.Fatalf("ParseRuleSet: %v", err) + } + + if len(rs.Rules) != 1 { + t.Fatalf("got %d rules, want 1", len(rs.Rules)) + } + + if rs.Rules[0].Field != "artist" { + t.Errorf("Field = %q, want artist", + rs.Rules[0].Field) + } + + if rs.Limit != 50 { + t.Errorf("Limit = %d, want 50", rs.Limit) + } + + if rs.SortField != "year" { + t.Errorf("SortField = %q, want year", rs.SortField) + } + + if rs.SortDir != "DESC" { + t.Errorf("SortDir = %q, want DESC", rs.SortDir) + } +} + +func TestParseRuleSet_Invalid(t *testing.T) { + t.Parallel() + + _, err := ParseRuleSet("not json at all") + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + + if !strings.Contains(err.Error(), "invalid smart playlist") { + t.Errorf( + "error should mention 'invalid smart playlist': %v", + err, + ) + } +} + +func TestParseRuleSet_EmptyRules(t *testing.T) { + t.Parallel() + + rs, err := ParseRuleSet(`{"rules":[]}`) + if err != nil { + t.Fatalf("ParseRuleSet: %v", err) + } + + if len(rs.Rules) != 0 { + t.Errorf("got %d rules, want 0", len(rs.Rules)) + } +} + +// --------------------------------------------------------------------------- +// Track field mapping test +// --------------------------------------------------------------------------- + +func TestEvaluate_TrackFieldMapping(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + // Fetch Bohemian Rhapsody and verify all library.Track fields + // are correctly populated. + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "is", + Value: "Bohemian Rhapsody", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + tr := tracks[0] + + if tr.TrackName != "Bohemian Rhapsody" { + t.Errorf("TrackName = %q", tr.TrackName) + } + + if tr.ArtistName != "Queen" { + t.Errorf("ArtistName = %q", tr.ArtistName) + } + + if tr.TrackLength != "354000" { + t.Errorf("TrackLength = %q, want 354000", + tr.TrackLength) + } + + if tr.FilePath != "/music/queen/bohemian_rhapsody.mp3" { + t.Errorf("FilePath = %q", tr.FilePath) + } + + if tr.TrackNumber != 11 { + t.Errorf("TrackNumber = %d, want 11", tr.TrackNumber) + } + + if tr.DiscNumber != 1 { + t.Errorf("DiscNumber = %d, want 1", tr.DiscNumber) + } + + if tr.Album != "A Night at the Opera" { + t.Errorf("Album = %q", tr.Album) + } + + if len(tr.Genre) != 1 || tr.Genre[0] != "Rock" { + t.Errorf("Genre = %v, want [Rock]", tr.Genre) + } + + if tr.Year != 1975 { + t.Errorf("Year = %d, want 1975", tr.Year) + } + + if tr.Composer != "Freddie Mercury" { + t.Errorf("Composer = %q", tr.Composer) + } + + if tr.FileType != ".mp3" { + t.Errorf("FileType = %q, want .mp3", tr.FileType) + } + + if tr.SampleRate != 44100 { + t.Errorf("SampleRate = %d", tr.SampleRate) + } + + if tr.BitDepth != 16 { + t.Errorf("BitDepth = %d", tr.BitDepth) + } + + if tr.Channels != 2 { + t.Errorf("Channels = %d", tr.Channels) + } + + if tr.Bitrate != 320000 { + t.Errorf("Bitrate = %d", tr.Bitrate) + } + + if tr.FileSize != 8500000 { + t.Errorf("FileSize = %d", tr.FileSize) + } +} + +// TestEvaluate_MultiGenreTrackGenreField verifies that a track with +// multiple genres has them split correctly into the []string field. +func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + seedSmartPlaylistData(t, db) + + tracks, err := Evaluate(db, RuleSet{ + Rules: []Rule{ + { + Field: "title", Operator: "is", + Value: "Under the Bridge", + }, + }, + }) + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("got %d tracks, want 1", len(tracks)) + } + + tr := tracks[0] + + if len(tr.Genre) != 2 { + t.Fatalf("Genre = %v, want 2 genres", tr.Genre) + } + + hasRock := false + hasAlt := false + + for _, g := range tr.Genre { + if g == "Rock" { + hasRock = true + } + + if g == "Alternative" { + hasAlt = true + } + } + + if !hasRock || !hasAlt { + t.Errorf( + "Genre = %v, want [Rock, Alternative]", tr.Genre, + ) + } +} diff --git a/frontend/index.ts b/frontend/index.ts index 69dd0e9..a42ab71 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -11,6 +11,8 @@ import '@components/artist-details/artist-details.ts'; import '@components/genres-view/genres-view.ts'; import '@components/genre-details/genre-details.ts'; import '@components/playlist-details/playlist-details.ts'; +import '@components/smart-playlist-details/smart-playlist-details.ts'; +import '@components/smart-playlist-editor/smart-playlist-editor.ts'; import '@components/search-bar/search-bar.ts'; import '@components/library-filter/library-filter.ts'; import '@components/track-details/track-details.ts'; @@ -139,6 +141,19 @@ document.addEventListener('navigate', (e: Event) => { currentDetailEl = plEl; break; } + case 'smart-playlist-details': { + const { playlistId, playlistName } = detail; + const spEl = document.createElement('smart-playlist-details'); + + spEl.setAttribute('playlist-id', String(playlistId)); + spEl.setAttribute('playlist-name', playlistName); + if (detail.autoEdit) { + spEl.setAttribute('auto-edit', ''); + } + mainContent.appendChild(spEl); + currentDetailEl = spEl; + break; + } case 'genre-details': { const { genreName } = detail; const genreEl = document.createElement('genre-details'); diff --git a/frontend/src/components/combobox/combobox.ts b/frontend/src/components/combobox/combobox.ts new file mode 100644 index 0000000..36c4716 --- /dev/null +++ b/frontend/src/components/combobox/combobox.ts @@ -0,0 +1,303 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { designTokens } from '../../styles/tokens.css'; + +/** + * `` — Typeable dropdown with autocomplete filtering and + * keyboard navigation. Accepts a flat `options` string array, filters as + * the user types, and emits `combobox-change` when a value is selected. + * + * Key implementation detail: option `
  • ` elements use `@mousedown` with + * `e.preventDefault()` so that the input's `blur` event does not close the + * dropdown before the click registers. + */ +@customElement('yj-combobox') +export class YjCombobox extends LitElement { + // ── Public reactive properties ────────────────────────────────── + + /** Full list of selectable options. */ + @property({ type: Array }) + options: string[] = []; + + /** Currently selected value (reflects to attribute for CSS hooks). */ + @property({ type: String, reflect: true }) + value = ''; + + /** Placeholder text shown when the input is empty. */ + @property({ type: String }) + placeholder = ''; + + /** Disables input and dropdown interaction. */ + @property({ type: Boolean }) + disabled = false; + + // ── Internal state ────────────────────────────────────────────── + + /** Text currently in the input — drives filtering. */ + @state() + private filterText = ''; + + /** Whether the dropdown is visible. */ + @state() + private open = false; + + /** Index into `filteredOptions` for keyboard highlight (-1 = none). */ + @state() + private highlightedIndex = -1; + + // ── Computed ──────────────────────────────────────────────────── + + /** Options that match the current filterText (case-insensitive substring). */ + private get filteredOptions(): string[] { + const opts = this.options ?? []; + if (!this.filterText) return opts; + const needle = this.filterText.toLowerCase(); + return opts.filter((o) => o.toLowerCase().includes(needle)); + } + + // ── Styles ────────────────────────────────────────────────────── + + static override styles = [ + designTokens, + css` + :host { + display: inline-block; + width: 100%; + } + + .combobox-wrapper { + position: relative; + display: inline-block; + width: 100%; + } + + input { + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + color: var(--yj-text-primary, #fff); + border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 4px; + padding: 4px 8px; + font-size: var(--yj-text-md); + font-family: inherit; + width: 100%; + box-sizing: border-box; + } + + input:focus { + outline: none; + border-color: var(--yj-accent, #ffd43b); + } + + input:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + max-height: 200px; + overflow-y: auto; + background: var(--yj-bg-surface, #282828); + border: 1px solid var(--yj-border-subtle, rgba(255, 255, 255, 0.1)); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0; + padding: 0; + list-style: none; + } + + .dropdown li { + padding: 4px 8px; + cursor: pointer; + color: var(--yj-text-primary, #fff); + font-size: var(--yj-text-md); + } + + .dropdown li:hover, + .dropdown li.highlighted { + background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12)); + } + `, + ]; + + // ── Lifecycle ─────────────────────────────────────────────────── + + override connectedCallback() { + super.connectedCallback(); + // Initialise filterText from the external value so an existing + // selection is visible immediately. + this.filterText = this.value; + } + + override updated(changed: Map) { + super.updated(changed); + + // Sync filterText when the parent sets `value` programmatically + // (e.g. when pre-populating the editor with saved rules). + if (changed.has('value') && !this.open) { + this.filterText = this.value; + } + + // Scroll the highlighted option into view. + if (changed.has('highlightedIndex') && this.highlightedIndex >= 0) { + const items = this.shadowRoot?.querySelectorAll('.dropdown li'); + items?.[this.highlightedIndex]?.scrollIntoView({ + block: 'nearest', + }); + } + } + + // ── Event handlers ────────────────────────────────────────────── + + private handleInput(e: Event) { + const input = e.target as HTMLInputElement; + this.filterText = input.value; + this.open = true; + this.highlightedIndex = -1; + } + + private handleFocus() { + // Clear filter so the full option list is visible on focus. + this.filterText = ''; + this.open = true; + this.highlightedIndex = -1; + } + + private handleBlur() { + // Use rAF as a safety net — mousedown on an option calls + // preventDefault() which should keep focus, but some browsers are + // inconsistent. The tiny delay lets any pending mousedown handler + // fire first. + requestAnimationFrame(() => { + this.open = false; + // Restore display text to the confirmed value. + this.filterText = this.value; + }); + } + + private handleKeydown(e: KeyboardEvent) { + const opts = this.filteredOptions; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!this.open) { + this.open = true; + this.highlightedIndex = 0; + } else if (opts.length > 0) { + this.highlightedIndex = + (this.highlightedIndex + 1) % opts.length; + } + break; + + case 'ArrowUp': + e.preventDefault(); + if (opts.length > 0 && this.open) { + this.highlightedIndex = + (this.highlightedIndex - 1 + opts.length) % + opts.length; + } + break; + + case 'Enter': + if ( + this.open && + this.highlightedIndex >= 0 && + this.highlightedIndex < opts.length + ) { + e.preventDefault(); + this.selectOption(opts[this.highlightedIndex]!); + } + break; + + case 'Escape': + e.preventDefault(); + this.open = false; + this.filterText = this.value; + break; + + case 'Tab': + // Close dropdown but let default Tab navigation proceed. + this.open = false; + this.filterText = this.value; + break; + + default: + break; + } + } + + // ── Selection ─────────────────────────────────────────────────── + + private selectOption(opt: string) { + this.value = opt; + this.filterText = opt; + this.open = false; + this.highlightedIndex = -1; + + this.dispatchEvent( + new CustomEvent('combobox-change', { + bubbles: true, + composed: true, + detail: { value: opt }, + }), + ); + } + + // ── Render ────────────────────────────────────────────────────── + + override render() { + const opts = this.filteredOptions; + + return html` +
    + + ${this.open && opts.length > 0 + ? html` + + ` + : nothing} +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'yj-combobox': YjCombobox; + } +} diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index b6bf585..95d9526 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { CreatePlaylist, CreatePlaylistWithTracks, + CreateSmartPlaylist, AddTracksToPlaylist, DeletePlaylist, RenamePlaylist, @@ -86,6 +87,7 @@ export class PlaylistView extends LitElement { @state() private loading = true; @state() private refreshing = false; @state() private creating = false; + @state() private creatingSmart = false; @state() private newPlaylistName = ''; @state() private playlistContextMenuOpen = false; @state() private playlistContextMenuIndex = -1; @@ -1003,7 +1005,9 @@ export class PlaylistView extends LitElement { bubbles: true, composed: true, detail: { - view: 'playlist-details', + view: entry.summary.IsSmart + ? 'smart-playlist-details' + : 'playlist-details', playlistId: entry.summary.ID, playlistName: entry.summary.Name, }, @@ -1021,10 +1025,14 @@ export class PlaylistView extends LitElement { ) => { if (!hasTrackPayload(e)) return; - // Don't allow dropping tracks back onto - // the same playlist. + // Don't allow dropping tracks onto smart + // playlists — they have no playlist_tracks rows. const entry = this.entries[index]; + if (entry?.summary.IsSmart) return; + + // Don't allow dropping tracks back onto + // the same playlist. if ( entry && getActiveDragSource() === 'playlist' && @@ -1473,6 +1481,22 @@ export class PlaylistView extends LitElement { private handleNewPlaylistClick = () => { this.creating = true; + this.creatingSmart = false; + this.newPlaylistName = ''; + + void this.updateComplete.then(() => { + const input = + this.shadowRoot?.querySelector( + '.create-form input', + ); + + input?.focus(); + }); + }; + + private handleNewSmartPlaylistClick = () => { + this.creatingSmart = true; + this.creating = false; this.newPlaylistName = ''; void this.updateComplete.then(() => { @@ -1487,6 +1511,7 @@ export class PlaylistView extends LitElement { private handleCancelCreate = () => { this.creating = false; + this.creatingSmart = false; this.newPlaylistName = ''; this.pendingDropPaths = []; }; @@ -1495,6 +1520,36 @@ export class PlaylistView extends LitElement { const name = this.newPlaylistName.trim(); if (!name) return; + if (this.creatingSmart) { + try { + const summary = await CreateSmartPlaylist( + name, + '{"rules":[],"limit":0,"sort_field":"","sort_dir":""}', + ); + + this.creatingSmart = false; + this.newPlaylistName = ''; + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { + view: 'smart-playlist-details', + playlistId: summary.ID, + playlistName: summary.Name, + autoEdit: true, + }, + }), + ); + } catch (err) { + console.error( + 'Failed to create smart playlist:', + err, + ); + } + return; + } + const paths = this.pendingDropPaths; try { @@ -1656,6 +1711,16 @@ export class PlaylistView extends LitElement { > New Playlist + @@ -1667,7 +1732,7 @@ export class PlaylistView extends LitElement { ${this.renderSortToolbar()} - ${this.creating + ${this.creating || this.creatingSmart ? this.renderCreateForm() : nothing} ${this.loading && @@ -1704,18 +1769,22 @@ export class PlaylistView extends LitElement { > Rename - - void this.onPlaylistContextAction( - 'set-default', - )} - > - - Set as Default Playlist - + ${this.entries[this.playlistContextMenuIndex]?.summary.IsSmart + ? nothing + : html` + + void this.onPlaylistContextAction( + 'set-default', + )} + > + + Set as Default Playlist + + `} ` : nothing} 0; + const placeholder = this.creatingSmart + ? 'Smart playlist name' + : 'Playlist name'; return html`
    ` - : nothing} + : entry.summary.IsSmart + ? html`` + : nothing} ${isRenaming ? html` 0) { + return `${hours}h ${minutes}m`; + } + + const seconds = totalSeconds % 60; + + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + + return `${seconds}s`; +} + +@customElement('smart-playlist-details') +export class SmartPlaylistDetails extends LitElement { + @property({ type: Number, attribute: 'playlist-id' }) + playlistId = 0; + + @property({ type: String, attribute: 'playlist-name' }) + playlistName = ''; + + @property({ type: Boolean, attribute: 'auto-edit' }) + autoEdit = false; + + @state() + private tracks: library.Track[] = []; + + @state() + private loading = true; + + @state() + private editing = false; + + @state() + private currentRulesJSON = ''; + + @state() + private pendingRulesJSON = ''; + + @state() + private saving = false; + + private playlistDeletedCleanup: (() => void) | null = null; + private playlistRenamedCleanup: (() => void) | null = null; + + // ================================================================= + // Styles + // ================================================================= + + static override styles = [designTokens, css` + :host { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + } + + /* ==================================== + * Header + * ==================================== */ + + .smart-playlist-header { + display: flex; + align-items: center; + gap: 20px; + padding: 16px 20px; + flex-shrink: 0; + border-bottom: 1px solid + var( + --yj-border-subtle, + rgba(255, 255, 255, 0.06) + ); + } + + .back-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var( + --yj-bg-overlay, + rgba(255, 255, 255, 0.06) + ); + color: var(--yj-text-primary, #fff); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease; + } + + .back-button:hover { + background: var( + --yj-bg-hover, + rgba(255, 255, 255, 0.12) + ); + } + + .back-button wa-icon { + font-size: 16px; + } + + .playlist-avatar { + width: 80px; + height: 80px; + border-radius: 8px; + overflow: hidden; + background: linear-gradient( + 135deg, + var(--yj-bg-overlay, #404040) 0%, + var(--yj-bg-surface, #282828) 100% + ); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .playlist-avatar wa-icon { + font-size: 32px; + color: var( + --yj-text-secondary, + #b3b3b3 + ); + } + + .playlist-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; + } + + .playlist-title { + font-size: 24px; + font-weight: 700; + color: var(--yj-text-primary, #fff); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + line-height: 1.2; + } + + .track-count { + font-size: var(--yj-text-md); + color: var( + --yj-text-secondary, + #b3b3b3 + ); + } + + /* ==================================== + * Actions + * ==================================== */ + + .playlist-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px 8px; + flex-shrink: 0; + } + + .action-button { + background: none; + border: 1px solid var(--yj-border-subtle, #555); + border-radius: 4px; + color: var(--yj-text-primary, #fff); + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + font-family: inherit; + transition: border-color 0.15s ease, color 0.15s ease; + } + + .action-button:hover { + border-color: var(--yj-accent, #ffd43b); + color: var(--yj-accent, #ffd43b); + } + + .action-button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .action-button:disabled:hover { + border-color: var(--yj-border-subtle, #555); + color: var(--yj-text-primary, #fff); + } + + .action-button wa-icon { + font-size: 12px; + } + + /* ==================================== + * Content + * ==================================== */ + + .content { + flex: 1; + overflow: hidden; + } + + track-list { + width: 100%; + height: 100%; + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + padding: 32px; + color: var(--yj-text-secondary, #b3b3b3); + } + + .empty-state { + padding: 32px 20px; + color: var(--yj-text-tertiary, #666); + font-size: 13px; + text-align: center; + } + + .editor-container { + flex: 1; + overflow: auto; + padding: 0 20px 20px; + } + `]; + + // ================================================================= + // Lifecycle + // ================================================================= + + override async connectedCallback() { + super.connectedCallback(); + await this.loadTracks(); + + if (this.autoEdit) { + this.autoEdit = false; + this.handleEditRules(); + } + + this.playlistDeletedCleanup = EventsOn( + Events.PlaylistDeleted, + (deletedId: number) => { + if (deletedId === this.playlistId) { + this.navigateBack(); + } + }, + ); + + this.playlistRenamedCleanup = EventsOn( + Events.PlaylistRenamed, + (summary: { ID: number; Name: string }) => { + if (summary.ID === this.playlistId) { + this.playlistName = summary.Name; + } + }, + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + if (this.playlistDeletedCleanup) { + this.playlistDeletedCleanup(); + this.playlistDeletedCleanup = null; + } + + if (this.playlistRenamedCleanup) { + this.playlistRenamedCleanup(); + this.playlistRenamedCleanup = null; + } + } + + // ================================================================= + // Data loading + // ================================================================= + + private async loadTracks() { + if (!this.playlistId) return; + + this.loading = true; + + try { + const result = await EvaluateSmartPlaylist(this.playlistId); + + this.tracks = result ?? []; + } catch (error) { + console.error( + 'Failed to evaluate smart playlist:', + error, + ); + this.tracks = []; + } finally { + this.loading = false; + } + } + + // ================================================================= + // Navigation + // ================================================================= + + private navigateBack() { + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { view: 'playlists' }, + }), + ); + } + + // ================================================================= + // Actions + // ================================================================= + + private handlePlay() { + const filePaths = this.tracks + .filter((t) => t.FilePath) + .map((t) => t.FilePath); + + if (filePaths.length === 0) return; + + queueStore.setQueue(filePaths, 0, false); + } + + private handleShuffle() { + const filePaths = this.tracks + .filter((t) => t.FilePath) + .map((t) => t.FilePath); + + if (filePaths.length === 0) return; + + queueStore.setQueue(filePaths, 0, true); + } + + private handleRefresh() { + void this.loadTracks(); + } + + private async handleEditRules() { + try { + const result = await GetSmartPlaylistRules(this.playlistId); + + this.currentRulesJSON = result; + this.pendingRulesJSON = result; + this.editing = true; + } catch (error) { + console.error('Failed to load smart playlist rules:', error); + } + } + + private async handleSaveRules() { + this.saving = true; + + try { + await UpdateSmartPlaylistRules( + this.playlistId, + this.pendingRulesJSON, + ); + + this.editing = false; + this.loadTracks(); + } catch (error) { + console.error('Failed to save smart playlist rules:', error); + } finally { + this.saving = false; + } + } + + private handleCancelEdit() { + this.editing = false; + this.pendingRulesJSON = ''; + } + + private handleRulesChanged(e: CustomEvent) { + this.pendingRulesJSON = e.detail.json; + } + + // ================================================================= + // Helpers + // ================================================================= + + private getTotalDuration(): string { + const totalMs = this.tracks.reduce( + (sum, t) => sum + Number(t.TrackLength || 0), + 0, + ); + + return formatTotalDuration(totalMs); + } + + // ================================================================= + // Render + // ================================================================= + + override render() { + const trackCount = this.tracks.length; + const trackLabel = trackCount === 1 ? 'track' : 'tracks'; + const hasPlayableTracks = this.tracks.some((t) => t.FilePath); + + return html` +
    + +
    + +
    +
    +

    + ${this.playlistName} +

    + ${!this.loading + ? html` + + ${trackCount} + ${trackLabel} + · ${this.getTotalDuration()} + + ` + : nothing} +
    +
    + + ${this.loading + ? html`
    + Evaluating smart playlist… +
    ` + : html` +
    + ${this.editing + ? html` + + + ` + : html` + + + + + `} +
    + ${this.editing + ? html` +
    + +
    + ` + : trackCount > 0 + ? html` +
    + +
    + ` + : html` +
    + No tracks match the current rules. + Configure rules and click Refresh. +
    + `} + `} + `; + } +} diff --git a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts new file mode 100644 index 0000000..b4886c6 --- /dev/null +++ b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts @@ -0,0 +1,909 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { library } from '@go/models'; +import { PreviewSmartPlaylist } from '@go/playlist/Service'; +import { libraryStore } from '@store/library-store'; +import { designTokens } from '../../styles/tokens.css'; +import '@components/combobox/combobox.ts'; + +// ── Field / Operator constants ────────────────────────────────────── + +/** All 16 fields matching the backend `fieldMap` keys. */ +const FIELDS: string[] = [ + 'title', + 'artist', + 'album', + 'genre', + 'year', + 'composer', + 'file_type', + 'duration', + 'sample_rate', + 'bit_depth', + 'channels', + 'bitrate', + 'file_size', + 'library', + 'track_number', + 'disc_number', +]; + +const NUMERIC_FIELDS = new Set([ + 'year', + 'duration', + 'sample_rate', + 'bit_depth', + 'channels', + 'bitrate', + 'file_size', + 'library', + 'track_number', + 'disc_number', +]); + +const TEXT_OPERATORS = [ + 'is', + 'is_not', + 'contains', + 'does_not_contain', + 'starts_with', + 'ends_with', + 'is_any_of', +]; + +const NUMERIC_OPERATORS = [ + 'is', + 'is_not', + 'greater_than', + 'less_than', + 'between', +]; + +const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'random']; + +// ── Helpers ───────────────────────────────────────────────────────── + +function getOperatorsForField(field: string): string[] { + return NUMERIC_FIELDS.has(field) ? NUMERIC_OPERATORS : TEXT_OPERATORS; +} + +/** + * Human-readable labels for operator values. + * `is_not` → "is not", `does_not_contain` → "does not contain", etc. + */ +function formatOperatorLabel(op: string): string { + return op.replace(/_/g, ' '); +} + +/** Returns autocomplete suggestions for a given field from libraryStore. */ +function getAutocompleteOptions(field: string): string[] { + switch (field) { + case 'artist': + return libraryStore.getCachedArtists()?.map((a) => a.Name) ?? []; + case 'genre': + return libraryStore.getCachedGenres()?.map((g) => g.Name) ?? []; + case 'album': + return libraryStore.getCachedAlbums()?.map((a) => a.Name) ?? []; + case 'title': { + const tracks = libraryStore.getCachedTracks(); + if (!tracks) return []; + return [...new Set(tracks.map((t) => t.TrackName).filter(Boolean))]; + } + case 'composer': { + const tracks = libraryStore.getCachedTracks(); + if (!tracks) return []; + return [...new Set(tracks.map((t) => t.Composer).filter(Boolean))]; + } + case 'file_type': { + const tracks = libraryStore.getCachedTracks(); + if (!tracks) return []; + return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))]; + } + case 'year': { + const tracks = libraryStore.getCachedTracks(); + if (!tracks) return []; + return [ + ...new Set( + tracks + .map((t) => t.Year) + .filter((y) => y > 0) + .map(String), + ), + ].sort(); + } + default: + return []; + } +} + +/** Format a field name for display: `file_type` → "File Type". */ +function formatFieldLabel(field: string): string { + return field + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +// ── Rule row type ─────────────────────────────────────────────────── + +interface RuleRow { + field: string; + operator: string; + value: string; + /** Second value for `between` operator (max). */ + value2: string; +} + +function emptyRule(): RuleRow { + return { field: '', operator: '', value: '', value2: '' }; +} + +// ── Component ─────────────────────────────────────────────────────── + +/** + * `` — Row-based rule builder with live preview. + * + * Accepts an initial `rules` JSON attribute (matching the backend RuleSet + * schema) and emits `rules-changed` CustomEvent whenever the user edits + * any row, limit, or sort control. A live preview panel calls + * `PreviewSmartPlaylist` with 300ms debounce and displays matching tracks. + */ +@customElement('smart-playlist-editor') +export class SmartPlaylistEditor extends LitElement { + // ── Public property ───────────────────────────────────────────── + + /** Initial rules JSON (attribute). Parsed in connectedCallback. */ + @property({ type: String }) + rules = ''; + + // ── Internal state ────────────────────────────────────────────── + + @state() private ruleRows: RuleRow[] = [emptyRule()]; + @state() private limit = 0; + @state() private sortField = ''; + @state() private sortDir = ''; + @state() private previewTracks: library.Track[] = []; + @state() private previewLoading = false; + @state() private previewError = ''; + + private previewTimer: ReturnType | null = null; + + // ── Styles ────────────────────────────────────────────────────── + + static override styles = [ + designTokens, + css` + :host { + display: block; + } + + /* ── Rule rows ────────────────────────── */ + + .rule-rows { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 0 8px; + } + + .rule-row { + display: grid; + grid-template-columns: 1fr 140px 1fr 28px; + gap: 6px; + align-items: start; + } + + .rule-row.between-row { + grid-template-columns: 1fr 140px 1fr 1fr 28px; + } + + /* ── Form controls ────────────────────── */ + + select, + input[type='number'] { + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + color: var(--yj-text-primary, #fff); + border: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 4px; + padding: 4px 8px; + font-size: var(--yj-text-md); + font-family: inherit; + width: 100%; + box-sizing: border-box; + } + + select:focus, + input[type='number']:focus { + outline: none; + border-color: var(--yj-accent, #ffd43b); + } + + input[type='number'] { + -moz-appearance: textfield; + } + + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* ── Remove button ────────────────────── */ + + .remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--yj-text-secondary, #b3b3b3); + cursor: pointer; + font-size: 14px; + padding: 0; + margin-top: 2px; + transition: color 0.15s ease, background-color 0.15s ease; + } + + .remove-btn:hover { + color: #ff6b6b; + background: rgba(255, 107, 107, 0.1); + } + + .remove-btn.hidden { + visibility: hidden; + } + + /* ── Add rule button ──────────────────── */ + + .add-rule-btn { + background: none; + border: 1px dashed + var(--yj-border-subtle, rgba(255, 255, 255, 0.15)); + border-radius: 4px; + color: var(--yj-text-secondary, #b3b3b3); + padding: 4px 12px; + font-size: var(--yj-text-sm); + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; + align-self: flex-start; + } + + .add-rule-btn:hover { + border-color: var(--yj-accent, #ffd43b); + color: var(--yj-accent, #ffd43b); + } + + /* ── Options row (limit, sort) ────────── */ + + .options-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0 4px; + border-top: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); + margin-top: 4px; + flex-wrap: wrap; + } + + .option-group { + display: flex; + align-items: center; + gap: 6px; + } + + .option-label { + font-size: var(--yj-text-sm); + color: var(--yj-text-secondary, #b3b3b3); + white-space: nowrap; + } + + .limit-input { + width: 64px; + } + + .sort-select { + min-width: 90px; + } + + .sort-dir-btn { + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + border: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 4px; + color: var(--yj-text-primary, #fff); + padding: 3px 8px; + font-size: var(--yj-text-sm); + font-family: inherit; + cursor: pointer; + min-width: 40px; + text-align: center; + transition: border-color 0.15s ease; + } + + .sort-dir-btn:hover { + border-color: var(--yj-accent, #ffd43b); + } + + /* ── Preview section ──────────────────── */ + + .preview-section { + border-top: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); + margin-top: 8px; + padding-top: 10px; + } + + .preview-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + + .preview-title { + font-size: var(--yj-text-sm); + font-weight: 600; + color: var(--yj-text-secondary, #b3b3b3); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .preview-count { + font-size: var(--yj-text-sm); + color: var(--yj-text-secondary, #b3b3b3); + } + + .preview-loading { + font-size: var(--yj-text-sm); + color: var(--yj-text-secondary, #b3b3b3); + padding: 8px 0; + } + + .preview-error { + font-size: var(--yj-text-sm); + color: #ff6b6b; + padding: 6px 0; + } + + .preview-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + .preview-track { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 3px 0; + font-size: var(--yj-text-sm); + color: var(--yj-text-primary, #fff); + border-bottom: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.03)); + } + + .preview-track:last-child { + border-bottom: none; + } + + .preview-track span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .preview-track .artist, + .preview-track .album { + color: var(--yj-text-secondary, #b3b3b3); + } + + .preview-empty { + font-size: var(--yj-text-sm); + color: var(--yj-text-tertiary, #666); + padding: 8px 0; + } + `, + ]; + + // ── Lifecycle ─────────────────────────────────────────────────── + + override connectedCallback() { + super.connectedCallback(); + this.parseInitialRules(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.previewTimer !== null) { + clearTimeout(this.previewTimer); + this.previewTimer = null; + } + } + + // ── Parse initial rules ───────────────────────────────────────── + + private parseInitialRules() { + if (!this.rules) { + this.ruleRows = [emptyRule()]; + return; + } + + try { + const parsed = JSON.parse(this.rules); + const rows: RuleRow[] = (parsed.rules ?? []).map( + (r: { field?: string; operator?: string; value?: string }) => { + const field = r.field ?? ''; + const operator = r.operator ?? ''; + let value = r.value ?? ''; + let value2 = ''; + + // Deserialize `is_any_of` JSON array back to comma string + if (operator === 'is_any_of' && value.startsWith('[')) { + try { + const arr = JSON.parse(value) as string[]; + value = arr.join(', '); + } catch { + // keep raw value + } + } + + // Deserialize `between` "min,max" into two fields + if (operator === 'between' && value.includes(',')) { + const parts = value.split(','); + value = parts[0]?.trim() ?? ''; + value2 = parts[1]?.trim() ?? ''; + } + + return { field, operator, value, value2 }; + }, + ); + + this.ruleRows = rows.length > 0 ? rows : [emptyRule()]; + this.limit = parsed.limit ?? 0; + this.sortField = parsed.sort_field ?? ''; + this.sortDir = parsed.sort_dir ?? ''; + } catch { + this.ruleRows = [emptyRule()]; + } + + // Trigger initial preview if rules are complete. + this.schedulePreview(); + } + + // ── Build JSON from current state ─────────────────────────────── + + private buildRulesJSON(): string { + const rules = this.ruleRows.map((row) => { + let value = row.value; + + // Serialize is_any_of comma-separated values to JSON array + if (row.operator === 'is_any_of' && value) { + const parts = value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + value = JSON.stringify(parts); + } + + // Serialize between as "min,max" + if (row.operator === 'between') { + value = `${row.value},${row.value2}`; + } + + return { + field: row.field, + operator: row.operator, + value, + }; + }); + + return JSON.stringify({ + rules, + limit: this.limit || 0, + sort_field: this.sortField || '', + sort_dir: this.sortDir || '', + }); + } + + // ── Rule mutation methods ─────────────────────────────────────── + + private updateField(index: number, newField: string) { + const row = this.ruleRows[index]; + if (!row) return; + + const wasNumeric = NUMERIC_FIELDS.has(row.field); + const isNumeric = NUMERIC_FIELDS.has(newField); + + row.field = newField; + + // Reset operator when field type changes (text↔numeric) + if (wasNumeric !== isNumeric || !row.operator) { + const ops = getOperatorsForField(newField); + row.operator = ops[0] ?? ''; + } + + // Reset value when field changes to avoid stale autocomplete data + row.value = ''; + row.value2 = ''; + + this.ruleRows = [...this.ruleRows]; + this.onRulesChanged(); + } + + private updateOperator(index: number, newOp: string) { + const row = this.ruleRows[index]; + if (!row) return; + + row.operator = newOp; + + // Clear value2 if no longer between + if (newOp !== 'between') { + row.value2 = ''; + } + + this.ruleRows = [...this.ruleRows]; + this.onRulesChanged(); + } + + private updateValue(index: number, newValue: string) { + const row = this.ruleRows[index]; + if (!row) return; + + row.value = newValue; + this.ruleRows = [...this.ruleRows]; + this.onRulesChanged(); + } + + private updateValue2(index: number, newValue: string) { + const row = this.ruleRows[index]; + if (!row) return; + + row.value2 = newValue; + this.ruleRows = [...this.ruleRows]; + this.onRulesChanged(); + } + + private addRule() { + this.ruleRows = [...this.ruleRows, emptyRule()]; + } + + private removeRule(index: number) { + if (this.ruleRows.length <= 1) return; + this.ruleRows = this.ruleRows.filter((_, i) => i !== index); + this.onRulesChanged(); + } + + private updateLimit(value: string) { + this.limit = Math.max(0, parseInt(value, 10) || 0); + this.onRulesChanged(); + } + + private updateSortField(value: string) { + this.sortField = value; + if (!value) this.sortDir = ''; + this.onRulesChanged(); + } + + private toggleSortDir() { + if (!this.sortDir) { + this.sortDir = 'ASC'; + } else if (this.sortDir === 'ASC') { + this.sortDir = 'DESC'; + } else { + this.sortDir = ''; + } + this.onRulesChanged(); + } + + // ── Change notification ───────────────────────────────────────── + + private onRulesChanged() { + const json = this.buildRulesJSON(); + + this.dispatchEvent( + new CustomEvent('rules-changed', { + bubbles: true, + composed: true, + detail: { json }, + }), + ); + + this.schedulePreview(); + } + + // ── Live preview ──────────────────────────────────────────────── + + private schedulePreview() { + if (this.previewTimer !== null) { + clearTimeout(this.previewTimer); + } + + this.previewTimer = setTimeout(() => { + this.previewTimer = null; + void this.runPreview(); + }, 300); + } + + private async runPreview() { + // Skip preview if any rule is incomplete + const incomplete = this.ruleRows.some( + (r) => + !r.field || + !r.value || + (r.operator === 'between' && !r.value2), + ); + if (incomplete) { + this.previewTracks = []; + this.previewError = ''; + return; + } + + const json = this.buildRulesJSON(); + this.previewLoading = true; + this.previewError = ''; + + try { + const tracks = await PreviewSmartPlaylist(json); + this.previewTracks = tracks ?? []; + } catch (error) { + console.error('Smart playlist preview failed:', error); + this.previewError = + error instanceof Error ? error.message : String(error); + this.previewTracks = []; + } finally { + this.previewLoading = false; + } + } + + // ── Render ────────────────────────────────────────────────────── + + override render() { + return html` +
    + ${this.ruleRows.map((row, index) => + this.renderRuleRow(row, index), + )} + +
    + + ${this.renderSortOptions()} ${this.renderPreview()} + `; + } + + private renderRuleRow(row: RuleRow, index: number) { + const isBetween = row.operator === 'between'; + const operators = row.field ? getOperatorsForField(row.field) : []; + const isNumeric = NUMERIC_FIELDS.has(row.field); + const isAnyOf = row.operator === 'is_any_of'; + + return html` +
    + + + this.updateField(index, e.detail.value)} + > + + + + + + ${isNumeric && !isBetween + ? html` + + this.updateValue( + index, + (e.target as HTMLInputElement).value, + )} + /> + ` + : isBetween + ? html` + + this.updateValue( + index, + (e.target as HTMLInputElement).value, + )} + /> + + this.updateValue2( + index, + (e.target as HTMLInputElement).value, + )} + /> + ` + : html` + + this.updateValue( + index, + e.detail.value, + )} + > + `} + + + +
    + `; + } + + private renderSortOptions() { + const sortDirLabel = !this.sortDir + ? '—' + : this.sortDir === 'ASC' + ? '↑' + : '↓'; + + return html` +
    +
    + Limit + + this.updateLimit( + (e.target as HTMLInputElement).value, + )} + /> +
    +
    + Sort by + + ${this.sortField + ? html` + + ` + : nothing} +
    +
    + `; + } + + private renderPreview() { + return html` +
    +
    + Preview + ${this.previewTracks.length > 0 && !this.previewLoading + ? html`${this.previewTracks.length} tracks` + : nothing} +
    + + ${this.previewLoading + ? html`
    + Evaluating rules… +
    ` + : this.previewError + ? html`
    + ${this.previewError} +
    ` + : this.previewTracks.length > 0 + ? html` +
    + ${this.previewTracks.map( + (t) => html` +
    + ${t.TrackName} + ${t.ArtistName} + ${t.Album} +
    + `, + )} +
    + ` + : html`
    + Complete all rule fields to see a + preview. +
    `} +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'smart-playlist-editor': SmartPlaylistEditor; + } +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 58af64c..ad94455 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -483,6 +483,7 @@ export namespace playlist { Name: string; CreatedAt: string; UpdatedAt: string; + IsSmart: boolean; static createFrom(source: any = {}) { return new Summary(source); @@ -494,6 +495,7 @@ export namespace playlist { this.Name = source["Name"]; this.CreatedAt = source["CreatedAt"]; this.UpdatedAt = source["UpdatedAt"]; + this.IsSmart = source["IsSmart"]; } } export class Track { diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 1deddc3..795c8cf 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import {playlist} from '../models'; import {context} from '../models'; +import {library} from '../models'; export function AddToDefaultPlaylist(arg1:Array):Promise; @@ -11,10 +12,14 @@ export function CreatePlaylist(arg1:string):Promise; export function CreatePlaylistWithTracks(arg1:string,arg2:Array):Promise; +export function CreateSmartPlaylist(arg1:string,arg2:string):Promise; + export function DeletePlaylist(arg1:number):Promise; export function EnsureDefaultPlaylist():Promise; +export function EvaluateSmartPlaylist(arg1:number):Promise>; + export function FindDuplicateTracksInPlaylist(arg1:number,arg2:Array):Promise; export function FindPhantomMatches(arg1:number,arg2:Array):Promise; @@ -56,3 +61,9 @@ export function SetContext(arg1:context.Context):Promise; export function SetFavoritesConfig(arg1:playlist.FavoritesConfigProvider):Promise; export function ToggleDefaultPlaylistTrack(arg1:string):Promise; + +export function UpdateSmartPlaylistRules(arg1:number,arg2:string):Promise; + +export function GetSmartPlaylistRules(arg1:number):Promise; + +export function PreviewSmartPlaylist(arg1:string):Promise>; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index e0f3a46..910b871 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -18,6 +18,10 @@ export function CreatePlaylistWithTracks(arg1, arg2) { return window['go']['playlist']['Service']['CreatePlaylistWithTracks'](arg1, arg2); } +export function CreateSmartPlaylist(arg1, arg2) { + return window['go']['playlist']['Service']['CreateSmartPlaylist'](arg1, arg2); +} + export function DeletePlaylist(arg1) { return window['go']['playlist']['Service']['DeletePlaylist'](arg1); } @@ -26,6 +30,10 @@ export function EnsureDefaultPlaylist() { return window['go']['playlist']['Service']['EnsureDefaultPlaylist'](); } +export function EvaluateSmartPlaylist(arg1) { + return window['go']['playlist']['Service']['EvaluateSmartPlaylist'](arg1); +} + export function FindDuplicateTracksInPlaylist(arg1, arg2) { return window['go']['playlist']['Service']['FindDuplicateTracksInPlaylist'](arg1, arg2); } @@ -109,3 +117,15 @@ export function SetFavoritesConfig(arg1) { export function ToggleDefaultPlaylistTrack(arg1) { return window['go']['playlist']['Service']['ToggleDefaultPlaylistTrack'](arg1); } + +export function UpdateSmartPlaylistRules(arg1, arg2) { + return window['go']['playlist']['Service']['UpdateSmartPlaylistRules'](arg1, arg2); +} + +export function GetSmartPlaylistRules(arg1) { + return window['go']['playlist']['Service']['GetSmartPlaylistRules'](arg1); +} + +export function PreviewSmartPlaylist(arg1) { + return window['go']['playlist']['Service']['PreviewSmartPlaylist'](arg1); +}