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>
269 lines
10 KiB
Markdown
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.
|
|
|