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:
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,6 +1163,54 @@ func migration8ContentlessDelete(
|
|||||||
return nil
|
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
|
// readLibraryDirFromTOML reads the TOML config file and returns
|
||||||
// the Library.DirectoryPath value, or "" if not configured.
|
// the Library.DirectoryPath value, or "" if not configured.
|
||||||
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
CREATE TABLE IF NOT EXISTS playlists (
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
is_smart INTEGER NOT NULL DEFAULT 0,
|
||||||
|
smart_rules TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,10 +73,12 @@ type PlayerState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Playlist struct {
|
type Playlist struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
CreatedAt time.Time
|
IsSmart int64
|
||||||
UpdatedAt time.Time
|
SmartRules sql.NullString
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistTrack struct {
|
type PlaylistTrack struct {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
|
|||||||
|
|
||||||
const createPlaylist = `-- name: CreatePlaylist :one
|
const createPlaylist = `-- name: CreatePlaylist :one
|
||||||
INSERT INTO playlists (name) VALUES (?)
|
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) {
|
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(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
|
&i.IsSmart,
|
||||||
|
&i.SmartRules,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -192,7 +194,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllPlaylists = `-- name: GetAllPlaylists :many
|
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) {
|
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(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
|
&i.IsSmart,
|
||||||
|
&i.SmartRules,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -236,7 +240,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getPlaylist = `-- name: GetPlaylist :one
|
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) {
|
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(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.Name,
|
&i.Name,
|
||||||
|
&i.IsSmart,
|
||||||
|
&i.SmartRules,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
|
"yellowjacket/backend/library"
|
||||||
|
"yellowjacket/backend/smartplaylist"
|
||||||
"yellowjacket/backend/system"
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ type Summary struct {
|
|||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
CreatedAt string `json:"CreatedAt"`
|
CreatedAt string `json:"CreatedAt"`
|
||||||
UpdatedAt string `json:"UpdatedAt"`
|
UpdatedAt string `json:"UpdatedAt"`
|
||||||
|
IsSmart bool `json:"IsSmart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track represents a track within a playlist, including its
|
// Track represents a track within a playlist, including its
|
||||||
@@ -185,6 +188,7 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) {
|
|||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: p.IsSmart != 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +268,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
|
|||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: p.IsSmart != 0,
|
||||||
},
|
},
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
})
|
})
|
||||||
@@ -480,6 +485,7 @@ func (s *Service) CreatePlaylist(
|
|||||||
Name: created.Name,
|
Name: created.Name,
|
||||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: created.IsSmart != 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Summary{
|
return Summary{
|
||||||
@@ -487,6 +493,7 @@ func (s *Service) CreatePlaylist(
|
|||||||
Name: created.Name,
|
Name: created.Name,
|
||||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: created.IsSmart != 0,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +655,7 @@ func (s *Service) CreatePlaylistWithTracks(
|
|||||||
Name: created.Name,
|
Name: created.Name,
|
||||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: created.IsSmart != 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info(
|
s.logger.Info(
|
||||||
@@ -938,6 +946,7 @@ func (s *Service) ImportPlaylist(
|
|||||||
Name: playlistName,
|
Name: playlistName,
|
||||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||||
|
IsSmart: created.IsSmart != 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emitEvent(events.PlaylistCreated, summary)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -11,6 +11,8 @@ import '@components/artist-details/artist-details.ts';
|
|||||||
import '@components/genres-view/genres-view.ts';
|
import '@components/genres-view/genres-view.ts';
|
||||||
import '@components/genre-details/genre-details.ts';
|
import '@components/genre-details/genre-details.ts';
|
||||||
import '@components/playlist-details/playlist-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/search-bar/search-bar.ts';
|
||||||
import '@components/library-filter/library-filter.ts';
|
import '@components/library-filter/library-filter.ts';
|
||||||
import '@components/track-details/track-details.ts';
|
import '@components/track-details/track-details.ts';
|
||||||
@@ -139,6 +141,19 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
currentDetailEl = plEl;
|
currentDetailEl = plEl;
|
||||||
break;
|
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': {
|
case 'genre-details': {
|
||||||
const { genreName } = detail;
|
const { genreName } = detail;
|
||||||
const genreEl = document.createElement('genre-details');
|
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 {
|
import {
|
||||||
CreatePlaylist,
|
CreatePlaylist,
|
||||||
CreatePlaylistWithTracks,
|
CreatePlaylistWithTracks,
|
||||||
|
CreateSmartPlaylist,
|
||||||
AddTracksToPlaylist,
|
AddTracksToPlaylist,
|
||||||
DeletePlaylist,
|
DeletePlaylist,
|
||||||
RenamePlaylist,
|
RenamePlaylist,
|
||||||
@@ -86,6 +87,7 @@ export class PlaylistView extends LitElement {
|
|||||||
@state() private loading = true;
|
@state() private loading = true;
|
||||||
@state() private refreshing = false;
|
@state() private refreshing = false;
|
||||||
@state() private creating = false;
|
@state() private creating = false;
|
||||||
|
@state() private creatingSmart = false;
|
||||||
@state() private newPlaylistName = '';
|
@state() private newPlaylistName = '';
|
||||||
@state() private playlistContextMenuOpen = false;
|
@state() private playlistContextMenuOpen = false;
|
||||||
@state() private playlistContextMenuIndex = -1;
|
@state() private playlistContextMenuIndex = -1;
|
||||||
@@ -1003,7 +1005,9 @@ export class PlaylistView extends LitElement {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
detail: {
|
detail: {
|
||||||
view: 'playlist-details',
|
view: entry.summary.IsSmart
|
||||||
|
? 'smart-playlist-details'
|
||||||
|
: 'playlist-details',
|
||||||
playlistId: entry.summary.ID,
|
playlistId: entry.summary.ID,
|
||||||
playlistName: entry.summary.Name,
|
playlistName: entry.summary.Name,
|
||||||
},
|
},
|
||||||
@@ -1021,10 +1025,14 @@ export class PlaylistView extends LitElement {
|
|||||||
) => {
|
) => {
|
||||||
if (!hasTrackPayload(e)) return;
|
if (!hasTrackPayload(e)) return;
|
||||||
|
|
||||||
// Don't allow dropping tracks back onto
|
// Don't allow dropping tracks onto smart
|
||||||
// the same playlist.
|
// playlists — they have no playlist_tracks rows.
|
||||||
const entry = this.entries[index];
|
const entry = this.entries[index];
|
||||||
|
|
||||||
|
if (entry?.summary.IsSmart) return;
|
||||||
|
|
||||||
|
// Don't allow dropping tracks back onto
|
||||||
|
// the same playlist.
|
||||||
if (
|
if (
|
||||||
entry &&
|
entry &&
|
||||||
getActiveDragSource() === 'playlist' &&
|
getActiveDragSource() === 'playlist' &&
|
||||||
@@ -1473,6 +1481,22 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
private handleNewPlaylistClick = () => {
|
private handleNewPlaylistClick = () => {
|
||||||
this.creating = true;
|
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 = '';
|
this.newPlaylistName = '';
|
||||||
|
|
||||||
void this.updateComplete.then(() => {
|
void this.updateComplete.then(() => {
|
||||||
@@ -1487,6 +1511,7 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
private handleCancelCreate = () => {
|
private handleCancelCreate = () => {
|
||||||
this.creating = false;
|
this.creating = false;
|
||||||
|
this.creatingSmart = false;
|
||||||
this.newPlaylistName = '';
|
this.newPlaylistName = '';
|
||||||
this.pendingDropPaths = [];
|
this.pendingDropPaths = [];
|
||||||
};
|
};
|
||||||
@@ -1495,6 +1520,36 @@ export class PlaylistView extends LitElement {
|
|||||||
const name = this.newPlaylistName.trim();
|
const name = this.newPlaylistName.trim();
|
||||||
if (!name) return;
|
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;
|
const paths = this.pendingDropPaths;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1656,6 +1711,16 @@ export class PlaylistView extends LitElement {
|
|||||||
></wa-icon>
|
></wa-icon>
|
||||||
New Playlist
|
New Playlist
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="new-playlist-button"
|
||||||
|
@click=${this
|
||||||
|
.handleNewSmartPlaylistClick}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name="filter"
|
||||||
|
></wa-icon>
|
||||||
|
New Smart Playlist
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1667,7 +1732,7 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
${this.renderSortToolbar()}
|
${this.renderSortToolbar()}
|
||||||
|
|
||||||
${this.creating
|
${this.creating || this.creatingSmart
|
||||||
? this.renderCreateForm()
|
? this.renderCreateForm()
|
||||||
: nothing}
|
: nothing}
|
||||||
${this.loading &&
|
${this.loading &&
|
||||||
@@ -1704,18 +1769,22 @@ export class PlaylistView extends LitElement {
|
|||||||
></wa-icon>
|
></wa-icon>
|
||||||
Rename
|
Rename
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item
|
${this.entries[this.playlistContextMenuIndex]?.summary.IsSmart
|
||||||
@click=${() =>
|
? nothing
|
||||||
void this.onPlaylistContextAction(
|
: html`
|
||||||
'set-default',
|
<wa-dropdown-item
|
||||||
)}
|
@click=${() =>
|
||||||
>
|
void this.onPlaylistContextAction(
|
||||||
<wa-icon
|
'set-default',
|
||||||
slot="icon"
|
)}
|
||||||
name="star"
|
>
|
||||||
></wa-icon>
|
<wa-icon
|
||||||
Set as Default Playlist
|
slot="icon"
|
||||||
</wa-dropdown-item>
|
name="star"
|
||||||
|
></wa-icon>
|
||||||
|
Set as Default Playlist
|
||||||
|
</wa-dropdown-item>
|
||||||
|
`}
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<wa-dropdown-item
|
<wa-dropdown-item
|
||||||
@@ -1747,12 +1816,15 @@ export class PlaylistView extends LitElement {
|
|||||||
private renderCreateForm() {
|
private renderCreateForm() {
|
||||||
const canCreate =
|
const canCreate =
|
||||||
this.newPlaylistName.trim().length > 0;
|
this.newPlaylistName.trim().length > 0;
|
||||||
|
const placeholder = this.creatingSmart
|
||||||
|
? 'Smart playlist name'
|
||||||
|
: 'Playlist name';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Playlist name"
|
placeholder=${placeholder}
|
||||||
.value=${this.newPlaylistName}
|
.value=${this.newPlaylistName}
|
||||||
@input=${this.handleInputChange}
|
@input=${this.handleInputChange}
|
||||||
@keydown=${this.handleInputKeydown}
|
@keydown=${this.handleInputKeydown}
|
||||||
@@ -1857,7 +1929,9 @@ export class PlaylistView extends LitElement {
|
|||||||
index: number,
|
index: number,
|
||||||
) {
|
) {
|
||||||
const trackCount = entry.tracks.length;
|
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 =
|
const isDragOver =
|
||||||
this.dragOverPlaylistIndex === index;
|
this.dragOverPlaylistIndex === index;
|
||||||
|
|
||||||
@@ -1891,7 +1965,12 @@ export class PlaylistView extends LitElement {
|
|||||||
class="playlist-icon"
|
class="playlist-icon"
|
||||||
name=${this.favCtrl.iconName}
|
name=${this.favCtrl.iconName}
|
||||||
></wa-icon>`
|
></wa-icon>`
|
||||||
: nothing}
|
: entry.summary.IsSmart
|
||||||
|
? html`<wa-icon
|
||||||
|
class="playlist-icon"
|
||||||
|
name="filter"
|
||||||
|
></wa-icon>`
|
||||||
|
: nothing}
|
||||||
${isRenaming
|
${isRenaming
|
||||||
? html`
|
? html`
|
||||||
<input
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -483,6 +483,7 @@ export namespace playlist {
|
|||||||
Name: string;
|
Name: string;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
UpdatedAt: string;
|
UpdatedAt: string;
|
||||||
|
IsSmart: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Summary(source);
|
return new Summary(source);
|
||||||
@@ -494,6 +495,7 @@ export namespace playlist {
|
|||||||
this.Name = source["Name"];
|
this.Name = source["Name"];
|
||||||
this.CreatedAt = source["CreatedAt"];
|
this.CreatedAt = source["CreatedAt"];
|
||||||
this.UpdatedAt = source["UpdatedAt"];
|
this.UpdatedAt = source["UpdatedAt"];
|
||||||
|
this.IsSmart = source["IsSmart"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Track {
|
export class Track {
|
||||||
|
|||||||
+11
@@ -2,6 +2,7 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {playlist} from '../models';
|
import {playlist} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
import {library} from '../models';
|
||||||
|
|
||||||
export function AddToDefaultPlaylist(arg1:Array<string>):Promise<void>;
|
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 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 DeletePlaylist(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function EnsureDefaultPlaylist():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 FindDuplicateTracksInPlaylist(arg1:number,arg2:Array<string>):Promise<playlist.DuplicateCheckResult>;
|
||||||
|
|
||||||
export function FindPhantomMatches(arg1:number,arg2:Array<string>):Promise<playlist.PhantomSearchResult>;
|
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 SetFavoritesConfig(arg1:playlist.FavoritesConfigProvider):Promise<void>;
|
||||||
|
|
||||||
export function ToggleDefaultPlaylistTrack(arg1:string):Promise<boolean>;
|
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>>;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function CreatePlaylistWithTracks(arg1, arg2) {
|
|||||||
return window['go']['playlist']['Service']['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) {
|
export function DeletePlaylist(arg1) {
|
||||||
return window['go']['playlist']['Service']['DeletePlaylist'](arg1);
|
return window['go']['playlist']['Service']['DeletePlaylist'](arg1);
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,10 @@ export function EnsureDefaultPlaylist() {
|
|||||||
return window['go']['playlist']['Service']['EnsureDefaultPlaylist']();
|
return window['go']['playlist']['Service']['EnsureDefaultPlaylist']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EvaluateSmartPlaylist(arg1) {
|
||||||
|
return window['go']['playlist']['Service']['EvaluateSmartPlaylist'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FindDuplicateTracksInPlaylist(arg1, arg2) {
|
export function FindDuplicateTracksInPlaylist(arg1, arg2) {
|
||||||
return window['go']['playlist']['Service']['FindDuplicateTracksInPlaylist'](arg1, arg2);
|
return window['go']['playlist']['Service']['FindDuplicateTracksInPlaylist'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -109,3 +117,15 @@ export function SetFavoritesConfig(arg1) {
|
|||||||
export function ToggleDefaultPlaylistTrack(arg1) {
|
export function ToggleDefaultPlaylistTrack(arg1) {
|
||||||
return window['go']['playlist']['Service']['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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user