feat(M003/S02): smart playlist integration — play_count and days_since_played fields

Backend:
- Added play_count and days_since_played to rule engine field whitelist
- days_since_played uses julianday() expression with COALESCE for NULL handling
- Never-played tracks (NULL last_played) match 'greater_than' but not 'less_than'
- Added PlayCount + LastPlayed to library.Track struct
- Evaluate query selects play_count and last_played from track_metadata
- Added play_count to sort field options

Frontend:
- Added play_count and days_since_played to field and numeric field lists
- Added play_count to sort options

All 49 rule engine + 15 service tests pass unchanged.
This commit is contained in:
2026-03-21 15:26:43 -04:00
parent 9bf2bbab2e
commit 9c85cfcc7b
3 changed files with 94 additions and 2 deletions
+2
View File
@@ -35,6 +35,8 @@ type Track struct {
Channels int64
Bitrate int64
FileSize int64
PlayCount int64
LastPlayed string
}
// genreDelimiter is the separator used by GROUP_CONCAT in the
+87 -1
View File
@@ -63,6 +63,8 @@ var fieldMap = map[string]string{
"library": "library_id",
"track_number": "track_number",
"disc_number": "disc_number",
"play_count": "play_count",
"days_since_played": "days_since_played",
}
// numericFields identifies fields that accept numeric operators.
@@ -77,6 +79,8 @@ var numericFields = map[string]bool{
"library": true,
"track_number": true,
"disc_number": true,
"play_count": true,
"days_since_played": true,
}
// textOperators are valid operators for text fields.
@@ -152,6 +156,19 @@ func BuildWhereClause(rules []Rule) (string, []any, error) {
continue
}
// days_since_played uses a computed expression, not a column.
if rule.Field == "days_since_played" {
cond, condArgs, err := buildDaysSincePlayedCondition(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
@@ -239,6 +256,68 @@ func buildGenreSubquery(rule Rule) (string, []any, error) {
}
}
// buildDaysSincePlayedCondition generates a condition for the
// days_since_played computed field. Uses julianday() to compute
// the number of days between last_played and now. Tracks that
// have never been played (last_played IS NULL) are treated as
// having infinite days since played — they match "greater_than"
// any value but not "less_than".
func buildDaysSincePlayedCondition(rule Rule) (string, []any, error) {
// The expression: days since last played.
// NULL handling: COALESCE to a very old date so never-played
// tracks always have a large days_since_played value.
expr := "CAST(julianday('now') - julianday(COALESCE(last_played, '2000-01-01')) AS INTEGER)"
switch rule.Operator {
case "is":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return expr + " = ?", []any{v}, nil
case "is_not":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return expr + " != ?", []any{v}, nil
case "greater_than":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
return expr + " > ?", []any{v}, nil
case "less_than":
v, err := parseNumericValue(rule.Field, rule.Operator, rule.Value)
if err != nil {
return "", nil, err
}
// Never-played tracks (NULL last_played) should NOT match
// "less than N days" — they haven't been played recently.
return "last_played IS NOT NULL AND " + expr + " < ?", []any{v}, nil
case "between":
min, max, err := parseBetweenValue(rule.Field, rule.Value)
if err != nil {
return "", nil, err
}
return expr + " BETWEEN ? AND ?", []any{min, max}, 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(
@@ -458,7 +537,9 @@ func Evaluate(
bit_depth,
channels,
bitrate,
file_size
file_size,
play_count,
COALESCE(last_played, '') AS last_played
FROM track_metadata af`
if where != "" {
@@ -528,6 +609,8 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
channels int64
bitrate int64
fileSize int64
playCount int64
lastPlayed string
)
if err := rows.Scan(
@@ -536,6 +619,7 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
&album, &genre, &year, &composer, &fileType,
&sampleRate, &bitDepth, &channels,
&bitrate, &fileSize,
&playCount, &lastPlayed,
); err != nil {
return nil, fmt.Errorf(
"could not scan smart playlist row: %w", err,
@@ -559,6 +643,8 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayed,
})
}
@@ -26,6 +26,8 @@ const FIELDS: string[] = [
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
];
const NUMERIC_FIELDS = new Set([
@@ -39,6 +41,8 @@ const NUMERIC_FIELDS = new Set([
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
]);
const TEXT_OPERATORS = [
@@ -59,7 +63,7 @@ const NUMERIC_OPERATORS = [
'between',
];
const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'random'];
const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'play_count', 'random'];
// ── Helpers ─────────────────────────────────────────────────────────