From 391f5f16a1ff0b06a4e00d552b83667e24123c32 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 09:16:07 -0400 Subject: [PATCH] feat(server/m7-352): cron loop + main.go wiring for system playlist build --- cmd/minstrel/main.go | 2 + internal/playlists/system_cron.go | 61 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 internal/playlists/system_cron.go diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 48d73218..3a2b10fa 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -18,6 +18,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/server" @@ -110,6 +111,7 @@ func run() error { srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding) + playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron")) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/internal/playlists/system_cron.go b/internal/playlists/system_cron.go new file mode 100644 index 00000000..408d88e5 --- /dev/null +++ b/internal/playlists/system_cron.go @@ -0,0 +1,61 @@ +package playlists + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// StartSystemPlaylistCron launches the in-process daily build loop in a +// goroutine. Caller passes a context that's cancelled on graceful shutdown. +// +// Lifecycle: +// 1. Clear any stale in_flight=true rows from a previously crashed process +// (safe at startup: by definition, nothing is running yet). +// 2. Run once at startup so users catch up after a server restart without +// waiting up to 24h. +// 3. Tick every 24h; runOnce on each tick. +// 4. Exit on context cancellation. +// +// Per-user errors are logged but don't halt the loop. Per-cycle errors (e.g., +// "list active users" failed) are logged but the next tick still fires. +func StartSystemPlaylistCron(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) { + go func() { + q := dbq.New(pool) + if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil { + logger.Warn("system playlist cron: startup recovery failed", "err", err) + } + + runOnce(ctx, pool, logger) + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runOnce(ctx, pool, logger) + } + } + }() +} + +func runOnce(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) { + q := dbq.New(pool) + users, err := q.ListActiveUsersForSystemPlaylists(ctx) + if err != nil { + logger.Error("system playlist cron: list active users failed", "err", err) + return + } + for _, userID := range users { + if err := BuildSystemPlaylists(ctx, pool, logger, userID, time.Now()); err != nil { + logger.Warn("system playlist build failed in cron", + "user_id", uuidStringPL(userID), "err", err) + } + } +}