Files
silo-server/internal/notifications/server_channel_payload.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

457 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notifications
import (
"encoding/json"
"fmt"
"sort"
"time"
)
// siloSenderName labels Discord posts and embed footers.
const siloSenderName = "Silo"
// Server-channel embed accent colors (decimal RGB).
const (
serverChannelColorContent = 5814783 // blurple — new content posts
serverChannelColorSubmitted = 15844367 // gold — request submitted
serverChannelColorApproved = 3066993 // green — request approved
serverChannelColorDeclined = 15158332 // red — request declined
serverChannelColorFulfilled = 5814783 // blurple — request fulfilled
)
// ContentMeta is the display metadata for one catalog item (series or
// movie), fetched in one batched query by the sweep worker. Only metadata a
// Discord embed renders is carried; everything else stays in the catalog.
type ContentMeta struct {
Title string
Year int
Type string // media_items.type: "movie" | "series"
Overview string
PosterPath string
// PosterSourcePath is the provider-origin path preserved when image
// caching rewrote PosterPath to a local storage key.
PosterSourcePath string
// PosterURL is the fetchable poster URL chosen by the sweep worker
// (System.discordPosterURL); empty renders the embed without an image.
PosterURL string
// Author is the primary credited author (item_people, kind=author),
// populated for audiobook/ebook items; empty for movies and series.
Author string
Genres []string
ContentRating string
RatingIMDB float64
RatingTMDB float64
IMDBID string
TMDBID string
TVDBID string
}
func (m ContentMeta) providerIDs() providerIDs {
return providerIDs{MediaType: m.Type, IMDB: m.IMDBID, TMDB: m.TMDBID, TVDB: m.TVDBID}
}
// ContentGroup is one rendered unit of a content digest: a flat item (movie,
// audiobook, ebook), or every new episode of one series in the batch.
type ContentGroup struct {
Kind string // EventKindEpisode or a flat item kind (item_kind.go)
LibraryID int
// Episode groups.
SeriesID string
Episodes []ReleaseEvent // ascending episode_key
// Flat item groups.
ItemID string
// Meta describes the series (episode groups) or the item itself, with
// Title already defaulted when the catalog row is missing.
Meta ContentMeta
}
// GroupContentEvents folds a batch of release events into display groups:
// episodes group per (library, series) so a season pack renders as one line,
// flat items render individually but dedupe by item across libraries. Events
// of a kind this node cannot describe (a newer node's kind string) are
// skipped. Group order follows first appearance in the batch (sweep order).
// metas is keyed by series_id / item_id; missing entries fall back to
// generic labels.
func GroupContentEvents(events []ReleaseEvent, metas map[string]ContentMeta) []ContentGroup {
type groupKey struct {
kind string
libraryID int
contentID string
}
index := make(map[groupKey]int)
seenItems := make(map[string]struct{})
groups := make([]ContentGroup, 0, len(events))
for _, event := range events {
kind := normalizeEventKind(event.Kind)
if kind != EventKindEpisode {
flat, ok := flatKindByString(kind)
if !ok {
continue
}
// The same item landing in two libraries (e.g. "Movies" and
// "Movies 4K") announces once. The dedupe key is kind-scoped so
// item ids can never collide across kinds.
itemKey := kind + ":" + event.ItemID
if _, dup := seenItems[itemKey]; dup {
continue
}
seenItems[itemKey] = struct{}{}
meta := metas[event.ItemID]
if meta.Title == "" {
meta.Title = "New " + flat.ItemType
}
groups = append(groups, ContentGroup{
Kind: kind,
LibraryID: event.LibraryID,
ItemID: event.ItemID,
Meta: meta,
})
} else {
key := groupKey{EventKindEpisode, event.LibraryID, event.SeriesID}
if at, ok := index[key]; ok {
groups[at].Episodes = append(groups[at].Episodes, event)
continue
}
meta := metas[event.SeriesID]
if meta.Title == "" {
meta.Title = genericEpisodeTitle
}
index[key] = len(groups)
groups = append(groups, ContentGroup{
Kind: EventKindEpisode,
LibraryID: event.LibraryID,
SeriesID: event.SeriesID,
Episodes: []ReleaseEvent{event},
Meta: meta,
})
}
}
for i := range groups {
sort.Slice(groups[i].Episodes, func(a, b int) bool {
return groups[i].Episodes[a].EpisodeKey < groups[i].Episodes[b].EpisodeKey
})
}
return groups
}
// eventEpisodeCode renders one release event's "S2 E05" code.
func eventEpisodeCode(event ReleaseEvent) string {
return fmt.Sprintf("S%d E%d", event.SeasonNumber, event.EpisodeNumber)
}
// episodeRangeLabel renders an episode group's span: "S2 E05" for one
// episode, "S2 E01E03" within a season, "S1 E10 S2 E03" across seasons.
func episodeRangeLabel(episodes []ReleaseEvent) string {
if len(episodes) == 0 {
return ""
}
first, last := episodes[0], episodes[len(episodes)-1]
switch {
case len(episodes) == 1:
return eventEpisodeCode(first)
case first.SeasonNumber == last.SeasonNumber:
return fmt.Sprintf("S%d E%dE%d", first.SeasonNumber, first.EpisodeNumber, last.EpisodeNumber)
default:
return fmt.Sprintf("%s %s", eventEpisodeCode(first), eventEpisodeCode(last))
}
}
// contentGroupTitle renders a group's display line.
func contentGroupTitle(group ContentGroup) string {
if _, ok := flatKindByString(group.Kind); ok {
return titleWithYear(group.Meta.Title, group.Meta.Year)
}
if len(group.Episodes) == 1 {
return fmt.Sprintf("%s — %s", group.Meta.Title, episodeRangeLabel(group.Episodes))
}
return fmt.Sprintf("%s — %d new episodes (%s)",
group.Meta.Title, len(group.Episodes), episodeRangeLabel(group.Episodes))
}
// serverChannelMaxEmbeds caps content digests at Discord's per-message embed
// limit; generic payloads share the cap so receivers see bounded bodies.
const serverChannelMaxEmbeds = discordDMMaxEmbeds
// BuildServerChannelDiscordContent renders a content digest as a Discord
// webhook body: one embed per group up to the 10-embed cap, the newest groups
// kept, with an overflow line for the rest. Pure function.
func BuildServerChannelDiscordContent(groups []ContentGroup, test bool) ([]byte, error) {
overflow := 0
if len(groups) > serverChannelMaxEmbeds {
overflow = len(groups) - serverChannelMaxEmbeds
groups = groups[len(groups)-serverChannelMaxEmbeds:]
}
now := time.Now().UTC().Format(time.RFC3339)
embeds := make([]discordEmbed, 0, len(groups))
for _, group := range groups {
author := "New episodes available on Silo"
if flat, ok := flatKindByString(group.Kind); ok {
author = "New " + flat.ItemType + " available on Silo"
} else if len(group.Episodes) == 1 {
author = "New episode available on Silo"
}
ids := group.Meta.providerIDs()
fields := make([]discordEmbedField, 0, 3)
if group.Meta.Author != "" {
fields = append(fields, discordEmbedField{
Name: "Author",
Value: truncateWithEllipsis(group.Meta.Author, discordFieldValueLimit),
Inline: true,
})
}
if rating := ratingLabel(group.Meta.RatingIMDB, group.Meta.RatingTMDB); rating != "" {
fields = append(fields, discordEmbedField{Name: "Rating", Value: rating, Inline: true})
}
if genres := genresLabel(group.Meta.Genres); genres != "" {
fields = append(fields, discordEmbedField{Name: "Genres", Value: genres, Inline: true})
}
embed := discordEmbed{
Title: truncateWithEllipsis(contentGroupTitle(group), discordTitleLimit),
URL: ids.titleURL(),
Description: embedDescription(group.Meta.Overview, ids),
Color: serverChannelColorContent,
Author: &discordEmbedAuthor{Name: author},
Footer: &discordEmbedFooter{Text: discordEmbedFooterText(group.Meta.ContentRating, test)},
Timestamp: now,
Fields: fields,
}
if group.Meta.PosterURL != "" {
embed.Thumbnail = &discordEmbedMedia{URL: group.Meta.PosterURL}
}
enforceDiscordTotalLimit(&embed)
embeds = append(embeds, embed)
}
body := discordWebhookBody{Embeds: embeds, Username: siloSenderName}
if overflow > 0 {
body.Content = fmt.Sprintf("…and %d more new items on Silo", overflow)
}
return json.Marshal(body)
}
// serverChannelContentBody is the canonical generic-webhook JSON for a
// content digest. Like the per-profile generic payload it carries no server
// URL and no artwork URLs.
type serverChannelContentBody struct {
Event string `json:"event"`
ChannelID string `json:"channel_id"`
Timestamp string `json:"timestamp"`
Version int `json:"version"`
Test bool `json:"test"`
Items []serverChannelContentRow `json:"items"`
// Truncated reports how many additional groups were dropped by the
// per-post item cap; 0 means the batch is complete.
Truncated int `json:"truncated,omitempty"`
}
type serverChannelContentRow struct {
Kind string `json:"kind"`
LibraryID int `json:"library_id"`
ItemID string `json:"item_id,omitempty"`
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
Author string `json:"author,omitempty"`
SeriesID string `json:"series_id,omitempty"`
SeriesTitle string `json:"series_title,omitempty"`
// Episode span for episode groups.
EpisodeCount int `json:"episode_count,omitempty"`
FirstSeason int `json:"first_season,omitempty"`
FirstEpisode int `json:"first_episode,omitempty"`
LastSeason int `json:"last_season,omitempty"`
LastEpisode int `json:"last_episode,omitempty"`
EpisodeLabel string `json:"episode_label,omitempty"`
}
// BuildServerChannelGenericContent renders a content digest as canonical Silo
// JSON. Pure function.
func BuildServerChannelGenericContent(groups []ContentGroup, channelID string, test bool) ([]byte, error) {
truncated := 0
if len(groups) > serverChannelMaxEmbeds {
truncated = len(groups) - serverChannelMaxEmbeds
groups = groups[len(groups)-serverChannelMaxEmbeds:]
}
items := make([]serverChannelContentRow, 0, len(groups))
for _, group := range groups {
row := serverChannelContentRow{
Kind: group.Kind,
LibraryID: group.LibraryID,
}
if _, ok := flatKindByString(group.Kind); ok {
row.ItemID = group.ItemID
row.Title = group.Meta.Title
row.Year = group.Meta.Year
row.Author = group.Meta.Author
} else {
row.SeriesID = group.SeriesID
row.SeriesTitle = group.Meta.Title
row.EpisodeCount = len(group.Episodes)
if len(group.Episodes) > 0 {
first := group.Episodes[0]
last := group.Episodes[len(group.Episodes)-1]
row.FirstSeason, row.FirstEpisode = first.SeasonNumber, first.EpisodeNumber
row.LastSeason, row.LastEpisode = last.SeasonNumber, last.EpisodeNumber
row.EpisodeLabel = episodeRangeLabel(group.Episodes)
}
}
items = append(items, row)
}
return json.Marshal(serverChannelContentBody{
Event: ServerChannelEventContentAdded,
ChannelID: channelID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Version: 1,
Test: test,
Items: items,
Truncated: truncated,
})
}
// requestEventDescription maps a lifecycle event to its embed description.
func requestEventDescription(event string) string {
switch event {
case ServerChannelEventRequestSubmitted:
return "New media request on Silo"
case ServerChannelEventRequestApproved:
return "Media request approved"
case ServerChannelEventRequestDeclined:
return "Media request declined"
case ServerChannelEventRequestFulfilled:
return "Requested media is now available on Silo"
default:
return genericNotificationTitle
}
}
func requestEventColor(event string) int {
switch event {
case ServerChannelEventRequestSubmitted:
return serverChannelColorSubmitted
case ServerChannelEventRequestApproved:
return serverChannelColorApproved
case ServerChannelEventRequestDeclined:
return serverChannelColorDeclined
default:
return serverChannelColorFulfilled
}
}
// BuildServerChannelRequestDiscord renders one request lifecycle event as a
// Discord webhook body. Pure function.
func BuildServerChannelRequestDiscord(event string, info RequestEventInfo) ([]byte, error) {
title := info.Title
if title == "" {
title = "Media request"
}
title = titleWithYear(title, info.Year)
ids := providerIDs{MediaType: info.MediaType, IMDB: info.IMDBID}
if info.TMDBID > 0 {
ids.TMDB = fmt.Sprintf("%d", info.TMDBID)
}
if info.TVDBID > 0 {
ids.TVDB = fmt.Sprintf("%d", info.TVDBID)
}
fields := make([]discordEmbedField, 0, 2)
if label := mediaTypeLabel(info.MediaType); label != "" {
fields = append(fields, discordEmbedField{Name: "Type", Value: label, Inline: true})
}
if info.RequesterName != "" {
fields = append(fields, discordEmbedField{
Name: "Requested by",
Value: truncateWithEllipsis(info.RequesterName, discordFieldValueLimit),
Inline: true,
})
}
embed := discordEmbed{
Title: truncateWithEllipsis(title, discordTitleLimit),
URL: ids.titleURL(),
Description: embedDescription(info.Overview, ids),
Color: requestEventColor(event),
Author: &discordEmbedAuthor{Name: requestEventDescription(event)},
Footer: &discordEmbedFooter{Text: siloSenderName},
Timestamp: time.Now().UTC().Format(time.RFC3339),
Fields: fields,
}
// Request poster paths are raw TMDB image paths from discovery, not
// stored catalog artwork.
if poster := tmdbRawImageURL(info.PosterPath); poster != "" {
embed.Thumbnail = &discordEmbedMedia{URL: poster}
}
enforceDiscordTotalLimit(&embed)
body := discordWebhookBody{Embeds: []discordEmbed{embed}, Username: siloSenderName}
// A mention only pings from message content, never from inside an embed,
// so the tag rides above the embed; the embed field keeps the plain
// username so the post stays readable when the user left the guild.
if info.RequesterDiscordID != "" {
body.Content = "<@" + info.RequesterDiscordID + ">"
body.AllowedMentions = &discordAllowedMentions{
Parse: []string{},
Users: []string{info.RequesterDiscordID},
}
}
return json.Marshal(body)
}
// serverChannelRequestBody is the canonical generic-webhook JSON for one
// request lifecycle event.
type serverChannelRequestBody struct {
Event string `json:"event"`
ChannelID string `json:"channel_id"`
Timestamp string `json:"timestamp"`
Version int `json:"version"`
Test bool `json:"test"`
Request serverChannelRequestPayload `json:"request"`
}
type serverChannelRequestPayload struct {
ID string `json:"id"`
TMDBID int `json:"tmdb_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
RequesterName string `json:"requester_name,omitempty"`
}
// BuildServerChannelRequestGeneric renders one request lifecycle event as
// canonical Silo JSON. Pure function.
func BuildServerChannelRequestGeneric(event string, info RequestEventInfo, channelID string) ([]byte, error) {
return json.Marshal(serverChannelRequestBody{
Event: event,
ChannelID: channelID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Version: 1,
Request: serverChannelRequestPayload{
ID: info.RequestID,
TMDBID: info.TMDBID,
MediaType: info.MediaType,
Title: info.Title,
Year: info.Year,
RequesterName: info.RequesterName,
},
})
}
// mediaTypeLabel renders a request media type as a display label.
func mediaTypeLabel(mediaType string) string {
switch mediaType {
case mediaTypeMovie:
return "Movie"
case mediaTypeSeries:
return "Series"
default:
return ""
}
}
// serverChannelHeaders builds the signed delivery headers for a generic
// server-channel POST, mirroring the per-profile webhook convention
// (X-Silo-Signature follows Stripe's t=...,v1=... form).
func serverChannelHeaders(event, channelID, secret string, now time.Time, body []byte) map[string]string {
timestamp := now.Unix()
return map[string]string{
"X-Silo-Event": event,
"X-Silo-Channel-Id": channelID,
"X-Silo-Timestamp": fmt.Sprintf("%d", timestamp),
"X-Silo-Signature": SignGenericWebhook(secret, timestamp, body),
}
}