diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go index a6c2e06d..116f9048 100644 --- a/internal/api/admin_quarantine.go +++ b/internal/api/admin_quarantine.go @@ -111,6 +111,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } + // Broadcast: affects every user who'd flagged this track. + h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, @@ -140,6 +142,9 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req } return } + // Broadcast: the track row is gone; every client's library/likes/queue + // invalidates so stale references stop showing. + h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, @@ -179,6 +184,8 @@ func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *htt } return } + // Broadcast: same rationale as delete-file — track row gone everywhere. + h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true) writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go index 80b99cb0..cdf3289a 100644 --- a/internal/api/events_publish.go +++ b/internal/api/events_publish.go @@ -47,6 +47,47 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str }) } +// 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), + }, + }) +} + // 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 diff --git a/internal/api/playlists.go b/internal/api/playlists.go index 4d845cee..1a94f5a9 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -185,6 +185,7 @@ func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "create") return } + h.publishPlaylistEvent("playlist.created", caller.ID, row.ID) writeJSON(w, http.StatusOK, playlistRowToView(row)) } @@ -240,6 +241,7 @@ func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "update") return } + h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID) writeJSON(w, http.StatusOK, playlistRowToView(row)) } @@ -258,6 +260,7 @@ func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "delete") return } + h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID) w.WriteHeader(http.StatusNoContent) } @@ -294,6 +297,7 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) { h.writePlaylistErr(w, err, "append tracks") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-append") @@ -323,6 +327,7 @@ func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Requ h.writePlaylistErr(w, err, "remove track") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-remove") @@ -355,6 +360,7 @@ func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) h.writePlaylistErr(w, err, "reorder") return } + h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID) detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID) if err != nil { h.writePlaylistErr(w, err, "get-after-reorder") diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go index fe2eacd9..1beec8b2 100644 --- a/internal/api/quarantine.go +++ b/internal/api/quarantine.go @@ -67,6 +67,9 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { } return } + // Broadcast: the flagging user's other clients invalidate their + // Hidden tab; admins' clients invalidate their quarantine queue. + h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true) writeJSON(w, http.StatusCreated, quarantineViewFrom(row)) } @@ -89,6 +92,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { writeErr(w, apierror.InternalMsg("unflag failed", err)) return } + // Scoped to user: only their other clients need to invalidate. + h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false) w.WriteHeader(http.StatusNoContent) }