Skip to content

Redth/inotify-relay

Repository files navigation

inotify-relay

Self-hosted service that watches filesystem paths and relays change events to configurable downstream targets — Jellyfin / Plex library scans, webhooks, etc. Built for NAS-style deployments (Synology, UGREEN, TrueNAS, Unraid…) where you have media volumes you want to surface to other services as soon as files change.

A web UI lets you configure rules (which paths to watch, which events to react to) and targets (where to send), with templated payloads, per-target retry/delay, and OIDC sign-in if you want SSO.

Why not just point Jellyfin's "real-time monitor" at the share?

Built-in monitors in Jellyfin/Plex tend to be flaky over SMB/NFS mounts, and each app reinvents its own logic. inotify-relay is one place to manage that plumbing — and it solves a real .NET pain along the way: Linux's FileSystemWatcher opens one inotify instance per watched directory, which blows past fs.inotify.max_user_instances (default 128) the moment you point it at a non-trivial media library. This project uses a hand-rolled inotify P/Invoke layer that keeps a single instance for the entire process.

Features

  • Linux inotify watcher (single fd, recursive, dynamic re-watching on subdir creation), Windows fallback via FileSystemWatcher.
  • Rules with glob filtering, inotify event filtering, debounce, stabilization, and any number of targets per rule.
  • Per-target coalescing with path subsumption — a torrent finishing into /movies/Inception/ produces one Jellyfin/Plex scan, not fifty. Within the sliding window, any descendant path whose ancestor was also queued is dropped; an empty/full-scan event wins outright.
  • Per-target path mappings (Sonarr/Radarr-style "Remote Path Mappings") — if inotify-relay sees /watch/movies/... but Jellyfin sees /data/movies/..., configure the rewrite once on the target and every templated path variable flips automatically. Longest matching prefix wins; raw originals stay accessible as {source.path}, {source.directory}, {source.sourceRoot}.
  • Built-in providers: Webhook, Jellyfin, Plex, Audiobookshelf.
  • Plain templating with variable substitution + filters ({path|replace:'/host':'/jellyfin'|lower}).
  • Per-target retry policy with exponential backoff.
  • Blazor admin UI, dark theme.
  • First-run setup wizard; local accounts + optional OIDC SSO (Authentik, Keycloak, Entra ID, Okta…) configured from the UI.
  • SQLite-backed, single-file persistence — everything lives under /data.

Container image

Built and published to GitHub Container Registry on every push to main and every git tag. Multi-arch: linux/amd64 + linux/arm64.

Available tags:

Tag When
latest every push to main
edge every push to main (alias of latest, common convention)
sha-abc1234 every push (pinnable by commit)
vX.Y.Z, vX.Y, vX each git tag matching v* (semver)
main, feature-foo every push to a branch

Pull it:

docker pull ghcr.io/redth/inotify-relay:latest
# or pin to a specific build:
docker pull ghcr.io/redth/inotify-relay:sha-abc1234

The image is signed with build provenance; verify with:

gh attestation verify oci://ghcr.io/redth/inotify-relay:latest --owner redth

Quick start (Docker Compose)

git clone https://github.com/redth/inotify-relay
cd inotify-relay
cp .env.example .env
# edit docker-compose.yaml to mount the directories you want to watch
docker compose up -d

Then open http://localhost:8080. On first run you'll be redirected to /setup to create the admin account.

Compose sketch

services:
  inotify-relay:
    image: ghcr.io/redth/inotify-relay:latest
    container_name: inotify-relay
    restart: unless-stopped
    ports:
      - "8080:8080"
    env_file: [.env]
    environment:
      - TZ=${TZ:-UTC}
      - INOTIFY_RELAY_DATA=/data
    volumes:
      - ./data:/data
      - /volume1/media/movies:/watch/movies:ro
      - /volume1/media/tv:/watch/tv:ro

A complete example is in docker-compose.yaml and .env.example.

Host kernel limits

The default Linux limits will not be enough for a media library. Set these on the host (you can't change them from inside an unprivileged container):

sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=512
# persist:
echo 'fs.inotify.max_user_watches=524288' | sudo tee -a /etc/sysctl.conf
echo 'fs.inotify.max_user_instances=512'  | sudo tee -a /etc/sysctl.conf

How rules work

A rule binds sources (paths + optional globs + recursion) to targets (provider configs). When a filesystem change matches a rule:

  1. The event is filtered by the rule's enabled event types (Created, ClosedWrite, Deleted, Renamed, etc.).
  2. Glob filtering is applied if set.
  3. Rule-level debounce collapses repeats of the same (rule, path, event) within DebounceMs.
  4. The event is queued for each bound target with the configured delay.
  5. Target-level coalescing (CoalesceMs per target) holds incoming work in a sliding window — every new event resets the timer. When the window goes quiet, the buffer is reduced by path subsumption: any path whose ancestor is also queued is dropped (so /movies/Inception/ wins over /movies/Inception/a.mkv), and an empty/full-scan marker subsumes everything. Defaults: webhook 0 (no coalescing), Jellyfin and Plex 5000 ms.
  6. Each surviving item is rendered through its template and delivered, with the target's retry policy.

Variables

Templates use {name} or {name|filter:'arg'}:

variable meaning
path absolute path, rewritten via the target's path mappings
directory parent directory of path (also rewritten)
sourceRoot the source root that matched (also rewritten)
relativePath path relative to source root — invariant under mapping
relativeDirectory parent dir relative to source root — invariant
source.path, source.directory, source.sourceRoot raw unmapped originals
filename basename including extension
name basename without extension
ext extension including dot
event normalized event name (Created, ClosedWrite, …)
isDirectory true / false
timestamp ISO 8601 UTC
oldPath previous path on Renamed (mapped); source.oldPath is raw
rule.name, rule.id for tagging
env.FOO environment variable lookup

Filters

  • lower, upper, trim
  • replace:'old':'new', regex:'pattern':'replacement'
  • prefix:'/path', suffix:'.bak'
  • combine:'/host/prefix', relativeTo:'/base'
  • urlencode, jsonescape
  • default:'fallback'

Example: Jellyfin partial scan with mismatched mounts

If your media lives on the host at /volume1/media/movies, mounted into inotify-relay at /watch/movies and into Jellyfin at /data/movies, configure a path mapping on the Jellyfin target:

From To
/watch/movies /data/movies

The path template can then just be {path} (or {directory}) — the mapping is applied automatically. If you need both the source and target views in one payload, use {source.path} for the unmapped original.

Configuration

  • Database / logs / config: /data (mount a volume).
  • Web UI: listens on :8080 (configurable via ASPNETCORE_URLS).
  • First-run admin: created from /setup. After that, normal /login.
  • OIDC: configured from Settings → Authentication in the UI. Callback URLs to register with your IdP are /signin-oidc and /signout-callback-oidc.

Local development

Requires the .NET 10 SDK.

dotnet build
dotnet run --project src/InotifyRelay.Web

The default data directory is <bin>/data unless you set INOTIFY_RELAY_DATA.

To rebuild EF Core migrations:

dotnet ef migrations remove --project src/InotifyRelay.Data --force
dotnet ef migrations add Initial --project src/InotifyRelay.Data --output-dir Migrations

Build the Docker image locally

docker build -t inotify-relay:dev -f docker/Dockerfile .

License

MIT — see LICENSE.

About

Watch for local filesystem changes via inotify and relay them anywhere!

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors