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;