From 08da4f277482200848c105c8528f482a8f2a7552 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 24 Jul 2026 15:01:49 -0400 Subject: [PATCH] feat(smartplaylist): materialize on creation and show track counts Smart playlists now evaluate and snapshot their rules at creation time instead of only lazily on first open, so the playlist list can show a real track count in place of the "Smart" label. A one-time idempotent startup sweep backfills snapshots for smart playlists created before creation-time materialization existed. Co-Authored-By: Claude Opus 4.8 --- backend/app.go | 3 + backend/playlist/playlist.go | 76 ++++++++++++++++++- .../components/playlist-view/playlist-view.ts | 4 +- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/backend/app.go b/backend/app.go index aa740ee..e82e8c4 100644 --- a/backend/app.go +++ b/backend/app.go @@ -202,6 +202,9 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.playlist.EnsureDefaultPlaylist() // Recover playlists that lost tracks from a pre-fix FullRescan. go yj.playlist.RepopulateFromM3U() + // Backfill snapshots for smart playlists created before + // creation-time materialization existed. + go yj.playlist.MaterializeUnmaterializedSmartPlaylists() // Initialize speaker hardware (player struct created in // NewYellowJacketApp for Wails binding registration). diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 7367fff..1e45273 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -2591,9 +2591,9 @@ func (s *Service) CreateSmartPlaylist( ) } - defer func() { _ = rows.Close() }() - if !rows.Next() { + _ = rows.Close() + return Summary{}, fmt.Errorf( "failed to create smart playlist: %w", errNoRowReturned, @@ -2610,6 +2610,8 @@ func (s *Service) CreateSmartPlaylist( if err := rows.Scan( &id, &retName, &createdAt, &updatedAt, ); err != nil { + _ = rows.Close() + s.logger.Error( "Failed to create smart playlist", "name", trimmed, "err", err, @@ -2620,11 +2622,26 @@ func (s *Service) CreateSmartPlaylist( ) } + // Close before RefreshSmartPlaylist issues its own queries + // (MaxOpenConns=1 test DBs would deadlock). + _ = rows.Close() + s.logger.Info( "Smart playlist created", "id", id, "name", retName, ) + // Materialize the rule set once at creation so the playlist has a + // track snapshot immediately (track counts, instant open). A failed + // evaluation is non-fatal — the lazy path re-materializes on first + // open. + if err := s.RefreshSmartPlaylist(id); err != nil { + s.logger.Warn( + "Failed to materialize smart playlist at creation", + "id", id, "err", err, + ) + } + summary := Summary{ ID: id, Name: retName, @@ -2821,6 +2838,61 @@ func (s *Service) GetSmartPlaylistTracks( return s.GetPlaylistTracks(playlistID) } +// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any +// smart playlist that has never been materialized (smart_snapshot_at +// IS NULL) — e.g. playlists created before creation-time +// materialization existed. It runs once at startup and is idempotent: +// once every smart playlist has a snapshot it becomes a no-op. Errors +// on individual playlists are logged and skipped so one bad rule set +// doesn't block the rest. +func (s *Service) MaterializeUnmaterializedSmartPlaylists() { + // SAFETY: Static SELECT for smart_snapshot_at column not yet in + // sqlc schema. No parameters. + rows, err := s.db.QueryContext( + `SELECT id FROM playlists + WHERE is_smart = 1 AND smart_snapshot_at IS NULL`, + ) + if err != nil { + s.logger.Error( + "Failed to list unmaterialized smart playlists", + "err", err, + ) + + return + } + + var ids []int64 + + for rows.Next() { + var id int64 + + if err := rows.Scan(&id); err != nil { + s.logger.Error( + "Failed to scan smart playlist id", "err", err, + ) + + continue + } + + ids = append(ids, id) + } + + // Close before RefreshSmartPlaylist issues its own queries + // (MaxOpenConns=1 test DBs would deadlock). + _ = rows.Close() + + for _, id := range ids { + if err := s.RefreshSmartPlaylist(id); err != nil { + s.logger.Warn( + "Failed to materialize smart playlist snapshot", + "id", id, "err", err, + ) + + continue + } + } +} + // EvaluateSmartPlaylist loads the rule set for a smart playlist // from the database and evaluates it against the track library, // returning the matching tracks. diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index ac101a7..5d6edf7 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -1930,9 +1930,7 @@ export class PlaylistView extends LitElement { index: number, ) { const trackCount = entry.tracks.length; - const countLabel = entry.summary.IsSmart - ? 'Smart' - : `${trackCount} track${trackCount !== 1 ? 's' : ''}`; + const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`; const isDragOver = this.dragOverPlaylistIndex === index;