feat(server): emit playlist.system_rebuilt on daily + manual system-playlist rebuild
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s

The daily 03:00 scheduler rebuild (and the manual refresh endpoint) replace
a user's system playlists + You-might-like rows but published no event, so a
client left open across the rebuild served yesterday's snapshot until a
manual reload — the stale-tab case behind #968. Add a user-scoped
playlist.system_rebuilt event (envelope {kind,user_id,data:{}}) from both the
scheduler (bus threaded into NewScheduler) and handleSystemPlaylistRefresh.
Clients consume it to invalidate home / system-playlist views and proactively
re-pull a stale active queue. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:59:27 -04:00
parent d4cc177db4
commit 16f76ea707
4 changed files with 53 additions and 3 deletions
+6 -1
View File
@@ -242,7 +242,12 @@ func run() error {
// active user's daily build at 03:00 in their stored timezone.
// Replaces the 24h-anchored cron loop (removed in the next commit
// of this arc).
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
playlistScheduler, err := playlists.NewScheduler(
pool,
logger.With("component", "playlist_scheduler"),
cfg.Storage.DataDir,
bus,
)
if err != nil {
return fmt.Errorf("init playlist scheduler: %w", err)
}
+15
View File
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
})
}
// publishSystemRebuilt notifies the owner's clients that their system
// playlists (and You-might-like rows) were regenerated, so they invalidate
// the home / system-playlist providers and a stale active queue can re-pull.
// Mirrors the daily scheduler's event; fired here from the manual refresh.
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
if h.eventbus == nil {
return
}
h.eventbus.Publish(eventbus.Event{
Kind: "playlist.system_rebuilt",
UserID: uuidToString(userID),
Data: map[string]any{},
})
}
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
+2
View File
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
// #968: announce the rebuild so the user's other clients refresh.
h.publishSystemRebuilt(user.ID)
q := dbq.New(h.pool)
v := kind
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
+30 -2
View File
@@ -28,6 +28,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
@@ -47,14 +48,21 @@ type Scheduler struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
mu sync.Mutex
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
}
// NewScheduler builds an idle scheduler. Caller must invoke Start
// before any builds fire.
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
// before any builds fire. bus may be nil (tests); rebuild events are
// then skipped.
func NewScheduler(
pool *pgxpool.Pool,
logger *slog.Logger,
dataDir string,
bus *eventbus.Bus,
) (*Scheduler, error) {
g, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("init gocron: %w", err)
@@ -64,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
pool: pool,
logger: logger,
dataDir: dataDir,
bus: bus,
jobs: map[pgtype.UUID]uuid.UUID{},
}, nil
}
@@ -234,7 +243,26 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
s.logger.Warn("scheduler: build failed",
"user_id", uuidStringPL(userID), "err", err)
return
}
// #968: tell the user's connected clients their home content was
// regenerated, so a tab/app left open across the rebuild refreshes its
// system-playlist + You-might-like views (and a stale active queue can
// re-pull) instead of serving yesterday's snapshot until a manual reload.
s.publishRebuilt(userID)
}
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
// No-op when the bus is nil (test construction).
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
if s.bus == nil {
return
}
s.bus.Publish(eventbus.Event{
Kind: "playlist.system_rebuilt",
UserID: uuidStringPL(userID),
Data: map[string]any{},
})
}
// Stop drains gocron and stops the scheduler loop.