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

269 lines
10 KiB
Markdown

# transmission-bt
> Moved from nuc to `nas` on 2026-08-30, together with the media
> dataset ([nas-install.md](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.21``193.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).
```bash
#!/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:
```sh
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.
```bash
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:
```sh
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
```sh
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`:
```json
"script-torrent-done-enabled": true,
"script-torrent-done-filename": "/usr/local/bin/jellyfin-scan.sh"
```
The hook POSTs to Jellyfin's `/Library/Refresh`:
```sh
#!/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:
```sh
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
```sh
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.