Monitors YouTube, Twitch, and Kick channels and sends Pushover notifications when they go live or offline. Optionally runs AI-powered briefing agents that search the web on a schedule and send notification summaries.
services:
omni-notify:
image: ghcr.io/micthiesen/omni-notify:latest
environment:
- PUSHOVER_TOKEN=xxx
- PUSHOVER_USER=xxx
- KICK_CLIENT_ID=xxx # only if monitoring Kick channels
- KICK_CLIENT_SECRET=xxx
volumes:
- ./channels.json:/app/channels.json:ro
restart: unless-stoppedChannels are configured in channels.json (path overridable via CHANNELS_CONFIG_PATH), keyed by display name, with platform usernames and per-streamer options inline:
{
"MKBHD": { "youtube": "@mkbhd" },
"Destiny": { "youtube": "@destiny", "kick": "destiny" },
"Shroud": { "twitch": "shroud", "tier": "background" }
}Platform fields are youtube / twitch / kick; each takes one username or an array of them. An invalid file fails startup with a validation error rather than silently dropping config.
One entry = one streamer. A streamer live on multiple platforms gets one "went live" notification when they start streaming anywhere and one "went offline" notification when all platforms go offline — no double-pings for multistreams.
Checks every 20 seconds (with random jitter) whether monitored channels are live. Sends a notification on aggregate status transitions (offline-everywhere → live-anywhere, or live-anywhere → offline-everywhere). State is persisted in SQLite so it survives restarts.
- YouTube: Scrapes the channel's
/livepage HTML. No API key needed, but could break if YouTube changes its page structure. - Twitch: Uses Twitch's public GraphQL API. No authentication required. More stable than YouTube scraping.
- Kick: Uses Kick's official public API (
api.kick.com/public/v1/channels). Requires registering an app at dev.kick.com and providingKICK_CLIENT_ID+KICK_CLIENT_SECRET(scope:channel:read). The app-only access token is cached and refreshed automatically.
Primary binding. When a streamer is live on multiple platforms, one binding is chosen as the "primary" for the notification URL and title. The first platform to go live wins, and sticks for the rest of the session. If they go live simultaneously, priority order is YouTube → Twitch → Kick.
Set OFFLINE_NOTIFICATIONS=false to only get notified when channels go live.
Alongside the platform fields, each channels.json entry accepts:
pushoverToken: override the Pushover token for this streamer's notifications.tier: set to"background"for second-tier streamers you check on the dashboard but never want pushed about. Mutes live/offline/title-change notifications, restricts viewer-record notifications to all-time highs only, and polls at a relaxed cadence (every 60s instead of 20s). Tracking, the dashboard, and/api/trigger-channelsare unaffected. Combining it with an explicitliveNotificationsis a config error.liveNotifications: set tofalseto mute live/offline/title-change notifications while keeping full-rate polling (e.g. for external integrations that need fast state). Viewer-record notifications (7d/30d/90d/all-time highs) still fire.
Title-change notifications are eagerly debounced: the first change fires immediately, further changes within 10 minutes are held with the last one winning.
AI agents that search the web on a schedule and send notification summaries. Requires TAVILY_API_KEY, BRIEFINGS_PATH, and an API key for your chosen model provider.
Create .md files in your briefings folder:
---
schedule: "0 0 8 * * *"
---
You are a morning news assistant. Today is {{date}}, {{time}}.
{{history:10}}
Search for the most important news from the past 24 hours.
Do not cover topics from past notifications above.- Filename becomes the task name (
CanadianNews.mdregisters as "CanadianNews") scheduleis a 6-field node-cron expression (with seconds)- The body is the prompt sent to the AI agent
| Placeholder | Description | Example |
|---|---|---|
{{date}} |
Current date (local timezone) | Thursday, February 6, 2026 |
{{time}} |
Current time (local timezone) | 9:00 AM EST |
{{history:N}} |
Last N notifications from this briefing | (titles + URLs) |
History is stored per-briefing in SQLite and auto-pruned to the last 50 entries.
A scheduled pipeline (default: Mon/Wed/Fri at 5pm) that picks at most one movie or TV title per run, acquires missing titles through Radarr or Sonarr, and sends a Pushover notification explaining the pick. Titles already available in Plex are recommended without another acquisition request.
Each run:
- Polls Plex history, series-level progress, local availability, and the Radarr/Sonarr tracked catalog. It labels passive outcomes (started, watched, abandoned, ignored) and incorporates explicit "good pick" or "not for me" feedback.
- Builds a candidate pool from TMDB (recommendations seeded by recent watches, genre discovery, trending, plus a novelty bucket outside your usual genres).
- Hard-filters in code: anything watched, in progress, tracked by Radarr/Sonarr, explicitly rejected, or recently recommended is dropped before a model sees it.
- Enriches candidates with structured TMDB commitment and creative metadata, then scores the pool with a cheap model (
RECS_SHORTLIST_MODEL) and keeps the top 5. - Researches the finalists with web search, then a strong model (
RECS_SELECTION_MODEL) picks exactly one title or decides to add nothing that day.
Plex is the source of watch history, in-progress state, and local availability. Radarr handles movie acquisition and Sonarr handles TV acquisition. All reads fail closed: an unavailable service skips the run instead of treating missing state as an empty library.
A separate weekly TasteReflection task maintains a versioned taste profile. It converts Plex observations and recommendation outcomes into an idempotent evidence ledger, computes behavioral statistics, and performs a bounded draft-and-critic reflection. Every learned claim must cite stored evidence. The latest profile is added to recommendation context and shown in the UI; code, prompts, and scoring rules are never self-modified. If no evidence changed, reflection exits without a model call.
See the recommendation review checkpoint for the decisions intentionally deferred until enough real recommendations have outcomes.
A sibling pipeline (default: Mon/Wed/Fri at 11am, enabled by setting PODCAST_TASTE_PATH) that recommends fresh podcast episodes from shows you don't already follow, and sends a Pushover notification per pick. It's people-first: the main goal is surfacing episodes where a voice you follow guests somewhere new (see docs/podcast-recs.md).
Each run:
- Reads subscribed shows and listen history from the Castro account (see docs/castro-sync.md). Subscribed shows are excluded and double as taste evidence alongside the seed profile and explicit feedback; a failed account read aborts the run. Without Castro credentials it runs off the seed profile and feedback alone.
- Tier 1 — guest appearances. For a rotating batch of the people in your profile's
## Voiceslist, finds recent episodes featuring them as guests via Podcast Index (byperson, free) with a Tavily person-search fallback for non-podcasters. A model gate default-includes them (following the person is the signal), capped so a press-tour week can surface several. - Tier 2 — topic/drama. Multi-angle web search → cheap shortlist → strong-model one-pick, conservative and suppressed once Tier 1 delivered enough.
- Verifies every candidate's release date against the show's actual RSS feed (or Podcast Index's resolved data), and hard-filters in code: older than 7 days, already recommended, show on 30-day cooldown, rejected, or subscribed.
Recommended episodes are never repeated. When Castro credentials are set, listen history labels outcomes (listened / abandoned / ignored) automatically and each selected episode is resolved by RSS URL and added to the end of the Castro queue before notification. Resolution or enqueue failure falls back safely to the recommendation deep link. The good-pick/not-for-me feedback buttons in the web UI are always available.
The same Castro credentials enable an independent CastroInboxCleanup task.
It runs hourly and silently clears Inbox episodes whose description begins with
This is a free preview, the standard marker used by Substack preview
episodes. It changes only the Inbox is_new state and never removes an episode
from the Queue. Matching is deliberately case-sensitive and prefix-only.
Enabled by setting PRESSPODS_AUTH_TOKEN (plus a Google API key and a TTS backend — either a self-hosted Higgs server via PRESSPODS_TTS_URL, or ELEVENLABS_API_KEY with PRESSPODS_TTS_PROVIDER=elevenlabs). Submit an article URL — from an iOS Shortcut via POST /pods/episodes?authToken=… with { "url": "…" }, or from the /pods page — and it becomes an episode in a private podcast RSS feed (GET /pods/rss?authToken=…) your podcast app subscribes to.
Each submission becomes a durable job (visible on /pods, with retries and per-run logs) processed by the PressPods task:
- Seven article retrievers run in parallel (Postlight, Readability, Extractus, Wayback, removepaywall, raw fetch, and Jina.ai when
JINA_API_KEYis set); an LLM rates each result's extraction quality and the best wins. - A broadcast-style cleaning pass rewrites the article for the ear (one-idea sentences, attribution-first quotes, number rounding, a cold-open hook and a spoken outro) and marks major sections for chapters.
- The configured TTS backend (self-hosted Higgs v3, or ElevenLabs v3) synthesizes each chunk separately, with a length-verify retry for the local model; ffmpeg denoises (Higgs), per-chunk-levels, two-pass loudness-normalizes to -16 LUFS, and joins the intro jingle click-free; the lead image and chapter markers are embedded as ID3 tags.
- The MP3 is stored on disk (next to the SQLite DB by default) and served at
/pods/audio/<id>.mp3with an unguessable content-addressed name; a Pushover notification announces the episode.
Transient failures (TTS 429/5xx, network blips) retry automatically with backoff; permanent failures surface on /pods with a retry button. Submitted articles are also bookmarked in Karakeep when KARAKEEP_URL/KARAKEEP_API_KEY are set. To expose the feed publicly, reverse-proxy just the /pods/* paths and set PRESSPODS_PUBLIC_URL to the public origin (or let it derive from X-Forwarded-* headers).
The built-in server (port FRONTEND_PORT, default 3000) serves the Omni Notify dashboard:
/shows live streamer status (who's live now, title, uptime, peak viewers), a stat strip, every scheduled task with its cron schedule, ticking next-run countdown, "Run now" button and expandable run history, plus a recent-activity feed with per-task filtering./petsis the pet weight tracker./recommendationslists every recommendation with poster, status, reasoning, service links, explicit feedback controls, filters, the current evidence-backed taste profile, and recent pipeline activity./podcastslists podcast episode recommendations with show artwork, status filters, episode/discussion links, good-pick/not-for-me feedback controls, and the podcast taste profile./feedback/recommendations/:idand/feedback/podcasts/:idare mobile-first one-tap rating pages. Pushover recommendation notifications deep-link here ("Rate this pick"), and the page links onward to the full recommendation view./podslists PressPods episodes with an inline player, costs, and processing logs, plus a submit-URL form and retry controls for failed jobs./briefingsis a browsable archive of briefing notifications (the last 50 stored per briefing)./emailsshows what the parcel and calendar email pipelines did with each email (why it was admitted or filtered, per-item results, honest processed/partial/failed outcomes) with per-email processing logs, one-click reprocess, block-sender and not-relevant/missed feedback actions, a forget-tracking-number escape hatch, and a user-editable sender-rules section. A shared LLM triage call gates both pipelines; corrections feed back into its prompt.
Updates are pushed in realtime over SSE (/api/events) on the same HTTP port — no extra ports needed; the UI falls back to polling /api/snapshot (and shows a "Reconnecting" badge) if the stream drops. Task runs are persisted in SQLite (last 50 per task) so history survives restarts.
To iterate on the frontend without real credentials, src/tools/preview-server.ts boots the real server with fake tasks, streamers, runs, recommendations, and a pet:
DB_NAME=/tmp/omni-preview.db FRONTEND_PORT=3999 npx tsx src/tools/preview-server.tsModels are configured via environment variables using provider:model format. Supported providers: google, anthropic, openai. You only need an API key for the provider you're using.
| Variable | Default | Used for |
|---|---|---|
BRIEFING_MODEL |
openai:gpt-5.6-luna |
Briefing agents |
EXTRACTION_MODEL |
openai:gpt-5.6-luna |
Parcel email extraction |
CALENDAR_EXTRACTION_MODEL |
openai:gpt-5.6-terra |
Calendar email extraction |
TRIAGE_MODEL |
openai:gpt-5.6-luna |
Shared email relevance triage |
RECS_SHORTLIST_MODEL |
openai:gpt-5.6-luna |
Recommendation shortlist scoring |
RECS_SELECTION_MODEL |
openai:gpt-5.6 |
Recommendation research + final pick |
TASTE_REFLECTION_MODEL |
openai:gpt-5.6-luna |
Weekly evidence-backed taste reflection |
PRESSPODS_METADATA_MODEL |
openai:gpt-5.6-luna |
PressPods per-retriever metadata + rating |
PRESSPODS_CLEANING_MODEL |
openai:gpt-5.6-terra |
PressPods narration rewrite |
Examples:
BRIEFING_MODEL=openai:gpt-5.6-luna
BRIEFING_MODEL=anthropic:claude-sonnet-5
BRIEFING_MODEL=google:gemini-3.5-flash| Variable | Required | Description |
|---|---|---|
PUSHOVER_USER |
Yes | Pushover user key |
PUSHOVER_TOKEN |
Yes | Pushover app token |
KICK_CLIENT_ID |
No | Kick OAuth client ID (dev.kick.com); required when channels.json has Kick channels |
KICK_CLIENT_SECRET |
No | Kick OAuth client secret |
OFFLINE_NOTIFICATIONS |
No | Send offline notifications (default: true) |
BRIEFING_MODEL |
No | AI model for briefings (default: openai:gpt-5.6-luna) |
EXTRACTION_MODEL |
No | AI model for parcel email extraction (default: openai:gpt-5.6-luna) |
CALENDAR_EXTRACTION_MODEL |
No | AI model for calendar email extraction (default: openai:gpt-5.6-terra) |
TRIAGE_MODEL |
No | AI model for shared email triage (default: openai:gpt-5.6-luna) |
EMAIL_TRANSPORT |
No | Email transport: fastmail (JMAP) or icloud (IMAP). Defaults to fastmail while FASTMAIL_API_TOKEN is set, else icloud when its credentials exist. |
CALDAV_PROVIDER |
No | CalDAV backend for calendar-event writes: fastmail or icloud (default: follows EMAIL_TRANSPORT) |
EMAIL_SELF_ADDRESS |
No | Own receiving address for self-sent-mail filtering (default: FASTMAIL_USERNAME) |
FASTMAIL_API_TOKEN |
No | Fastmail JMAP API token (enables the Fastmail email transport) |
FASTMAIL_APP_PASSWORD / FASTMAIL_USERNAME |
No | Fastmail CalDAV credentials |
FASTMAIL_CALENDAR_ID |
No | Pins the Fastmail calendar (default: auto-discover) |
ICLOUD_USERNAME |
No | iCloud primary username (user@icloud.com, not the custom-domain address); IMAP + CalDAV |
ICLOUD_APP_PASSWORD |
No | iCloud app-specific password (IMAP + CalDAV) |
ICLOUD_CALENDAR_NAME |
No | Display name of the iCloud calendar to write to (e.g. Personal) |
ICLOUD_CALENDAR_URL |
No | Pins the iCloud calendar collection URL (default: RFC 6764 discovery) |
GOOGLE_GENERATIVE_AI_API_KEY |
No | Required for google: models |
ANTHROPIC_API_KEY |
No | Required for anthropic: models |
OPENAI_API_KEY |
No | Required for openai: models |
TAVILY_API_KEY |
No | Tavily web search (required for briefings + recommendations) |
BRIEFINGS_PATH |
No | Folder containing .md briefing configs |
CHANNELS_CONFIG_PATH |
No | Path to channels.json (default: ./channels.json) |
TMDB_API_KEY |
No | TMDB API key (required for recommendations; v3 key or v4 read token) |
RECS_SCHEDULE |
No | Recommendation cron (default: 0 0 17 * * 1,3,5) |
TASTE_REFLECTION_MODEL |
No | Model for evidence-backed taste reflection (default: openai:gpt-5.6-luna) |
TASTE_REFLECTION_SCHEDULE |
No | Taste-profile reflection cron (default: 0 0 4 * * 0, Sunday 4am) |
RECS_PUBLIC_URL |
No | Public/LAN Omni base URL used by notification links (default: http://omni.boris) |
PUSHOVER_RECS_TOKEN |
No | Pushover token for recommendations (falls back to PUSHOVER_TOKEN) |
PODCAST_TASTE_PATH |
No | Markdown listener profile (required to enable podcast recommendations) |
PODCAST_RECS_SCHEDULE |
No | Podcast recommendation cron (default: 0 0 11 * * 1,3,5) |
PUSHOVER_PODCAST_TOKEN |
No | Pushover token for podcast recs (falls back to PUSHOVER_TOKEN) |
CASTRO_ACCESS_ID / CASTRO_SECRET_KEY |
No | Castro device credentials (account reads, queue writes, and hourly Inbox cleanup) |
PODCASTINDEX_KEY / PODCASTINDEX_SECRET |
No | Podcast Index API (guest-appearance discovery; quote the secret — it contains #) |
PODCAST_VOICE_ROTATION_MAX / PODCAST_MAX_GUEST_PICKS |
No | Voices searched per run (default 12) / Tier-1 guest cap (default 6) |
PODCAST_TASTE_REFLECTION_MODEL |
No | Model for weekly podcast taste reflection (default: openai:gpt-5.6-luna) |
PODCAST_TASTE_REFLECTION_SCHEDULE |
No | Podcast taste reflection cron (default: 0 0 5 * * 0, Sunday 5am) |
PRESSPODS_AUTH_TOKEN |
No | Long random secret; enables PressPods and authenticates /pods/episodes + /pods/rss |
PRESSPODS_TTS_PROVIDER |
No | TTS backend: higgs (self-hosted, default) or elevenlabs |
PRESSPODS_TTS_URL |
For higgs | mlx-audio server URL, e.g. http://10.10.1.90:8000 |
PRESSPODS_TTS_MODEL |
No | Higgs model repo (default bosonai/higgs-audio-v3-tts-4b) |
ELEVENLABS_API_KEY |
For elevenlabs | ElevenLabs v3 TTS |
ELEVENLABS_VOICE_MALE / ELEVENLABS_VOICE_FEMALE |
No | Voice-id overrides for narration (default: Brian / Matilda) |
PRESSPODS_PUBLIC_URL |
No | Public origin for RSS enclosure URLs (else derived from forwarded headers) |
PRESSPODS_AUDIO_DIR |
No | Episode MP3 directory (default: press-pods-audio next to the DB) |
JINA_API_KEY |
No | Enables the Jina.ai Reader retriever |
PUSHOVER_PRESSPODS_TOKEN |
No | Pushover token for PressPods (falls back to PUSHOVER_TOKEN) |
KARAKEEP_URL / KARAKEEP_API_KEY |
No | Bookmark submitted articles in Karakeep |
PLEX_URL / PLEX_TOKEN |
For recommendations | Plex server URL and token |
PLEX_ACCOUNT_ID |
For shared Plex servers | Account ID used to scope viewing history; multiple detected accounts fail closed without it |
RADARR_URL / RADARR_API_KEY |
For recommendations | Radarr v3 API connection |
RADARR_ROOT_FOLDER_PATH / RADARR_QUALITY_PROFILE_ID |
For recommendations | Defaults for acquired movies |
SONARR_URL / SONARR_API_KEY |
For recommendations | Sonarr v3 API connection |
SONARR_ROOT_FOLDER_PATH / SONARR_QUALITY_PROFILE_ID |
For recommendations | Defaults for acquired series |
LOG_LEVEL |
No | debug, info, warn, or error |
pnpm dev # Development with hot reload
pnpm build # TypeScript compilation
pnpm test # Run tests (vitest)
pnpm check # Biome linting + formattingInspired by youtube_live_alert.