feat(#392): publish quarantine + playlist mutation events
Slice 3a — extends the producer set onto the SSE bus. quarantine events (5 producer sites): - quarantine.flagged (user-side flag): broadcast so the flagger's other clients invalidate their Hidden tab and admins' clients invalidate their queue. - quarantine.unflagged (user-side unflag): scoped to the user. - quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin actions): broadcast because a single admin action can affect every user who'd flagged that track. playlist events (6 producer sites): - playlist.created / .updated / .deleted: owner-scoped. - playlist.tracks_changed: emitted for AppendTracks / RemoveTrack / Reorder. Owner-scoped. Single kind for all three mutations because the client invalidation logic is identical (refetch the detail). Public-playlist subscribers (other users viewing someone else's public playlist) intentionally left out — needs a separate broadcast kind, not exercised yet at single-household scale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user