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
|
||||
}
|
||||
|
||||
@@ -1154,6 +1163,54 @@ func migration8ContentlessDelete(
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration9SmartPlaylists adds the is_smart and smart_rules
|
||||
// columns to the playlists table for smart playlist support.
|
||||
func migration9SmartPlaylists(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
logger.Info(
|
||||
"applying migration 9: smart playlist columns",
|
||||
)
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`ALTER TABLE playlists
|
||||
ADD COLUMN is_smart INTEGER NOT NULL DEFAULT 0`,
|
||||
); err != nil {
|
||||
if !isDuplicateColumnErr(err) {
|
||||
return fmt.Errorf(
|
||||
"migration 9: could not add is_smart column: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`ALTER TABLE playlists
|
||||
ADD COLUMN smart_rules TEXT`,
|
||||
); err != nil {
|
||||
if !isDuplicateColumnErr(err) {
|
||||
return fmt.Errorf(
|
||||
"migration 9: could not add smart_rules column: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
ctx, "PRAGMA user_version = 9",
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not set user_version to 9: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info("migration 9 complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLibraryDirFromTOML reads the TOML config file and returns
|
||||
// the Library.DirectoryPath value, or "" if not configured.
|
||||
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
||||
|
||||
@@ -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 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_smart INTEGER NOT NULL DEFAULT 0,
|
||||
smart_rules TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -73,10 +73,12 @@ type PlayerState struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID int64
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID int64
|
||||
Name string
|
||||
IsSmart int64
|
||||
SmartRules sql.NullString
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
|
||||
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
|
||||
|
||||
const createPlaylist = `-- name: CreatePlaylist :one
|
||||
INSERT INTO playlists (name) VALUES (?)
|
||||
RETURNING id, name, created_at, updated_at
|
||||
RETURNING id, name, is_smart, smart_rules, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
|
||||
@@ -91,6 +91,8 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -192,7 +194,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
|
||||
}
|
||||
|
||||
const getAllPlaylists = `-- name: GetAllPlaylists :many
|
||||
SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC
|
||||
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
|
||||
@@ -207,6 +209,8 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -236,7 +240,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
|
||||
}
|
||||
|
||||
const getPlaylist = `-- name: GetPlaylist :one
|
||||
SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
|
||||
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
|
||||
@@ -245,6 +249,8 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.IsSmart,
|
||||
&i.SmartRules,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user