diff --git a/internal/api/handlers/admin_server_channels.go b/internal/api/handlers/admin_server_channels.go index 788c7ba2..5c13d3c2 100644 --- a/internal/api/handlers/admin_server_channels.go +++ b/internal/api/handlers/admin_server_channels.go @@ -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, diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index f1da04d1..caf5bbf9 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -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: diff --git a/internal/librarykind/librarykind.go b/internal/librarykind/librarykind.go new file mode 100644 index 00000000..a6320912 --- /dev/null +++ b/internal/librarykind/librarykind.go @@ -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" +} diff --git a/internal/librarykind/librarykind_test.go b/internal/librarykind/librarykind_test.go new file mode 100644 index 00000000..c833daea --- /dev/null +++ b/internal/librarykind/librarykind_test.go @@ -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) + } +} diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index d2229b7e..d7e4026d 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -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) { diff --git a/internal/notifications/availability_detector.go b/internal/notifications/availability_detector.go index d766f0f9..315da658 100644 --- a/internal/notifications/availability_detector.go +++ b/internal/notifications/availability_detector.go @@ -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) + }, } } diff --git a/internal/notifications/discord_embed_meta.go b/internal/notifications/discord_embed_meta.go index 15e60dca..efa76d36 100644 --- a/internal/notifications/discord_embed_meta.go +++ b/internal/notifications/discord_embed_meta.go @@ -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. diff --git a/internal/notifications/fanout_logic.go b/internal/notifications/fanout_logic.go index a254107e..605e2642 100644 --- a/internal/notifications/fanout_logic.go +++ b/internal/notifications/fanout_logic.go @@ -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 { diff --git a/internal/notifications/fanout_worker.go b/internal/notifications/fanout_worker.go index 6760b63a..2f262561 100644 --- a/internal/notifications/fanout_worker.go +++ b/internal/notifications/fanout_worker.go @@ -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 { diff --git a/internal/notifications/item_kind.go b/internal/notifications/item_kind.go new file mode 100644 index 00000000..99745126 --- /dev/null +++ b/internal/notifications/item_kind.go @@ -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 +} diff --git a/internal/notifications/item_kind_test.go b/internal/notifications/item_kind_test.go new file mode 100644 index 00000000..97e258ef --- /dev/null +++ b/internal/notifications/item_kind_test.go @@ -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) + } +} diff --git a/internal/notifications/release_repo.go b/internal/notifications/release_repo.go index 99f81b53..5b68c50f 100644 --- a/internal/notifications/release_repo.go +++ b/internal/notifications/release_repo.go @@ -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 { diff --git a/internal/notifications/release_types.go b/internal/notifications/release_types.go index 7a2355e1..d8211669 100644 --- a/internal/notifications/release_types.go +++ b/internal/notifications/release_types.go @@ -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 diff --git a/internal/notifications/server_channel_logic_test.go b/internal/notifications/server_channel_logic_test.go index ed7af60b..9ff41625 100644 --- a/internal/notifications/server_channel_logic_test.go +++ b/internal/notifications/server_channel_logic_test.go @@ -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) } diff --git a/internal/notifications/server_channel_payload.go b/internal/notifications/server_channel_payload.go index ab8c2d06..6a7d7fb5 100644 --- a/internal/notifications/server_channel_payload.go +++ b/internal/notifications/server_channel_payload.go @@ -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) diff --git a/internal/notifications/server_channel_repo.go b/internal/notifications/server_channel_repo.go index 0e866f7a..e71fb0d0 100644 --- a/internal/notifications/server_channel_repo.go +++ b/internal/notifications/server_channel_repo.go @@ -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) diff --git a/internal/notifications/server_channel_service.go b/internal/notifications/server_channel_service.go index aa7b2666..8c58c316 100644 --- a/internal/notifications/server_channel_service.go +++ b/internal/notifications/server_channel_service.go @@ -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:] } diff --git a/internal/notifications/server_channel_types.go b/internal/notifications/server_channel_types.go index 875a8f4f..a05cf5f8 100644 --- a/internal/notifications/server_channel_types.go +++ b/internal/notifications/server_channel_types.go @@ -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 diff --git a/internal/notifications/server_channel_worker.go b/internal/notifications/server_channel_worker.go index 22614af6..a46af4f6 100644 --- a/internal/notifications/server_channel_worker.go +++ b/internal/notifications/server_channel_worker.go @@ -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) } diff --git a/internal/notifications/system.go b/internal/notifications/system.go index 5eb4678d..1933c28b 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -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", diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 612706b9..6905acc0 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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))) } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index b0aba96d..2acb7f29 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -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 { diff --git a/migrations/sql/20260702033712_audiobook_ebook_notifications.sql b/migrations/sql/20260702033712_audiobook_ebook_notifications.sql new file mode 100644 index 00000000..4c1778bd --- /dev/null +++ b/migrations/sql/20260702033712_audiobook_ebook_notifications.sql @@ -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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4edd24d6..33d9f465 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/pages/admin-settings/ServerNotificationChannels.tsx b/web/src/pages/admin-settings/ServerNotificationChannels.tsx index 4f11e32b..e8b9cd4b 100644 --- a/web/src/pages/admin-settings/ServerNotificationChannels.tsx +++ b/web/src/pages/admin-settings/ServerNotificationChannels.tsx @@ -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 }, ], }, {