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:
Quick
2026-07-02 11:42:28 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 287ff3658f
commit 866392fecd
25 changed files with 889 additions and 349 deletions
@@ -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,
+10 -28
View File
@@ -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:
+103
View File
@@ -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"
}
+82
View File
@@ -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)
}
}
+6 -24
View File
@@ -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)
},
}
}
+4 -2
View File
@@ -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.
+4 -3
View File
@@ -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 {
+4 -4
View File
@@ -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 {
+70
View File
@@ -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
}
+79
View File
@@ -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)
}
}
+66 -42
View File
@@ -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 {
+12 -8
View File
@@ -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)
+11 -4
View File
@@ -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
+24 -11
View File
@@ -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)
}
+24 -14
View File
@@ -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
View File
@@ -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)))
}
-79
View File
@@ -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
+4
View File
@@ -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 },
],
},
{