feat(M002): smart playlists — rule engine, editor UI, sidebar integration

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
This commit is contained in:
2026-03-21 12:53:00 -04:00
parent e974bd2a22
commit 477b7ff6a2
17 changed files with 5342 additions and 26 deletions
+57
View File
@@ -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 {
+182
View File
@@ -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, &notNull, &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(&regSmart, &regRules); 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,
)
}
}
@@ -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
);
+6 -4
View File
@@ -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 {
@@ -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,
)
+294
View File
@@ -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
}
+665
View File
@@ -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(&regularID); 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(&regularID); 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(&regularID); 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(),
)
}
}
+597
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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');
@@ -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';
/**
* `<yj-combobox>` — 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 `<li>` 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<string, unknown>) {
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`
<div class="combobox-wrapper">
<input
.value=${this.filterText}
@input=${this.handleInput}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@keydown=${this.handleKeydown}
?disabled=${this.disabled}
placeholder=${this.placeholder}
autocomplete="off"
role="combobox"
aria-expanded=${this.open}
aria-autocomplete="list"
/>
${this.open && opts.length > 0
? html`
<ul class="dropdown" role="listbox">
${opts.map(
(opt, i) => html`
<li
role="option"
aria-selected=${i === this.highlightedIndex}
class=${i === this.highlightedIndex
? 'highlighted'
: ''}
@mousedown=${(e: Event) => {
e.preventDefault();
this.selectOption(opt);
}}
>
${opt}
</li>
`,
)}
</ul>
`
: nothing}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'yj-combobox': YjCombobox;
}
}
@@ -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<HTMLInputElement>(
'.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 {
></wa-icon>
New Playlist
</button>
<button
class="new-playlist-button"
@click=${this
.handleNewSmartPlaylistClick}
>
<wa-icon
name="filter"
></wa-icon>
New Smart Playlist
</button>
</div>
</div>
@@ -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 {
></wa-icon>
Rename
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
void this.onPlaylistContextAction(
'set-default',
)}
>
<wa-icon
slot="icon"
name="star"
></wa-icon>
Set as Default Playlist
</wa-dropdown-item>
${this.entries[this.playlistContextMenuIndex]?.summary.IsSmart
? nothing
: html`
<wa-dropdown-item
@click=${() =>
void this.onPlaylistContextAction(
'set-default',
)}
>
<wa-icon
slot="icon"
name="star"
></wa-icon>
Set as Default Playlist
</wa-dropdown-item>
`}
`
: nothing}
<wa-dropdown-item
@@ -1747,12 +1816,15 @@ export class PlaylistView extends LitElement {
private renderCreateForm() {
const canCreate =
this.newPlaylistName.trim().length > 0;
const placeholder = this.creatingSmart
? 'Smart playlist name'
: 'Playlist name';
return html`
<div class="create-form">
<input
type="text"
placeholder="Playlist name"
placeholder=${placeholder}
.value=${this.newPlaylistName}
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
@@ -1857,7 +1929,9 @@ export class PlaylistView extends LitElement {
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = entry.summary.IsSmart
? 'Smart'
: `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
@@ -1891,7 +1965,12 @@ export class PlaylistView extends LitElement {
class="playlist-icon"
name=${this.favCtrl.iconName}
></wa-icon>`
: nothing}
: entry.summary.IsSmart
? html`<wa-icon
class="playlist-icon"
name="filter"
></wa-icon>`
: nothing}
${isRenaming
? html`
<input
@@ -0,0 +1,564 @@
import { LitElement, html, css, nothing } from 'lit';
import {
customElement,
property,
state,
} from 'lit/decorators.js';
import { library } from '@go/models';
import {
EvaluateSmartPlaylist,
GetSmartPlaylistRules,
UpdateSmartPlaylistRules,
} from '@go/playlist/Service';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { queueStore } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
/**
* Format total milliseconds as a human-readable duration.
* e.g. 8_100_000 → "2h 15m", 180_000 → "3m 0s", 45_000 → "0m 45s"
*/
function formatTotalDuration(totalMs: number): string {
if (totalMs <= 0) return '0m';
const totalSeconds = Math.floor(totalMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 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`
<div class="smart-playlist-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to playlists"
aria-label="Back to playlists"
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<div class="playlist-avatar">
<wa-icon name="filter"></wa-icon>
</div>
<div class="playlist-info">
<h1
class="playlist-title"
title="${this.playlistName}"
>
${this.playlistName}
</h1>
${!this.loading
? html`
<span class="track-count">
${trackCount}
${trackLabel}
· ${this.getTotalDuration()}
</span>
`
: nothing}
</div>
</div>
${this.loading
? html`<div class="loading">
Evaluating smart playlist…
</div>`
: html`
<div class="playlist-actions">
${this.editing
? html`
<button
class="action-button"
@click=${this.handleSaveRules}
?disabled=${this.saving}
title="Save rules"
>
<wa-icon name="floppy-disk"></wa-icon>
${this.saving ? 'Saving…' : 'Save Rules'}
</button>
<button
class="action-button"
@click=${this.handleCancelEdit}
?disabled=${this.saving}
title="Cancel editing"
>
<wa-icon name="xmark"></wa-icon>
Cancel
</button>
`
: html`
<button
class="action-button"
@click=${this.handlePlay}
?disabled=${!hasPlayableTracks}
title="Play all tracks"
>
<wa-icon name="play"></wa-icon>
Play
</button>
<button
class="action-button"
@click=${this.handleShuffle}
?disabled=${!hasPlayableTracks}
title="Shuffle all tracks"
>
<wa-icon name="shuffle"></wa-icon>
Shuffle
</button>
<button
class="action-button"
@click=${this.handleRefresh}
title="Re-evaluate smart playlist rules"
>
<wa-icon name="arrow-rotate-right"></wa-icon>
Refresh
</button>
<button
class="action-button"
@click=${this.handleEditRules}
title="Edit smart playlist rules"
>
<wa-icon name="pen-to-square"></wa-icon>
Edit Rules
</button>
`}
</div>
${this.editing
? html`
<div class="editor-container">
<smart-playlist-editor
.rules=${this.currentRulesJSON}
@rules-changed=${this.handleRulesChanged}
></smart-playlist-editor>
</div>
`
: trackCount > 0
? html`
<div class="content">
<track-list
.externalTracks=${this.tracks}
></track-list>
</div>
`
: html`
<div class="empty-state">
No tracks match the current rules.
Configure rules and click Refresh.
</div>
`}
`}
`;
}
}
@@ -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 ───────────────────────────────────────────────────────
/**
* `<smart-playlist-editor>` — 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<typeof setTimeout> | 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`
<div class="rule-rows">
${this.ruleRows.map((row, index) =>
this.renderRuleRow(row, index),
)}
<button class="add-rule-btn" @click=${this.addRule}>
+ Add Rule
</button>
</div>
${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`
<div class="rule-row ${isBetween ? 'between-row' : ''}">
<!-- Field combobox -->
<yj-combobox
.options=${FIELDS}
.value=${row.field}
placeholder="Select field…"
@combobox-change=${(e: CustomEvent) =>
this.updateField(index, e.detail.value)}
></yj-combobox>
<!-- Operator select -->
<select
@change=${(e: Event) =>
this.updateOperator(
index,
(e.target as HTMLSelectElement).value,
)}
?disabled=${!row.field}
>
${!row.field
? html`<option value="">—</option>`
: nothing}
${operators.map(
(op) => html`
<option
value=${op}
?selected=${op === row.operator}
>
${formatOperatorLabel(op)}
</option>
`,
)}
</select>
<!-- Value input -->
${isNumeric && !isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Value"
?disabled=${!row.field}
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Min"
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
<input
type="number"
.value=${row.value2}
placeholder="Max"
@input=${(e: Event) =>
this.updateValue2(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: html`
<yj-combobox
.options=${getAutocompleteOptions(row.field)}
.value=${row.value}
placeholder=${isAnyOf
? 'Comma-separated values'
: 'Value'}
?disabled=${!row.field}
@combobox-change=${(e: CustomEvent) =>
this.updateValue(
index,
e.detail.value,
)}
></yj-combobox>
`}
<!-- Remove button -->
<button
class="remove-btn ${this.ruleRows.length <= 1 ? 'hidden' : ''}"
@click=${() => this.removeRule(index)}
title="Remove rule"
aria-label="Remove rule"
>
</button>
</div>
`;
}
private renderSortOptions() {
const sortDirLabel = !this.sortDir
? '—'
: this.sortDir === 'ASC'
? '↑'
: '↓';
return html`
<div class="options-row">
<div class="option-group">
<span class="option-label">Limit</span>
<input
type="number"
class="limit-input"
min="0"
.value=${String(this.limit || '')}
placeholder="∞"
@input=${(e: Event) =>
this.updateLimit(
(e.target as HTMLInputElement).value,
)}
/>
</div>
<div class="option-group">
<span class="option-label">Sort by</span>
<select
class="sort-select"
@change=${(e: Event) =>
this.updateSortField(
(e.target as HTMLSelectElement).value,
)}
>
<option value="" ?selected=${!this.sortField}>
None
</option>
${SORT_FIELDS.map(
(f) => html`
<option
value=${f}
?selected=${f === this.sortField}
>
${formatFieldLabel(f)}
</option>
`,
)}
</select>
${this.sortField
? html`
<button
class="sort-dir-btn"
@click=${this.toggleSortDir}
title=${this.sortDir === 'ASC'
? 'Ascending'
: this.sortDir === 'DESC'
? 'Descending'
: 'No direction'}
>
${sortDirLabel}
</button>
`
: nothing}
</div>
</div>
`;
}
private renderPreview() {
return html`
<div class="preview-section">
<div class="preview-header">
<span class="preview-title">Preview</span>
${this.previewTracks.length > 0 && !this.previewLoading
? html`<span class="preview-count"
>${this.previewTracks.length} tracks</span
>`
: nothing}
</div>
${this.previewLoading
? html`<div class="preview-loading">
Evaluating rules…
</div>`
: this.previewError
? html`<div class="preview-error">
${this.previewError}
</div>`
: this.previewTracks.length > 0
? html`
<div class="preview-list">
${this.previewTracks.map(
(t) => html`
<div class="preview-track">
<span class="title"
>${t.TrackName}</span
>
<span class="artist"
>${t.ArtistName}</span
>
<span class="album"
>${t.Album}</span
>
</div>
`,
)}
</div>
`
: html`<div class="preview-empty">
Complete all rule fields to see a
preview.
</div>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'smart-playlist-editor': SmartPlaylistEditor;
}
}
+2
View File
@@ -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 {
+11
View File
@@ -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<string>):Promise<void>;
@@ -11,10 +12,14 @@ export function CreatePlaylist(arg1:string):Promise<playlist.Summary>;
export function CreatePlaylistWithTracks(arg1:string,arg2:Array<string>):Promise<playlist.Summary>;
export function CreateSmartPlaylist(arg1:string,arg2:string):Promise<playlist.Summary>;
export function DeletePlaylist(arg1:number):Promise<void>;
export function EnsureDefaultPlaylist():Promise<void>;
export function EvaluateSmartPlaylist(arg1:number):Promise<Array<library.Track>>;
export function FindDuplicateTracksInPlaylist(arg1:number,arg2:Array<string>):Promise<playlist.DuplicateCheckResult>;
export function FindPhantomMatches(arg1:number,arg2:Array<string>):Promise<playlist.PhantomSearchResult>;
@@ -56,3 +61,9 @@ export function SetContext(arg1:context.Context):Promise<void>;
export function SetFavoritesConfig(arg1:playlist.FavoritesConfigProvider):Promise<void>;
export function ToggleDefaultPlaylistTrack(arg1:string):Promise<boolean>;
export function UpdateSmartPlaylistRules(arg1:number,arg2:string):Promise<void>;
export function GetSmartPlaylistRules(arg1:number):Promise<string>;
export function PreviewSmartPlaylist(arg1:string):Promise<Array<library.Track>>;
+20
View File
@@ -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);
}