Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).
- Extend release_events with a kind discriminator and add a movie
availability spine (movie_availability + kind-keyed
notification_content_seed_state; first full scan seeds silently so
upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
grouping, per-channel backoff, and auto-disable; request events post
best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
SSRF guard, embed limits, HMAC signing; shared type/name validation
extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
Channels section in the notifications admin settings UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
118 lines
5.5 KiB
SQL
118 lines
5.5 KiB
SQL
-- +goose Up
|
|
-- +goose StatementBegin
|
|
-- Admin server notification channels ("community channels") + movie event
|
|
-- spine. Server channels are admin-owned outbound webhooks fed straight from
|
|
-- release_events via a per-channel watermark sweep (no per-profile fanout),
|
|
-- announcing newly added movies/episodes and media request lifecycle events.
|
|
|
|
-- 1) release_events gains a kind discriminator so movie events share the
|
|
-- table (the dedupe_key column was designed for exactly this). Episode rows
|
|
-- keep their shape; movie rows carry item_id (media_items.content_id) with
|
|
-- dedupe keys in a distinct "movie:{library_id}:{item_id}" keyspace.
|
|
ALTER TABLE public.release_events
|
|
ADD COLUMN kind text NOT NULL DEFAULT 'episode',
|
|
ADD COLUMN item_id text;
|
|
ALTER TABLE public.release_events
|
|
ALTER COLUMN series_id DROP NOT NULL,
|
|
ALTER COLUMN episode_id DROP NOT NULL,
|
|
ALTER COLUMN season_number DROP NOT NULL,
|
|
ALTER COLUMN episode_number DROP NOT NULL,
|
|
ALTER COLUMN episode_key DROP NOT NULL;
|
|
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)
|
|
);
|
|
|
|
-- Server-channel sweep cursor: strictly increasing (created_at, id) walk.
|
|
CREATE INDEX release_events_sweep_idx
|
|
ON public.release_events (created_at, id);
|
|
|
|
-- 2) Movie availability facts: one-way "movie first became available in this
|
|
-- library" rows, mirror of episode_availability. Persist across file churn so
|
|
-- re-added files never re-notify.
|
|
CREATE TABLE public.movie_availability (
|
|
library_id integer NOT NULL,
|
|
item_id text NOT NULL,
|
|
available_at timestamptz NOT NULL DEFAULT now(),
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT movie_availability_pkey PRIMARY KEY (library_id, item_id)
|
|
);
|
|
|
|
-- 3) Per-kind seed markers for every content kind after episodes (movies
|
|
-- now; audiobooks/music later need only a new kind value). Deliberately
|
|
-- separate from notification_library_seed_state: that table was already
|
|
-- seed-marked for every scanned library (including movie libraries) by the
|
|
-- episode-only seeding pass, with zero movie availability rows. Reusing it
|
|
-- would emit release events for the entire movie back catalog on the first
|
|
-- post-upgrade scan. A kind seeds silently until a row lands here.
|
|
CREATE TABLE public.notification_content_seed_state (
|
|
library_id integer NOT NULL REFERENCES public.media_folders(id) ON DELETE CASCADE,
|
|
kind text NOT NULL,
|
|
seeded_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT notification_content_seed_state_pkey PRIMARY KEY (library_id, kind)
|
|
);
|
|
|
|
-- 4) Admin server channels. url/signing_secret ciphertexts hold enc:v1:
|
|
-- envelopes (internal/secret) with channel-scoped AADs distinct from the
|
|
-- profile webhook AAD namespace. created_by_user_id has no FK by convention
|
|
-- (informational only). The watermark defaults to creation time so a new
|
|
-- channel never replays history; re-enabling a disabled channel fast-forwards
|
|
-- it to now() in code.
|
|
CREATE TABLE public.notification_server_channels (
|
|
id text PRIMARY KEY,
|
|
name varchar(64) NOT NULL,
|
|
type text NOT NULL,
|
|
url_ciphertext text NOT NULL,
|
|
url_host varchar(253) NOT NULL,
|
|
signing_secret_ciphertext text,
|
|
enabled boolean NOT NULL DEFAULT true,
|
|
notify_new_movies boolean NOT NULL DEFAULT true,
|
|
notify_new_episodes boolean NOT NULL DEFAULT true,
|
|
notify_request_submitted boolean NOT NULL DEFAULT false,
|
|
notify_request_approved boolean NOT NULL DEFAULT false,
|
|
notify_request_declined boolean NOT NULL DEFAULT false,
|
|
notify_request_fulfilled boolean NOT NULL DEFAULT false,
|
|
watermark_created_at timestamptz NOT NULL DEFAULT now(),
|
|
watermark_id text NOT NULL DEFAULT '',
|
|
last_attempt_at timestamptz,
|
|
consecutive_failures integer NOT NULL DEFAULT 0,
|
|
disabled_reason varchar(256),
|
|
last_success_at timestamptz,
|
|
last_failure_at timestamptz,
|
|
last_failure_status integer,
|
|
last_failure_message varchar(256),
|
|
created_by_user_id integer NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT notification_server_channels_name_key UNIQUE (name),
|
|
CONSTRAINT notification_server_channels_type_check CHECK (type IN ('discord', 'generic')),
|
|
CONSTRAINT notification_server_channels_secret_check
|
|
CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL)
|
|
);
|
|
-- +goose StatementEnd
|
|
|
|
-- +goose Down
|
|
-- +goose StatementBegin
|
|
DROP TABLE IF EXISTS public.notification_server_channels;
|
|
DROP TABLE IF EXISTS public.notification_content_seed_state;
|
|
DROP TABLE IF EXISTS public.movie_availability;
|
|
DROP INDEX IF EXISTS public.release_events_sweep_idx;
|
|
DELETE FROM public.release_events WHERE kind <> 'episode';
|
|
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
|
|
ALTER COLUMN series_id SET NOT NULL,
|
|
ALTER COLUMN episode_id SET NOT NULL,
|
|
ALTER COLUMN season_number SET NOT NULL,
|
|
ALTER COLUMN episode_number SET NOT NULL,
|
|
ALTER COLUMN episode_key SET NOT NULL;
|
|
ALTER TABLE public.release_events
|
|
DROP COLUMN IF EXISTS item_id,
|
|
DROP COLUMN IF EXISTS kind;
|
|
-- +goose StatementEnd
|