Files
doc/nas/transmission-bt.md
T
Julien LutranandClaude Opus 5 9468f30f92 jellyfin: scan on download completion — inotify cannot work over NFS
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>
2026-08-31 20:21:36 +02:00

10 KiB

transmission-bt

Moved from nuc to nas on 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 still 10.8.0.21. The watch folder moved with it: /export/media/.watchdir on nas, not /srv/media/.watchdir on 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.21193.70.35.17:51845, AllowedIPs 0.0.0.0/0 (verified: curl ifconfig.me from the container returns ks4's IP)
  • Downloads: /media/downloads (= tank/media, same dataset Jellyfin reads); in-progress files in /media/.incomplete so Jellyfin never scans partials
  • Watch folder: scp a .torrent into /export/media/.watchdir on the host and it auto-downloads (see "Watch folder" below)
  • transmission-daemon is BindsTo=wg-quick@wg0.service and binds peer traffic to 10.8.0.21 — three independent layers against leaks (no default route, unit binding, socket binding)

Anti-leak design

  1. netplan gives eth0 only: LAN /24 (web UI + DNS via blocky) and a /32 host route to the WG endpoint via the home gateway.
  2. wg-quick full-tunnel mode adds its fwmark policy routing + iptables anti-leak rule (iptables package required — its absence makes wg-quick fail with iptables-restore: command not found).
  3. LAN traffic keeps working thanks to wg-quick's suppress_prefixlength 0 rule (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: true makes 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: true self-cleans the folder. A malformed .torrent is not trashed and gets retried each poll — delete it by hand.
  • The watchdir is owned by debian-transmission (0775): root's scp writes 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 launch hang 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 as debian-transmission, so it must be group-readable, and nothing wider.
  • Reachable despite the kill switch: 192.168.0.5 is 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.