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.
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.
- 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.
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-abc1234The image is signed with build provenance; verify with:
gh attestation verify oci://ghcr.io/redth/inotify-relay:latest --owner redthgit 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 -dThen open http://localhost:8080. On first run you'll be redirected to
/setup to create the admin account.
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:roA complete example is in docker-compose.yaml and
.env.example.
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.confA rule binds sources (paths + optional globs + recursion) to targets (provider configs). When a filesystem change matches a rule:
- The event is filtered by the rule's enabled event types (Created, ClosedWrite, Deleted, Renamed, etc.).
- Glob filtering is applied if set.
- Rule-level debounce collapses repeats of the same
(rule, path, event)withinDebounceMs. - The event is queued for each bound target with the configured delay.
- Target-level coalescing (
CoalesceMsper 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: webhook0(no coalescing), Jellyfin and Plex5000ms. - Each surviving item is rendered through its template and delivered, with the target's retry policy.
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 |
lower,upper,trimreplace:'old':'new',regex:'pattern':'replacement'prefix:'/path',suffix:'.bak'combine:'/host/prefix',relativeTo:'/base'urlencode,jsonescapedefault:'fallback'
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.
- Database / logs / config:
/data(mount a volume). - Web UI: listens on
:8080(configurable viaASPNETCORE_URLS). - First-run admin: created from
/setup. After that, normal/login. - OIDC: configured from
Settings → Authenticationin the UI. Callback URLs to register with your IdP are/signin-oidcand/signout-callback-oidc.
Requires the .NET 10 SDK.
dotnet build
dotnet run --project src/InotifyRelay.WebThe 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 Migrationsdocker build -t inotify-relay:dev -f docker/Dockerfile .MIT — see LICENSE.