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

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

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

Frontend:
- yj-combobox: reusable typeable dropdown with keyboard nav, ARIA, blur-race fix
- smart-playlist-editor: row-based rule builder with live preview
- smart-playlist-details: evaluate, refresh, play, shuffle, edit rules
- Sidebar: filter icon, Smart badge, create button, routing
- Queue snapshot on play/shuffle
This commit is contained in:
2026-03-21 12:53:00 -04:00
parent e974bd2a22
commit 477b7ff6a2
17 changed files with 5342 additions and 26 deletions
+57
View File
@@ -332,6 +332,15 @@ func runMigrations(
}
}
// Migration 9: add smart playlist columns to playlists.
if version < 9 {
if err := migration9SmartPlaylists(
ctx, db, logger,
); err != nil {
return err
}
}
return nil
}
@@ -1154,6 +1163,54 @@ func migration8ContentlessDelete(
return nil
}
// migration9SmartPlaylists adds the is_smart and smart_rules
// columns to the playlists table for smart playlist support.
func migration9SmartPlaylists(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 9: smart playlist columns",
)
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlists
ADD COLUMN is_smart INTEGER NOT NULL DEFAULT 0`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 9: could not add is_smart column: %w",
err,
)
}
}
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlists
ADD COLUMN smart_rules TEXT`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 9: could not add smart_rules column: %w",
err,
)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 9",
); err != nil {
return fmt.Errorf(
"could not set user_version to 9: %w", err,
)
}
logger.Info("migration 9 complete")
return nil
}
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
+182
View File
@@ -587,3 +587,185 @@ func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
)
}
}
// ---------------------------------------------------------------------------
// Migration 9 integration tests
// ---------------------------------------------------------------------------
func TestMigration9SmartPlaylistColumns(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 9.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 9 {
t.Errorf("user_version = %d, want >= 9", version)
}
// Verify playlists table has is_smart and smart_rules columns.
hasSmart := false
hasRules := false
ptRows, err := db.QueryContext(
"PRAGMA table_info(playlists)",
)
if err != nil {
t.Fatalf("PRAGMA table_info(playlists): %v", err)
}
for ptRows.Next() {
var (
cid int64
name string
colType string
notNull int64
dfltValue sql.NullString
pk int64
)
if err := ptRows.Scan(
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = ptRows.Close()
t.Fatalf("scan playlists table_info: %v", err)
}
if name == "is_smart" {
hasSmart = true
}
if name == "smart_rules" {
hasRules = true
}
}
_ = ptRows.Close()
if !hasSmart {
t.Error("playlists missing is_smart column")
}
if !hasRules {
t.Error("playlists missing smart_rules column")
}
// Insert a smart playlist with rules.
rulesJSON := `{"rules":[{"field":"genre","operator":"is","value":"Rock"}]}`
_, err = db.ExecContext(
"INSERT INTO playlists (name, is_smart, smart_rules) VALUES (?, 1, ?)",
"Rock Songs", rulesJSON,
)
if err != nil {
t.Fatalf("insert smart playlist: %v", err)
}
// Read it back and verify.
rows, err := db.QueryContext(
"SELECT is_smart, smart_rules FROM playlists WHERE name = ?",
"Rock Songs",
)
if err != nil {
t.Fatalf("query smart playlist: %v", err)
}
if !rows.Next() {
_ = rows.Close()
t.Fatal("smart playlist not found")
}
var (
isSmart int64
smartRules sql.NullString
)
if err := rows.Scan(&isSmart, &smartRules); err != nil {
_ = rows.Close()
t.Fatalf("scan smart playlist: %v", err)
}
_ = rows.Close()
if isSmart != 1 {
t.Errorf("is_smart = %d, want 1", isSmart)
}
if !smartRules.Valid || smartRules.String != rulesJSON {
t.Errorf(
"smart_rules = %q, want %q",
smartRules.String, rulesJSON,
)
}
// Insert a regular playlist (default is_smart) and verify
// it defaults to 0.
_, err = db.ExecContext(
"INSERT INTO playlists (name) VALUES (?)",
"Regular Playlist",
)
if err != nil {
t.Fatalf("insert regular playlist: %v", err)
}
regRows, err := db.QueryContext(
"SELECT is_smart, smart_rules FROM playlists WHERE name = ?",
"Regular Playlist",
)
if err != nil {
t.Fatalf("query regular playlist: %v", err)
}
if !regRows.Next() {
_ = regRows.Close()
t.Fatal("regular playlist not found")
}
var (
regSmart int64
regRules sql.NullString
)
if err := regRows.Scan(&regSmart, &regRules); err != nil {
_ = regRows.Close()
t.Fatalf("scan regular playlist: %v", err)
}
_ = regRows.Close()
if regSmart != 0 {
t.Errorf("regular playlist is_smart = %d, want 0", regSmart)
}
if regRules.Valid {
t.Errorf(
"regular playlist smart_rules should be NULL, got %q",
regRules.String,
)
}
}
@@ -1,6 +1,8 @@
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
is_smart INTEGER NOT NULL DEFAULT 0,
smart_rules TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+6 -4
View File
@@ -73,10 +73,12 @@ type PlayerState struct {
}
type Playlist struct {
ID int64
Name string
CreatedAt time.Time
UpdatedAt time.Time
ID int64
Name string
IsSmart int64
SmartRules sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistTrack struct {
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (name) VALUES (?)
RETURNING id, name, created_at, updated_at
RETURNING id, name, is_smart, smart_rules, created_at, updated_at
`
func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
@@ -91,6 +91,8 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
err := row.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -192,7 +194,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
}
const getAllPlaylists = `-- name: GetAllPlaylists :many
SELECT id, name, created_at, updated_at FROM playlists ORDER BY updated_at DESC
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC
`
func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
@@ -207,6 +209,8 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
if err := rows.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -236,7 +240,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
}
const getPlaylist = `-- name: GetPlaylist :one
SELECT id, name, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
`
func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
@@ -245,6 +249,8 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
err := row.Scan(
&i.ID,
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.CreatedAt,
&i.UpdatedAt,
)
+294
View File
@@ -21,6 +21,8 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/library"
"yellowjacket/backend/smartplaylist"
"yellowjacket/backend/system"
)
@@ -48,6 +50,7 @@ type Summary struct {
Name string `json:"Name"`
CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"UpdatedAt"`
IsSmart bool `json:"IsSmart"`
}
// Track represents a track within a playlist, including its
@@ -185,6 +188,7 @@ func (s *Service) GetAllPlaylists() ([]Summary, error) {
Name: p.Name,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
IsSmart: p.IsSmart != 0,
})
}
@@ -264,6 +268,7 @@ func (s *Service) GetAllPlaylistsWithTracks() (
Name: p.Name,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
IsSmart: p.IsSmart != 0,
},
Tracks: tracks,
})
@@ -480,6 +485,7 @@ func (s *Service) CreatePlaylist(
Name: created.Name,
CreatedAt: created.CreatedAt.Format(time.RFC3339),
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
IsSmart: created.IsSmart != 0,
})
return Summary{
@@ -487,6 +493,7 @@ func (s *Service) CreatePlaylist(
Name: created.Name,
CreatedAt: created.CreatedAt.Format(time.RFC3339),
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
IsSmart: created.IsSmart != 0,
}, nil
}
@@ -648,6 +655,7 @@ func (s *Service) CreatePlaylistWithTracks(
Name: created.Name,
CreatedAt: created.CreatedAt.Format(time.RFC3339),
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
IsSmart: created.IsSmart != 0,
}
s.logger.Info(
@@ -938,6 +946,7 @@ func (s *Service) ImportPlaylist(
Name: playlistName,
CreatedAt: created.CreatedAt.Format(time.RFC3339),
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
IsSmart: created.IsSmart != 0,
}
s.emitEvent(events.PlaylistCreated, summary)
@@ -2327,3 +2336,288 @@ func sortCandidatesByScore(candidates []CandidateTrack) {
},
)
}
// =================================================================
// Smart playlist methods
// =================================================================
// errNotSmartPlaylist is returned when an operation that requires
// a smart playlist is performed on a regular playlist or a
// non-existent playlist.
var errNotSmartPlaylist = errors.New(
"playlist not found or is not a smart playlist",
)
// errNoRowReturned is returned when an INSERT ... RETURNING
// query does not return the expected row.
var errNoRowReturned = errors.New(
"no row returned from insert",
)
// CreateSmartPlaylist creates a new smart playlist with the given
// name and JSON rule set. The rules are validated before storage.
func (s *Service) CreateSmartPlaylist(
name, rulesJSON string,
) (Summary, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return Summary{}, errEmptyName
}
// Validate rules JSON before storing.
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
return Summary{}, fmt.Errorf(
"invalid smart playlist rules: %w", err,
)
}
// SAFETY: Hand-crafted INSERT for smart playlist with
// is_smart and smart_rules columns not yet in sqlc schema.
// All values are parameterized.
rows, err := s.db.QueryContext(
`INSERT INTO playlists (name, is_smart, smart_rules)
VALUES (?, 1, ?)
RETURNING id, name, created_at, updated_at`,
trimmed, rulesJSON,
)
if err != nil {
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
)
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w", err,
)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w",
errNoRowReturned,
)
}
var (
id int64
retName string
createdAt string
updatedAt string
)
if err := rows.Scan(
&id, &retName, &createdAt, &updatedAt,
); err != nil {
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
)
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w", err,
)
}
s.logger.Info(
"Smart playlist created",
"id", id, "name", retName,
)
summary := Summary{
ID: id,
Name: retName,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
IsSmart: true,
}
s.emitEvent(events.PlaylistCreated, summary)
return summary, nil
}
// UpdateSmartPlaylistRules updates the rule set for an existing
// smart playlist. Returns an error if the playlist does not exist
// or is not a smart playlist.
func (s *Service) UpdateSmartPlaylistRules(
playlistID int64,
rulesJSON string,
) error {
// Validate rules JSON before storing.
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
return fmt.Errorf(
"invalid smart playlist rules: %w", err,
)
}
// SAFETY: Hand-crafted UPDATE for smart_rules column not
// yet in sqlc schema. All values are parameterized.
// Only updates rows where is_smart = 1.
result, err := s.db.ExecContext(
`UPDATE playlists
SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_smart = 1`,
rulesJSON, playlistID,
)
if err != nil {
s.logger.Error(
"Failed to update smart playlist rules",
"playlistId", playlistID, "err", err,
)
return fmt.Errorf(
"failed to update smart playlist rules: %w", err,
)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(
"could not check rows affected: %w", err,
)
}
if affected == 0 {
return errNotSmartPlaylist
}
s.logger.Info(
"Smart playlist rules updated",
"playlistId", playlistID,
)
s.emitEvent(events.PlaylistTracksChanged, playlistID)
return nil
}
// EvaluateSmartPlaylist loads the rule set for a smart playlist
// from the database and evaluates it against the track library,
// returning the matching tracks.
func (s *Service) EvaluateSmartPlaylist(
playlistID int64,
) ([]library.Track, error) {
// SAFETY: Hand-crafted SELECT for smart_rules column not
// yet in sqlc schema. Parameterized by playlist ID.
rows, err := s.db.QueryContext(
`SELECT smart_rules FROM playlists
WHERE id = ? AND is_smart = 1`,
playlistID,
)
if err != nil {
return nil, fmt.Errorf(
"failed to load smart playlist rules: %w", err,
)
}
if !rows.Next() {
_ = rows.Close()
return nil, errNotSmartPlaylist
}
var rulesJSON string
if err := rows.Scan(&rulesJSON); err != nil {
_ = rows.Close()
return nil, fmt.Errorf(
"failed to load smart playlist rules: %w", err,
)
}
// Close rows before calling Evaluate, which opens its own
// query. With MaxOpenConns=1 (test DBs), a deferred close
// would deadlock.
_ = rows.Close()
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
if err != nil {
return nil, fmt.Errorf(
"corrupt smart playlist rules for id %d: %w",
playlistID, err,
)
}
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
if err != nil {
return nil, fmt.Errorf(
"smart playlist evaluation failed for id %d: %w",
playlistID, err,
)
}
return tracks, nil
}
// PreviewSmartPlaylist evaluates a rule set from raw JSON without
// requiring a saved playlist. This powers live preview in the rule
// editor — the frontend sends rules as they are being edited and
// receives matching tracks immediately.
func (s *Service) PreviewSmartPlaylist(
rulesJSON string,
) ([]library.Track, error) {
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
if err != nil {
return nil, fmt.Errorf(
"invalid smart playlist rules: %w", err,
)
}
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
if err != nil {
return nil, fmt.Errorf(
"smart playlist preview failed: %w", err,
)
}
s.logger.Info(
"Smart playlist preview evaluated",
"trackCount", len(tracks),
)
return tracks, nil
}
// GetSmartPlaylistRules returns the raw JSON rule string for an
// existing smart playlist. This is used when the user opens the
// rule editor for an existing smart playlist.
func (s *Service) GetSmartPlaylistRules(
playlistID int64,
) (string, error) {
// SAFETY: Hand-crafted SELECT for smart_rules column not
// yet in sqlc schema. Parameterized by playlist ID.
rows, err := s.db.QueryContext(
`SELECT smart_rules FROM playlists
WHERE id = ? AND is_smart = 1`,
playlistID,
)
if err != nil {
return "", fmt.Errorf(
"failed to load smart playlist rules: %w", err,
)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return "", errNotSmartPlaylist
}
var rulesJSON string
if err := rows.Scan(&rulesJSON); err != nil {
return "", fmt.Errorf(
"failed to scan smart playlist rules: %w", err,
)
}
s.logger.Info(
"Smart playlist rules loaded",
"playlistId", playlistID,
)
return rulesJSON, nil
}
+665
View File
@@ -0,0 +1,665 @@
package playlist
import (
"encoding/json"
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/smartplaylist"
)
// ---------------------------------------------------------------------------
// Test helpers — seed data
// ---------------------------------------------------------------------------
// seedSmartTestTracks inserts a minimal set of tracks with the full
// FK chain required for smart playlist evaluation tests.
//
// ID 1: "Electric Song" by "Band A" album "Album One" (2020) genre=Rock duration=300000ms
// ID 2: "Acoustic Vibes" by "Band B" album "Album Two" (2015) genre=Jazz duration=240000ms
// ID 3: "Heavy Metal" by "Band A" album "Album One" (2020) genre=Metal duration=420000ms
func seedSmartTestTracks(t *testing.T, db *database.DB) {
t.Helper()
type track struct {
id int64
filePath string
title string
artist string
album string
year int64
genre string
lenMs int64
}
tracks := []track{
{
1, "/music/band_a/electric.mp3",
"Electric Song", "Band A", "Album One",
2020, "Rock", 300000,
},
{
2, "/music/band_b/acoustic.flac",
"Acoustic Vibes", "Band B", "Album Two",
2015, "Jazz", 240000,
},
{
3, "/music/band_a/heavy.mp3",
"Heavy Metal", "Band A", "Album One",
2020, "Metal", 420000,
},
}
// Build unique sets for artist_credit and release_groups.
artistMap := map[string]int64{}
albumMap := map[string]int64{}
var artistID, albumID int64
for _, tr := range tracks {
if _, ok := artistMap[tr.artist]; !ok {
artistID++
artistMap[tr.artist] = artistID
}
if _, ok := albumMap[tr.album]; !ok {
albumID++
albumMap[tr.album] = albumID
}
}
// Insert artist_credit rows.
for text, id := range artistMap {
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
id, text,
)
if err != nil {
t.Fatalf("insert artist_credit %q: %v", text, err)
}
}
// Insert release_groups.
for name, id := range albumMap {
_, err := db.ExecContext(
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
id, name,
)
if err != nil {
t.Fatalf("insert release_group %q: %v", name, err)
}
}
// Insert genres.
genreMap := map[string]int64{}
var genreID int64
for _, tr := range tracks {
if _, ok := genreMap[tr.genre]; !ok {
genreID++
genreMap[tr.genre] = genreID
_, err := db.ExecContext(
"INSERT INTO genres (id, name) VALUES (?, ?)",
genreID, tr.genre,
)
if err != nil {
t.Fatalf("insert genre %q: %v", tr.genre, err)
}
}
}
// Insert tracks with full FK chain.
for _, tr := range tracks {
acID := artistMap[tr.artist]
rgID := albumMap[tr.album]
// Insert recording.
_, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, year) "+
"VALUES (?, ?, ?, ?)",
tr.id, tr.title, acID, tr.year,
)
if err != nil {
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
}
// Insert audio_file.
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, "+
"length_milliseconds, file_type_id, recording_id, "+
"sample_rate, bit_depth, channels, bitrate, file_size) "+
"VALUES (?, ?, ?, 0, ?, 44100, 16, 2, 320000, 5000000)",
tr.id, tr.filePath, tr.lenMs, tr.id,
)
if err != nil {
t.Fatalf("insert audio_file %d: %v", tr.id, err)
}
// Link recording to release_group.
_, err = db.ExecContext(
"INSERT INTO release_group_recordings "+
"(release_group_id, recording_id) VALUES (?, ?)",
rgID, tr.id,
)
if err != nil {
t.Fatalf("insert release_group_recordings %d→%d: %v",
rgID, tr.id, err)
}
// Insert recording_genres link.
gID := genreMap[tr.genre]
_, err = db.ExecContext(
"INSERT INTO recording_genres "+
"(recording_id, genre_id) VALUES (?, ?)",
tr.id, gID,
)
if err != nil {
t.Fatalf("insert recording_genres %d→%d: %v",
tr.id, gID, err)
}
}
}
// newTestService constructs a playlist.Service with only the
// fields needed for smart playlist operations (db and logger).
func newTestService(t *testing.T, db *database.DB) *Service {
t.Helper()
return &Service{
db: db,
logger: slog.Default(),
}
}
// makeRulesJSON is a helper that marshals rules into a valid JSON
// string for use in tests.
func makeRulesJSON(t *testing.T, rules smartplaylist.RuleSet) string {
t.Helper()
data, err := json.Marshal(rules)
if err != nil {
t.Fatalf("could not marshal rules: %v", err)
}
return string(data)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestSmartPlaylistCreateAndEvaluate(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "artist", Operator: "is", Value: "Band A"},
},
})
// Create smart playlist.
summary, err := svc.CreateSmartPlaylist("My Smart PL", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
if summary.Name != "My Smart PL" {
t.Errorf("Name = %q, want %q", summary.Name, "My Smart PL")
}
if summary.ID <= 0 {
t.Errorf("ID = %d, want > 0", summary.ID)
}
if summary.CreatedAt == "" {
t.Error("CreatedAt is empty")
}
if summary.UpdatedAt == "" {
t.Error("UpdatedAt is empty")
}
// Evaluate the smart playlist.
tracks, err := svc.EvaluateSmartPlaylist(summary.ID)
if err != nil {
t.Fatalf("EvaluateSmartPlaylist failed: %v", err)
}
// Band A has tracks 1 and 3.
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2", len(tracks))
}
// Verify tracks belong to Band A.
for _, tr := range tracks {
if tr.ArtistName != "Band A" {
t.Errorf("track %q has artist %q, want Band A",
tr.TrackName, tr.ArtistName)
}
}
}
func TestSmartPlaylistUpdateRules(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
// Create with artist filter for Band A (2 tracks).
initialRules := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "artist", Operator: "is", Value: "Band A"},
},
})
summary, err := svc.CreateSmartPlaylist("Update Test", initialRules)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
// Update to artist = Band B (1 track).
newRules := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "artist", Operator: "is", Value: "Band B"},
},
})
if err := svc.UpdateSmartPlaylistRules(summary.ID, newRules); err != nil {
t.Fatalf("UpdateSmartPlaylistRules failed: %v", err)
}
// Evaluate — should now return only Band B tracks.
tracks, err := svc.EvaluateSmartPlaylist(summary.ID)
if err != nil {
t.Fatalf("EvaluateSmartPlaylist failed: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
if tracks[0].ArtistName != "Band B" {
t.Errorf("artist = %q, want Band B", tracks[0].ArtistName)
}
}
func TestSmartPlaylistCreateInvalidJSON(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
_, err := svc.CreateSmartPlaylist("Bad", "not json")
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestSmartPlaylistCreateEmptyName(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "title", Operator: "contains", Value: "test"},
},
})
_, err := svc.CreateSmartPlaylist("", rulesJSON)
if err == nil {
t.Fatal("expected error for empty name, got nil")
}
}
func TestSmartPlaylistEvaluateNonSmartPlaylist(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Create a regular playlist via direct SQL.
// SAFETY: Test-only insert for regular playlist.
rows, err := db.QueryContext(
`INSERT INTO playlists (name) VALUES (?)
RETURNING id`,
"Regular PL",
)
if err != nil {
t.Fatalf("insert regular playlist: %v", err)
}
var regularID int64
if rows.Next() {
if err := rows.Scan(&regularID); err != nil {
t.Fatalf("scan regular playlist id: %v", err)
}
}
_ = rows.Close()
// Evaluate should fail — not a smart playlist.
_, err = svc.EvaluateSmartPlaylist(regularID)
if err == nil {
t.Fatal("expected error evaluating non-smart playlist, got nil")
}
}
func TestSmartPlaylistEvaluateNonExistent(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Evaluate a playlist ID that doesn't exist.
_, err := svc.EvaluateSmartPlaylist(99999)
if err == nil {
t.Fatal("expected error evaluating non-existent playlist, got nil")
}
}
func TestSmartPlaylistUpdateNonSmartPlaylist(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Create a regular playlist.
rows, err := db.QueryContext(
`INSERT INTO playlists (name) VALUES (?)
RETURNING id`,
"Regular PL",
)
if err != nil {
t.Fatalf("insert regular playlist: %v", err)
}
var regularID int64
if rows.Next() {
if err := rows.Scan(&regularID); err != nil {
t.Fatalf("scan regular playlist id: %v", err)
}
}
_ = rows.Close()
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "title", Operator: "contains", Value: "test"},
},
})
// Update should fail — not a smart playlist.
err = svc.UpdateSmartPlaylistRules(regularID, rulesJSON)
if err == nil {
t.Fatal("expected error updating non-smart playlist, got nil")
}
}
func TestSmartPlaylistUpdateInvalidJSON(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Create a real smart playlist first.
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "title", Operator: "contains", Value: "test"},
},
})
summary, err := svc.CreateSmartPlaylist("Valid PL", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
// Update with invalid JSON.
err = svc.UpdateSmartPlaylistRules(summary.ID, "bad json")
if err == nil {
t.Fatal("expected error for invalid JSON update, got nil")
}
}
func TestSmartPlaylistGenreEvaluation(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "genre", Operator: "is", Value: "Rock"},
},
})
summary, err := svc.CreateSmartPlaylist("Genre Test", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
tracks, err := svc.EvaluateSmartPlaylist(summary.ID)
if err != nil {
t.Fatalf("EvaluateSmartPlaylist failed: %v", err)
}
// Only track 1 ("Electric Song") has genre exactly "Rock".
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
if tracks[0].TrackName != "Electric Song" {
t.Errorf("track = %q, want Electric Song", tracks[0].TrackName)
}
}
func TestSmartPlaylistYearNumericFilter(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "year", Operator: "greater_than", Value: "2019"},
},
})
summary, err := svc.CreateSmartPlaylist("Year Test", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
tracks, err := svc.EvaluateSmartPlaylist(summary.ID)
if err != nil {
t.Fatalf("EvaluateSmartPlaylist failed: %v", err)
}
// Tracks 1 and 3 have year=2020, track 2 has year=2015.
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2", len(tracks))
}
for _, tr := range tracks {
if tr.Year <= 2019 {
t.Errorf("track %q has year %d, want > 2019",
tr.TrackName, tr.Year)
}
}
}
// ---------------------------------------------------------------------------
// Preview and GetRules tests
// ---------------------------------------------------------------------------
func TestSmartPlaylistPreview(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "artist", Operator: "is", Value: "Band A"},
},
})
// Create and evaluate via saved playlist for comparison.
summary, err := svc.CreateSmartPlaylist("Preview Compare", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
savedTracks, err := svc.EvaluateSmartPlaylist(summary.ID)
if err != nil {
t.Fatalf("EvaluateSmartPlaylist failed: %v", err)
}
// Preview with same rules — should return same tracks.
previewTracks, err := svc.PreviewSmartPlaylist(rulesJSON)
if err != nil {
t.Fatalf("PreviewSmartPlaylist failed: %v", err)
}
if len(previewTracks) != len(savedTracks) {
t.Fatalf(
"preview returned %d tracks, saved returned %d",
len(previewTracks), len(savedTracks),
)
}
// Verify all preview tracks are Band A.
for _, tr := range previewTracks {
if tr.ArtistName != "Band A" {
t.Errorf(
"preview track %q has artist %q, want Band A",
tr.TrackName, tr.ArtistName,
)
}
}
}
func TestSmartPlaylistPreviewInvalidRules(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
_, err := svc.PreviewSmartPlaylist("not valid json")
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestSmartPlaylistGetRules(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedSmartTestTracks(t, db)
svc := newTestService(t, db)
rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{
Rules: []smartplaylist.Rule{
{Field: "genre", Operator: "is", Value: "Rock"},
},
})
summary, err := svc.CreateSmartPlaylist("Get Rules Test", rulesJSON)
if err != nil {
t.Fatalf("CreateSmartPlaylist failed: %v", err)
}
got, err := svc.GetSmartPlaylistRules(summary.ID)
if err != nil {
t.Fatalf("GetSmartPlaylistRules failed: %v", err)
}
if got != rulesJSON {
t.Errorf(
"GetSmartPlaylistRules = %q, want %q",
got, rulesJSON,
)
}
}
func TestSmartPlaylistGetRulesNotFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
_, err := svc.GetSmartPlaylistRules(99999)
if err == nil {
t.Fatal(
"expected error for non-existent playlist, got nil",
)
}
if err.Error() != errNotSmartPlaylist.Error() {
t.Errorf(
"error = %q, want %q",
err.Error(), errNotSmartPlaylist.Error(),
)
}
}
func TestSmartPlaylistGetRulesRegularPlaylist(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Create a regular playlist via direct SQL.
// SAFETY: Test-only insert for regular playlist.
rows, err := db.QueryContext(
`INSERT INTO playlists (name) VALUES (?)
RETURNING id`,
"Regular PL For GetRules",
)
if err != nil {
t.Fatalf("insert regular playlist: %v", err)
}
var regularID int64
if rows.Next() {
if err := rows.Scan(&regularID); err != nil {
t.Fatalf("scan regular playlist id: %v", err)
}
}
_ = rows.Close()
// GetSmartPlaylistRules should fail — not a smart playlist.
_, err = svc.GetSmartPlaylistRules(regularID)
if err == nil {
t.Fatal(
"expected error for regular playlist, got nil",
)
}
if err.Error() != errNotSmartPlaylist.Error() {
t.Errorf(
"error = %q, want %q",
err.Error(), errNotSmartPlaylist.Error(),
)
}
}
+597
View File
@@ -0,0 +1,597 @@
// Package smartplaylist builds parameterized SQL WHERE clauses from
// JSON rule definitions and evaluates them against the track_metadata
// view. Field names are whitelisted; values are always parameterized.
package smartplaylist
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"yellowjacket/backend/database"
"yellowjacket/backend/library"
)
// Sentinel errors for rule validation.
var (
errInvalidField = errors.New("invalid field: not in allowed field list")
errInvalidOperator = errors.New("invalid operator for field type")
errEmptyIsAnyOf = errors.New("is_any_of requires at least one value")
errBetweenCount = errors.New("between requires exactly 2 values")
errBetweenFormat = errors.New("between value must be \"min,max\" or [\"min\",\"max\"]")
errUnsupportedOp = errors.New("unsupported operator")
errInvalidSortField = errors.New("invalid sort field: not in allowed field list")
errNotNumeric = errors.New("value must be numeric")
)
// Rule represents a single filter condition for a smart playlist.
type Rule struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
}
// RuleSet holds the complete filter configuration for a smart
// playlist, including optional sort and limit.
type RuleSet struct {
Rules []Rule `json:"rules"`
Limit int `json:"limit,omitempty"`
SortField string `json:"sort_field,omitempty"`
SortDir string `json:"sort_dir,omitempty"`
}
// fieldMap maps user-facing rule field names to track_metadata column
// names. Field names MUST come from this map — never interpolated
// from user input.
var fieldMap = map[string]string{
"title": "title",
"artist": "artist_name",
"album": "album",
"genre": "genre",
"year": "year",
"composer": "composer",
"file_type": "file_type",
"duration": "length_milliseconds",
"sample_rate": "sample_rate",
"bit_depth": "bit_depth",
"channels": "channels",
"bitrate": "bitrate",
"file_size": "file_size",
"library": "library_id",
"track_number": "track_number",
"disc_number": "disc_number",
}
// numericFields identifies fields that accept numeric operators.
var numericFields = map[string]bool{
"year": true,
"duration": true,
"sample_rate": true,
"bit_depth": true,
"channels": true,
"bitrate": true,
"file_size": true,
"library": true,
"track_number": true,
"disc_number": true,
}
// textOperators are valid operators for text fields.
var textOperators = map[string]bool{
"is": true,
"is_not": true,
"contains": true,
"does_not_contain": true,
"starts_with": true,
"ends_with": true,
"is_any_of": true,
}
// numericOperators are valid operators for numeric fields.
var numericOperators = map[string]bool{
"is": true,
"is_not": true,
"greater_than": true,
"less_than": true,
"between": true,
}
// genreExactOps require a subquery against recording_genres JOIN
// genres instead of matching the concatenated genre column.
var genreExactOps = map[string]bool{
"is": true,
"is_not": true,
"is_any_of": true,
}
// genreDelimiter matches the GROUP_CONCAT delimiter in
// track_metadata_view.sql.
const genreDelimiter = "||"
// BuildWhereClause builds a parameterized SQL WHERE clause from a
// slice of rules. It is a pure function — no database access needed.
// Returns the clause (without the leading "WHERE"), the parameter
// args, and any validation error.
func BuildWhereClause(rules []Rule) (string, []any, error) {
if len(rules) == 0 {
return "", nil, nil
}
conditions := make([]string, 0, len(rules))
args := make([]any, 0, len(rules))
for _, rule := range rules {
col, ok := fieldMap[rule.Field]
if !ok {
return "", nil, fmt.Errorf(
"%w: %q", errInvalidField, rule.Field,
)
}
isNumeric := numericFields[rule.Field]
if err := validateOperator(rule.Operator, isNumeric); err != nil {
return "", nil, fmt.Errorf(
"field %q: %w", rule.Field, err,
)
}
// Genre exact-match operators use a subquery.
if rule.Field == "genre" && genreExactOps[rule.Operator] {
cond, condArgs, err := buildGenreSubquery(rule)
if err != nil {
return "", nil, err
}
conditions = append(conditions, cond)
args = append(args, condArgs...)
continue
}
cond, condArgs, err := buildCondition(col, rule, isNumeric)
if err != nil {
return "", nil, err
}
conditions = append(conditions, cond)
args = append(args, condArgs...)
}
return strings.Join(conditions, " AND "), args, nil
}
// validateOperator checks that the operator is valid for the field
// type.
func validateOperator(op string, isNumeric bool) error {
if isNumeric {
if !numericOperators[op] {
return fmt.Errorf(
"%w: %q for numeric field", errInvalidOperator, op,
)
}
} else {
if !textOperators[op] {
return fmt.Errorf(
"%w: %q for text field", errInvalidOperator, op,
)
}
}
return nil
}
// buildGenreSubquery generates a subquery condition against
// recording_genres JOIN genres for exact genre matching.
func buildGenreSubquery(rule Rule) (string, []any, error) {
subquery := `af.id IN (
SELECT rg_sub.recording_id FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE `
switch rule.Operator {
case "is":
return subquery + "g.name = ?)", []any{rule.Value}, nil
case "is_not":
return `af.id NOT IN (
SELECT rg_sub.recording_id FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE g.name = ?)`, []any{rule.Value}, nil
case "is_any_of":
var values []string
if err := json.Unmarshal(
[]byte(rule.Value), &values,
); err != nil {
return "", nil, fmt.Errorf(
"field %q: is_any_of value must be a JSON "+
"string array: %w",
rule.Field, err,
)
}
if len(values) == 0 {
return "", nil, fmt.Errorf(
"field %q: %w", rule.Field, errEmptyIsAnyOf,
)
}
placeholders := make([]string, len(values))
condArgs := make([]any, len(values))
for i, v := range values {
placeholders[i] = "?"
condArgs[i] = v
}
return subquery + "g.name IN (" +
strings.Join(placeholders, ", ") + "))", condArgs, nil
default:
return "", nil, fmt.Errorf(
"%w: %q", errUnsupportedOp, rule.Operator,
)
}
}
// buildCondition generates a single SQL condition for a non-genre-
// subquery rule.
func buildCondition(
col string, rule Rule, isNumeric bool,
) (string, []any, error) {
switch rule.Operator {
case "is":
if isNumeric {
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return col + " = ?", []any{v}, nil
}
return col + " = ?", []any{rule.Value}, nil
case "is_not":
if isNumeric {
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return col + " != ?", []any{v}, nil
}
return col + " != ?", []any{rule.Value}, nil
case "contains":
return col + " LIKE ?",
[]any{"%" + rule.Value + "%"}, nil
case "does_not_contain":
return col + " NOT LIKE ?",
[]any{"%" + rule.Value + "%"}, nil
case "starts_with":
return col + " LIKE ?",
[]any{rule.Value + "%"}, nil
case "ends_with":
return col + " LIKE ?",
[]any{"%" + rule.Value}, nil
case "is_any_of":
var values []string
if err := json.Unmarshal(
[]byte(rule.Value), &values,
); err != nil {
return "", nil, fmt.Errorf(
"field %q: is_any_of value must be a JSON "+
"string array: %w",
rule.Field, err,
)
}
if len(values) == 0 {
return "", nil, fmt.Errorf(
"field %q: %w", rule.Field, errEmptyIsAnyOf,
)
}
placeholders := make([]string, len(values))
condArgs := make([]any, len(values))
for i, v := range values {
placeholders[i] = "?"
condArgs[i] = v
}
return col + " IN (" +
strings.Join(placeholders, ", ") + ")", condArgs, nil
case "greater_than":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return col + " > ?", []any{v}, nil
case "less_than":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return col + " < ?", []any{v}, nil
case "between":
lo, hi, err := parseBetweenValue(rule.Field, rule.Value)
if err != nil {
return "", nil, err
}
return col + " BETWEEN ? AND ?",
[]any{lo, hi}, nil
default:
return "", nil, fmt.Errorf(
"%w: %q", errUnsupportedOp, rule.Operator,
)
}
}
// parseNumericValue converts a string value to int64 for numeric
// field comparisons.
func parseNumericValue(field, op, value string) (int64, error) {
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf(
"field %q operator %q: %w: %w",
field, op, errNotNumeric, err,
)
}
return v, nil
}
// parseBetweenValue parses "min,max" or JSON ["min","max"] into two
// integer values.
func parseBetweenValue(
field, value string,
) (int64, int64, error) {
// Try JSON array first.
var arr []string
if err := json.Unmarshal([]byte(value), &arr); err == nil {
if len(arr) != 2 {
return 0, 0, fmt.Errorf(
"field %q: %w: got %d",
field, errBetweenCount, len(arr),
)
}
lo, err := strconv.ParseInt(arr[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf(
"field %q between lo: %w: %w",
field, errNotNumeric, err,
)
}
hi, err := strconv.ParseInt(arr[1], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf(
"field %q between hi: %w: %w",
field, errNotNumeric, err,
)
}
return lo, hi, nil
}
// Fall back to comma-separated.
parts := strings.SplitN(value, ",", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf(
"field %q: %w", field, errBetweenFormat,
)
}
lo, err := strconv.ParseInt(
strings.TrimSpace(parts[0]), 10, 64,
)
if err != nil {
return 0, 0, fmt.Errorf(
"field %q between lo: %w: %w",
field, errNotNumeric, err,
)
}
hi, err := strconv.ParseInt(
strings.TrimSpace(parts[1]), 10, 64,
)
if err != nil {
return 0, 0, fmt.Errorf(
"field %q between hi: %w: %w",
field, errNotNumeric, err,
)
}
return lo, hi, nil
}
// Evaluate runs the rule set against the track_metadata view and
// returns matching tracks.
func Evaluate(
db *database.DB, ruleSet RuleSet,
) ([]library.Track, error) {
where, args, err := BuildWhereClause(ruleSet.Rules)
if err != nil {
return nil, fmt.Errorf(
"smart playlist rule error: %w", err,
)
}
// SAFETY: Dynamic WHERE clause built from whitelisted field
// names and parameterized values only. Sort field is validated
// against fieldMap. No user-supplied strings are interpolated.
query := `SELECT
file_path,
length_milliseconds,
title,
artist_name,
track_number,
disc_number,
album,
genre,
year,
composer,
file_type,
sample_rate,
bit_depth,
channels,
bitrate,
file_size
FROM track_metadata af`
if where != "" {
query += "\nWHERE " + where
}
// Sort.
if ruleSet.SortField != "" {
if ruleSet.SortField == "random" {
query += "\nORDER BY RANDOM()"
} else {
sortCol, ok := fieldMap[ruleSet.SortField]
if !ok {
return nil, fmt.Errorf(
"%w: %q", errInvalidSortField,
ruleSet.SortField,
)
}
dir := "ASC"
if strings.EqualFold(ruleSet.SortDir, "DESC") {
dir = "DESC"
}
query += "\nORDER BY " + sortCol + " " + dir
}
}
// Limit.
if ruleSet.Limit > 0 {
query += "\nLIMIT ?"
args = append(args, ruleSet.Limit)
}
rows, err := db.QueryContext(query, args...)
if err != nil {
return nil, fmt.Errorf(
"smart playlist query failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
return scanTracks(rows)
}
// scanTracks reads all rows from a query result into a Track slice.
func scanTracks(rows *sql.Rows) ([]library.Track, error) {
var tracks []library.Track
for rows.Next() {
var (
filePath string
lengthMs int64
title string
artistName string
trackNumber sql.NullInt64
discNumber sql.NullInt64
album string
genre string
year int64
composer string
fileType string
sampleRate int64
bitDepth int64
channels int64
bitrate int64
fileSize int64
)
if err := rows.Scan(
&filePath, &lengthMs, &title, &artistName,
&trackNumber, &discNumber,
&album, &genre, &year, &composer, &fileType,
&sampleRate, &bitDepth, &channels,
&bitrate, &fileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan smart playlist row: %w", err,
)
}
tracks = append(tracks, library.Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"smart playlist row iteration error: %w", err,
)
}
return tracks, nil
}
// ParseRuleSet parses a JSON string into a validated RuleSet.
func ParseRuleSet(jsonStr string) (RuleSet, error) {
var rs RuleSet
if err := json.Unmarshal(
[]byte(jsonStr), &rs,
); err != nil {
return RuleSet{}, fmt.Errorf(
"invalid smart playlist rules JSON: %w", err,
)
}
return rs, nil
}
// splitGenres splits a GROUP_CONCAT genre string into individual
// genre names. An empty string returns nil.
func splitGenres(concatenated string) []string {
if concatenated == "" {
return nil
}
return strings.Split(concatenated, genreDelimiter)
}
File diff suppressed because it is too large Load Diff