// Small helpers for posting live events to the SSE bus (#392). Producers // call these from their handler success paths so the wire shape stays in // one place. Each helper is a no-op when h.eventbus is nil (older callers // or tests that don't construct a bus); production always has one. package api import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "github.com/jackc/pgx/v5/pgtype" ) // publishLikeEvent broadcasts a track/album/artist like or unlike. The // event is scoped to the user (so only their own clients invalidate); the // payload includes the entity_type and entity_id so the dispatcher can // invalidate the right query keys. func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType string, liked bool) { if h.eventbus == nil { return } kind := "track.liked" if !liked { kind = "track.unliked" } if entityType == "album" { if liked { kind = "album.liked" } else { kind = "album.unliked" } } if entityType == "artist" { if liked { kind = "artist.liked" } else { kind = "artist.unliked" } } h.eventbus.Publish(eventbus.Event{ Kind: kind, UserID: uuidToString(userID), Data: map[string]any{ "entity_type": entityType, "entity_id": uuidToString(entityID), }, }) } // publishQuarantineEvent broadcasts a quarantine action. User-side // flag/unflag are scoped to the user; admin-side resolve / delete-file / // delete-via-lidarr are broadcast (empty UserID) because a single admin // action can affect every user who'd flagged that track — every client // invalidates and the non-affected ones no-op when the query result // matches their cache. func (h *handlers) publishQuarantineEvent(kind string, userID, trackID pgtype.UUID, broadcast bool) { if h.eventbus == nil { return } scope := "" if !broadcast { scope = uuidToString(userID) } h.eventbus.Publish(eventbus.Event{ Kind: kind, UserID: scope, Data: map[string]any{ "track_id": uuidToString(trackID), }, }) } // publishPlaylistEvent broadcasts a playlist mutation to the owner so // their other clients invalidate the affected playlist (and playlist list) // providers. Public-playlist subscribers are out of scope here — they'd // need a separate "playlists.public_updated" broadcast, deferred until // the multi-user case is exercised. func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.UUID) { if h.eventbus == nil { return } h.eventbus.Publish(eventbus.Event{ Kind: kind, UserID: uuidToString(ownerID), Data: map[string]any{ "playlist_id": uuidToString(playlistID), }, }) } // 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 // route to the requester's user_id, not the admin's — the original user // is who needs to see the update. func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) { if h.eventbus == nil { return } h.eventbus.Publish(eventbus.Event{ Kind: "request.status_changed", UserID: uuidToString(row.UserID), Data: map[string]any{ "request_id": uuidToString(row.ID), "status": string(row.Status), "kind": string(row.Kind), }, }) }