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

Recovered from orphaned worktree commits (complete-milestone failed to merge).

Backend:
- Migration 9: is_smart + smart_rules columns on playlists table
- smartplaylist package: parameterized WHERE clause builder, field whitelist, genre subquery
- playlist.Service: Create/Update/Evaluate/Preview/GetRules smart playlist methods
- 65 tests (49 rule engine + 15 service + 1 migration)

Frontend:
- yj-combobox: reusable typeable dropdown with keyboard nav, ARIA, blur-race fix
- smart-playlist-editor: row-based rule builder with live preview
- smart-playlist-details: evaluate, refresh, play, shuffle, edit rules
- Sidebar: filter icon, Smart badge, create button, routing
- Queue snapshot on play/shuffle
This commit is contained in:
2026-03-21 12:53:00 -04:00
parent e974bd2a22
commit 477b7ff6a2
17 changed files with 5342 additions and 26 deletions
@@ -1,6 +1,8 @@
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
is_smart INTEGER NOT NULL DEFAULT 0,
smart_rules TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+6 -4
View File
@@ -73,10 +73,12 @@ type PlayerState struct {
}
type Playlist struct {
ID int64
Name string
CreatedAt time.Time
UpdatedAt time.Time
ID int64
Name string
IsSmart int64
SmartRules sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistTrack struct {
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (name) VALUES (?)
RETURNING id, name, created_at, updated_at
RETURNING id, name, is_smart, smart_rules, created_at, updated_at
`
func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
@@ -91,6 +91,8 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
err := row.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -192,7 +194,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
}
const getAllPlaylists = `-- name: GetAllPlaylists :many
SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC
`
func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
@@ -207,6 +209,8 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
if err := rows.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -236,7 +240,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
}
const getPlaylist = `-- name: GetPlaylist :one
SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
`
func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
@@ -245,6 +249,8 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
err := row.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
)