Files
silo-server/internal/api/handlers/admin_server_channels.go
866392fecd feat(notifications): announce new audiobooks and ebooks on server channels (#260)
Audiobook and ebook libraries previously never entered the Recently
Added pipeline: availability detection only ran for TV/movie/mixed
libraries and release_events only knew episode/movie kinds, so server
channels (Discord/generic webhooks) could not announce new audiobooks
or ebooks.

Generalize the movie path into a flat-item-kind registry
(internal/notifications/item_kind.go) driving availability detection,
recording, channel toggles, payload rendering, test fixtures, and the
admin backfill seeder. New kinds share a kind-discriminated
item_availability table; movie_availability stays as-is. Channels gain
notify_new_audiobooks/notify_new_ebooks toggles (default on, additive
API fields) and embeds carry the author from item_people. Flood-safe by
construction: existing libraries seed silently on their first
post-upgrade full scan.

Extract internal/librarykind to replace the is*LibraryType helper
copies that had drifted across scanner, libraryingest, and metadata
(metadata's movie check silently included mixed; now spelled
explicitly).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:42:28 -04:00

241 lines
9.3 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
// AdminServerChannelsHandler exposes admin CRUD for server notification
// channels (community broadcast destinations). All routes are mounted inside
// the admin-only group.
type AdminServerChannelsHandler struct {
system *notifications.System
}
// NewAdminServerChannelsHandler creates the handler.
func NewAdminServerChannelsHandler(system *notifications.System) *AdminServerChannelsHandler {
return &AdminServerChannelsHandler{system: system}
}
func (h *AdminServerChannelsHandler) service() *notifications.ServerChannelService {
if h == nil || h.system == nil {
return nil
}
return h.system.ServerChannels
}
// serverChannelResponse is the API view of a server channel. Like webhooks it
// never includes the destination URL (Discord webhook tokens are bearer
// credentials in the URL path) or the stored signing secret — only url_host.
type serverChannelResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URLHost string `json:"url_host"`
Enabled bool `json:"enabled"`
NotifyNewMovies bool `json:"notify_new_movies"`
NotifyNewEpisodes bool `json:"notify_new_episodes"`
NotifyNewAudiobooks bool `json:"notify_new_audiobooks"`
NotifyNewEbooks bool `json:"notify_new_ebooks"`
NotifyRequestSubmitted bool `json:"notify_request_submitted"`
NotifyRequestApproved bool `json:"notify_request_approved"`
NotifyRequestDeclined bool `json:"notify_request_declined"`
NotifyRequestFulfilled bool `json:"notify_request_fulfilled"`
ConsecutiveFailures int `json:"consecutive_failures"`
DisabledReason *string `json:"disabled_reason"`
LastSuccessAt *time.Time `json:"last_success_at"`
LastFailureAt *time.Time `json:"last_failure_at"`
LastFailureStatus *int `json:"last_failure_status"`
LastFailureMessage *string `json:"last_failure_message"`
CreatedAt time.Time `json:"created_at"`
// SigningSecret is present only in create / rotate-secret responses.
SigningSecret string `json:"signing_secret,omitempty"`
}
func serverChannelToResponse(ch notifications.ServerChannel) serverChannelResponse {
return serverChannelResponse{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
URLHost: ch.URLHost,
Enabled: ch.Enabled,
NotifyNewMovies: ch.NotifyNewMovies,
NotifyNewEpisodes: ch.NotifyNewEpisodes,
NotifyNewAudiobooks: ch.NotifyNewAudiobooks,
NotifyNewEbooks: ch.NotifyNewEbooks,
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
NotifyRequestApproved: ch.NotifyRequestApproved,
NotifyRequestDeclined: ch.NotifyRequestDeclined,
NotifyRequestFulfilled: ch.NotifyRequestFulfilled,
ConsecutiveFailures: ch.ConsecutiveFailures,
DisabledReason: ch.DisabledReason,
LastSuccessAt: ch.LastSuccessAt,
LastFailureAt: ch.LastFailureAt,
LastFailureStatus: ch.LastFailureStatus,
LastFailureMessage: ch.LastFailureMessage,
CreatedAt: ch.CreatedAt,
}
}
type serverChannelRequest struct {
Name *string `json:"name"`
URL *string `json:"url"`
Type *string `json:"type"`
Enabled *bool `json:"enabled"`
NotifyNewMovies *bool `json:"notify_new_movies"`
NotifyNewEpisodes *bool `json:"notify_new_episodes"`
NotifyNewAudiobooks *bool `json:"notify_new_audiobooks"`
NotifyNewEbooks *bool `json:"notify_new_ebooks"`
NotifyRequestSubmitted *bool `json:"notify_request_submitted"`
NotifyRequestApproved *bool `json:"notify_request_approved"`
NotifyRequestDeclined *bool `json:"notify_request_declined"`
NotifyRequestFulfilled *bool `json:"notify_request_fulfilled"`
}
func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
return notifications.ServerChannelInput{
Name: r.Name,
URL: r.URL,
Type: r.Type,
Enabled: r.Enabled,
NotifyNewMovies: r.NotifyNewMovies,
NotifyNewEpisodes: r.NotifyNewEpisodes,
NotifyNewAudiobooks: r.NotifyNewAudiobooks,
NotifyNewEbooks: r.NotifyNewEbooks,
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
NotifyRequestApproved: r.NotifyRequestApproved,
NotifyRequestDeclined: r.NotifyRequestDeclined,
NotifyRequestFulfilled: r.NotifyRequestFulfilled,
}
}
func writeServerChannelError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, notifications.ErrServerChannelsDisabled):
writeError(w, http.StatusForbidden, "server_channels_disabled", "Server channels are disabled")
case errors.Is(err, notifications.ErrServerChannelNotFound):
writeError(w, http.StatusNotFound, "not_found", "Server channel not found")
case errors.Is(err, notifications.ErrServerChannelLimit):
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Server channel limit reached")
case errors.Is(err, notifications.ErrServerChannelInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Server channel operation failed")
}
}
// HandleList handles GET /admin/notifications/server-channels.
func (h *AdminServerChannelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
channels, err := service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list server channels")
return
}
responses := make([]serverChannelResponse, 0, len(channels))
for _, ch := range channels {
responses = append(responses, serverChannelToResponse(ch))
}
writeJSON(w, http.StatusOK, map[string]any{"channels": responses})
}
// HandleCreate handles POST /admin/notifications/server-channels. For generic
// channels the response carries the signing secret exactly once.
func (h *AdminServerChannelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, signingSecret, err := service.Create(r.Context(), apimw.GetUserID(r.Context()), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
response := serverChannelToResponse(*ch)
response.SigningSecret = signingSecret
writeJSON(w, http.StatusCreated, response)
}
// HandleUpdate handles PUT /admin/notifications/server-channels/{id}.
func (h *AdminServerChannelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, err := service.Update(r.Context(), chi.URLParam(r, "id"), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, serverChannelToResponse(*ch))
}
// HandleDelete handles DELETE /admin/notifications/server-channels/{id}.
// Idempotent.
func (h *AdminServerChannelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
if err := service.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeServerChannelError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRotateSecret handles POST /admin/notifications/server-channels/{id}/rotate-secret.
func (h *AdminServerChannelsHandler) HandleRotateSecret(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
signingSecret, err := service.RotateSecret(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
}
// HandleTest handles POST /admin/notifications/server-channels/{id}/test. The
// test send is synchronous and never touches the watermark or failure
// counters.
func (h *AdminServerChannelsHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
result, err := service.Test(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}