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
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
PlayCount int64
|
||||||
|
LastPlayed string
|
||||||
}
|
}
|
||||||
|
|
||||||
// genreDelimiter is the separator used by GROUP_CONCAT in the
|
// genreDelimiter is the separator used by GROUP_CONCAT in the
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ var fieldMap = map[string]string{
|
|||||||
"library": "library_id",
|
"library": "library_id",
|
||||||
"track_number": "track_number",
|
"track_number": "track_number",
|
||||||
"disc_number": "disc_number",
|
"disc_number": "disc_number",
|
||||||
|
"play_count": "play_count",
|
||||||
|
"days_since_played": "days_since_played",
|
||||||
}
|
}
|
||||||
|
|
||||||
// numericFields identifies fields that accept numeric operators.
|
// numericFields identifies fields that accept numeric operators.
|
||||||
@@ -77,6 +79,8 @@ var numericFields = map[string]bool{
|
|||||||
"library": true,
|
"library": true,
|
||||||
"track_number": true,
|
"track_number": true,
|
||||||
"disc_number": true,
|
"disc_number": true,
|
||||||
|
"play_count": true,
|
||||||
|
"days_since_played": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// textOperators are valid operators for text fields.
|
// textOperators are valid operators for text fields.
|
||||||
@@ -152,6 +156,19 @@ func BuildWhereClause(rules []Rule) (string, []any, error) {
|
|||||||
continue
|
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)
|
cond, condArgs, err := buildCondition(col, rule, isNumeric)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
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-
|
// buildCondition generates a single SQL condition for a non-genre-
|
||||||
// subquery rule.
|
// subquery rule.
|
||||||
func buildCondition(
|
func buildCondition(
|
||||||
@@ -458,7 +537,9 @@ func Evaluate(
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
channels,
|
channels,
|
||||||
bitrate,
|
bitrate,
|
||||||
file_size
|
file_size,
|
||||||
|
play_count,
|
||||||
|
COALESCE(last_played, '') AS last_played
|
||||||
FROM track_metadata af`
|
FROM track_metadata af`
|
||||||
|
|
||||||
if where != "" {
|
if where != "" {
|
||||||
@@ -528,6 +609,8 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
|
|||||||
channels int64
|
channels int64
|
||||||
bitrate int64
|
bitrate int64
|
||||||
fileSize int64
|
fileSize int64
|
||||||
|
playCount int64
|
||||||
|
lastPlayed string
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -536,6 +619,7 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
|
|||||||
&album, &genre, &year, &composer, &fileType,
|
&album, &genre, &year, &composer, &fileType,
|
||||||
&sampleRate, &bitDepth, &channels,
|
&sampleRate, &bitDepth, &channels,
|
||||||
&bitrate, &fileSize,
|
&bitrate, &fileSize,
|
||||||
|
&playCount, &lastPlayed,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"could not scan smart playlist row: %w", err,
|
"could not scan smart playlist row: %w", err,
|
||||||
@@ -559,6 +643,8 @@ func scanTracks(rows *sql.Rows) ([]library.Track, error) {
|
|||||||
Channels: channels,
|
Channels: channels,
|
||||||
Bitrate: bitrate,
|
Bitrate: bitrate,
|
||||||
FileSize: fileSize,
|
FileSize: fileSize,
|
||||||
|
PlayCount: playCount,
|
||||||
|
LastPlayed: lastPlayed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const FIELDS: string[] = [
|
|||||||
'library',
|
'library',
|
||||||
'track_number',
|
'track_number',
|
||||||
'disc_number',
|
'disc_number',
|
||||||
|
'play_count',
|
||||||
|
'days_since_played',
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMERIC_FIELDS = new Set([
|
const NUMERIC_FIELDS = new Set([
|
||||||
@@ -39,6 +41,8 @@ const NUMERIC_FIELDS = new Set([
|
|||||||
'library',
|
'library',
|
||||||
'track_number',
|
'track_number',
|
||||||
'disc_number',
|
'disc_number',
|
||||||
|
'play_count',
|
||||||
|
'days_since_played',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TEXT_OPERATORS = [
|
const TEXT_OPERATORS = [
|
||||||
@@ -59,7 +63,7 @@ const NUMERIC_OPERATORS = [
|
|||||||
'between',
|
'between',
|
||||||
];
|
];
|
||||||
|
|
||||||
const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'random'];
|
const SORT_FIELDS = ['title', 'artist', 'album', 'year', 'duration', 'play_count', 'random'];
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user