Requests previously only notified the community server channels for submitted/approved/declined and the requester personally for fulfilled. This closes the gap and makes request posts addressable: - New request.approved / request.declined delivery types ride the operational dispatch path to the requesting profile: inbox, websocket toast, email, Discord DM, personal webhooks (gated by the existing notify_requests flag), and web push. Submitted stays broadcast-only (the requester performed the action themselves). Title/year/decline reason travel in reason_flags since no catalog item exists yet. - Request status notices are transactional: digest-mode recipients get an off-schedule early send (watermark-durable, last_digest_at left alone) instead of waiting for the digest hour. Per-episode recipients were already immediate via the dispatch nudge. - At-most-once per (profile, request, type) via a partial unique index (migration 20260612100000), mirroring the fulfilled dedupe. - Server-channel Discord request posts can @mention the requester via their OAuth-linked identity (notifications.server_channels. mention_requesters, default off). Resolved lazily in the sweep worker only when a Discord destination is about to receive the event; the ping uses content-level mention with pinned allowed_mentions, and the Discord identity never leaks into generic webhook payloads. Android/Apple clients render the new inbox types with their generic fallback until they add them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
828 lines
27 KiB
Go
828 lines
27 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/discord"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/mail"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// UserLister enumerates login accounts for the interest backfill. Satisfied
|
|
// by *auth.UserRepository.
|
|
type UserLister interface {
|
|
List(ctx context.Context) ([]*models.User, error)
|
|
}
|
|
|
|
// ImageURLResolver presigns stored image paths into client-fetchable URLs.
|
|
// Satisfied by *catalog.DetailService.
|
|
type ImageURLResolver interface {
|
|
PresignImageURL(ctx context.Context, path, imageType, size string) string
|
|
}
|
|
|
|
// System bundles the user-facing release-notification services: availability
|
|
// detection, interest maintenance, fanout, inbox repositories, and websocket
|
|
// tickets. It is distinct from the operational Hub in hub.go.
|
|
type System struct {
|
|
Settings *Settings
|
|
Releases *ReleaseRepository
|
|
Interests *InterestRepository
|
|
Deliveries *DeliveryRepository
|
|
Preferences *PreferencesRepository
|
|
Detector *AvailabilityDetector
|
|
Interest *InterestUpdater
|
|
Fanout *FanoutWorker
|
|
Tickets TicketStore
|
|
// Webhooks is nil when no at-rest cipher is configured (webhook URLs are
|
|
// credentials and must not be stored in plaintext).
|
|
Webhooks *WebhookService
|
|
// ServerChannels is nil when no at-rest cipher is configured (channel
|
|
// URLs are credentials, same rule as webhooks).
|
|
ServerChannels *ServerChannelService
|
|
// WebPush is nil when the settings store is not writable (VAPID keys
|
|
// could not be provisioned).
|
|
WebPush *WebPushService
|
|
// EmailPrefs is nil when no mail sender was provided.
|
|
EmailPrefs *EmailPrefsRepository
|
|
// DiscordPrefs holds Discord DM link + mode state; the channel only
|
|
// delivers once an admin configures bot credentials in settings.
|
|
DiscordPrefs *DiscordPrefsRepository
|
|
|
|
mailSender mail.Sender
|
|
emailWorker *accountChannelWorker[string]
|
|
discordWorker *accountChannelWorker[int]
|
|
discordClient *discord.Client
|
|
// publicURL is the server's externally reachable base URL, used as the
|
|
// fallback for tokenized email links (see SetPublicURL).
|
|
publicURL string
|
|
|
|
webhookRepo *WebhookRepository
|
|
webhookDispatcher *WebhookDispatcher
|
|
webhookRetry *WebhookRetryWorker
|
|
webPushRepo *WebPushRepository
|
|
webPushDispatcher *WebPushDispatcher
|
|
// serverChannelWorker sweeps release_events into admin broadcast posts;
|
|
// nil without the at-rest cipher.
|
|
serverChannelWorker *serverChannelWorker
|
|
// dispatcher is the same MultiDispatcher the fanout worker uses; the
|
|
// operational dispatch path shares it so every delivery reaches every
|
|
// configured channel the same way.
|
|
dispatcher Dispatcher
|
|
|
|
pool *pgxpool.Pool
|
|
stores userstore.UserStoreProvider
|
|
users UserLister
|
|
images ImageURLResolver
|
|
logger *slog.Logger
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewSystem wires the notification system. hub may be nil (no realtime
|
|
// publishing); redisClient may be nil (in-memory websocket tickets);
|
|
// mailSender may be nil (no email channel).
|
|
func NewSystem(
|
|
pool *pgxpool.Pool,
|
|
settingsReader SettingReader,
|
|
stores userstore.UserStoreProvider,
|
|
scopes ScopeResolver,
|
|
users UserLister,
|
|
hub *evt.Hub,
|
|
redisClient *redis.Client,
|
|
cipher *secret.Cipher,
|
|
mailSender mail.Sender,
|
|
) *System {
|
|
settings := NewSettings(settingsReader)
|
|
releases := NewReleaseRepository(pool)
|
|
interests := NewInterestRepository(pool)
|
|
deliveries := NewDeliveryRepository(pool)
|
|
preferences := NewPreferencesRepository(pool)
|
|
|
|
wsDispatcher := NewWebsocketDispatcher(hub)
|
|
dispatchers := []Dispatcher{wsDispatcher}
|
|
|
|
// Outbound webhooks require the at-rest cipher: destination URLs are
|
|
// bearer credentials (Discord) and must never be stored in plaintext.
|
|
var webhookRepo *WebhookRepository
|
|
var webhookService *WebhookService
|
|
var webhookDispatcher *WebhookDispatcher
|
|
var webhookRetry *WebhookRetryWorker
|
|
var sender *webhookSender
|
|
if cipher != nil {
|
|
webhookRepo = NewWebhookRepository(pool)
|
|
sender = newWebhookSender(webhookRepo, deliveries, cipher, settings)
|
|
webhookService = newWebhookService(webhookRepo, cipher, settings, sender)
|
|
webhookDispatcher = newWebhookDispatcher(sender)
|
|
webhookRetry = newWebhookRetryWorker(sender)
|
|
dispatchers = append(dispatchers, webhookDispatcher)
|
|
}
|
|
|
|
// Admin server channels (broadcast destinations) share the cipher
|
|
// requirement: their URLs are credentials too.
|
|
var serverChannelService *ServerChannelService
|
|
var serverChannelSweep *serverChannelWorker
|
|
if cipher != nil {
|
|
serverChannelRepo := NewServerChannelRepository(pool)
|
|
serverChannelSenderInst := newServerChannelSender(cipher, settings)
|
|
serverChannelService = newServerChannelService(serverChannelRepo, cipher, settings, serverChannelSenderInst)
|
|
serverChannelSweep = newServerChannelWorker(pool, serverChannelRepo, releases, serverChannelSenderInst, settings)
|
|
}
|
|
|
|
// Web push needs a writable settings store to self-provision its VAPID
|
|
// keypair. The reader main passes is the encrypted settings repo, which
|
|
// also writes; tests may pass a read-only stub.
|
|
var webPushRepo *WebPushRepository
|
|
var webPushService *WebPushService
|
|
var webPushDispatcher *WebPushDispatcher
|
|
var webPushSenderInst *webPushSender
|
|
if writer, ok := settingsReader.(SettingWriter); ok && writer != nil {
|
|
webPushRepo = NewWebPushRepository(pool)
|
|
webPushService = newWebPushService(webPushRepo, settings, writer)
|
|
webPushSenderInst = newWebPushSender(webPushRepo, deliveries, webPushService, settings)
|
|
webPushDispatcher = newWebPushDispatcher(webPushSenderInst)
|
|
dispatchers = append(dispatchers, webPushDispatcher)
|
|
}
|
|
|
|
// Email rides the shared SMTP core. Unlike the per-target channels it
|
|
// keeps no outbox: its dispatcher only nudges the watermark sweep.
|
|
var emailPrefs *EmailPrefsRepository
|
|
var emailChannelInst *emailChannel
|
|
var emailWorker *accountChannelWorker[string]
|
|
if mailSender != nil {
|
|
emailPrefs = NewEmailPrefsRepository(pool)
|
|
emailChannelInst = &emailChannel{
|
|
prefs: emailPrefs,
|
|
deliveries: deliveries,
|
|
settings: settings,
|
|
sender: mailSender,
|
|
}
|
|
emailWorker = newAccountChannelWorker(pool, emailChannelInst)
|
|
dispatchers = append(dispatchers, newNudgeDispatcher(emailWorker))
|
|
}
|
|
|
|
// Discord DMs ride the same account-watermark engine as email. The
|
|
// channel is always wired (its credentials live in settings and may be
|
|
// configured at runtime); enabled() gates each pass on the bot token.
|
|
discordPrefs := NewDiscordPrefsRepository(pool)
|
|
discordClient := discord.NewClient()
|
|
discordWorker, discordChannelInst := newDiscordWorker(pool, deliveries, discordPrefs, settings, discordClient)
|
|
dispatchers = append(dispatchers, newNudgeDispatcher(discordWorker))
|
|
|
|
multiDispatcher := NewMultiDispatcher(dispatchers...)
|
|
fanout := NewFanoutWorker(pool, releases, interests, deliveries, preferences, settings, multiDispatcher)
|
|
if webhookRepo != nil {
|
|
fanout.SetWebhookOutbox(webhookRepo, newProfileRateLimiter())
|
|
}
|
|
if webPushRepo != nil {
|
|
fanout.SetWebPushOutbox(webPushRepo)
|
|
}
|
|
detector := NewAvailabilityDetector(releases, settings)
|
|
detector.SetFanoutNudge(func() {
|
|
fanout.Nudge()
|
|
serverChannelSweep.Nudge() // nil-safe
|
|
})
|
|
interest := NewInterestUpdater(pool, interests, stores, scopes)
|
|
|
|
system := &System{
|
|
Settings: settings,
|
|
Releases: releases,
|
|
Interests: interests,
|
|
Deliveries: deliveries,
|
|
Preferences: preferences,
|
|
Detector: detector,
|
|
Interest: interest,
|
|
Fanout: fanout,
|
|
Tickets: NewTicketStore(redisClient),
|
|
Webhooks: webhookService,
|
|
ServerChannels: serverChannelService,
|
|
WebPush: webPushService,
|
|
EmailPrefs: emailPrefs,
|
|
DiscordPrefs: discordPrefs,
|
|
mailSender: mailSender,
|
|
emailWorker: emailWorker,
|
|
discordWorker: discordWorker,
|
|
discordClient: discordClient,
|
|
webhookRepo: webhookRepo,
|
|
webhookDispatcher: webhookDispatcher,
|
|
webhookRetry: webhookRetry,
|
|
webPushRepo: webPushRepo,
|
|
webPushDispatcher: webPushDispatcher,
|
|
serverChannelWorker: serverChannelSweep,
|
|
dispatcher: multiDispatcher,
|
|
pool: pool,
|
|
stores: stores,
|
|
users: users,
|
|
logger: slog.Default().With("component", "notifications.system"),
|
|
}
|
|
wsDispatcher.payload = system.PayloadForRow
|
|
if emailChannelInst != nil {
|
|
emailChannelInst.profileName = system.lookupProfileName
|
|
}
|
|
if sender != nil {
|
|
sender.operational = system.DispatchOperational
|
|
sender.posterURL = system.discordPosterURL
|
|
}
|
|
discordChannelInst.posterURL = system.discordPosterURL
|
|
if serverChannelSweep != nil {
|
|
serverChannelSweep.posterURL = system.discordPosterURL
|
|
serverChannelSweep.requesterDiscordID = system.requesterDiscordID
|
|
}
|
|
if webPushSenderInst != nil {
|
|
webPushSenderInst.payload = system.PayloadForRow
|
|
}
|
|
return system
|
|
}
|
|
|
|
// SetImageResolver wires presigned poster URLs into notification payloads.
|
|
// Optional; without it clients fall back to thumbhash placeholders.
|
|
func (s *System) SetImageResolver(resolver ImageURLResolver) {
|
|
if s != nil {
|
|
s.images = resolver
|
|
}
|
|
}
|
|
|
|
// discordPosterURL resolves a delivery's poster to a URL a Discord embed may
|
|
// carry, honoring the admin's poster mode: nothing when posters are off,
|
|
// public provider CDN URLs when derivable, and — only on the explicit
|
|
// "server" opt-in — a presigned URL from this server's own image storage.
|
|
// Wired into the Discord send paths as their posterURL hook.
|
|
func (s *System) discordPosterURL(ctx context.Context, posterPath, posterSourcePath string) string {
|
|
mode := s.Settings.DiscordPosterMode(ctx)
|
|
if mode == DiscordPostersOff {
|
|
return ""
|
|
}
|
|
if url := embedPosterURL(posterPath, posterSourcePath); url != "" {
|
|
return url
|
|
}
|
|
if mode != DiscordPostersServer || s.images == nil || posterPath == "" {
|
|
return ""
|
|
}
|
|
return s.images.PresignImageURL(ctx, posterPath, "poster", "")
|
|
}
|
|
|
|
// PayloadForRow converts a row to its wire shape, attaching a presigned
|
|
// poster URL when an image resolver is configured.
|
|
func (s *System) PayloadForRow(ctx context.Context, row DeliveryRow) DeliveryRowPayload {
|
|
payload := PayloadForRow(row)
|
|
if s != nil && s.images != nil && row.PosterPath != "" {
|
|
payload.PosterURL = s.images.PresignImageURL(ctx, row.PosterPath, "poster", "")
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// PayloadsForRows converts rows to their wire shape with poster URLs.
|
|
func (s *System) PayloadsForRows(ctx context.Context, rows []DeliveryRow) []DeliveryRowPayload {
|
|
payloads := make([]DeliveryRowPayload, 0, len(rows))
|
|
for _, row := range rows {
|
|
payloads = append(payloads, s.PayloadForRow(ctx, row))
|
|
}
|
|
return payloads
|
|
}
|
|
|
|
// Start launches the fanout worker, interest updater, and (when configured)
|
|
// the webhook dispatch pool and retry worker under ctx.
|
|
func (s *System) Start(ctx context.Context) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.wg.Add(2)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.Fanout.Run(ctx)
|
|
}()
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.Interest.Run(ctx)
|
|
}()
|
|
if s.webhookDispatcher != nil {
|
|
s.wg.Add(2)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.webhookDispatcher.Run(ctx)
|
|
}()
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.webhookRetry.Run(ctx)
|
|
}()
|
|
}
|
|
if s.serverChannelWorker != nil {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.serverChannelWorker.Run(ctx)
|
|
}()
|
|
}
|
|
if s.emailWorker != nil {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.emailWorker.Run(ctx)
|
|
}()
|
|
}
|
|
if s.discordWorker != nil {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.discordWorker.Run(ctx)
|
|
}()
|
|
}
|
|
if s.webPushDispatcher != nil {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.webPushDispatcher.Run(ctx)
|
|
}()
|
|
// Provision the VAPID keypair eagerly so a broken settings store
|
|
// surfaces at startup instead of on the first subscribe, and the
|
|
// capability endpoint never pays the generation latency.
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
if _, err := s.WebPush.PublicKey(ctx); err != nil && ctx.Err() == nil {
|
|
s.logger.Error("web push VAPID provisioning failed", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Wait blocks until the background loops exit (after their context is
|
|
// canceled), so shutdown can drain in-flight work.
|
|
func (s *System) Wait() {
|
|
if s != nil {
|
|
s.wg.Wait()
|
|
}
|
|
}
|
|
|
|
// PurgeProfile removes all notification state for a deleted profile.
|
|
// Profiles may live in per-user SQLite stores, so Postgres cascades cannot
|
|
// cover this.
|
|
func (s *System) PurgeProfile(ctx context.Context, profileID string) error {
|
|
if s == nil || profileID == "" {
|
|
return nil
|
|
}
|
|
if err := s.Interests.DeleteAllForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge interest rows: %w", err)
|
|
}
|
|
if err := s.Deliveries.DeleteAllForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge deliveries: %w", err)
|
|
}
|
|
if err := s.Preferences.DeleteForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge preferences: %w", err)
|
|
}
|
|
if s.webhookRepo != nil {
|
|
if err := s.webhookRepo.DeleteAllForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge webhooks: %w", err)
|
|
}
|
|
}
|
|
if s.webPushRepo != nil {
|
|
if err := s.webPushRepo.DeleteAllForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge web push subscriptions: %w", err)
|
|
}
|
|
}
|
|
if s.EmailPrefs != nil {
|
|
if err := s.EmailPrefs.DeleteForProfile(ctx, profileID); err != nil {
|
|
return fmt.Errorf("purge email prefs: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SeedAvailability inserts episode_availability and movie_availability for
|
|
// every currently playable episode and movie without creating release events,
|
|
// then writes the per-library, per-kind seed markers. Idempotent and
|
|
// rerunnable; it exists for libraries that predate the notifications feature.
|
|
//
|
|
// Library selection is load-bearing for flood and loss safety:
|
|
// - already-seeded libraries are skipped: availability there is owned by
|
|
// the scan-end detector, and a silent (emitEvents=false) insert racing an
|
|
// in-flight scan would permanently suppress the claimed episode's release
|
|
// event;
|
|
// - never-fully-scanned libraries are skipped: their catalog is empty or
|
|
// partial, so seed-marking them now would make the first real scan emit
|
|
// release events for the entire back catalog. The detector writes their
|
|
// marker when that first full scan completes.
|
|
func (s *System) SeedAvailability(ctx context.Context, progress func(percent int, message string)) error {
|
|
report := func(percent int, message string) {
|
|
if progress != nil {
|
|
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 {
|
|
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},
|
|
}
|
|
for passIdx, pass := range passes {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT mf.id
|
|
FROM media_folders mf
|
|
WHERE mf.last_scanned_at IS NOT NULL
|
|
AND NOT EXISTS (`+pass.seedCondition+`)
|
|
ORDER BY mf.id`)
|
|
if err != nil {
|
|
return fmt.Errorf("list libraries: %w", err)
|
|
}
|
|
libraryIDs := make([]int, 0, 8)
|
|
for rows.Next() {
|
|
var id int
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("scan library id: %w", err)
|
|
}
|
|
libraryIDs = append(libraryIDs, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
totalSeeded := 0
|
|
for i, libraryID := range libraryIDs {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
inserted, _, err := pass.record(ctx, libraryID, false)
|
|
if err != nil {
|
|
return fmt.Errorf("seed library %d (%s): %w", libraryID, pass.kind, err)
|
|
}
|
|
if err := s.Releases.MarkContentSeeded(ctx, libraryID, pass.kind); err != nil {
|
|
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),
|
|
fmt.Sprintf("Seeded library %d %s availability (%d new rows)", libraryID, pass.kind, inserted))
|
|
}
|
|
s.logger.Info("availability seeding completed",
|
|
"kind", pass.kind, "libraries", len(libraryIDs), "availability_rows", totalSeeded)
|
|
}
|
|
report(100, "Availability seeding completed")
|
|
return nil
|
|
}
|
|
|
|
const interestRebuildTask = "interest_rebuild"
|
|
|
|
// RebuildInterest incrementally rebuilds profile_series_interest from
|
|
// favorites, watchlist, and watch progress for every profile. Checkpointed
|
|
// per profile so a crash resumes with at most one profile of repeated work;
|
|
// recomputes are idempotent upserts. Completed runs reset and start over
|
|
// (the task doubles as periodic drift repair).
|
|
func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, message string)) error {
|
|
report := func(percent int, message string) {
|
|
if progress != nil {
|
|
progress(percent, message)
|
|
}
|
|
}
|
|
if s.users == nil {
|
|
return fmt.Errorf("interest rebuild requires a user lister")
|
|
}
|
|
|
|
checkpoint, completedAt, err := s.loadBackfillCheckpoint(ctx, interestRebuildTask)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if completedAt != nil {
|
|
// Start a fresh repair pass.
|
|
checkpoint = ""
|
|
if err := s.resetBackfillCheckpoint(ctx, interestRebuildTask); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
users, err := s.users.List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list users: %w", err)
|
|
}
|
|
sort.Slice(users, func(i, j int) bool { return users[i].ID < users[j].ID })
|
|
|
|
processed := 0
|
|
for userIdx, user := range users {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
store, err := s.stores.ForUser(ctx, user.ID)
|
|
if err != nil {
|
|
s.logger.Warn("interest rebuild: open user store failed", "user_id", user.ID, "error", err)
|
|
continue
|
|
}
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
s.logger.Warn("interest rebuild: list profiles failed", "user_id", user.ID, "error", err)
|
|
continue
|
|
}
|
|
sort.Slice(profiles, func(i, j int) bool { return profiles[i].ID < profiles[j].ID })
|
|
|
|
for _, profile := range profiles {
|
|
key := backfillKey(user.ID, profile.ID)
|
|
if checkpoint != "" && key <= checkpoint {
|
|
continue
|
|
}
|
|
if err := s.rebuildProfileInterest(ctx, store, user.ID, profile.ID); err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
s.logger.Warn("interest rebuild: profile failed",
|
|
"user_id", user.ID, "profile_id", profile.ID, "error", err)
|
|
}
|
|
if err := s.saveBackfillCheckpoint(ctx, interestRebuildTask, key); err != nil {
|
|
return err
|
|
}
|
|
processed++
|
|
}
|
|
report((userIdx+1)*100/max(len(users), 1),
|
|
fmt.Sprintf("Rebuilt interest for %d profiles", processed))
|
|
}
|
|
|
|
if err := s.completeBackfillCheckpoint(ctx, interestRebuildTask); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("interest rebuild completed", "profiles", processed)
|
|
return nil
|
|
}
|
|
|
|
// rebuildProfileInterest recomputes every series the profile has any
|
|
// relationship with (favorites, watchlist, progress, completed watch
|
|
// history), plus every series that already has interest rows in Postgres —
|
|
// rows whose sources were all removed must be recomputed too, or the
|
|
// drift-repair pass would keep notifying about unfollowed shows forever.
|
|
func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.UserStore, userID int, profileID string) error {
|
|
const pageSize = 500
|
|
itemIDs := make(map[string]struct{}, 64)
|
|
|
|
for offset := 0; ; offset += pageSize {
|
|
favorites, err := store.ListFavorites(ctx, profileID, pageSize, offset)
|
|
if err != nil {
|
|
return fmt.Errorf("list favorites: %w", err)
|
|
}
|
|
for _, favorite := range favorites {
|
|
itemIDs[favorite.MediaItemID] = struct{}{}
|
|
}
|
|
if len(favorites) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
for offset := 0; ; offset += pageSize {
|
|
watchlist, err := store.ListWatchlist(ctx, profileID, pageSize, offset)
|
|
if err != nil {
|
|
return fmt.Errorf("list watchlist: %w", err)
|
|
}
|
|
for _, entry := range watchlist {
|
|
itemIDs[entry.MediaItemID] = struct{}{}
|
|
}
|
|
if len(watchlist) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
for offset := 0; ; offset += pageSize {
|
|
progress, err := store.ListProgress(ctx, profileID, "", pageSize, offset)
|
|
if err != nil {
|
|
return fmt.Errorf("list progress: %w", err)
|
|
}
|
|
for _, entry := range progress {
|
|
itemIDs[entry.MediaItemID] = struct{}{}
|
|
}
|
|
if len(progress) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
// History imports (watch providers, history import runs) may record
|
|
// watched episodes without any progress row; they are watch relationships
|
|
// all the same.
|
|
for offset := 0; ; offset += pageSize {
|
|
history, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
|
|
ProfileID: profileID,
|
|
Limit: pageSize,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list completed history: %w", err)
|
|
}
|
|
for _, entry := range history {
|
|
itemIDs[entry.MediaItemID] = struct{}{}
|
|
}
|
|
if len(history) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
seriesIDs, err := s.batchResolveSeries(ctx, itemIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := s.Interests.ListSeriesForProfile(ctx, profileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, seriesID := range existing {
|
|
seriesIDs[seriesID] = struct{}{}
|
|
}
|
|
for seriesID := range seriesIDs {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err := s.Interest.RecomputeSeries(ctx, userID, profileID, seriesID); err != nil {
|
|
s.logger.Warn("interest rebuild: series recompute failed",
|
|
"user_id", userID, "profile_id", profileID, "series_id", seriesID, "error", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// batchResolveSeries maps item IDs (episodes, seasons, series) to their
|
|
// series IDs; movies resolve to nothing.
|
|
func (s *System) batchResolveSeries(ctx context.Context, itemIDs map[string]struct{}) (map[string]struct{}, error) {
|
|
ids := make([]string, 0, len(itemIDs))
|
|
for id := range itemIDs {
|
|
ids = append(ids, id)
|
|
}
|
|
seriesIDs := make(map[string]struct{}, len(ids))
|
|
const chunkSize = 500
|
|
for start := 0; start < len(ids); start += chunkSize {
|
|
end := min(start+chunkSize, len(ids))
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT series_id FROM episodes WHERE content_id = ANY($1)
|
|
UNION
|
|
SELECT series_id FROM seasons WHERE content_id = ANY($1)
|
|
UNION
|
|
SELECT content_id FROM media_items WHERE content_id = ANY($1) AND type = 'series'`,
|
|
ids[start:end])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve series ids: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var seriesID string
|
|
if err := rows.Scan(&seriesID); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan series id: %w", err)
|
|
}
|
|
if seriesID != "" {
|
|
seriesIDs[seriesID] = struct{}{}
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return seriesIDs, nil
|
|
}
|
|
|
|
// RetentionStats reports what a retention pass removed.
|
|
type RetentionStats struct {
|
|
DeliveriesDeleted int64
|
|
EventsDeleted int64
|
|
StaleEventsDeleted int64
|
|
InterestPruned int64
|
|
WebhookAttemptsDeleted int64
|
|
WebPushAttemptsDeleted int64
|
|
DiscordLinkStatesDeleted int64
|
|
}
|
|
|
|
// RunRetention applies the retention policy: read deliveries past the read
|
|
// window, unread past the unread window, processed release events past the
|
|
// debug window, unprocessed release events past the fanout staleness horizon,
|
|
// and inert interest rows.
|
|
func (s *System) RunRetention(ctx context.Context) (RetentionStats, error) {
|
|
var stats RetentionStats
|
|
now := time.Now().UTC()
|
|
readCutoff := now.AddDate(0, 0, -s.Settings.ReadRetentionDays(ctx))
|
|
unreadCutoff := now.AddDate(0, 0, -s.Settings.UnreadRetentionDays(ctx))
|
|
eventCutoff := now.AddDate(0, 0, -s.Settings.EventRetentionDays(ctx))
|
|
|
|
deleted, err := s.Deliveries.DeleteOld(ctx, readCutoff, unreadCutoff)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune deliveries: %w", err)
|
|
}
|
|
stats.DeliveriesDeleted = deleted
|
|
|
|
events, err := s.Releases.DeleteProcessedBefore(ctx, eventCutoff)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune release events: %w", err)
|
|
}
|
|
stats.EventsDeleted = events
|
|
|
|
// Unprocessed events accumulate without bound when fanout is disabled
|
|
// while availability detection keeps emitting; the fanout worker would
|
|
// suppress them as stale anyway, so retention reclaims them directly.
|
|
staleEvents, err := s.Releases.DeleteUnprocessedBefore(ctx, now.Add(-s.Settings.MaxEventAge(ctx)))
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune stale release events: %w", err)
|
|
}
|
|
stats.StaleEventsDeleted = staleEvents
|
|
|
|
pruned, err := s.Interests.PruneInert(ctx)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune interest rows: %w", err)
|
|
}
|
|
stats.InterestPruned = pruned
|
|
|
|
if s.webhookRepo != nil {
|
|
attempts, err := s.webhookRepo.DeleteOldAttempts(ctx, now)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune webhook attempts: %w", err)
|
|
}
|
|
stats.WebhookAttemptsDeleted = attempts
|
|
}
|
|
if s.webPushRepo != nil {
|
|
attempts, err := s.webPushRepo.DeleteOldAttempts(ctx, now)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune web push attempts: %w", err)
|
|
}
|
|
stats.WebPushAttemptsDeleted = attempts
|
|
}
|
|
if s.DiscordPrefs != nil {
|
|
states, err := s.DiscordPrefs.DeleteExpiredLinkStates(ctx)
|
|
if err != nil {
|
|
return stats, fmt.Errorf("prune discord link states: %w", err)
|
|
}
|
|
stats.DiscordLinkStatesDeleted = states
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func backfillKey(userID int, profileID string) string {
|
|
return fmt.Sprintf("%010d|%s", userID, profileID)
|
|
}
|
|
|
|
func (s *System) loadBackfillCheckpoint(ctx context.Context, task string) (string, *time.Time, error) {
|
|
var key *string
|
|
var completedAt *time.Time
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT last_processed_key, completed_at FROM notification_backfill_state WHERE task = $1`,
|
|
task,
|
|
).Scan(&key, &completedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil, nil
|
|
}
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("load backfill checkpoint: %w", err)
|
|
}
|
|
checkpoint := ""
|
|
if key != nil {
|
|
checkpoint = *key
|
|
}
|
|
return checkpoint, completedAt, nil
|
|
}
|
|
|
|
func (s *System) saveBackfillCheckpoint(ctx context.Context, task, key string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at)
|
|
VALUES ($1, $2, now(), now())
|
|
ON CONFLICT (task) DO UPDATE SET
|
|
last_processed_key = EXCLUDED.last_processed_key,
|
|
updated_at = now()`,
|
|
task, key)
|
|
if err != nil {
|
|
return fmt.Errorf("save backfill checkpoint: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *System) resetBackfillCheckpoint(ctx context.Context, task string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO notification_backfill_state (task, last_processed_key, started_at, updated_at, completed_at)
|
|
VALUES ($1, NULL, now(), now(), NULL)
|
|
ON CONFLICT (task) DO UPDATE SET
|
|
last_processed_key = NULL,
|
|
started_at = now(),
|
|
updated_at = now(),
|
|
completed_at = NULL`,
|
|
task)
|
|
if err != nil {
|
|
return fmt.Errorf("reset backfill checkpoint: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *System) completeBackfillCheckpoint(ctx context.Context, task string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE notification_backfill_state SET completed_at = now(), updated_at = now() WHERE task = $1`,
|
|
task)
|
|
if err != nil {
|
|
return fmt.Errorf("complete backfill checkpoint: %w", err)
|
|
}
|
|
return nil
|
|
}
|