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:
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user