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>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
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 E01–E03" 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%d–E%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),
|
||
}
|
||
}
|