From 9c85cfcc7b95042fa766e157f14456e4304b64a4 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 21 Mar 2026 15:26:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(M003/S02):=20smart=20playlist=20integratio?= =?UTF-8?q?n=20=E2=80=94=20play=5Fcount=20and=20days=5Fsince=5Fplayed=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/library/query.go | 2 + backend/smartplaylist/smartplaylist.go | 88 ++++++++++++++++++- .../smart-playlist-editor.ts | 6 +- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/backend/library/query.go b/backend/library/query.go index cd4044a..e707e77 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -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 diff --git a/backend/smartplaylist/smartplaylist.go b/backend/smartplaylist/smartplaylist.go index b3246c3..34ecb36 100644 --- a/backend/smartplaylist/smartplaylist.go +++ b/backend/smartplaylist/smartplaylist.go @@ -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, }) } diff --git a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts index 4b5a205..ea560a1 100644 --- a/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts +++ b/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts @@ -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 ─────────────────────────────────────────────────────────