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

199 lines
5.8 KiB
Go

package notifications
import (
"fmt"
"strings"
)
// Discord embed metadata helpers shared by the per-profile webhook/DM
// builders and the server-channel builders. Everything here is pure and may
// only ever emit public provider origins (TMDB, IMDb, TVDB and their image
// CDNs) — never the user's own server origin (docs/superpowers/plans/
// notifications/04, "Server URL leakage").
// discordOverviewLimit clips overviews well below Discord's 4096-char
// description cap: a full synopsis crowds the embed; a teaser reads better.
const discordOverviewLimit = 350
// Media-type discriminator values shared by catalog rows (media_items.type)
// and request payloads.
const (
mediaTypeMovie = "movie"
mediaTypeSeries = "series"
mediaTypeAudiobook = "audiobook"
mediaTypeEbook = "ebook"
)
// providerIDs carries the external database identifiers an embed can link to.
// MediaType distinguishes the movie/series URL forms ("movie" | "series").
type providerIDs struct {
MediaType string
IMDB string
TMDB string
TVDB string
}
func (ids providerIDs) tmdbURL() string {
if ids.TMDB == "" {
return ""
}
kind := mediaTypeMovie
if ids.MediaType == mediaTypeSeries {
kind = "tv"
}
return "https://www.themoviedb.org/" + kind + "/" + ids.TMDB
}
func (ids providerIDs) imdbURL() string {
if ids.IMDB == "" {
return ""
}
return "https://www.imdb.com/title/" + ids.IMDB + "/"
}
func (ids providerIDs) tvdbURL() string {
if ids.TVDB == "" {
return ""
}
kind := mediaTypeMovie
if ids.MediaType == mediaTypeSeries {
kind = mediaTypeSeries
}
return "https://thetvdb.com/dereferrer/" + kind + "/" + ids.TVDB
}
// titleURL picks the embed title's click-through link, preferring TMDB (the
// richest public page for both movies and series).
func (ids providerIDs) titleURL() string {
for _, url := range []string{ids.tmdbURL(), ids.imdbURL(), ids.tvdbURL()} {
if url != "" {
return url
}
}
return ""
}
// linkLine renders the external database links as one markdown line:
// "[TMDB](…) • [IMDb](…) • [TVDB](…)". Empty when no IDs are known.
func (ids providerIDs) linkLine() string {
links := make([]string, 0, 3)
if url := ids.tmdbURL(); url != "" {
links = append(links, "[TMDB]("+url+")")
}
if url := ids.imdbURL(); url != "" {
links = append(links, "[IMDb]("+url+")")
}
if url := ids.tvdbURL(); url != "" {
links = append(links, "[TVDB]("+url+")")
}
return strings.Join(links, " • ")
}
// publicArtworkURL maps a stored artwork path to a public provider CDN URL,
// or "" when no such URL exists. Plugin-scheme paths mirror the plugins'
// own resolvers; verbatim http(s) paths are provider-supplied external URLs.
// Locally cached artwork (bare storage keys) deliberately yields "" so embeds
// never name the server's storage origin.
func publicArtworkURL(path string) string {
switch {
case strings.HasPrefix(path, "tmdb://"):
// tmdb://poster/abc.jpg → CDN file abc.jpg; w500 suits thumbnails.
if _, file, ok := strings.Cut(strings.TrimPrefix(path, "tmdb://"), "/"); ok && file != "" {
return "https://image.tmdb.org/t/p/w500/" + file
}
return ""
case strings.HasPrefix(path, "tvdb://"):
return "https://artworks.thetvdb.com/" + strings.TrimPrefix(path, "tvdb://")
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
return path
default:
return ""
}
}
// embedPosterURL resolves the best public poster URL for an embed: the
// stored poster path when it is provider-origin, else the provider source
// path preserved by image caching (empty when neither resolves publicly).
func embedPosterURL(posterPath, posterSourcePath string) string {
if url := publicArtworkURL(posterPath); url != "" {
return url
}
return publicArtworkURL(posterSourcePath)
}
// tmdbRawImageURL renders a raw TMDB image path ("/abc.jpg", as stored on
// media requests) as a public CDN URL.
func tmdbRawImageURL(path string) string {
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return "https://image.tmdb.org/t/p/w500" + path
}
// ratingLabel renders "★ 8.4 IMDb", preferring IMDb's score; "" when neither
// rating is known.
func ratingLabel(imdb, tmdb float64) string {
switch {
case imdb > 0:
return fmt.Sprintf("★ %.1f IMDb", imdb)
case tmdb > 0:
return fmt.Sprintf("★ %.1f TMDB", tmdb)
default:
return ""
}
}
// genresLabel renders up to three genres as a comma-separated line.
func genresLabel(genres []string) string {
kept := make([]string, 0, 3)
for _, genre := range genres {
if genre = strings.TrimSpace(genre); genre != "" {
kept = append(kept, genre)
}
if len(kept) == 3 {
break
}
}
return strings.Join(kept, ", ")
}
// overviewSnippet clips an overview to the embed teaser length, ending on a
// word boundary so the cut reads naturally.
func overviewSnippet(text string) string {
text = strings.TrimSpace(text)
if len(text) <= discordOverviewLimit {
return text
}
clipped := strings.TrimSuffix(truncateWithEllipsis(text, discordOverviewLimit), "…")
if at := strings.LastIndexByte(clipped, ' '); at > discordOverviewLimit/2 {
clipped = clipped[:at]
}
return strings.TrimRight(clipped, " ,.;:") + "…"
}
// titleWithYear appends the release year when known: "Dune (2021)".
func titleWithYear(title string, year int) string {
if year > 0 {
return fmt.Sprintf("%s (%d)", title, year)
}
return title
}
// embedDescription joins the overview teaser and the provider link line.
func embedDescription(overview string, ids providerIDs) string {
parts := make([]string, 0, 2)
if snippet := overviewSnippet(overview); snippet != "" {
parts = append(parts, snippet)
}
if links := ids.linkLine(); links != "" {
parts = append(parts, links)
}
return strings.Join(parts, "\n\n")
}