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,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/library"
|
||||
"yellowjacket/backend/smartplaylist"
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
@@ -48,6 +50,7 @@ type Summary struct {
|
||||
Name string `json:"Name"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
UpdatedAt string `json:"UpdatedAt"`
|
||||
IsSmart bool `json:"IsSmart"`
|
||||
}
|
||||
|
||||
// Track represents a track within a playlist, including its
|
||||
@@ -185,6 +188,7 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) {
|
||||
Name: p.Name,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: p.IsSmart != 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,6 +268,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
|
||||
Name: p.Name,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: p.IsSmart != 0,
|
||||
},
|
||||
Tracks: tracks,
|
||||
})
|
||||
@@ -480,6 +485,7 @@ func (s *Service) CreatePlaylist(
|
||||
Name: created.Name,
|
||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: created.IsSmart != 0,
|
||||
})
|
||||
|
||||
return Summary{
|
||||
@@ -487,6 +493,7 @@ func (s *Service) CreatePlaylist(
|
||||
Name: created.Name,
|
||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: created.IsSmart != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -648,6 +655,7 @@ func (s *Service) CreatePlaylistWithTracks(
|
||||
Name: created.Name,
|
||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: created.IsSmart != 0,
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
@@ -938,6 +946,7 @@ func (s *Service) ImportPlaylist(
|
||||
Name: playlistName,
|
||||
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
||||
IsSmart: created.IsSmart != 0,
|
||||
}
|
||||
|
||||
s.emitEvent(events.PlaylistCreated, summary)
|
||||
@@ -2327,3 +2336,288 @@ func sortCandidatesByScore(candidates []CandidateTrack) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Smart playlist methods
|
||||
// =================================================================
|
||||
|
||||
// errNotSmartPlaylist is returned when an operation that requires
|
||||
// a smart playlist is performed on a regular playlist or a
|
||||
// non-existent playlist.
|
||||
var errNotSmartPlaylist = errors.New(
|
||||
"playlist not found or is not a smart playlist",
|
||||
)
|
||||
|
||||
// errNoRowReturned is returned when an INSERT ... RETURNING
|
||||
// query does not return the expected row.
|
||||
var errNoRowReturned = errors.New(
|
||||
"no row returned from insert",
|
||||
)
|
||||
|
||||
// CreateSmartPlaylist creates a new smart playlist with the given
|
||||
// name and JSON rule set. The rules are validated before storage.
|
||||
func (s *Service) CreateSmartPlaylist(
|
||||
name, rulesJSON string,
|
||||
) (Summary, error) {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return Summary{}, errEmptyName
|
||||
}
|
||||
|
||||
// Validate rules JSON before storing.
|
||||
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
|
||||
return Summary{}, fmt.Errorf(
|
||||
"invalid smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// SAFETY: Hand-crafted INSERT for smart playlist with
|
||||
// is_smart and smart_rules columns not yet in sqlc schema.
|
||||
// All values are parameterized.
|
||||
rows, err := s.db.QueryContext(
|
||||
`INSERT INTO playlists (name, is_smart, smart_rules)
|
||||
VALUES (?, 1, ?)
|
||||
RETURNING id, name, created_at, updated_at`,
|
||||
trimmed, rulesJSON,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to create smart playlist",
|
||||
"name", trimmed, "err", err,
|
||||
)
|
||||
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w",
|
||||
errNoRowReturned,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
retName string
|
||||
createdAt string
|
||||
updatedAt string
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&id, &retName, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to create smart playlist",
|
||||
"name", trimmed, "err", err,
|
||||
)
|
||||
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist created",
|
||||
"id", id, "name", retName,
|
||||
)
|
||||
|
||||
summary := Summary{
|
||||
ID: id,
|
||||
Name: retName,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
IsSmart: true,
|
||||
}
|
||||
|
||||
s.emitEvent(events.PlaylistCreated, summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// UpdateSmartPlaylistRules updates the rule set for an existing
|
||||
// smart playlist. Returns an error if the playlist does not exist
|
||||
// or is not a smart playlist.
|
||||
func (s *Service) UpdateSmartPlaylistRules(
|
||||
playlistID int64,
|
||||
rulesJSON string,
|
||||
) error {
|
||||
// Validate rules JSON before storing.
|
||||
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// SAFETY: Hand-crafted UPDATE for smart_rules column not
|
||||
// yet in sqlc schema. All values are parameterized.
|
||||
// Only updates rows where is_smart = 1.
|
||||
result, err := s.db.ExecContext(
|
||||
`UPDATE playlists
|
||||
SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_smart = 1`,
|
||||
rulesJSON, playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to update smart playlist rules",
|
||||
"playlistId", playlistID, "err", err,
|
||||
)
|
||||
|
||||
return fmt.Errorf(
|
||||
"failed to update smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not check rows affected: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return errNotSmartPlaylist
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist rules updated",
|
||||
"playlistId", playlistID,
|
||||
)
|
||||
|
||||
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateSmartPlaylist loads the rule set for a smart playlist
|
||||
// from the database and evaluates it against the track library,
|
||||
// returning the matching tracks.
|
||||
func (s *Service) EvaluateSmartPlaylist(
|
||||
playlistID int64,
|
||||
) ([]library.Track, error) {
|
||||
// SAFETY: Hand-crafted SELECT for smart_rules column not
|
||||
// yet in sqlc schema. Parameterized by playlist ID.
|
||||
rows, err := s.db.QueryContext(
|
||||
`SELECT smart_rules FROM playlists
|
||||
WHERE id = ? AND is_smart = 1`,
|
||||
playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to load smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
_ = rows.Close()
|
||||
|
||||
return nil, errNotSmartPlaylist
|
||||
}
|
||||
|
||||
var rulesJSON string
|
||||
|
||||
if err := rows.Scan(&rulesJSON); err != nil {
|
||||
_ = rows.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to load smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Close rows before calling Evaluate, which opens its own
|
||||
// query. With MaxOpenConns=1 (test DBs), a deferred close
|
||||
// would deadlock.
|
||||
_ = rows.Close()
|
||||
|
||||
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"corrupt smart playlist rules for id %d: %w",
|
||||
playlistID, err,
|
||||
)
|
||||
}
|
||||
|
||||
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"smart playlist evaluation failed for id %d: %w",
|
||||
playlistID, err,
|
||||
)
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// PreviewSmartPlaylist evaluates a rule set from raw JSON without
|
||||
// requiring a saved playlist. This powers live preview in the rule
|
||||
// editor — the frontend sends rules as they are being edited and
|
||||
// receives matching tracks immediately.
|
||||
func (s *Service) PreviewSmartPlaylist(
|
||||
rulesJSON string,
|
||||
) ([]library.Track, error) {
|
||||
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"smart playlist preview failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist preview evaluated",
|
||||
"trackCount", len(tracks),
|
||||
)
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// GetSmartPlaylistRules returns the raw JSON rule string for an
|
||||
// existing smart playlist. This is used when the user opens the
|
||||
// rule editor for an existing smart playlist.
|
||||
func (s *Service) GetSmartPlaylistRules(
|
||||
playlistID int64,
|
||||
) (string, error) {
|
||||
// SAFETY: Hand-crafted SELECT for smart_rules column not
|
||||
// yet in sqlc schema. Parameterized by playlist ID.
|
||||
rows, err := s.db.QueryContext(
|
||||
`SELECT smart_rules FROM playlists
|
||||
WHERE id = ? AND is_smart = 1`,
|
||||
playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to load smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
if !rows.Next() {
|
||||
return "", errNotSmartPlaylist
|
||||
}
|
||||
|
||||
var rulesJSON string
|
||||
|
||||
if err := rows.Scan(&rulesJSON); err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to scan smart playlist rules: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist rules loaded",
|
||||
"playlistId", playlistID,
|
||||
)
|
||||
|
||||
return rulesJSON, nil
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user