Finished downloads stopped appearing in Jellyfin after the 2026-08-30 storage move, and everything looked healthy: the file was on nas, visible through NFS inside the container, and /media/downloads is a configured library. The cause is that Jellyfin watches libraries with inotify, which only reports changes made through the local mount — transmission now writes on nas while jellyfin-server reads over NFS on nuc, so no event ever reaches it. EnableRealtimeMonitor is true and SupportsLibraryMonitor reports true, which is why it looks fine. Previously both shared one local dataset on nuc and it worked. transmission now calls Jellyfin's /Library/Refresh via script-torrent-done. The hook always exits 0 and never blocks (transmission runs it synchronously; a hanging hook stalls the daemon), and both the success and missing-key paths are tested. nuc is usually powered off, so a failed request is expected and logged rather than treated as an error — the scheduled scan catches up. Also records that nuc's mount is read-only, so reorganising downloads into movies/tv-shows must now happen on nas, and the ordered checklist for "my download is not in Jellyfin" — the first three checks all passed when this was hit, which is what made it confusing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
transmission-bt
Moved from nuc to
nason 2026-08-30, together with the media dataset (nas-install.md §7). Its WireGuard tunnel is entirely in-container, so ks4 needed no change — the peer is still10.8.0.21. The watch folder moved with it:/export/media/.watchdiron nas, not/srv/media/.watchdiron nuc.
BitTorrent client in an unprivileged Incus container on nas, with an
always-on VPN: all peer traffic exits via ks4's public IP through a
WireGuard tunnel to the wireguard container on ks4. Kill switch by
construction — eth0 has no default route, so with the tunnel down
the container simply has no path to the internet.
- Image:
images:ubuntu/24.04, IP:192.168.0.7(macvlan, static via netplan) - Web UI:
http://192.168.0.7:9091— no auth (rpc-authentication-required: false); access control is the RPC whitelist (192.168.0.*only) - Egress: WG peer
10.8.0.21→193.70.35.17:51845,AllowedIPs 0.0.0.0/0(verified:curl ifconfig.mefrom the container returns ks4's IP) - Downloads:
/media/downloads(=tank/media, same dataset Jellyfin reads); in-progress files in/media/.incompleteso Jellyfin never scans partials - Watch folder:
scpa.torrentinto/export/media/.watchdiron the host and it auto-downloads (see "Watch folder" below) transmission-daemonisBindsTo=wg-quick@wg0.serviceand binds peer traffic to10.8.0.21— three independent layers against leaks (no default route, unit binding, socket binding)
Anti-leak design
- netplan gives
eth0only: LAN/24(web UI + DNS via blocky) and a/32host route to the WG endpoint via the home gateway. wg-quickfull-tunnel mode adds its fwmark policy routing + iptables anti-leak rule (iptablespackage required — its absence makeswg-quickfail withiptables-restore: command not found).- LAN traffic keeps working thanks to wg-quick's
suppress_prefixlength 0rule (connected routes win over the tunnel's default).
Install script
Run as root on the Incus host. Requires the peer added on ks4 (below).
#!/usr/bin/env bash
set -euxo pipefail
CNAME="${CNAME:-transmission-bt}"
IMAGE="${IMAGE:-images:ubuntu/24.04}"
incus launch "$IMAGE" "$CNAME"
sleep 8
incus config set "$CNAME" environment.DEBIAN_FRONTEND=noninteractive
incus exec "$CNAME" -- timedatectl set-timezone Europe/Paris
# static LAN config, NO default route (kill switch), /32 to the WG endpoint
incus exec "$CNAME" -- bash -c 'cat > /etc/netplan/10-lxc.yaml <<EOF
network:
version: 2
ethernets:
eth0:
addresses: [192.168.0.7/24]
nameservers:
addresses: [192.168.0.254]
routes:
- to: 193.70.35.17/32
via: 192.168.0.2
EOF
chmod 600 /etc/netplan/10-lxc.yaml
netplan apply'
# packages need a temporary default route (removed right after)
incus exec "$CNAME" -- ip route add default via 192.168.0.2
incus exec "$CNAME" -- apt-get update
incus exec "$CNAME" -- apt-get install -y --no-install-recommends \
transmission-daemon wireguard-tools iptables curl
incus exec "$CNAME" -- ip route del default via 192.168.0.2
# WireGuard full tunnel (generate key, print pubkey for the ks4 side)
incus exec "$CNAME" -- bash -c 'umask 077
wg genkey > /etc/wireguard/wg0.key
wg pubkey < /etc/wireguard/wg0.key
cat > /etc/wireguard/wg0.conf <<EOF
[Interface]
Address = 10.8.0.21/24
PrivateKey = $(cat /etc/wireguard/wg0.key)
[Peer]
# wireguard container on ks4 — ALL traffic routes through it
PublicKey = TVs6d7bXTvJ0ZluTLb8wR+zIrsLvkH1944pzM+3dZXM=
Endpoint = 193.70.35.17:51845
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
EOF'
incus exec "$CNAME" -- systemctl enable --now wg-quick@wg0
# media share (same dataset as jellyfin-server)
incus config device add "$CNAME" media disk source=/export/media path=/media shift=true
incus exec "$CNAME" -- mkdir -p /media/downloads /media/.incomplete
incus exec "$CNAME" -- chown debian-transmission:debian-transmission \
/media/downloads /media/.incomplete
# transmission config (edit only while the daemon is stopped)
incus exec "$CNAME" -- systemctl stop transmission-daemon
incus exec "$CNAME" -- bash -c '
cd /var/lib/transmission-daemon/.config/transmission-daemon
sed -i \
-e "s|\"download-dir\":.*|\"download-dir\": \"/media/downloads\",|" \
-e "s|\"incomplete-dir\":.*|\"incomplete-dir\": \"/media/.incomplete\",|" \
-e "s|\"incomplete-dir-enabled\":.*|\"incomplete-dir-enabled\": true,|" \
-e "s|\"rpc-whitelist\":.*|\"rpc-whitelist\": \"127.0.0.1,::1,192.168.0.*\",|" \
-e "s|\"rpc-authentication-required\":.*|\"rpc-authentication-required\": false,|" \
-e "s|\"bind-address-ipv4\":.*|\"bind-address-ipv4\": \"10.8.0.21\",|" \
settings.json'
# transmission lives and dies with the tunnel
incus exec "$CNAME" -- mkdir -p /etc/systemd/system/transmission-daemon.service.d
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/transmission-daemon.service.d/vpn.conf <<EOF
[Unit]
BindsTo=wg-quick@wg0.service
After=wg-quick@wg0.service
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl start transmission-daemon
incus config set "$CNAME" boot.autostart=true
On ks4 (root), authorize the peer with the pubkey printed above:
incus exec wireguard -- wg set wg0 peer <PUBKEY> allowed-ips 10.8.0.21/32
incus exec wireguard -- wg-quick save wg0
Watch folder (auto-add torrents)
Drop a .torrent into /export/media/.watchdir on the host and Transmission
auto-adds it and starts downloading — no web UI needed. The folder lives on
the shared tank/media dataset (/media/.watchdir inside the container).
Edit the active config only while the daemon is stopped (it rewrites
settings.json on exit). The active file is
/var/lib/transmission-daemon/info/settings.json — the daemon runs with
--config-dir /var/lib/transmission-daemon/info from
/etc/default/transmission-daemon, not the .config dir.
incus exec transmission-bt -- install -d -o debian-transmission \
-g debian-transmission -m 0775 /media/.watchdir
incus exec transmission-bt -- systemctl stop transmission-daemon
incus exec transmission-bt -- python3 - <<'PY'
import json
p = "/var/lib/transmission-daemon/info/settings.json"
c = json.load(open(p))
c.update({
"watch-dir": "/media/.watchdir",
"watch-dir-enabled": True,
"watch-dir-force-generic": True, # poll (reliable across the shift mount)
"trash-original-torrent-files": True, # delete the .torrent once added
})
json.dump(c, open(p, "w"), indent=4)
PY
incus exec transmission-bt -- systemctl start transmission-daemon
Usage — the .torrent is consumed within a few seconds:
scp some.torrent root@192.168.0.4:/export/media/.watchdir/
watch-dir-force-generic: truemakes Transmission poll the folder instead of using inotify, so it reliably sees files written from the host side across the shift-mounted share.trash-original-torrent-files: trueself-cleans the folder. A malformed.torrentis not trashed and gets retried each poll — delete it by hand.- The watchdir is owned by
debian-transmission(0775): root'sscpwrites fine, and Transmission can read the file and remove it after adding.
Verification
incus exec transmission-bt -- wg show wg0 latest-handshakes # non-zero timestamp
incus exec transmission-bt -- curl -s https://ifconfig.me # must print 193.70.35.17
incus exec transmission-bt -- bash -c "ping -c1 -W2 8.8.8.8 || echo kill-switch OK" # with wg0 down
# web UI must be tested from a LAN machine — the macvlan quirk means the
# nas host itself cannot reach 192.168.0.7 (macvlan, by design)
Notes
- No inbound peer port is forwarded (would need a proxy device on ks4
- DNAT through the tunnel); torrents work fine outbound-only, just connect to fewer peers.
- Jellyfin sees finished downloads under
/media/downloads— add it as a library folder or move files into the movie/show trees. - The image server check can make
incus launchhang on slow WAN — launching from the cached image fingerprint (incus image list) bypasses it.
Jellyfin library scan on completion (2026-08-31)
Jellyfin cannot notice finished downloads by itself any more. It watches
libraries with inotify, but since the media moved to nas the writer
(transmission, here) and the reader (jellyfin-server on nuc, over NFS)
are on different machines — an inotify event never crosses that. Before
the move both shared one local dataset on nuc, so it just worked.
So transmission tells Jellyfin explicitly, via
script-torrent-done:
"script-torrent-done-enabled": true,
"script-torrent-done-filename": "/usr/local/bin/jellyfin-scan.sh"
The hook POSTs to Jellyfin's /Library/Refresh:
#!/bin/sh
KEY_FILE=/etc/jellyfin-scan.key
JF=http://192.168.0.5:8096
NAME="${TR_TORRENT_NAME:-unknown}"
[ -r "$KEY_FILE" ] || { logger -t jellyfin-scan "no readable key file; skipped ($NAME)"; exit 0; }
KEY=$(tr -d " \t\r\n" < "$KEY_FILE")
if curl -fsS -m 15 -X POST -H "X-Emby-Token: $KEY" "$JF/Library/Refresh" >/dev/null 2>&1; then
logger -t jellyfin-scan "library scan requested after: $NAME"
else
logger -t jellyfin-scan "library scan request FAILED (nuc off?) after: $NAME"
fi
exit 0
Design points, each of which matters:
- Always
exit 0, never block. transmission runs the hook synchronously; a hanging or failing hook stalls the daemon. Both paths are tested — success and unreadable-key both exit 0. - nuc is usually powered off. The request then fails, logs
FAILED (nuc off?), and Jellyfin picks the file up on its next scheduled scan. Not an error worth alerting on. - The key file is
640 root:debian-transmission— the hook runs asdebian-transmission, so it must be group-readable, and nothing wider. - Reachable despite the kill switch:
192.168.0.5is on the directly connected LAN, so it needs no default route.
Verify:
incus exec transmission-bt -- su -s /bin/sh debian-transmission \
-c 'TR_TORRENT_NAME=selftest /usr/local/bin/jellyfin-scan.sh'
incus exec transmission-bt -- journalctl -t jellyfin-scan -n 3
Rotating the key: create a new one in Jellyfin (Dashboard → API Keys), then
printf %s '<new-key>' | incus exec transmission-bt -- sh -c \
'umask 027; cat > /etc/jellyfin-scan.key; chown root:debian-transmission /etc/jellyfin-scan.key'
⚠️ Edit settings.json only while the daemon is stopped —
transmission rewrites the whole file on shutdown and will silently
discard changes made underneath it.