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>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Fable 5
parent
287ff3658f
commit
866392fecd
@@ -41,6 +41,8 @@ type serverChannelResponse struct {
|
||||
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"`
|
||||
@@ -65,6 +67,8 @@ func serverChannelToResponse(ch notifications.ServerChannel) serverChannelRespon
|
||||
Enabled: ch.Enabled,
|
||||
NotifyNewMovies: ch.NotifyNewMovies,
|
||||
NotifyNewEpisodes: ch.NotifyNewEpisodes,
|
||||
NotifyNewAudiobooks: ch.NotifyNewAudiobooks,
|
||||
NotifyNewEbooks: ch.NotifyNewEbooks,
|
||||
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
|
||||
NotifyRequestApproved: ch.NotifyRequestApproved,
|
||||
NotifyRequestDeclined: ch.NotifyRequestDeclined,
|
||||
@@ -86,6 +90,8 @@ type serverChannelRequest struct {
|
||||
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"`
|
||||
@@ -100,6 +106,8 @@ func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
|
||||
Enabled: r.Enabled,
|
||||
NotifyNewMovies: r.NotifyNewMovies,
|
||||
NotifyNewEpisodes: r.NotifyNewEpisodes,
|
||||
NotifyNewAudiobooks: r.NotifyNewAudiobooks,
|
||||
NotifyNewEbooks: r.NotifyNewEbooks,
|
||||
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
|
||||
NotifyRequestApproved: r.NotifyRequestApproved,
|
||||
NotifyRequestDeclined: r.NotifyRequestDeclined,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/librarykind"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
@@ -365,11 +366,16 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode
|
||||
// cancellation), and a slow pass must not delay scan completion or the
|
||||
// serialized scan queue.
|
||||
if e.availability != nil {
|
||||
lk := librarykind.Of(folder.Type)
|
||||
// Audiobook/ebook are dedicated library types, never part of "mixed"
|
||||
// (the scanner routes mixed folders through the video pipeline only).
|
||||
kinds := notifications.AvailabilityKinds{
|
||||
Episodes: isTVLibraryType(folder.Type) || isMixedLibraryType(folder.Type),
|
||||
Movies: isMovieLibraryType(folder.Type) || isMixedLibraryType(folder.Type),
|
||||
Episodes: lk.TV || lk.Mixed,
|
||||
Movies: lk.Movie || lk.Mixed,
|
||||
Audiobooks: lk.Audiobook,
|
||||
Ebooks: lk.Ebook,
|
||||
}
|
||||
if kinds.Episodes || kinds.Movies {
|
||||
if kinds.Any() {
|
||||
go e.availability.HandleIngestCompleted(scanCtx, folder.ID, mode == scopeModeLibrary, matchScopes, kinds)
|
||||
}
|
||||
}
|
||||
@@ -449,7 +455,7 @@ func scopeMatchPaths(folder *models.MediaFolder, mode scopeMode, scopePath strin
|
||||
}
|
||||
|
||||
func shouldWaitForTVQueueSettle(folder *models.MediaFolder, scanResult *scanner.ScanResult) bool {
|
||||
if folder == nil || (!isTVLibraryType(folder.Type) && !isMixedLibraryType(folder.Type)) {
|
||||
if folder == nil || (!librarykind.IsTV(folder.Type) && !librarykind.IsMixed(folder.Type)) {
|
||||
return false
|
||||
}
|
||||
if scanResult == nil {
|
||||
@@ -458,30 +464,6 @@ func shouldWaitForTVQueueSettle(folder *models.MediaFolder, scanResult *scanner.
|
||||
return scanResult.New > 0 || scanResult.Updated > 0
|
||||
}
|
||||
|
||||
func isTVLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "series", "tv", "show", "tvshows":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMixedLibraryType(libraryType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(libraryType)) == "mixed"
|
||||
}
|
||||
|
||||
// isMovieLibraryType mirrors the scanner's movie library naming
|
||||
// (internal/scanner/scanner.go).
|
||||
func isMovieLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) scan(ctx context.Context, folder *models.MediaFolder, mode scopeMode, scopePath string) ([]string, *scanner.ScanResult, error) {
|
||||
switch mode {
|
||||
case scopeModeLibrary:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package librarykind is the single home of media_folders.type
|
||||
// classification. The type column is free text ("movies", "TV", " Audiobooks "
|
||||
// ...), so every consumer must apply the same normalization and vocabulary;
|
||||
// scanner, libraryingest, and metadata previously kept private copies of these
|
||||
// predicates that drifted apart.
|
||||
//
|
||||
// Predicates are strict: IsMovie does not include mixed libraries. Call sites
|
||||
// that treat mixed libraries as movie-bearing must say so explicitly
|
||||
// (IsMovie(t) || IsMixed(t)).
|
||||
package librarykind
|
||||
|
||||
import "strings"
|
||||
|
||||
// Kinds reports every classification of one media_folders.type value.
|
||||
// Movie/TV/Mixed feed the video scan pipeline; Audiobook/Ebook/Podcast/Manga
|
||||
// are single-purpose library types with dedicated scan pipelines and are
|
||||
// never part of a mixed library.
|
||||
type Kinds struct {
|
||||
Movie bool
|
||||
TV bool
|
||||
Mixed bool
|
||||
Audiobook bool
|
||||
Ebook bool
|
||||
Podcast bool
|
||||
Manga bool
|
||||
}
|
||||
|
||||
// Of resolves every predicate for one library type in a single call.
|
||||
func Of(libraryType string) Kinds {
|
||||
return Kinds{
|
||||
Movie: IsMovie(libraryType),
|
||||
TV: IsTV(libraryType),
|
||||
Mixed: IsMixed(libraryType),
|
||||
Audiobook: IsAudiobook(libraryType),
|
||||
Ebook: IsEbook(libraryType),
|
||||
Podcast: IsPodcast(libraryType),
|
||||
Manga: IsManga(libraryType),
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(libraryType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(libraryType))
|
||||
}
|
||||
|
||||
// IsMovie reports whether the library type is a dedicated movie library.
|
||||
func IsMovie(libraryType string) bool {
|
||||
switch normalize(libraryType) {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTV reports whether the library type is a dedicated TV/series library.
|
||||
func IsTV(libraryType string) bool {
|
||||
switch normalize(libraryType) {
|
||||
case "series", "tv", "show", "tvshows":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsMixed reports whether the library type mixes movies and TV.
|
||||
func IsMixed(libraryType string) bool {
|
||||
return normalize(libraryType) == "mixed"
|
||||
}
|
||||
|
||||
// IsAudiobook reports whether the library type is an audiobook library.
|
||||
func IsAudiobook(libraryType string) bool {
|
||||
switch normalize(libraryType) {
|
||||
case "audiobook", "audiobooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsEbook reports whether the library type is an ebook library.
|
||||
func IsEbook(libraryType string) bool {
|
||||
switch normalize(libraryType) {
|
||||
case "ebook", "ebooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPodcast reports whether the library type is a podcast library.
|
||||
func IsPodcast(libraryType string) bool {
|
||||
switch normalize(libraryType) {
|
||||
case "podcast", "podcasts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsManga reports whether the library type is a manga library.
|
||||
func IsManga(libraryType string) bool {
|
||||
return normalize(libraryType) == "manga"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package librarykind
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(string) bool
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"IsMovie", IsMovie, "movie", true},
|
||||
{"IsMovie", IsMovie, "movies", true},
|
||||
{"IsMovie", IsMovie, " MOVIES ", true},
|
||||
{"IsMovie", IsMovie, "mixed", false},
|
||||
{"IsMovie", IsMovie, "series", false},
|
||||
{"IsMovie", IsMovie, "", false},
|
||||
|
||||
{"IsTV", IsTV, "series", true},
|
||||
{"IsTV", IsTV, "tv", true},
|
||||
{"IsTV", IsTV, "show", true},
|
||||
{"IsTV", IsTV, "tvshows", true},
|
||||
{"IsTV", IsTV, "TV", true},
|
||||
{"IsTV", IsTV, "mixed", false},
|
||||
{"IsTV", IsTV, "movies", false},
|
||||
|
||||
{"IsMixed", IsMixed, "mixed", true},
|
||||
{"IsMixed", IsMixed, " Mixed ", true},
|
||||
{"IsMixed", IsMixed, "movies", false},
|
||||
|
||||
{"IsAudiobook", IsAudiobook, "audiobooks", true},
|
||||
{"IsAudiobook", IsAudiobook, "audiobook", true},
|
||||
{"IsAudiobook", IsAudiobook, "Audiobook", true},
|
||||
{"IsAudiobook", IsAudiobook, " AUDIOBOOKS ", true},
|
||||
{"IsAudiobook", IsAudiobook, "movies", false},
|
||||
{"IsAudiobook", IsAudiobook, "series", false},
|
||||
{"IsAudiobook", IsAudiobook, "", false},
|
||||
|
||||
{"IsPodcast", IsPodcast, "podcasts", true},
|
||||
{"IsPodcast", IsPodcast, "podcast", true},
|
||||
{"IsPodcast", IsPodcast, "Podcast", true},
|
||||
{"IsPodcast", IsPodcast, " PODCASTS ", true},
|
||||
{"IsPodcast", IsPodcast, "series", false},
|
||||
{"IsPodcast", IsPodcast, "audiobooks", false},
|
||||
{"IsPodcast", IsPodcast, "", false},
|
||||
|
||||
{"IsEbook", IsEbook, "ebooks", true},
|
||||
{"IsEbook", IsEbook, "ebook", true},
|
||||
{"IsEbook", IsEbook, "Ebook", true},
|
||||
{"IsEbook", IsEbook, " EBOOKS ", true},
|
||||
{"IsEbook", IsEbook, "audiobooks", false},
|
||||
{"IsEbook", IsEbook, "movies", false},
|
||||
{"IsEbook", IsEbook, "", false},
|
||||
|
||||
{"IsManga", IsManga, "manga", true},
|
||||
{"IsManga", IsManga, "Manga", true},
|
||||
{"IsManga", IsManga, " MANGA ", true},
|
||||
{"IsManga", IsManga, "ebooks", false},
|
||||
{"IsManga", IsManga, "movies", false},
|
||||
{"IsManga", IsManga, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.fn(tc.in); got != tc.want {
|
||||
t.Errorf("%s(%q) = %v, want %v", tc.name, tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOf(t *testing.T) {
|
||||
if got := Of(" Audiobooks "); got != (Kinds{Audiobook: true}) {
|
||||
t.Errorf("Of(audiobooks) = %+v", got)
|
||||
}
|
||||
if got := Of("mixed"); got != (Kinds{Mixed: true}) {
|
||||
t.Errorf("Of(mixed) = %+v", got)
|
||||
}
|
||||
if got := Of("movies"); got != (Kinds{Movie: true}) {
|
||||
t.Errorf("Of(movies) = %+v", got)
|
||||
}
|
||||
if got := Of("unknown"); got != (Kinds{}) {
|
||||
t.Errorf("Of(unknown) = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/librarykind"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
@@ -1171,8 +1172,11 @@ func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (us
|
||||
}
|
||||
useSeriesQueue = w.enableTVSeriesRootQueue &&
|
||||
w.seriesClaimer != nil &&
|
||||
(isTVLibraryType(folderType) || isMixedLibraryType(folderType))
|
||||
useMovieQueue = w.movieClaimer != nil && isMovieLibraryType(folderType)
|
||||
(librarykind.IsTV(folderType) || librarykind.IsMixed(folderType))
|
||||
// Mixed libraries feed the movie queue too: their unmatched files may be
|
||||
// either kind, and the movie queue is the fallback lane.
|
||||
useMovieQueue = w.movieClaimer != nil &&
|
||||
(librarykind.IsMovie(folderType) || librarykind.IsMixed(folderType))
|
||||
return useSeriesQueue, useMovieQueue, nil
|
||||
}
|
||||
|
||||
@@ -1267,28 +1271,6 @@ func truncateSeriesQueueError(errText string) string {
|
||||
return errText[:1024]
|
||||
}
|
||||
|
||||
func isTVLibraryType(folderType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
||||
case "series", "tv", "show", "tvshows":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMixedLibraryType(folderType string) bool {
|
||||
return strings.ToLower(strings.TrimSpace(folderType)) == "mixed"
|
||||
}
|
||||
|
||||
func isMovieLibraryType(folderType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
||||
case "movie", "movies", "mixed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RetryUnmatchedItemsByFolderAndPathPrefix revisits linked unmatched items in
|
||||
// scope once. Per-item retry failures are counted as warnings, not fatal.
|
||||
func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (retried int, stillUnmatched int, err error) {
|
||||
|
||||
@@ -39,8 +39,15 @@ func (d *AvailabilityDetector) SetFanoutNudge(nudge func()) {
|
||||
// AvailabilityKinds selects which content kinds an ingest scope covers.
|
||||
// Each kind keeps its own seed marker and silent-seeding semantics.
|
||||
type AvailabilityKinds struct {
|
||||
Episodes bool
|
||||
Movies bool
|
||||
Episodes bool
|
||||
Movies bool
|
||||
Audiobooks bool
|
||||
Ebooks bool
|
||||
}
|
||||
|
||||
// Any reports whether at least one kind is selected.
|
||||
func (k AvailabilityKinds) Any() bool {
|
||||
return k.Episodes || k.Movies || k.Audiobooks || k.Ebooks
|
||||
}
|
||||
|
||||
// availabilityKindOps abstracts the per-kind recording calls so episode and
|
||||
@@ -77,12 +84,24 @@ func (d *AvailabilityDetector) HandleIngestCompleted(ctx context.Context, librar
|
||||
recordForPaths: d.releases.RecordAvailabilityForPaths,
|
||||
})
|
||||
}
|
||||
if kinds.Movies {
|
||||
d.runKind(detectCtx, libraryID, fullLibrary, scopePaths, availabilityKindOps{
|
||||
kind: EventKindMovie,
|
||||
recordForLibrary: d.releases.RecordMovieAvailabilityForLibrary,
|
||||
recordForPaths: d.releases.RecordMovieAvailabilityForPaths,
|
||||
})
|
||||
for _, k := range flatItemKinds {
|
||||
if k.Selected(kinds) {
|
||||
d.runKind(detectCtx, libraryID, fullLibrary, scopePaths, d.flatKindOps(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flatKindOps binds one flat item kind's registry entry to the shared
|
||||
// detection flow.
|
||||
func (d *AvailabilityDetector) flatKindOps(k flatItemKind) availabilityKindOps {
|
||||
return availabilityKindOps{
|
||||
kind: k.Kind,
|
||||
recordForLibrary: func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) {
|
||||
return d.releases.RecordItemAvailabilityForLibrary(ctx, k, libraryID, emitEvents)
|
||||
},
|
||||
recordForPaths: func(ctx context.Context, libraryID int, scopePaths []string, emitEvents bool) (int, int, error) {
|
||||
return d.releases.RecordItemAvailabilityForPaths(ctx, k, libraryID, scopePaths, emitEvents)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ const discordOverviewLimit = 350
|
||||
// Media-type discriminator values shared by catalog rows (media_items.type)
|
||||
// and request payloads.
|
||||
const (
|
||||
mediaTypeMovie = "movie"
|
||||
mediaTypeSeries = "series"
|
||||
mediaTypeMovie = "movie"
|
||||
mediaTypeSeries = "series"
|
||||
mediaTypeAudiobook = "audiobook"
|
||||
mediaTypeEbook = "ebook"
|
||||
)
|
||||
|
||||
// providerIDs carries the external database identifiers an embed can link to.
|
||||
|
||||
@@ -33,9 +33,10 @@ func EvaluateRecipient(interest SeriesInterest, prefs Preferences, episodeKey in
|
||||
}
|
||||
|
||||
// PartitionEventsByKind splits claimed events into episode events (which fan
|
||||
// out to profiles) and everything else (movie events, which only feed the
|
||||
// server-channel broadcast sweep). Order is preserved within each partition.
|
||||
// The common all-episode batch returns the input slice unchanged.
|
||||
// out to profiles) and everything else (flat item kinds — movies, audiobooks,
|
||||
// ebooks — which only feed the server-channel broadcast sweep). Order is
|
||||
// preserved within each partition. The common all-episode batch returns the
|
||||
// input slice unchanged.
|
||||
func PartitionEventsByKind(events []ReleaseEvent) (episodes, others []ReleaseEvent) {
|
||||
allEpisodes := true
|
||||
for _, event := range events {
|
||||
|
||||
@@ -158,10 +158,10 @@ func (w *FanoutWorker) processBatch(ctx context.Context) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Non-episode kinds (movies) have no per-profile interest and never fan
|
||||
// out; mark them processed immediately so retention reclaims them. This
|
||||
// must happen before the burst cap: movie events have no series_id, and
|
||||
// ApplyBurstCap groups by (library_id, series_id). The server-channel
|
||||
// Non-episode kinds (movies, audiobooks, ebooks) have no per-profile
|
||||
// interest and never fan out; mark them processed immediately so retention
|
||||
// reclaims them. This must happen before the burst cap: flat item events
|
||||
// have no series_id, and ApplyBurstCap groups by (library_id, series_id). The server-channel
|
||||
// sweep reads events by cursor regardless of processed state.
|
||||
events, others := PartitionEventsByKind(claimed)
|
||||
if err := w.releases.MarkProcessed(ctx, tx, eventIDs(others), nil); err != nil {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package notifications
|
||||
|
||||
// Availability-fact tables for flat item kinds. Movies keep the dedicated
|
||||
// table they launched with; every later kind shares item_availability, which
|
||||
// carries a kind discriminator column.
|
||||
const (
|
||||
movieAvailabilityTable = "movie_availability"
|
||||
itemAvailabilityTable = "item_availability"
|
||||
)
|
||||
|
||||
// flatItemKind describes one non-episode release-event kind: a single
|
||||
// media_items row becomes newly available and is announced broadcast-only
|
||||
// (server channels), with no per-profile fanout. Movies were the first flat
|
||||
// kind; audiobooks and ebooks followed. Adding another kind means one entry
|
||||
// here, one AvailabilityKinds flag wired in the library-ingest executor, one
|
||||
// notify_new_* channel column, and a migration widening the release_events
|
||||
// kind CHECK constraints.
|
||||
type flatItemKind struct {
|
||||
// Kind is the release_events.kind / seed-state value, and doubles as the
|
||||
// dedupe-key prefix ("audiobook:{library_id}:{item_id}").
|
||||
Kind string
|
||||
// ItemType is the media_items.type value the kind announces. It doubles
|
||||
// as the display noun in copy ("New audiobook available on Silo").
|
||||
ItemType string
|
||||
// AvailabilityTable receives the one-way "first became available in this
|
||||
// library" facts for the kind.
|
||||
AvailabilityTable string
|
||||
// WantsToggle reads the channel's notify_new_* toggle for the kind.
|
||||
WantsToggle func(ServerChannel) bool
|
||||
// Selected reads the ingest scope's AvailabilityKinds flag for the kind.
|
||||
Selected func(AvailabilityKinds) bool
|
||||
}
|
||||
|
||||
// flatItemKinds is the ordered registry of every flat release-event kind.
|
||||
// Order affects only fixture/sample ordering.
|
||||
var flatItemKinds = []flatItemKind{
|
||||
{
|
||||
Kind: EventKindMovie,
|
||||
ItemType: mediaTypeMovie,
|
||||
AvailabilityTable: movieAvailabilityTable,
|
||||
WantsToggle: func(c ServerChannel) bool { return c.NotifyNewMovies },
|
||||
Selected: func(k AvailabilityKinds) bool { return k.Movies },
|
||||
},
|
||||
{
|
||||
Kind: EventKindAudiobook,
|
||||
ItemType: mediaTypeAudiobook,
|
||||
AvailabilityTable: itemAvailabilityTable,
|
||||
WantsToggle: func(c ServerChannel) bool { return c.NotifyNewAudiobooks },
|
||||
Selected: func(k AvailabilityKinds) bool { return k.Audiobooks },
|
||||
},
|
||||
{
|
||||
Kind: EventKindEbook,
|
||||
ItemType: mediaTypeEbook,
|
||||
AvailabilityTable: itemAvailabilityTable,
|
||||
WantsToggle: func(c ServerChannel) bool { return c.NotifyNewEbooks },
|
||||
Selected: func(k AvailabilityKinds) bool { return k.Ebooks },
|
||||
},
|
||||
}
|
||||
|
||||
// flatKindByString looks up a registry entry by event kind. ok is false for
|
||||
// episode and for unknown kinds (a newer node's kind string reaching an old
|
||||
// node), so callers skip rather than misrender what they cannot describe.
|
||||
func flatKindByString(kind string) (flatItemKind, bool) {
|
||||
for _, k := range flatItemKinds {
|
||||
if k.Kind == kind {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
return flatItemKind{}, false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package notifications
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFlatItemKindRegistry(t *testing.T) {
|
||||
seenKinds := make(map[string]struct{})
|
||||
for _, k := range flatItemKinds {
|
||||
if _, dup := seenKinds[k.Kind]; dup {
|
||||
t.Fatalf("duplicate registry kind %q", k.Kind)
|
||||
}
|
||||
seenKinds[k.Kind] = struct{}{}
|
||||
if k.Kind == EventKindEpisode {
|
||||
t.Fatal("episodes are not a flat item kind")
|
||||
}
|
||||
if k.ItemType == "" || k.AvailabilityTable == "" {
|
||||
t.Fatalf("incomplete registry entry %+v", k)
|
||||
}
|
||||
if k.WantsToggle == nil || k.Selected == nil {
|
||||
t.Fatalf("registry entry %q missing accessors", k.Kind)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{EventKindMovie, EventKindAudiobook, EventKindEbook} {
|
||||
if _, ok := flatKindByString(kind); !ok {
|
||||
t.Errorf("flatKindByString(%q) not found", kind)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{EventKindEpisode, "", "music"} {
|
||||
if _, ok := flatKindByString(kind); ok {
|
||||
t.Errorf("flatKindByString(%q) unexpectedly found", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatItemKindToggleAndSelectionMapping(t *testing.T) {
|
||||
// Each kind's channel toggle and ingest-scope flag must read its own
|
||||
// field, not a neighbor's.
|
||||
toggles := map[string]ServerChannel{
|
||||
EventKindMovie: {NotifyNewMovies: true},
|
||||
EventKindAudiobook: {NotifyNewAudiobooks: true},
|
||||
EventKindEbook: {NotifyNewEbooks: true},
|
||||
}
|
||||
selections := map[string]AvailabilityKinds{
|
||||
EventKindMovie: {Movies: true},
|
||||
EventKindAudiobook: {Audiobooks: true},
|
||||
EventKindEbook: {Ebooks: true},
|
||||
}
|
||||
for _, k := range flatItemKinds {
|
||||
for kind, ch := range toggles {
|
||||
if got, want := k.WantsToggle(ch), kind == k.Kind; got != want {
|
||||
t.Errorf("kind %q WantsToggle(channel with only %q on) = %v, want %v",
|
||||
k.Kind, kind, got, want)
|
||||
}
|
||||
}
|
||||
for kind, sel := range selections {
|
||||
if got, want := k.Selected(sel), kind == k.Kind; got != want {
|
||||
t.Errorf("kind %q Selected(scope with only %q on) = %v, want %v",
|
||||
k.Kind, kind, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleContentGroupsCoverEveryKind(t *testing.T) {
|
||||
groups := sampleContentGroups()
|
||||
if len(groups) != len(flatItemKinds)+1 {
|
||||
t.Fatalf("got %d sample groups, want %d", len(groups), len(flatItemKinds)+1)
|
||||
}
|
||||
for i, k := range flatItemKinds {
|
||||
if groups[i].Kind != k.Kind {
|
||||
t.Errorf("sample group %d kind = %q, want %q", i, groups[i].Kind, k.Kind)
|
||||
}
|
||||
if groups[i].Meta.Title == "" {
|
||||
t.Errorf("sample group %d missing title", i)
|
||||
}
|
||||
}
|
||||
if groups[len(groups)-1].Kind != EventKindEpisode {
|
||||
t.Fatalf("last sample group should be the episode fixture, got %q", groups[len(groups)-1].Kind)
|
||||
}
|
||||
}
|
||||
@@ -141,30 +141,52 @@ func (r *ReleaseRepository) MarkContentSeeded(ctx context.Context, libraryID int
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordMovieAvailabilityForLibrary inserts movie_availability rows for every
|
||||
// movie currently present in the library (one-way, idempotent) and, when
|
||||
// emitEvents is true, creates movie release events for the newly inserted
|
||||
// rows. Returns (availability rows inserted, release events created).
|
||||
func (r *ReleaseRepository) RecordMovieAvailabilityForLibrary(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) {
|
||||
query := `
|
||||
INSERT INTO movie_availability (library_id, item_id)
|
||||
SELECT mil.media_folder_id, mi.content_id
|
||||
FROM media_item_libraries mil
|
||||
JOIN media_items mi ON mi.content_id = mil.content_id AND mi.type = 'movie'
|
||||
WHERE mil.media_folder_id = $1
|
||||
ON CONFLICT (library_id, item_id) DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
return r.recordMovieAvailability(ctx, libraryID, emitEvents, query, []any{libraryID})
|
||||
// RecordItemAvailabilityForLibrary inserts availability rows for every item
|
||||
// of the kind currently present in the library (one-way, idempotent) and,
|
||||
// when emitEvents is true, creates release events for the newly inserted
|
||||
// rows. Shared by every flat item kind (item_kind.go). Returns (availability
|
||||
// rows inserted, release events created).
|
||||
func (r *ReleaseRepository) RecordItemAvailabilityForLibrary(ctx context.Context, k flatItemKind, libraryID int, emitEvents bool) (int, int, error) {
|
||||
args := []any{libraryID, k.ItemType}
|
||||
var query string
|
||||
if k.AvailabilityTable == movieAvailabilityTable {
|
||||
query = `
|
||||
INSERT INTO movie_availability (library_id, item_id)
|
||||
SELECT mil.media_folder_id, mi.content_id
|
||||
FROM media_item_libraries mil
|
||||
JOIN media_items mi ON mi.content_id = mil.content_id AND mi.type = $2
|
||||
WHERE mil.media_folder_id = $1
|
||||
ON CONFLICT (library_id, item_id) DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO item_availability (library_id, item_id, kind)
|
||||
SELECT mil.media_folder_id, mi.content_id, $3
|
||||
FROM media_item_libraries mil
|
||||
JOIN media_items mi ON mi.content_id = mil.content_id AND mi.type = $2
|
||||
WHERE mil.media_folder_id = $1
|
||||
ON CONFLICT (library_id, item_id, kind) DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
args = append(args, k.Kind)
|
||||
}
|
||||
return r.recordItemAvailability(ctx, k, libraryID, emitEvents, query, args)
|
||||
}
|
||||
|
||||
// RecordMovieAvailabilityForPaths inserts availability rows for movies whose
|
||||
// playable files live under the given scope paths (subtree/file ingest), and
|
||||
// optionally creates release events for newly inserted rows.
|
||||
func (r *ReleaseRepository) RecordMovieAvailabilityForPaths(ctx context.Context, libraryID int, scopePaths []string, emitEvents bool) (int, int, error) {
|
||||
// RecordItemAvailabilityForPaths inserts availability rows for items of the
|
||||
// kind whose files live under the given scope paths (subtree/file ingest),
|
||||
// and optionally creates release events for newly inserted rows.
|
||||
func (r *ReleaseRepository) RecordItemAvailabilityForPaths(ctx context.Context, k flatItemKind, libraryID int, scopePaths []string, emitEvents bool) (int, int, error) {
|
||||
if len(scopePaths) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
args := []any{libraryID}
|
||||
args := []any{libraryID, k.ItemType}
|
||||
// The column list doubles as the ON CONFLICT target: both tables' primary
|
||||
// keys are exactly their insert columns.
|
||||
insertCols, selectExtra := "(library_id, item_id)", ""
|
||||
if k.AvailabilityTable != movieAvailabilityTable {
|
||||
args = append(args, k.Kind)
|
||||
insertCols, selectExtra = "(library_id, item_id, kind)", ", $3"
|
||||
}
|
||||
scopeConds := make([]string, 0, len(scopePaths))
|
||||
for _, path := range scopePaths {
|
||||
args = append(args, path)
|
||||
@@ -173,49 +195,50 @@ func (r *ReleaseRepository) RecordMovieAvailabilityForPaths(ctx context.Context,
|
||||
fmt.Sprintf("(mf.file_path = $%d OR starts_with(mf.file_path, $%d || '/'))", idx, idx))
|
||||
}
|
||||
query := `
|
||||
INSERT INTO movie_availability (library_id, item_id)
|
||||
SELECT DISTINCT mf.media_folder_id, mi.content_id
|
||||
INSERT INTO ` + k.AvailabilityTable + ` ` + insertCols + `
|
||||
SELECT DISTINCT mf.media_folder_id, mi.content_id` + selectExtra + `
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id AND mi.type = 'movie'
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id AND mi.type = $2
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.episode_id IS NULL
|
||||
AND mf.content_id IS NOT NULL
|
||||
AND (` + strings.Join(scopeConds, " OR ") + `)
|
||||
ON CONFLICT (library_id, item_id) DO NOTHING
|
||||
ON CONFLICT ` + insertCols + ` DO NOTHING
|
||||
RETURNING item_id, available_at`
|
||||
return r.recordMovieAvailability(ctx, libraryID, emitEvents, query, args)
|
||||
return r.recordItemAvailability(ctx, k, libraryID, emitEvents, query, args)
|
||||
}
|
||||
|
||||
// recordMovieAvailability is the movie counterpart of recordAvailability: insert
|
||||
// availability facts and the optional release events in one transaction.
|
||||
func (r *ReleaseRepository) recordMovieAvailability(ctx context.Context, libraryID int, emitEvents bool, query string, args []any) (int, int, error) {
|
||||
// recordItemAvailability is the flat-item counterpart of recordAvailability:
|
||||
// insert availability facts and the optional release events in one
|
||||
// transaction.
|
||||
func (r *ReleaseRepository) recordItemAvailability(ctx context.Context, k flatItemKind, libraryID int, emitEvents bool, query string, args []any) (int, int, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("begin movie availability tx: %w", err)
|
||||
return 0, 0, fmt.Errorf("begin item availability tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
rows, err := tx.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("insert movie availability: %w", err)
|
||||
return 0, 0, fmt.Errorf("insert %s availability: %w", k.Kind, err)
|
||||
}
|
||||
type newMovie struct {
|
||||
type newItem struct {
|
||||
ItemID string
|
||||
AvailableAt time.Time
|
||||
}
|
||||
inserted := make([]newMovie, 0, 16)
|
||||
inserted := make([]newItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var row newMovie
|
||||
var row newItem
|
||||
if err := rows.Scan(&row.ItemID, &row.AvailableAt); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, fmt.Errorf("scan inserted movie availability: %w", err)
|
||||
return 0, 0, fmt.Errorf("scan inserted %s availability: %w", k.Kind, err)
|
||||
}
|
||||
inserted = append(inserted, row)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("read inserted movie availability: %w", err)
|
||||
return 0, 0, fmt.Errorf("read inserted %s availability: %w", k.Kind, err)
|
||||
}
|
||||
|
||||
events := 0
|
||||
@@ -241,23 +264,23 @@ func (r *ReleaseRepository) recordMovieAvailability(ctx context.Context, library
|
||||
eventArgs = append(eventArgs,
|
||||
ulid.Make().String(),
|
||||
libraryID,
|
||||
EventKindMovie,
|
||||
k.Kind,
|
||||
row.ItemID,
|
||||
row.AvailableAt,
|
||||
MovieDedupeKey(libraryID, row.ItemID),
|
||||
ItemDedupeKey(k.Kind, libraryID, row.ItemID),
|
||||
)
|
||||
}
|
||||
sb.WriteString(" ON CONFLICT (dedupe_key) DO NOTHING")
|
||||
tag, err := tx.Exec(ctx, sb.String(), eventArgs...)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("insert movie release events: %w", err)
|
||||
return 0, 0, fmt.Errorf("insert %s release events: %w", k.Kind, err)
|
||||
}
|
||||
events += int(tag.RowsAffected())
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit movie availability tx: %w", err)
|
||||
return 0, 0, fmt.Errorf("commit item availability tx: %w", err)
|
||||
}
|
||||
return len(inserted), events, nil
|
||||
}
|
||||
@@ -270,10 +293,11 @@ func EpisodeDedupeKey(libraryID int, seriesID string, episodeKey int) string {
|
||||
return fmt.Sprintf("episode:%d:%s:%d", libraryID, seriesID, episodeKey)
|
||||
}
|
||||
|
||||
// MovieDedupeKey composes the release_events dedupe key for a movie. The
|
||||
// "movie:" prefix keeps the keyspace disjoint from episode keys.
|
||||
func MovieDedupeKey(libraryID int, itemID string) string {
|
||||
return fmt.Sprintf("movie:%d:%s", libraryID, itemID)
|
||||
// ItemDedupeKey composes the release_events dedupe key for a flat item kind
|
||||
// (movie, audiobook, ebook). The kind prefix keeps each kind's keyspace
|
||||
// disjoint from the others and from episode keys.
|
||||
func ItemDedupeKey(kind string, libraryID int, itemID string) string {
|
||||
return fmt.Sprintf("%s:%d:%s", kind, libraryID, itemID)
|
||||
}
|
||||
|
||||
type newAvailability struct {
|
||||
|
||||
@@ -23,11 +23,15 @@ const SuppressedReasonSeriesBurst = "series_burst"
|
||||
const SuppressedReasonStale = "stale"
|
||||
|
||||
// Release event kinds. Episode events carry the series/episode columns and
|
||||
// fan out to interested profiles; movie events carry ItemID only and exist
|
||||
// for the server-channel broadcast feed (no per-profile fanout in v1).
|
||||
// fan out to interested profiles; flat item kinds (movie, audiobook, ebook)
|
||||
// carry ItemID only and exist for the server-channel broadcast feed (no
|
||||
// per-profile fanout in v1). The flat kinds are described by the registry in
|
||||
// item_kind.go.
|
||||
const (
|
||||
EventKindEpisode = "episode"
|
||||
EventKindMovie = "movie"
|
||||
EventKindEpisode = "episode"
|
||||
EventKindMovie = "movie"
|
||||
EventKindAudiobook = "audiobook"
|
||||
EventKindEbook = "ebook"
|
||||
)
|
||||
|
||||
// normalizeEventKind treats an unset kind as episode — the single home of
|
||||
@@ -43,14 +47,14 @@ func normalizeEventKind(kind string) string {
|
||||
// ReleaseEvent is one logical "content became newly available in a library"
|
||||
// event. New episode events use dedupe_key
|
||||
// "episode:{library_id}:{series_id}:{episode_key}" so episode-id churn from a
|
||||
// series re-ID or episode-row re-mint does not create another release. Movie
|
||||
// events use "movie:{library_id}:{item_id}".
|
||||
// series re-ID or episode-row re-mint does not create another release. Flat
|
||||
// item kinds use "{kind}:{library_id}:{item_id}".
|
||||
type ReleaseEvent struct {
|
||||
ID string
|
||||
LibraryID int
|
||||
Kind string
|
||||
// ItemID is the media_items content id for movie events; empty for
|
||||
// episode events.
|
||||
// ItemID is the media_items content id for flat item kind events (movie,
|
||||
// audiobook, ebook); empty for episode events.
|
||||
ItemID string
|
||||
SeriesID string
|
||||
EpisodeID string
|
||||
|
||||
@@ -28,10 +28,14 @@ func episodeEvent(id string, libraryID int, seriesID string, season, episode int
|
||||
}
|
||||
|
||||
func movieEvent(id string, libraryID int, itemID string) ReleaseEvent {
|
||||
return itemEvent(id, libraryID, EventKindMovie, itemID)
|
||||
}
|
||||
|
||||
func itemEvent(id string, libraryID int, kind, itemID string) ReleaseEvent {
|
||||
return ReleaseEvent{
|
||||
ID: id,
|
||||
LibraryID: libraryID,
|
||||
Kind: EventKindMovie,
|
||||
Kind: kind,
|
||||
ItemID: itemID,
|
||||
}
|
||||
}
|
||||
@@ -128,12 +132,73 @@ func TestGroupContentEvents(t *testing.T) {
|
||||
t.Fatalf("unexpected groups %+v", groups)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audiobooks and ebooks render like movies with author metadata", func(t *testing.T) {
|
||||
metas := map[string]ContentMeta{
|
||||
"book-1": {Title: "Project Hail Mary", Year: 2021, Author: "Andy Weir"},
|
||||
}
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||||
}, metas)
|
||||
if len(groups) != 1 || groups[0].Kind != EventKindAudiobook {
|
||||
t.Fatalf("unexpected groups %+v", groups)
|
||||
}
|
||||
if got := contentGroupTitle(groups[0]); got != "Project Hail Mary (2021)" {
|
||||
t.Fatalf("unexpected audiobook title %q", got)
|
||||
}
|
||||
if groups[0].Meta.Author != "Andy Weir" {
|
||||
t.Fatalf("author not carried: %+v", groups[0].Meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same item id in two kinds never cross-dedupes", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
itemEvent("1", 1, EventKindAudiobook, "item-1"),
|
||||
itemEvent("2", 1, EventKindEbook, "item-1"),
|
||||
}
|
||||
if groups := GroupContentEvents(events, nil); len(groups) != 2 {
|
||||
t.Fatalf("got %d groups, want 2 (dedupe must be kind-scoped)", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same audiobook in two libraries announces once", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||||
itemEvent("2", 2, EventKindAudiobook, "book-1"),
|
||||
}
|
||||
if groups := GroupContentEvents(events, nil); len(groups) != 1 {
|
||||
t.Fatalf("got %d groups, want 1 (cross-library dedupe)", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing flat item titles fall back per kind", func(t *testing.T) {
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
itemEvent("1", 1, EventKindAudiobook, "a"),
|
||||
itemEvent("2", 1, EventKindEbook, "b"),
|
||||
}, nil)
|
||||
if groups[0].Meta.Title != "New audiobook" || groups[1].Meta.Title != "New ebook" {
|
||||
t.Fatalf("unexpected fallbacks %q / %q", groups[0].Meta.Title, groups[1].Meta.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown future kinds are skipped, not misrendered", func(t *testing.T) {
|
||||
events := []ReleaseEvent{
|
||||
itemEvent("1", 1, "music", "album-1"),
|
||||
movieEvent("2", 1, "movie-1"),
|
||||
}
|
||||
groups := GroupContentEvents(events, titles)
|
||||
if len(groups) != 1 || groups[0].Kind != EventKindMovie {
|
||||
t.Fatalf("unexpected groups %+v", groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerChannelWantsToggles(t *testing.T) {
|
||||
ch := ServerChannel{
|
||||
NotifyNewMovies: true,
|
||||
NotifyNewEpisodes: false,
|
||||
NotifyNewAudiobooks: true,
|
||||
NotifyNewEbooks: false,
|
||||
NotifyRequestSubmitted: true,
|
||||
NotifyRequestFulfilled: false,
|
||||
}
|
||||
@@ -143,8 +208,10 @@ func TestServerChannelWantsToggles(t *testing.T) {
|
||||
}{
|
||||
{EventKindMovie, true},
|
||||
{EventKindEpisode, false},
|
||||
{"", false}, // legacy rows follow the episode toggle
|
||||
{"audiobook", false}, // unknown future kinds are never announced
|
||||
{EventKindAudiobook, true},
|
||||
{EventKindEbook, false},
|
||||
{"", false}, // legacy rows follow the episode toggle
|
||||
{"music", false}, // unknown future kinds are never announced
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ch.WantsContentKind(tc.kind); got != tc.want {
|
||||
@@ -245,6 +312,89 @@ func TestBuildServerChannelDiscordContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelDiscordContentAudiobook(t *testing.T) {
|
||||
metas := map[string]ContentMeta{
|
||||
"book-1": {
|
||||
Title: "Project Hail Mary",
|
||||
Year: 2021,
|
||||
Type: "audiobook",
|
||||
Author: "Andy Weir",
|
||||
Genres: []string{"Sci-Fi"},
|
||||
},
|
||||
"ebook-1": {Title: "The Martian", Year: 2011, Type: "ebook", Author: "Andy Weir"},
|
||||
}
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||||
itemEvent("2", 1, EventKindEbook, "ebook-1"),
|
||||
}, metas)
|
||||
|
||||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded struct {
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Embeds) != 2 {
|
||||
t.Fatalf("got %d embeds, want 2", len(decoded.Embeds))
|
||||
}
|
||||
|
||||
audiobook := decoded.Embeds[0]
|
||||
if audiobook.Author == nil || audiobook.Author.Name != "New audiobook available on Silo" {
|
||||
t.Fatalf("unexpected author line %+v", audiobook.Author)
|
||||
}
|
||||
if audiobook.Title != "Project Hail Mary (2021)" {
|
||||
t.Fatalf("unexpected title %q", audiobook.Title)
|
||||
}
|
||||
if len(audiobook.Fields) != 2 ||
|
||||
audiobook.Fields[0].Name != "Author" || audiobook.Fields[0].Value != "Andy Weir" ||
|
||||
audiobook.Fields[1].Name != "Genres" || audiobook.Fields[1].Value != "Sci-Fi" {
|
||||
t.Fatalf("unexpected fields %+v", audiobook.Fields)
|
||||
}
|
||||
|
||||
ebook := decoded.Embeds[1]
|
||||
if ebook.Author == nil || ebook.Author.Name != "New ebook available on Silo" {
|
||||
t.Fatalf("unexpected ebook author line %+v", ebook.Author)
|
||||
}
|
||||
if len(ebook.Fields) != 1 || ebook.Fields[0].Name != "Author" {
|
||||
t.Fatalf("unexpected ebook fields %+v", ebook.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelGenericContentAudiobook(t *testing.T) {
|
||||
metas := map[string]ContentMeta{
|
||||
"book-1": {Title: "Project Hail Mary", Year: 2021, Author: "Andy Weir"},
|
||||
}
|
||||
groups := GroupContentEvents([]ReleaseEvent{
|
||||
itemEvent("1", 4, EventKindAudiobook, "book-1"),
|
||||
}, metas)
|
||||
|
||||
body, err := BuildServerChannelGenericContent(groups, "chan-1", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded serverChannelContentBody
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decoded.Items) != 1 {
|
||||
t.Fatalf("got %d items, want 1", len(decoded.Items))
|
||||
}
|
||||
item := decoded.Items[0]
|
||||
if item.Kind != EventKindAudiobook || item.ItemID != "book-1" ||
|
||||
item.Title != "Project Hail Mary" || item.Year != 2021 ||
|
||||
item.Author != "Andy Weir" || item.LibraryID != 4 {
|
||||
t.Fatalf("unexpected audiobook item %+v", item)
|
||||
}
|
||||
// Flat items never carry episode span fields.
|
||||
if item.EpisodeCount != 0 || item.SeriesID != "" {
|
||||
t.Fatalf("unexpected episode fields on flat item %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerChannelDiscordContentOverflow(t *testing.T) {
|
||||
groups := make([]ContentGroup, 0, 14)
|
||||
for i := 0; i < 14; i++ {
|
||||
@@ -441,12 +591,18 @@ func TestReleaseDedupeKeysAreDisjoint(t *testing.T) {
|
||||
if episodeKey != "episode:3:series-abc:2000004" {
|
||||
t.Fatalf("unexpected episode dedupe key %q", episodeKey)
|
||||
}
|
||||
if MovieDedupeKey(3, "series-abc:2000004") == episodeKey {
|
||||
if ItemDedupeKey(EventKindMovie, 3, "series-abc:2000004") == episodeKey {
|
||||
t.Fatal("movie and episode dedupe keys must live in separate keyspaces")
|
||||
}
|
||||
if got := MovieDedupeKey(3, "abc"); got != "movie:3:abc" {
|
||||
if got := ItemDedupeKey(EventKindMovie, 3, "abc"); got != "movie:3:abc" {
|
||||
t.Fatalf("unexpected movie dedupe key %q", got)
|
||||
}
|
||||
if got := ItemDedupeKey(EventKindAudiobook, 3, "abc"); got != "audiobook:3:abc" {
|
||||
t.Fatalf("unexpected audiobook dedupe key %q", got)
|
||||
}
|
||||
if ItemDedupeKey(EventKindAudiobook, 3, "abc") == ItemDedupeKey(EventKindEbook, 3, "abc") {
|
||||
t.Fatal("flat kinds must keep disjoint dedupe keyspaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionEventsByKind(t *testing.T) {
|
||||
@@ -457,15 +613,17 @@ func TestPartitionEventsByKind(t *testing.T) {
|
||||
legacy,
|
||||
movieEvent("3", 1, "m"),
|
||||
episodeEvent("4", 1, "s", 1, 3),
|
||||
itemEvent("5", 1, EventKindAudiobook, "b"),
|
||||
itemEvent("6", 1, EventKindEbook, "e"),
|
||||
}
|
||||
episodes, others := PartitionEventsByKind(events)
|
||||
if len(episodes) != 3 || len(others) != 1 {
|
||||
t.Fatalf("got %d/%d, want 3 episodes and 1 other", len(episodes), len(others))
|
||||
if len(episodes) != 3 || len(others) != 3 {
|
||||
t.Fatalf("got %d/%d, want 3 episodes and 3 others", len(episodes), len(others))
|
||||
}
|
||||
if episodes[0].ID != "1" || episodes[1].ID != "2" || episodes[2].ID != "4" {
|
||||
t.Fatalf("episode order not preserved: %+v", episodes)
|
||||
}
|
||||
if others[0].ID != "3" {
|
||||
if others[0].ID != "3" || others[1].ID != "5" || others[2].ID != "6" {
|
||||
t.Fatalf("unexpected non-episode partition: %+v", others)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ type ContentMeta struct {
|
||||
PosterSourcePath string
|
||||
// PosterURL is the fetchable poster URL chosen by the sweep worker
|
||||
// (System.discordPosterURL); empty renders the embed without an image.
|
||||
PosterURL string
|
||||
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
|
||||
@@ -47,26 +50,28 @@ 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 movie, or every
|
||||
// new episode of one series in the batch.
|
||||
// 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 | EventKindMovie
|
||||
Kind string // EventKindEpisode or a flat item kind (item_kind.go)
|
||||
LibraryID int
|
||||
// Episode groups.
|
||||
SeriesID string
|
||||
Episodes []ReleaseEvent // ascending episode_key
|
||||
// Movie groups.
|
||||
// Flat item groups.
|
||||
ItemID string
|
||||
// Meta describes the series (episode groups) or the movie itself, with
|
||||
// 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,
|
||||
// movies render individually but dedupe by item across libraries. 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.
|
||||
// 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
|
||||
@@ -74,29 +79,35 @@ func GroupContentEvents(events []ReleaseEvent, metas map[string]ContentMeta) []C
|
||||
contentID string
|
||||
}
|
||||
index := make(map[groupKey]int)
|
||||
seenMovies := make(map[string]struct{})
|
||||
seenItems := make(map[string]struct{})
|
||||
groups := make([]ContentGroup, 0, len(events))
|
||||
|
||||
for _, event := range events {
|
||||
switch normalizeEventKind(event.Kind) {
|
||||
case EventKindMovie:
|
||||
// The same movie landing in two libraries (e.g. "Movies" and
|
||||
// "Movies 4K") announces once.
|
||||
if _, dup := seenMovies[event.ItemID]; dup {
|
||||
kind := normalizeEventKind(event.Kind)
|
||||
if kind != EventKindEpisode {
|
||||
flat, ok := flatKindByString(kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seenMovies[event.ItemID] = struct{}{}
|
||||
// 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 movie"
|
||||
meta.Title = "New " + flat.ItemType
|
||||
}
|
||||
groups = append(groups, ContentGroup{
|
||||
Kind: EventKindMovie,
|
||||
Kind: kind,
|
||||
LibraryID: event.LibraryID,
|
||||
ItemID: event.ItemID,
|
||||
Meta: meta,
|
||||
})
|
||||
default:
|
||||
} else {
|
||||
key := groupKey{EventKindEpisode, event.LibraryID, event.SeriesID}
|
||||
if at, ok := index[key]; ok {
|
||||
groups[at].Episodes = append(groups[at].Episodes, event)
|
||||
@@ -148,16 +159,14 @@ func episodeRangeLabel(episodes []ReleaseEvent) string {
|
||||
|
||||
// contentGroupTitle renders a group's display line.
|
||||
func contentGroupTitle(group ContentGroup) string {
|
||||
switch group.Kind {
|
||||
case EventKindMovie:
|
||||
if _, ok := flatKindByString(group.Kind); ok {
|
||||
return titleWithYear(group.Meta.Title, group.Meta.Year)
|
||||
default:
|
||||
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))
|
||||
}
|
||||
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
|
||||
@@ -177,13 +186,20 @@ func BuildServerChannelDiscordContent(groups []ContentGroup, test bool) ([]byte,
|
||||
embeds := make([]discordEmbed, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
author := "New episodes available on Silo"
|
||||
if group.Kind == EventKindMovie {
|
||||
author = "New movie 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, 2)
|
||||
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})
|
||||
}
|
||||
@@ -234,6 +250,7 @@ type serverChannelContentRow struct {
|
||||
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.
|
||||
@@ -259,12 +276,12 @@ func BuildServerChannelGenericContent(groups []ContentGroup, channelID string, t
|
||||
Kind: group.Kind,
|
||||
LibraryID: group.LibraryID,
|
||||
}
|
||||
switch group.Kind {
|
||||
case EventKindMovie:
|
||||
if _, ok := flatKindByString(group.Kind); ok {
|
||||
row.ItemID = group.ItemID
|
||||
row.Title = group.Meta.Title
|
||||
row.Year = group.Meta.Year
|
||||
default:
|
||||
row.Author = group.Meta.Author
|
||||
} else {
|
||||
row.SeriesID = group.SeriesID
|
||||
row.SeriesTitle = group.Meta.Title
|
||||
row.EpisodeCount = len(group.Episodes)
|
||||
|
||||
@@ -23,6 +23,7 @@ func NewServerChannelRepository(pool *pgxpool.Pool) *ServerChannelRepository {
|
||||
const serverChannelColumns = `
|
||||
id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled,
|
||||
notify_new_movies, notify_new_episodes,
|
||||
notify_new_audiobooks, notify_new_ebooks,
|
||||
notify_request_submitted, notify_request_approved,
|
||||
notify_request_declined, notify_request_fulfilled,
|
||||
watermark_created_at, watermark_id,
|
||||
@@ -36,6 +37,7 @@ func scanServerChannel(row pgx.Row) (*ServerChannel, error) {
|
||||
&ch.ID, &ch.Name, &ch.Type, &ch.URLCiphertext, &ch.URLHost,
|
||||
&ch.SigningSecretCiphertext, &ch.Enabled,
|
||||
&ch.NotifyNewMovies, &ch.NotifyNewEpisodes,
|
||||
&ch.NotifyNewAudiobooks, &ch.NotifyNewEbooks,
|
||||
&ch.NotifyRequestSubmitted, &ch.NotifyRequestApproved,
|
||||
&ch.NotifyRequestDeclined, &ch.NotifyRequestFulfilled,
|
||||
&ch.WatermarkCreatedAt, &ch.WatermarkID,
|
||||
@@ -123,12 +125,14 @@ func (r *ServerChannelRepository) InsertWithLimit(ctx context.Context, ch Server
|
||||
INSERT INTO notification_server_channels
|
||||
(id, name, type, url_ciphertext, url_host, signing_secret_ciphertext, enabled,
|
||||
notify_new_movies, notify_new_episodes,
|
||||
notify_new_audiobooks, notify_new_ebooks,
|
||||
notify_request_submitted, notify_request_approved,
|
||||
notify_request_declined, notify_request_fulfilled,
|
||||
created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
||||
ch.ID, ch.Name, ch.Type, ch.URLCiphertext, ch.URLHost, ch.SigningSecretCiphertext, ch.Enabled,
|
||||
ch.NotifyNewMovies, ch.NotifyNewEpisodes,
|
||||
ch.NotifyNewAudiobooks, ch.NotifyNewEbooks,
|
||||
ch.NotifyRequestSubmitted, ch.NotifyRequestApproved,
|
||||
ch.NotifyRequestDeclined, ch.NotifyRequestFulfilled,
|
||||
ch.CreatedByUserID); err != nil {
|
||||
@@ -153,13 +157,15 @@ func (r *ServerChannelRepository) Update(ctx context.Context, ch ServerChannel)
|
||||
name = $2, url_ciphertext = $3, url_host = $4,
|
||||
signing_secret_ciphertext = $5, enabled = $6,
|
||||
notify_new_movies = $7, notify_new_episodes = $8,
|
||||
notify_request_submitted = $9, notify_request_approved = $10,
|
||||
notify_request_declined = $11, notify_request_fulfilled = $12,
|
||||
notify_new_audiobooks = $9, notify_new_ebooks = $10,
|
||||
notify_request_submitted = $11, notify_request_approved = $12,
|
||||
notify_request_declined = $13, notify_request_fulfilled = $14,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
ch.ID, ch.Name, ch.URLCiphertext, ch.URLHost,
|
||||
ch.SigningSecretCiphertext, ch.Enabled,
|
||||
ch.NotifyNewMovies, ch.NotifyNewEpisodes,
|
||||
ch.NotifyNewAudiobooks, ch.NotifyNewEbooks,
|
||||
ch.NotifyRequestSubmitted, ch.NotifyRequestApproved,
|
||||
ch.NotifyRequestDeclined, ch.NotifyRequestFulfilled)
|
||||
if err != nil {
|
||||
@@ -202,7 +208,8 @@ func (r *ServerChannelRepository) ListEnabledForContent(ctx context.Context) ([]
|
||||
SELECT `+serverChannelColumns+`
|
||||
FROM notification_server_channels
|
||||
WHERE enabled AND disabled_reason IS NULL
|
||||
AND (notify_new_movies OR notify_new_episodes)
|
||||
AND (notify_new_movies OR notify_new_episodes
|
||||
OR notify_new_audiobooks OR notify_new_ebooks)
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list content server channels: %w", err)
|
||||
|
||||
@@ -47,6 +47,8 @@ type ServerChannelInput struct {
|
||||
Enabled *bool
|
||||
NotifyNewMovies *bool
|
||||
NotifyNewEpisodes *bool
|
||||
NotifyNewAudiobooks *bool
|
||||
NotifyNewEbooks *bool
|
||||
NotifyRequestSubmitted *bool
|
||||
NotifyRequestApproved *bool
|
||||
NotifyRequestDeclined *bool
|
||||
@@ -98,6 +100,8 @@ func (s *ServerChannelService) Create(ctx context.Context, createdByUserID int,
|
||||
Enabled: boolOrDefault(input.Enabled, true),
|
||||
NotifyNewMovies: boolOrDefault(input.NotifyNewMovies, true),
|
||||
NotifyNewEpisodes: boolOrDefault(input.NotifyNewEpisodes, true),
|
||||
NotifyNewAudiobooks: boolOrDefault(input.NotifyNewAudiobooks, true),
|
||||
NotifyNewEbooks: boolOrDefault(input.NotifyNewEbooks, true),
|
||||
NotifyRequestSubmitted: boolOrDefault(input.NotifyRequestSubmitted, false),
|
||||
NotifyRequestApproved: boolOrDefault(input.NotifyRequestApproved, false),
|
||||
NotifyRequestDeclined: boolOrDefault(input.NotifyRequestDeclined, false),
|
||||
@@ -178,6 +182,12 @@ func (s *ServerChannelService) Update(ctx context.Context, id string, input Serv
|
||||
if input.NotifyNewEpisodes != nil {
|
||||
ch.NotifyNewEpisodes = *input.NotifyNewEpisodes
|
||||
}
|
||||
if input.NotifyNewAudiobooks != nil {
|
||||
ch.NotifyNewAudiobooks = *input.NotifyNewAudiobooks
|
||||
}
|
||||
if input.NotifyNewEbooks != nil {
|
||||
ch.NotifyNewEbooks = *input.NotifyNewEbooks
|
||||
}
|
||||
if input.NotifyRequestSubmitted != nil {
|
||||
ch.NotifyRequestSubmitted = *input.NotifyRequestSubmitted
|
||||
}
|
||||
@@ -261,26 +271,41 @@ func (s *ServerChannelService) Test(ctx context.Context, id string) (*WebhookTes
|
||||
return s.sender.sendContent(ctx, ch, sampleContentGroups(), true).testResult(), nil
|
||||
}
|
||||
|
||||
// sampleContentGroups is the fixture used for test sends.
|
||||
// sampleContentGroups is the fixture used for test sends: one group per flat
|
||||
// item kind plus an episode group, so a test post exercises every render
|
||||
// path the channel can receive.
|
||||
func sampleContentGroups() []ContentGroup {
|
||||
return []ContentGroup{
|
||||
{
|
||||
Kind: EventKindMovie,
|
||||
groups := make([]ContentGroup, 0, len(flatItemKinds)+1)
|
||||
for _, k := range flatItemKinds {
|
||||
meta := ContentMeta{Title: "Silo Test " + capitalize(k.ItemType), Year: 2026}
|
||||
if k.Kind != EventKindMovie {
|
||||
meta.Author = "Test Author"
|
||||
}
|
||||
groups = append(groups, ContentGroup{
|
||||
Kind: k.Kind,
|
||||
LibraryID: 1,
|
||||
ItemID: "test-movie",
|
||||
Meta: ContentMeta{Title: "Silo Test Movie", Year: 2026},
|
||||
},
|
||||
{
|
||||
Kind: EventKindEpisode,
|
||||
LibraryID: 1,
|
||||
SeriesID: "test-series",
|
||||
Meta: ContentMeta{Title: "Silo Test Series"},
|
||||
Episodes: []ReleaseEvent{
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 1, EpisodeKey: EpisodeKey(1, 1)},
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 2, EpisodeKey: EpisodeKey(1, 2)},
|
||||
},
|
||||
},
|
||||
ItemID: "test-" + k.Kind,
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
return append(groups, ContentGroup{
|
||||
Kind: EventKindEpisode,
|
||||
LibraryID: 1,
|
||||
SeriesID: "test-series",
|
||||
Meta: ContentMeta{Title: "Silo Test Series"},
|
||||
Episodes: []ReleaseEvent{
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 1, EpisodeKey: EpisodeKey(1, 1)},
|
||||
{Kind: EventKindEpisode, LibraryID: 1, SeriesID: "test-series",
|
||||
SeasonNumber: 1, EpisodeNumber: 2, EpisodeKey: EpisodeKey(1, 2)},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// capitalize upper-cases the first ASCII letter of a display noun.
|
||||
func capitalize(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ type ServerChannel struct {
|
||||
Enabled bool
|
||||
NotifyNewMovies bool
|
||||
NotifyNewEpisodes bool
|
||||
NotifyNewAudiobooks bool
|
||||
NotifyNewEbooks bool
|
||||
NotifyRequestSubmitted bool
|
||||
NotifyRequestApproved bool
|
||||
NotifyRequestDeclined bool
|
||||
@@ -61,14 +63,14 @@ func serverChannelSecretAAD(id string) string { return "server_channel:" + id +
|
||||
// release event kind. Unknown kinds (added by future versions) are skipped:
|
||||
// an old node must not announce content it cannot render.
|
||||
func (c ServerChannel) WantsContentKind(kind string) bool {
|
||||
switch normalizeEventKind(kind) {
|
||||
case EventKindEpisode:
|
||||
normalized := normalizeEventKind(kind)
|
||||
if normalized == EventKindEpisode {
|
||||
return c.NotifyNewEpisodes
|
||||
case EventKindMovie:
|
||||
return c.NotifyNewMovies
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if k, ok := flatKindByString(normalized); ok {
|
||||
return k.WantsToggle(c)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WantsRequestEvent reports whether the channel's toggles include the given
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -242,8 +243,10 @@ func (w *serverChannelWorker) processChannel(ctx context.Context, channelID stri
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// loadContentMeta batch-fetches display metadata for every series and movie
|
||||
// in the batch, keyed by content id.
|
||||
// loadContentMeta batch-fetches display metadata for every series and flat
|
||||
// item in the batch, keyed by content id. The author join only yields rows
|
||||
// for items with author credits (audiobooks, ebooks); movies and series scan
|
||||
// an empty string.
|
||||
func loadContentMeta(ctx context.Context, tx pgx.Tx, events []ReleaseEvent) (map[string]ContentMeta, error) {
|
||||
idSet := make(map[string]struct{}, len(events))
|
||||
ids := make([]string, 0, len(events))
|
||||
@@ -257,7 +260,7 @@ func loadContentMeta(ctx context.Context, tx pgx.Tx, events []ReleaseEvent) (map
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Kind == EventKindMovie {
|
||||
if _, ok := flatKindByString(normalizeEventKind(event.Kind)); ok {
|
||||
add(event.ItemID)
|
||||
} else {
|
||||
add(event.SeriesID)
|
||||
@@ -268,14 +271,23 @@ func loadContentMeta(ctx context.Context, tx pgx.Tx, events []ReleaseEvent) (map
|
||||
return metas, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT content_id, title, COALESCE(year, 0), COALESCE(type, ''),
|
||||
COALESCE(overview, ''), COALESCE(poster_path, ''),
|
||||
COALESCE(poster_source_path, ''),
|
||||
COALESCE(genres, '{}'::text[]), COALESCE(content_rating, ''),
|
||||
COALESCE(rating_imdb, 0), COALESCE(rating_tmdb, 0),
|
||||
COALESCE(imdb_id, ''), COALESCE(tmdb_id, ''), COALESCE(tvdb_id, '')
|
||||
FROM media_items
|
||||
WHERE content_id = ANY($1)`, ids)
|
||||
SELECT mi.content_id, mi.title, COALESCE(mi.year, 0), COALESCE(mi.type, ''),
|
||||
COALESCE(mi.overview, ''), COALESCE(mi.poster_path, ''),
|
||||
COALESCE(mi.poster_source_path, ''),
|
||||
COALESCE(mi.genres, '{}'::text[]), COALESCE(mi.content_rating, ''),
|
||||
COALESCE(mi.rating_imdb, 0), COALESCE(mi.rating_tmdb, 0),
|
||||
COALESCE(mi.imdb_id, ''), COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''),
|
||||
COALESCE(author.name, '')
|
||||
FROM media_items mi
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT p.name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = mi.content_id AND ip.kind = $2
|
||||
ORDER BY ip.sort_order, p.name
|
||||
LIMIT 1
|
||||
) author ON TRUE
|
||||
WHERE mi.content_id = ANY($1)`, ids, int(models.PersonKindAuthor))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load content metadata: %w", err)
|
||||
}
|
||||
@@ -288,6 +300,7 @@ func loadContentMeta(ctx context.Context, tx pgx.Tx, events []ReleaseEvent) (map
|
||||
&meta.Genres, &meta.ContentRating,
|
||||
&meta.RatingIMDB, &meta.RatingTMDB,
|
||||
&meta.IMDBID, &meta.TMDBID, &meta.TVDBID,
|
||||
&meta.Author,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan content metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -452,20 +452,29 @@ func (s *System) SeedAvailability(ctx context.Context, progress func(percent int
|
||||
progress(percent, message)
|
||||
}
|
||||
}
|
||||
// Episode and movie availability seed independently: each kind has its
|
||||
// own seed markers, because the episode pass historically marked every
|
||||
// scanned library (movie libraries included) with zero movie rows.
|
||||
passes := []struct {
|
||||
// Each kind seeds independently with its own seed markers, because the
|
||||
// episode pass historically marked every scanned library (movie libraries
|
||||
// included) with zero movie rows. The flat item kinds come from the
|
||||
// registry so a new kind cannot be forgotten here.
|
||||
type seedPass struct {
|
||||
kind string
|
||||
seedCondition string
|
||||
record func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error)
|
||||
}{
|
||||
{EventKindEpisode,
|
||||
`SELECT 1 FROM notification_library_seed_state seed WHERE seed.library_id = mf.id`,
|
||||
s.Releases.RecordAvailabilityForLibrary},
|
||||
{EventKindMovie,
|
||||
`SELECT 1 FROM notification_content_seed_state seed WHERE seed.library_id = mf.id AND seed.kind = 'movie'`,
|
||||
s.Releases.RecordMovieAvailabilityForLibrary},
|
||||
}
|
||||
passes := []seedPass{{
|
||||
kind: EventKindEpisode,
|
||||
seedCondition: `SELECT 1 FROM notification_library_seed_state seed WHERE seed.library_id = mf.id`,
|
||||
record: s.Releases.RecordAvailabilityForLibrary,
|
||||
}}
|
||||
for _, k := range flatItemKinds {
|
||||
passes = append(passes, seedPass{
|
||||
kind: k.Kind,
|
||||
seedCondition: `SELECT 1 FROM notification_content_seed_state seed
|
||||
WHERE seed.library_id = mf.id AND seed.kind = '` + k.Kind + `'`,
|
||||
record: func(ctx context.Context, libraryID int, emitEvents bool) (int, int, error) {
|
||||
return s.Releases.RecordItemAvailabilityForLibrary(ctx, k, libraryID, emitEvents)
|
||||
},
|
||||
})
|
||||
}
|
||||
for passIdx, pass := range passes {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
@@ -504,9 +513,10 @@ func (s *System) SeedAvailability(ctx context.Context, progress func(percent int
|
||||
return fmt.Errorf("mark library %d seeded (%s): %w", libraryID, pass.kind, err)
|
||||
}
|
||||
totalSeeded += inserted
|
||||
// Each pass owns half the progress range.
|
||||
passBase := passIdx * 50
|
||||
report(passBase+(i+1)*50/max(len(libraryIDs), 1),
|
||||
// Each pass owns an equal slice of the progress range.
|
||||
passSpan := 100 / len(passes)
|
||||
passBase := passIdx * passSpan
|
||||
report(passBase+(i+1)*passSpan/max(len(libraryIDs), 1),
|
||||
fmt.Sprintf("Seeded library %d %s availability (%d new rows)", libraryID, pass.kind, inserted))
|
||||
}
|
||||
s.logger.Info("availability seeding completed",
|
||||
|
||||
+14
-56
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/librarykind"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/naming"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
@@ -257,7 +258,7 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
|
||||
defer stopWatch()
|
||||
|
||||
if isAudiobookLibraryType(folder.Type) {
|
||||
if librarykind.IsAudiobook(folder.Type) {
|
||||
if err := s.ScanAudiobookFolder(watchCtx, folder, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -267,7 +268,7 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isPodcastLibraryType(folder.Type) {
|
||||
if librarykind.IsPodcast(folder.Type) {
|
||||
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -277,14 +278,14 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isMangaLibraryType(folder.Type) {
|
||||
if librarykind.IsManga(folder.Type) {
|
||||
if err := s.ScanMangaFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if librarykind.IsEbook(folder.Type) {
|
||||
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,7 +301,7 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
|
||||
cleanSubtree := filepath.Clean(subtreePath)
|
||||
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
|
||||
defer stopWatch()
|
||||
if isAudiobookLibraryType(folder.Type) {
|
||||
if librarykind.IsAudiobook(folder.Type) {
|
||||
scanRoot, err := cleanScopedAudiobookScanRoot(subtreePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -313,13 +314,13 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
if isMangaLibraryType(folder.Type) {
|
||||
if librarykind.IsManga(folder.Type) {
|
||||
if err := s.scanMangaPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if librarykind.IsEbook(folder.Type) {
|
||||
if err := s.scanEbookPaths(watchCtx, folder, []string{cleanSubtree}, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -346,49 +347,6 @@ func scopedFolderPaths(folder *models.MediaFolder, paths []string) *models.Media
|
||||
return &clone
|
||||
}
|
||||
|
||||
func isMovieLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "movie", "movies":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func isAudiobookLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "audiobook", "audiobooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func isPodcastLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "podcast", "podcasts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isEbookLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "ebook", "ebooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMangaLibraryType(t string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "manga":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// walkMode tells walkLogicalTree which file extensions to surface and
|
||||
// which library-specific filename heuristics (sample/extra skipping)
|
||||
// to apply.
|
||||
@@ -407,15 +365,15 @@ const (
|
||||
// pass arbitrary types preserve their prior behavior.
|
||||
func walkModeFor(folderType string) walkMode {
|
||||
switch {
|
||||
case isMovieLibraryType(folderType):
|
||||
case librarykind.IsMovie(folderType):
|
||||
return walkModeMovie
|
||||
case isAudiobookLibraryType(folderType):
|
||||
case librarykind.IsAudiobook(folderType):
|
||||
return walkModeAudiobook
|
||||
case isPodcastLibraryType(folderType):
|
||||
case librarykind.IsPodcast(folderType):
|
||||
return walkModePodcast
|
||||
case isEbookLibraryType(folderType):
|
||||
case librarykind.IsEbook(folderType):
|
||||
return walkModeEbook
|
||||
case isMangaLibraryType(folderType):
|
||||
case librarykind.IsManga(folderType):
|
||||
// Manga chapters are .cbz/.cbr archives, surfaced by the ebook walk.
|
||||
return walkModeEbook
|
||||
default:
|
||||
@@ -1592,7 +1550,7 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models.
|
||||
}
|
||||
|
||||
cleanFile := filepath.Clean(filePath)
|
||||
if isAudiobookLibraryType(folder.Type) {
|
||||
if librarykind.IsAudiobook(folder.Type) {
|
||||
if !SupportsAudioFile(cleanFile) {
|
||||
return fmt.Errorf("unrecognized audio extension: %s", strings.ToLower(filepath.Ext(cleanFile)))
|
||||
}
|
||||
|
||||
@@ -161,85 +161,6 @@ func testStringSliceContains(values []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestIsAudiobookLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"audiobooks", true},
|
||||
{"audiobook", true},
|
||||
{"Audiobook", true},
|
||||
{" AUDIOBOOKS ", true},
|
||||
{"movies", false},
|
||||
{"series", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isAudiobookLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isAudiobookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPodcastLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"podcasts", true},
|
||||
{"podcast", true},
|
||||
{"Podcast", true},
|
||||
{" PODCASTS ", true},
|
||||
{"series", false},
|
||||
{"audiobooks", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isPodcastLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isPodcastLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEbookLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ebooks", true},
|
||||
{"ebook", true},
|
||||
{"Ebook", true},
|
||||
{" EBOOKS ", true},
|
||||
{"audiobooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isEbookLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isEbookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMangaLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"manga", true},
|
||||
{"Manga", true},
|
||||
{" MANGA ", true},
|
||||
{"ebooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isMangaLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isMangaLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
||||
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
||||
if got := walkModeFor(libraryType); got != walkModeEbook {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Audiobook and ebook "recently added" notifications. Both kinds are flat
|
||||
-- item kinds like movies (item_id only, no series/episode columns): they
|
||||
-- widen the existing item-shaped branch of the release_events constraints
|
||||
-- and get their own server-channel toggles.
|
||||
|
||||
ALTER TABLE public.release_events
|
||||
DROP CONSTRAINT release_events_kind_check,
|
||||
DROP CONSTRAINT release_events_kind_shape_check;
|
||||
ALTER TABLE public.release_events
|
||||
ADD CONSTRAINT release_events_kind_check
|
||||
CHECK (kind IN ('episode', 'movie', 'audiobook', 'ebook')),
|
||||
ADD CONSTRAINT release_events_kind_shape_check CHECK (
|
||||
(kind = 'episode' AND series_id IS NOT NULL AND episode_id IS NOT NULL
|
||||
AND season_number IS NOT NULL AND episode_number IS NOT NULL
|
||||
AND episode_key IS NOT NULL)
|
||||
OR (kind IN ('movie', 'audiobook', 'ebook') AND item_id IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Generic flat-item availability facts for every kind after movie: one-way
|
||||
-- "item first became available in this library" rows, movie_availability's
|
||||
-- shape plus a kind discriminator. Movies deliberately stay in their original
|
||||
-- table (renaming a populated hot-path table buys nothing); a future kind
|
||||
-- widens the kind CHECKs here and on release_events, no new table.
|
||||
CREATE TABLE public.item_availability (
|
||||
library_id integer NOT NULL,
|
||||
item_id text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
available_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT item_availability_pkey PRIMARY KEY (library_id, item_id, kind),
|
||||
CONSTRAINT item_availability_kind_check CHECK (kind IN ('audiobook', 'ebook'))
|
||||
);
|
||||
|
||||
-- Per-channel content toggles for the new kinds, defaulting on like the
|
||||
-- movie/episode toggles. No back-catalog flood risk: existing audiobook and
|
||||
-- ebook libraries have no notification_content_seed_state rows for these
|
||||
-- kinds yet, so their first full scan seeds silently.
|
||||
ALTER TABLE public.notification_server_channels
|
||||
ADD COLUMN notify_new_audiobooks boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN notify_new_ebooks boolean NOT NULL DEFAULT true;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.notification_server_channels
|
||||
DROP COLUMN IF EXISTS notify_new_ebooks,
|
||||
DROP COLUMN IF EXISTS notify_new_audiobooks;
|
||||
|
||||
DROP TABLE IF EXISTS public.item_availability;
|
||||
|
||||
DELETE FROM public.release_events WHERE kind IN ('audiobook', 'ebook');
|
||||
ALTER TABLE public.release_events
|
||||
DROP CONSTRAINT IF EXISTS release_events_kind_shape_check,
|
||||
DROP CONSTRAINT IF EXISTS release_events_kind_check;
|
||||
ALTER TABLE public.release_events
|
||||
ADD CONSTRAINT release_events_kind_check CHECK (kind IN ('episode', 'movie')),
|
||||
ADD CONSTRAINT release_events_kind_shape_check CHECK (
|
||||
(kind = 'episode' AND series_id IS NOT NULL AND episode_id IS NOT NULL
|
||||
AND season_number IS NOT NULL AND episode_number IS NOT NULL
|
||||
AND episode_key IS NOT NULL)
|
||||
OR (kind = 'movie' AND item_id IS NOT NULL)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
@@ -2521,6 +2521,8 @@ export interface ServerNotificationChannel {
|
||||
enabled: boolean;
|
||||
notify_new_movies: boolean;
|
||||
notify_new_episodes: boolean;
|
||||
notify_new_audiobooks: boolean;
|
||||
notify_new_ebooks: boolean;
|
||||
notify_request_submitted: boolean;
|
||||
notify_request_approved: boolean;
|
||||
notify_request_declined: boolean;
|
||||
@@ -2543,6 +2545,8 @@ export interface ServerNotificationChannelInput {
|
||||
enabled?: boolean;
|
||||
notify_new_movies?: boolean;
|
||||
notify_new_episodes?: boolean;
|
||||
notify_new_audiobooks?: boolean;
|
||||
notify_new_ebooks?: boolean;
|
||||
notify_request_submitted?: boolean;
|
||||
notify_request_approved?: boolean;
|
||||
notify_request_declined?: boolean;
|
||||
|
||||
@@ -44,6 +44,8 @@ import { formatRelativeTime } from "@/lib/date";
|
||||
type ChannelNotifyKey =
|
||||
| "notify_new_movies"
|
||||
| "notify_new_episodes"
|
||||
| "notify_new_audiobooks"
|
||||
| "notify_new_ebooks"
|
||||
| "notify_request_submitted"
|
||||
| "notify_request_approved"
|
||||
| "notify_request_declined"
|
||||
@@ -61,6 +63,8 @@ const EVENT_SECTIONS: { label: string; fields: ChannelNotifyField[] }[] = [
|
||||
fields: [
|
||||
{ key: "notify_new_movies", label: "New movies", defaultValue: true },
|
||||
{ key: "notify_new_episodes", label: "New episodes", defaultValue: true },
|
||||
{ key: "notify_new_audiobooks", label: "New audiobooks", defaultValue: true },
|
||||
{ key: "notify_new_ebooks", label: "New ebooks", defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user