Media all drives, Parts A-C on Tester 1: S1-S3 proven (hardlink with one bind per drive; today's shape copies); R-1014..R-1019 filed
gates / gates (push) Successful in 7m46s

Two test drives enrolled through the dashboard; no test media in any backup tier (A6, three proofs); Tester 1 follows a
credential-free local mirror of the drill catalog (R-992 worked around a third way). The live-fix task may start.
This commit is contained in:
2026-10-14 17:36:37 +02:00
parent 70250ffb95
commit 8395aee4c2
41 changed files with 1866 additions and 3 deletions
+33
View File
@@ -0,0 +1,33 @@
**S1–S3 pushed; the live-fix task may start.**
> This report is `REPORT-media-all-drives-2026-10-14.md`, not the root `REPORT.md`: the repo rule (`CLAUDE.md`, „two
> sessions in this repo clobber each other") sends a parallel session to a topic file. The task asked for the line at
> the top of `REPORT.md`; it is here instead.
**Resume point:** Parts A–C done and pushed. Next: Part D (Plex path check, the test sheet, then STOP for the operator).
# Media apps on every drive — Tester 1 rig (2026-10-14)
Evidence: `documentation/audits/media-all-drives-2026-10-14/README.md` (one row per part).
## In plain words
1. **The chain works with the new folder layout.** A film went from the download app to the library on the same
drive as one file with two names. No extra copy. Through both download apps.
2. **Today's layout cannot work.** The download apps save where Sonarr and Radarr never look. And even with the same
folders, two separate mounts force a full copy. Measured.
3. **Each film can go to its own drive** by a tag. A wrong tag still works, but makes a full copy across drives.
4. **No test film reached any backup.** Proven three ways before the first download.
5. **A power cut hit Tester 1 at 17:25.** It came from another session's test on the HP box. Tester 1 came back by
itself.
## Claims in the task that turned out wrong
- „`media/` only keeps photos and documents out of these apps' reach" — `media/` holds `photos`, `books`,
`audiobooks` and more in the skeleton. Documents are outside it; photos are not.
- R-615: removing the cache is no longer needed (a changed `repo_url` re-clones, seen live).
- The `/media/shows` copy is in Plex too, not only Sonarr and Jellyfin (R-1015).
## Register
Rows before 133 → after 139 · opened 6 (R-1014..R-1019) · closed 0.
@@ -0,0 +1,9 @@
== /api/system/info 200
{"ok": true, "data": {"sync_status": {"last_sync": "2026-10-14T13:44:42.082522426Z", "last_status": "ok", "syncing": false}, "system": {"total_mem_mb": 4096, "used_mem_mb": 680, "avail_mem_mb": 3416, "mem_percent": 16.6015625, "disk_total_gb": 68.72599792480469, "disk_used_gb": 4.120944976806641, "disk_avail_gb": 61.089439392089844, "disk_percent": 6.319461258646124, "disk_known": true, "hdd_total_gb": 48.914520263671875, "hdd_used_gb": 0.23301315307617188, "hdd_avail_gb": 46.1658821105957, "hdd_percent": 0.5021954763190453, "hdd_configured": true, "hdd_known": true, "cpu_percent": 17.57910597689603, "load_avg_1": 0.21, "load_avg_5": 0.3, "load_avg_15": 0.32, "temperature_celsius": 0}}}
== /api/system/memory 200
{"data": {"vmid": 9201, "allocated_mb": 4096, "usage_mb": 944, "host_total_mb": 7872, "min_mb": 2048, "max_mb": 5824, "floor_mb": 2048, "running": true}, "ok": true}
== /api/disks 200
{"data": {"vmid": 9201, "disks": [{"name": "863c67fd-3e21-4d9f-9a74-3c5fa406f2a2", "type": "usb", "state": "attached", "backing_device": "/dev/sdb", "mount_path": "/mnt/adatlemez", "class": "", "role": "user-data", "data_bearing": false, "data_reason": "", "total_bytes": 52521566208, "used_bytes": 250195968, "used_fraction": 0.0047636806375718965, "durable_id": "uuid:863c67fd-3e21-4d9f-9a74-3c5fa406f2a2", "guest_attached": false, "guest_path": "/mnt/felhom-drives/adatlemez", "bound_under_parent": true, "smart": {"health": "UNKNOWN", "model_name": "QEMU QEMU HARDDISK", "temperature_c": 0, "power_on_hours": null, "reallocated_sectors": null, "pending_sectors": null, "offline_uncorrectable": null, "critical_warning": null, "media_errors": null, "percentage_used": null}}, {"name": "local", "type": "local", "state": "attached", "backing_device": "", "mount_path": "", "class": "", "role": "system", "data_bearing": false, "data_reason": "", "total_bytes": 62903865344, "used_bytes": 13649629184, "used_fraction": 0.216991898818217, "durable_id": "path:/var/lib/vz", "guest_attached": false, "backup_target": true, "bound_under_parent": false, "smart": {"health": "UNKNOWN", "model_name": "QEMU QEMU HARDDISK", "temperature_c": 0, "power_on_hours": null, "reallocated_sectors": null, "pending_sectors": null, "offline_uncorrectable": null, "critical_warning": null, "media_errors": null, "percentage_used": null}}, {"name": "local-lvm", "type": "lvmthin", "state": "attached", "backing_device": "", "mount_path": "", "class": "", "role": "system", "data_bearing": false, "data_reason": "", "total_bytes": 121521569792, "used_bytes": 7813836937, "used_fraction": 0.0642999999948519, "durable_id": "pve/data", "guest_attached": false, "bound_under_parent": false, "smart": {"health": "UNKNOWN", "temperature_c": null, "power_on_hours": null, "reallocated_sectors": null, "pending_sectors": null, "offline_uncorrectable": null, "critical_warning": null, "media_errors": null, "percentage_used": null}}, {"name": "felhom-pbs", "type": "pbs", "state": "attached", "backing_device": "", "mount_path": "", "class": "", "role": "backup", "data_bearing": false, "data_reason": "", "total_bytes": 0, "used_bytes": 0, "used_fraction": 0, "durable_id": "10.77.0.1:felhom-offsite#c6:07:28:3f:5b:7b:5a:41:90:28:d7:ca:4f:37:14:70:56:39:2e:2f:0b:71:e8:06:ca:60:4a:d5:56:5f:3c:fd", "guest_attached": false, "bound_under_parent": false, "smart": {"health": "UNKNOWN", "temperature_c": null, "power_on_hours": nul
== /api/storage/backup-target 200
{"data": {"degraded": true, "known": true, "label": "", "message": "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez.", "offer_label": "Adatlemez", "offer_message": "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba után is vissza tudod állítani a rendszert.", "offer_path": "/mnt/felhom-drives/adatlemez", "target": "local"}, "ok": true}
== deployed: [('bookstack', 'running'), ('paperless-ngx', 'running'), ('privatebin', 'running')]
@@ -0,0 +1,30 @@
2026-10-14T15:57:57+02:00
bios: ovmf
boot: order=scsi0
cores: 4
cpu: host
efidisk0: nvme-scratch:341/vm-341-disk-0.raw,efitype=4m,pre-enrolled-keys=0,size=528K
machine: q35
memory: 8192
meta: creation-qemu=11.0.0,ctime=1791141969
name: night1004-tester1
net0: virtio=BC:24:11:AC:E3:F9,bridge=vmbr0
onboot: 1
ostype: l26
scsi0: nvme-scratch:341/vm-341-disk-1.raw,size=200G
scsi1: nvme-scratch:341/vm-341-disk-2.raw,size=50G
scsihw: virtio-scsi-single
smbios1: uuid=451c8d94-abf2-46e4-a278-b579a9f315b5
vga: std
vmgenid: acdf89db-a810-445c-9d88-7bccd688d3c1
Filesystem 1B-blocks Used Available Use% Mounted on
/dev/nvme0n1 1006980812800 135772049408 819981512704 15% /mnt/hdd_1
total 76833588
4 drwxr----- 2 root root 4096 Oct 4 21:26 .
4 drwxr-xr-x 5 root root 4096 Oct 14 05:42 ..
528 -rw-r----- 1 root root 540672 Oct 14 14:07 vm-341-disk-0.raw
75117456 -rw-r----- 1 root root 214748364800 Oct 14 15:57 vm-341-disk-1.raw
1715596 -rw-r----- 1 root root 53687091200 Oct 14 15:57 vm-341-disk-2.raw
total used free shared buff/cache available
Mem: 29994 11576 10048 164 8973 18417
Swap: 8191 0 8191
@@ -0,0 +1,49 @@
2026-10-14T15:57:59+02:00
felhom-agent 0.156.1
arch: amd64
cores: 3
features: nesting=1,keyctl=1
hookscript: local:snippets/felhom-guest-hook.sh
hostname: tester-1
memory: 4096
mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/felhom,backup=1,size=70G
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
net0: name=eth0,bridge=vmbr0,hwaddr=BC:24:11:FF:2F:FC,ip=dhcp,type=veth
net1: name=eth1,bridge=vmbr9,hwaddr=BC:24:11:6C:BB:E5,ip=169.254.253.2/30,type=veth
onboot: 1
ostype: debian
rootfs: local-lvm:vm-9201-disk-0,size=32G
swap: 512
unprivileged: 1
NAME SIZE TYPE MOUNTPOINT SERIAL
sda 200G disk drive-scsi0
|-sda1 1007K part
|-sda2 1G part /boot/efi
`-sda3 199G part
|-pve-swap 7.7G lvm [SWAP]
|-pve-root 59.8G lvm /
|-pve-data_tmeta 1.2G lvm
| `-pve-data-tpool 113.2G lvm
| |-pve-data 113.2G lvm
| |-pve-vm--9201--disk--0 32G lvm
| `-pve-vm--9201--disk--1 70G lvm
`-pve-data_tdata 113.2G lvm
`-pve-data-tpool 113.2G lvm
|-pve-data 113.2G lvm
|-pve-vm--9201--disk--0 32G lvm
`-pve-vm--9201--disk--1 70G lvm
sdb 50G disk /mnt/adatlemez drive-scsi1
total used free shared buff/cache available
Mem: 7872 2668 2247 105 3333 5203
Swap: 7871 0 7871
felhom-controller gitea.dooplex.hu/admin/felhom-controller:0.310.0 Up About an hour (healthy)
paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15 Up 2 hours (healthy)
paperless-postgres postgres:18-alpine Up 2 hours (healthy)
paperless-redis redis:7-alpine Up 2 hours (healthy)
privatebin privatebin/pdo:2.0.6 Up 2 hours (healthy)
bookstack lscr.io/linuxserver/bookstack:26.09.1 Up 2 hours (healthy)
bookstack-db mariadb:12.3 Up 2 hours (healthy)
filebrowser gtstef/filebrowser:1.5.6-stable Up 2 hours (healthy)
cloudflared cloudflare/cloudflared:2026.9.3 Up 2 hours (healthy)
traefik traefik:v3.7.13 Up 2 hours
@@ -0,0 +1,33 @@
+ date -Is
2026-10-14T15:58:36+02:00
+ df -B1G /mnt/hdd_1
+ tail -1
/dev/nvme0n1 938 127 764 15% /mnt/hdd_1
+ qm set 341 --scsi2 nvme-scratch:100,backup=0
update VM 341: -scsi2 nvme-scratch:100,backup=0
Formatting '/mnt/hdd_1/images/341/vm-341-disk-3.raw', fmt=raw size=107374182400 preallocation=off
scsi2: successfully created disk 'nvme-scratch:341/vm-341-disk-3.raw,backup=0,size=100G'
+ qm set 341 --scsi3 nvme-scratch:100,backup=0
update VM 341: -scsi3 nvme-scratch:100,backup=0
Formatting '/mnt/hdd_1/images/341/vm-341-disk-4.raw', fmt=raw size=107374182400 preallocation=off
scsi3: successfully created disk 'nvme-scratch:341/vm-341-disk-4.raw,backup=0,size=100G'
+ qm config 341
+ grep -E '^scsi|^memory|hotplug'
memory: 8192
scsi0: nvme-scratch:341/vm-341-disk-1.raw,size=200G
scsi1: nvme-scratch:341/vm-341-disk-2.raw,size=50G
scsi2: nvme-scratch:341/vm-341-disk-3.raw,backup=0,size=100G
scsi3: nvme-scratch:341/vm-341-disk-4.raw,backup=0,size=100G
scsihw: virtio-scsi-single
+ ls -las /mnt/hdd_1/images/341/
total 76833596
4 drwxr----- 2 root root 4096 Oct 14 15:58 .
4 drwxr-xr-x 5 root root 4096 Oct 14 05:42 ..
528 -rw-r----- 1 root root 540672 Oct 14 14:07 vm-341-disk-0.raw
75117456 -rw-r----- 1 root root 214748364800 Oct 14 15:58 vm-341-disk-1.raw
1715596 -rw-r----- 1 root root 53687091200 Oct 14 15:58 vm-341-disk-2.raw
4 -rw-r----- 1 root root 107374182400 Oct 14 15:58 vm-341-disk-3.raw
4 -rw-r----- 1 root root 107374182400 Oct 14 15:58 vm-341-disk-4.raw
+ df -B1G /mnt/hdd_1
+ tail -1
/dev/nvme0n1 938 127 764 15% /mnt/hdd_1
@@ -0,0 +1,5 @@
15:59:08 candidates -> 200 ['/dev/sdc', '/dev/sdd']
15:59:08 POST /api/storage/init {"device": "/dev/sdc", "fstype": "ext4", "mount_name": "media_a", "label": "Media A", "set_default": false, "confirmed": false, "durable_id": ""} -> 200 {"data": {"phase": "formatting", "started": true}, "ok": true}
15:59:10 status -> 200 {"device": "/dev/sdc", "durable_id": "", "error": "", "opsign": "", "phase": "mounting", "reason": "", "started_at": "2026-10-14T13:59:08.76516334Z", "updated_at": "2026-10-14T13:59:09.122147197Z", "where": "/mnt/media_a"}
15:59:12 status -> 200 {"device": "/dev/sdc", "durable_id": "", "error": "", "opsign": "", "phase": "done", "reason": "", "started_at": "2026-10-14T13:59:08.76516334Z", "updated_at": "2026-10-14T13:59:10.953891979Z", "where": "/mnt/felhom-drives/media_a"}
RESULT done
@@ -0,0 +1,5 @@
15:59:16 candidates -> 200 ['/dev/sdd']
15:59:16 POST /api/storage/init {"device": "/dev/sdd", "fstype": "ext4", "mount_name": "media_b", "label": "Media B", "set_default": false, "confirmed": false, "durable_id": ""} -> 200 {"data": {"phase": "formatting", "started": true}, "ok": true}
15:59:18 status -> 200 {"device": "/dev/sdd", "durable_id": "", "error": "", "opsign": "", "phase": "mounting", "reason": "", "started_at": "2026-10-14T13:59:16.535337994Z", "updated_at": "2026-10-14T13:59:16.879143306Z", "where": "/mnt/media_b"}
15:59:20 status -> 200 {"device": "/dev/sdd", "durable_id": "", "error": "", "opsign": "", "phase": "done", "reason": "", "started_at": "2026-10-14T13:59:16.535337994Z", "updated_at": "2026-10-14T13:59:18.641630025Z", "where": "/mnt/felhom-drives/media_b"}
RESULT done
@@ -0,0 +1,79 @@
backup-target 200 {"data": {"degraded": true, "known": true, "label": "", "message": "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez.", "offer_label": "Adatlemez", "offer_message": "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba után is vissza tudod állítani a rendszert.", "offer_path": "/mnt/felhom-drives/adatlemez", "target": "local"}, "ok": true}
storage page 200 77
Tárhely — Felhom.eu
Indítópult
Vezérlőpult
Alkalmazások
Tárhely
Meghajtók
Hálózati tárhely
Biztonsági mentés
Áttekintés
Távoli mentés
Alkalmazások
Visszaállítás
Megosztás
Hálózati megosztás
Rendszermonitor
Hibakeresés
Beállítások
Rendszer
Értesítések
Biztonság és hozzáférés
0.310.0
Magyar
English
Kijelentkezés ↗
Tárhely — Meghajtók
Adattárolók
Külső meghajtók kezelése alkalmazásadatok tárolásához.
Megőrzött adatok
Eltávolított alkalmazások lemezen maradt adatai
Adatlemez
/mnt/felhom-drives/adatlemez
Alapértelmezett
Aktív
0,2 GB / 48,9 GB
ext4 · /dev/sdb[/felhom-data] · QEMU HARDDISK
1 alkalmazás használja
Paperless-ngx
6,3 KB
Mozgatás
Új telepítések letiltása
Áthelyezés ide…
Media A (/mnt/felhom-drives/media_a)
Media B (/mnt/felhom-drives/media_b)
Összes adat áthelyezése
Végleges leszerelés
Media A
/mnt/felhom-drives/media_a
Aktív
0,0 GB / 97,9 GB
ext4 · /dev/sdc[/felhom-data] · QEMU HARDDISK
Nincs alkalmazás ezen a tárolón
Legyen alapértelmezett
Új telepítések letiltása
Végleges leszerelés
Media B
/mnt/felhom-drives/media_b
Aktív
0,0 GB / 97,9 GB
ext4 · /dev/sdd[/felhom-data] · QEMU HARDDISK
Nincs alkalmazás ezen a tárolón
Legyen alapértelmezett
Új telepítések letiltása
Végleges leszerelés
Adatok áthelyezése
…
Új meghajtó inicializálása
Meglévő meghajtó csatolása
Nem regisztrált meghajtók
az ügynök által észlelt, még nem regisztrált adatmeghajtók
Rendszermeghajtók
védett — csak operátori aláírással módosíthatók
Már csatlakoztatott tárhely hozzáadása kézzel
Elérési út
Pl. /mnt/hdd_1 — a meghajtónak már csatolva kell lennie
Megnevezés (opcionális)
Legyen alapértelmezett új telepítéseknél
Hozzáadás
@@ -0,0 +1,160 @@
Wed Oct 14 13:59:31 UTC 2026
TARGET SOURCE FSTYPE PROPAGATION
/mnt/felhom-drives /dev/mapper/pve-root[/mnt/felhom-drives] ext4 shared,slave
|-/mnt/felhom-drives/adatlemez /dev/sdb[/felhom-data] ext4 shared,slave
|-/mnt/felhom-drives/media_a /dev/sdc[/felhom-data] ext4 shared,slave
`-/mnt/felhom-drives/media_b /dev/sdd[/felhom-data] ext4 shared,slave
/mnt/felhom-drives /dev/mapper/pve-root[/mnt/felhom-drives] ext4 shared,slave
|-/mnt/felhom-drives/adatlemez /dev/sdb[/felhom-data] ext4 shared,slave
|-/mnt/felhom-drives/media_a /dev/sdc[/felhom-data] ext4 shared,slave
`-/mnt/felhom-drives/media_b /dev/sdd[/felhom-data] ext4 shared,slave
== media_a
total 12
drwxr-xr-x 3 root root 4096 Oct 14 13:59 .
drwxr-xr-x 5 nobody nogroup 4096 Oct 14 13:59 ..
drwxrwsr-x 6 root 1000 4096 Oct 14 13:59 userdata
/mnt/felhom-drives/media_a
/mnt/felhom-drives/media_a/userdata
/mnt/felhom-drives/media_a/userdata/documents
/mnt/felhom-drives/media_a/userdata/downloads
/mnt/felhom-drives/media_a/userdata/media
/mnt/felhom-drives/media_a/userdata/media/audiobooks
/mnt/felhom-drives/media_a/userdata/media/books
/mnt/felhom-drives/media_a/userdata/media/comics
/mnt/felhom-drives/media_a/userdata/media/grimmory
/mnt/felhom-drives/media_a/userdata/media/metube
/mnt/felhom-drives/media_a/userdata/media/movies
/mnt/felhom-drives/media_a/userdata/media/music
/mnt/felhom-drives/media_a/userdata/media/nzbget
/mnt/felhom-drives/media_a/userdata/media/photos
/mnt/felhom-drives/media_a/userdata/media/podcasts
/mnt/felhom-drives/media_a/userdata/media/torrents
/mnt/felhom-drives/media_a/userdata/media/transmission
/mnt/felhom-drives/media_a/userdata/media/tv
/mnt/felhom-drives/media_a/userdata/media/usenet
/mnt/felhom-drives/media_a/userdata/roms
== media_b
total 12
drwxr-xr-x 3 root root 4096 Oct 14 13:59 .
drwxr-xr-x 5 nobody nogroup 4096 Oct 14 13:59 ..
drwxrwsr-x 6 root 1000 4096 Oct 14 13:59 userdata
/mnt/felhom-drives/media_b
/mnt/felhom-drives/media_b/userdata
/mnt/felhom-drives/media_b/userdata/documents
/mnt/felhom-drives/media_b/userdata/downloads
/mnt/felhom-drives/media_b/userdata/media
/mnt/felhom-drives/media_b/userdata/media/audiobooks
/mnt/felhom-drives/media_b/userdata/media/books
/mnt/felhom-drives/media_b/userdata/media/comics
/mnt/felhom-drives/media_b/userdata/media/grimmory
/mnt/felhom-drives/media_b/userdata/media/metube
/mnt/felhom-drives/media_b/userdata/media/movies
/mnt/felhom-drives/media_b/userdata/media/music
/mnt/felhom-drives/media_b/userdata/media/nzbget
/mnt/felhom-drives/media_b/userdata/media/photos
/mnt/felhom-drives/media_b/userdata/media/podcasts
/mnt/felhom-drives/media_b/userdata/media/torrents
/mnt/felhom-drives/media_b/userdata/media/transmission
/mnt/felhom-drives/media_b/userdata/media/tv
/mnt/felhom-drives/media_b/userdata/media/usenet
/mnt/felhom-drives/media_b/userdata/roms
== adatlemez
total 20
drwxr-xr-x 5 root root 4096 Oct 4 19:47 .
drwxr-xr-x 5 nobody nogroup 4096 Oct 14 13:59 ..
drwxr-xr-x 3 root root 4096 Oct 4 19:45 appdata
drwxr-xr-x 5 root root 4096 Oct 5 03:30 backups
drwxrwsr-x 6 root 1000 4096 Oct 4 19:44 userdata
/mnt/felhom-drives/adatlemez
/mnt/felhom-drives/adatlemez/appdata
/mnt/felhom-drives/adatlemez/appdata/paperless
/mnt/felhom-drives/adatlemez/appdata/paperless/export
/mnt/felhom-drives/adatlemez/appdata/paperless/media
/mnt/felhom-drives/adatlemez/backups
/mnt/felhom-drives/adatlemez/backups/offsite-proof
/mnt/felhom-drives/adatlemez/backups/primary
/mnt/felhom-drives/adatlemez/backups/primary/paperless-ngx
/mnt/felhom-drives/adatlemez/backups/secondary
/mnt/felhom-drives/adatlemez/backups/secondary/bookstack
/mnt/felhom-drives/adatlemez/backups/secondary/privatebin
/mnt/felhom-drives/adatlemez/userdata
/mnt/felhom-drives/adatlemez/userdata/documents
/mnt/felhom-drives/adatlemez/userdata/downloads
/mnt/felhom-drives/adatlemez/userdata/media
/mnt/felhom-drives/adatlemez/userdata/media/audiobooks
/mnt/felhom-drives/adatlemez/userdata/media/books
/mnt/felhom-drives/adatlemez/userdata/media/comics
/mnt/felhom-drives/adatlemez/userdata/media/grimmory
/mnt/felhom-drives/adatlemez/userdata/media/metube
/mnt/felhom-drives/adatlemez/userdata/media/movies
/mnt/felhom-drives/adatlemez/userdata/media/music
/mnt/felhom-drives/adatlemez/userdata/media/nzbget
/mnt/felhom-drives/adatlemez/userdata/media/photos
/mnt/felhom-drives/adatlemez/userdata/media/podcasts
/mnt/felhom-drives/adatlemez/userdata/media/torrents
/mnt/felhom-drives/adatlemez/userdata/media/transmission
/mnt/felhom-drives/adatlemez/userdata/media/tv
/mnt/felhom-drives/adatlemez/userdata/media/usenet
/mnt/felhom-drives/adatlemez/userdata/roms
2026-10-14T13:59:18.752620558Z /mnt/felhom-drives/adatlemez/userdata->/srv/adatlemez true rprivate; /mnt/sys_drive/felhom-data/userdata/import->/srv/beolvasas true rprivate; /mnt/felhom-drives/media_a/userdata->/srv/media_a true rprivate; /mnt/felhom-drives/media_b/userdata->/srv/media_b true rprivate; /opt/docker/stacks/filebrowser/config.yaml->/home/filebrowser/config.yaml false rprivate; /var/lib/docker/volumes/filebrowser_filebrowser_data/_data->/home/filebrowser/data true ;
2026/10/14 13:55:42 [INFO] [scheduler] Job agent-channel-health completed (took 707ms)
2026/10/14 13:55:51 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:00 [INFO] [web] Login from 192.168.0.180
2026/10/14 13:56:01 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:01 [INFO] Health probes: 1 ok (of 1 probed)
2026/10/14 13:56:11 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:21 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:21 [INFO] Health probes: 2 ok (of 2 probed)
2026/10/14 13:56:31 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:32 [INFO] [web] Login from 192.168.0.180
2026/10/14 13:56:41 [INFO] [scheduler] Running job: stack-scan
2026/10/14 13:56:41 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:41 [INFO] [stacks] ScanStacks complete: 67 stacks found (3 deployed, 64 available)
2026/10/14 13:56:41 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:56:41 [INFO] [scheduler] Job stack-scan completed (took 125ms)
2026/10/14 13:56:41 [INFO] [scheduler] Running job: agent-channel-health
2026/10/14 13:56:42 [INFO] [scheduler] Job agent-channel-health completed (took 485ms)
2026/10/14 13:56:51 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:01 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:11 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:21 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:31 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:41 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:57:41 [INFO] [scheduler] Running job: agent-channel-health
2026/10/14 13:57:42 [INFO] [scheduler] Job agent-channel-health completed (took 473ms)
2026/10/14 13:57:51 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:01 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:11 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:21 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:31 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:41 [INFO] [scheduler] Running job: stack-scan
2026/10/14 13:58:41 [INFO] [stacks] ScanStacks complete: 67 stacks found (3 deployed, 64 available)
2026/10/14 13:58:41 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:41 [INFO] [scheduler] Job stack-scan completed (took 81ms)
2026/10/14 13:58:41 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:58:41 [INFO] [scheduler] Running job: agent-channel-health
2026/10/14 13:58:42 [INFO] [scheduler] Job agent-channel-health completed (took 480ms)
2026/10/14 13:58:49 [INFO] [web] Login from 192.168.0.180
2026/10/14 13:58:51 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:59:01 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:59:08 [INFO] [web] Login from 192.168.0.180
2026/10/14 13:59:08 [INFO] [web] storage init "/mnt/media_a" started (device /dev/sdc, fs ext4)
2026/10/14 13:59:09 [INFO] [web] enroll /dev/sdc: mounting the agent-verified new filesystem 4ebfd36d-87d3-4b63-839e-a5a710fc799a
2026/10/14 13:59:10 [INFO] [web] enroll: drive bound into guest: /mnt/media_a
2026/10/14 13:59:10 [INFO] [settings] Added storage path: /mnt/felhom-drives/media_a
2026/10/14 13:59:10 [INFO] [settings] Settings saved
2026/10/14 13:59:10 [INFO] [web] storage init done: /mnt/felhom-drives/media_a registered (2188ms)
2026/10/14 13:59:10 [INFO] [stacks] ParseComposeHDDMounts: found 2 HDD mounts for /opt/docker/stacks/paperless-ngx/docker-compose.yml
2026/10/14 13:59:11 [INFO] [web] FileBrowser mounts synced (recreated) — 2 storage path(s), config updated
2026/10/14 13:59:11 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
2026/10/14 13:59:16 [INFO] [web] Login from 192.168.0.180
2026/10/14 13:59:16 [INFO] [web] storage init "/mnt/media_b" started (device /dev/sdd, fs ext4)
2026/10/14 13:59:16 [INFO] [web] enroll /dev/sdd: mounting the agent-verified new filesystem e15652d5-0aa7-4eb8-93a1-afb2839d527c
2026/10/14 13:59:18 [INFO] [web] enroll: drive bound into guest: /mnt/media_b
2026/10/14 13:59:18 [INFO] [settings] Added storage path: /mnt/felhom-drives/media_b
2026/10/14 13:59:18 [INFO] [settings] Settings saved
2026/10/14 13:59:18 [INFO] [web] storage init done: /mnt/felhom-drives/media_b registered (2106ms)
2026/10/14 13:59:18 [INFO] [stacks] ParseComposeHDDMounts: found 2 HDD mounts for /opt/docker/stacks/paperless-ngx/docker-compose.yml
2026/10/14 13:59:19 [INFO] [web] FileBrowser mounts synced (recreated) — 3 storage path(s), config updated
2026/10/14 13:59:21 [INFO] [stacks] Status refresh: 9 containers across 67 stacks
@@ -0,0 +1,7 @@
2026-10-14T15:59:52+02:00
--- cluster backup jobs:
[]
ls: cannot access '/etc/pve/jobs.cfg': No such file or directory
scsi2: nvme-scratch:341/vm-341-disk-3.raw,backup=0,size=100G
scsi3: nvme-scratch:341/vm-341-disk-4.raw,backup=0,size=100G
@@ -0,0 +1,32 @@
2026-10-14T15:59:55+02:00
mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/felhom,backup=1,size=70G
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1
rootfs: local-lvm:vm-9201-disk-0,size=32G
total 4
-rw-r--r-- 1 root root 1495 Oct 14 04:44 lxc-9201.log
2026-10-14 04:42:22 INFO: Starting Backup of VM 9201 (lxc)
2026-10-14 04:42:22 INFO: including mount point rootfs ('/') in backup
2026-10-14 04:42:22 INFO: including mount point mp0 ('/var/lib/felhom') in backup
2026-10-14 04:42:22 INFO: excluding bind mount point mp8 ('/mnt/felhom-drives') from backup (not a volume)
2026-10-14 04:42:22 INFO: excluding bind mount point mp9 ('/etc/felhom-bootstrap') from backup (not a volume)
2026-10-14 04:42:22 INFO: backup mode: snapshot
2026-10-14 04:42:24 INFO: creating vzdump archive '/var/lib/vz/dump/vzdump-lxc-9201-2026_10_14-04_42_22.tar.zst'
2026-10-14 04:44:52 INFO: Total bytes written: 5447720960 (5.1GiB, 35MiB/s)
2026-10-14 04:44:53 INFO: Finished Backup of VM 9201 (00:02:31)
total 5245972
-rw-r--r-- 1 root root 1495 Oct 14 04:44 vzdump-lxc-9201-2026_10_14-04_42_22.log
-rw-r--r-- 1 root root 16 Oct 14 04:44 vzdump-lxc-9201-2026_10_14-04_42_22.tar.zst.notes
-rw-r--r-- 1 root root 1575398000 Oct 14 04:44 vzdump-lxc-9201-2026_10_14-04_42_22.tar.zst
-rw-r--r-- 1 root root 1495 Oct 13 04:40 vzdump-lxc-9201-2026_10_13-04_38_03.log
== UPID:felhom:0014BF84:005ED7D4:6ACBEB45:vzdump:9201:felhom-agent@pve!agent:
INFO: starting new backup job: vzdump 9201 --notes-template 'felhom local-api' --compress zstd --mode snapshot --storage felhom-pbs --node felhom
INFO: Starting Backup of VM 9201 (lxc)
INFO: including mount point rootfs ('/') in backup
INFO: including mount point mp0 ('/var/lib/felhom') in backup
INFO: excluding bind mount point mp8 ('/mnt/felhom-drives') from backup (not a volume)
INFO: excluding bind mount point mp9 ('/etc/felhom-bootstrap') from backup (not a volume)
INFO: Starting backup: [tester-1]:ct/9201/2026-10-11T20:02:13Z
INFO: Starting backup protocol: Sun Oct 11 22:02:14 2026
INFO: Finished Backup of VM 9201 (00:00:58)
TASK OK
@@ -0,0 +1,50 @@
# A6(3) — the PRODUCTION classifier (controller main acbbf4b = 0.310.0 source) over template dirs; nsRoot = drive A
# DRILL templates (the layout under test):
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/sonarr backup-block=false classified-binds=0 hasClassification=false
tier2-secondary: hasClassification=false paths=0 skipped=0
tier3-offsite: hasClassification=false paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/radarr backup-block=false classified-binds=0 hasClassification=false
tier2-secondary: hasClassification=false paths=0 skipped=0
tier3-offsite: hasClassification=false paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/qbittorrent backup-block=true classified-binds=0 hasClassification=true
tier2-secondary: hasClassification=true paths=0 skipped=0
tier3-offsite: hasClassification=true paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/transmission backup-block=true classified-binds=0 hasClassification=true
tier2-secondary: hasClassification=true paths=0 skipped=0
tier3-offsite: hasClassification=true paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/plex backup-block=false classified-binds=0 hasClassification=false
tier2-secondary: hasClassification=false paths=0 skipped=0
tier3-offsite: hasClassification=false paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates/jellyfin backup-block=false classified-binds=0 hasClassification=false
tier2-secondary: hasClassification=false paths=0 skipped=0
tier3-offsite: hasClassification=false paths=0 skipped=0
# CONTROL 1 — LIVE templates (today's shape):
== /mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/templates/sonarr backup-block=true classified-binds=2 hasClassification=true
bind root=userdata rel="media/tv" ro=false class="excluded" origin=explicit
bind root=userdata rel="downloads" ro=false class="excluded" origin=explicit
tier2-secondary: hasClassification=true paths=0 skipped=0
tier3-offsite: hasClassification=true paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/templates/qbittorrent backup-block=true classified-binds=1 hasClassification=true
bind root=userdata rel="media/torrents" ro=false class="optional" origin=explicit
tier2-secondary: hasClassification=true paths=1 skipped=0
CAPTURED /mnt/felhom-drives/media_a/userdata/media/torrents (optional)
tier3-offsite: hasClassification=true paths=0 skipped=0
== /mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/templates/transmission backup-block=true classified-binds=2 hasClassification=true
bind root=userdata rel="media/transmission" ro=false class="optional" origin=explicit
bind root=userdata rel="media/transmission/watch" ro=false class="mandatory" origin=default_writable
tier2-secondary: hasClassification=true paths=1 skipped=0
CAPTURED /mnt/felhom-drives/media_a/userdata/media/transmission (optional)
tier3-offsite: hasClassification=true paths=1 skipped=0
CAPTURED /mnt/felhom-drives/media_a/userdata/media/transmission/watch (mandatory)
# CONTROL 2 (decoy, must show CAPTURED mandatory) — live sonarr with a backup block that does NOT list its writable ${USERDATA_PATH} binds:
== /tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/bf86b82e-7538-4da2-99f7-a6f96249f7fb/scratchpad/decoy/sonarr-unlisted backup-block=true classified-binds=2 hasClassification=true
bind root=userdata rel="media/tv" ro=false class="mandatory" origin=default_writable
bind root=userdata rel="downloads" ro=false class="mandatory" origin=default_writable
tier2-secondary: hasClassification=true paths=2 skipped=0
CAPTURED /mnt/felhom-drives/media_a/userdata/downloads (mandatory)
CAPTURED /mnt/felhom-drives/media_a/userdata/media/tv (mandatory)
tier3-offsite: hasClassification=true paths=2 skipped=0
CAPTURED /mnt/felhom-drives/media_a/userdata/downloads (mandatory)
CAPTURED /mnt/felhom-drives/media_a/userdata/media/tv (mandatory)
@@ -0,0 +1,19 @@
# Predictions — written before Part A4/A5/A6 ran (2026-10-14 ~16:00 CEST)
A4 enrolment
- Both disks enrol through /api/storage/init (ext4) and mount at /mnt/felhom-drives/media_a and media_b in the guest.
- Neither becomes the Tier-1 backup target: the target role is written ONLY by /api/storage/backup-target/assign
(the offer's acceptance), never by registration. The target stays `local`.
- Neither becomes the default storage: this run sends set_default=false (the wizard's checkbox is ON by default).
A5 skeleton and file browser
- Both drives get felhom-data/userdata/{media/{movies,tv,music,...},downloads,...}.
- FileBrowser's container is recreated and binds both new drives' userdata/.
- The dashboard says nothing about apps needing a restart (no such notice exists in v0.310.0).
A6 backups
- (1) demo-hp: scsi2/scsi3 carry backup=0 (read back), and demo-hp has no vzdump job for VM 341 at all.
- (2) Tester 1's whole-guest backup (vzdump of 9201) does not include /mnt/felhom-drives: mp8 is a host bind mount,
out of vzdump scope (07 §6.1 FACT). The new drives arrive UNDER mp8 (bound_under_parent), so no new mp line.
- (3) A plain-host-path bind is not classified (classifyRoot knows only ${..._PATH}), so it is in no app capture set
at any tier. An unlisted writable ${USERDATA_PATH} bind WOULD be mandatory (Tier 3 included).
@@ -0,0 +1,37 @@
bookstack Up 5 minutes (healthy)
bookstack-db Up 5 minutes (healthy)
cloudflared Up 5 minutes (healthy)
felhom-controller Up 5 minutes (healthy)
filebrowser Up 5 minutes (healthy)
paperless-postgres Up 5 minutes (healthy)
paperless-redis Up 5 minutes (healthy)
paperless-webserver Up 5 minutes (healthy)
privatebin Up 5 minutes (healthy)
qbittorrent Up 4 minutes (healthy)
radarr Up 4 minutes (healthy)
sonarr Up 4 minutes (healthy)
traefik Up 5 minutes
transmission Up 4 minutes (healthy)
TARGET SOURCE
/mnt/felhom-drives /dev/mapper/pve-root[/mnt/felhom-drives]
|-/mnt/felhom-drives/media_a /dev/sdc[/felhom-data]
|-/mnt/felhom-drives/adatlemez /dev/sdb[/felhom-data]
`-/mnt/felhom-drives/media_b /dev/sdd[/felhom-data]
sonarr sees: 14 14
radarr sees: 14 14
qbittorrent sees: 14 14
transmission sees: 14 14
24: repo_url: file:///opt/docker/felhom-controller/drill-catalog.git
2026/10/14 15:28:40 [INFO] [stacks] Stack paperless-ngx started successfully (took 11.8s)
2026/10/14 15:28:40 [INFO] [gate] boot 1791991571-10166: live bind confirmed — recreating drive-backed app radarr (state=starting) onto /mnt/felhom-drives/media_a
2026/10/14 15:28:44 [INFO] [stacks] Stack paperless-ngx post-start status:
2026/10/14 15:28:44 [INFO] [stacks] paperless-postgres postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873 running Up 14 seconds (healthy)
2026/10/14 15:28:44 [INFO] [stacks] paperless-redis redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499 running Up 14 seconds (healthy)
2026/10/14 15:28:44 [INFO] [stacks] paperless-webserver ghcr.io/paperless-ngx/paperless-ngx:2.20.15@sha256:6c86cad803970ea782683a8e80e7403444c5bf3cf70de63b4d3c8e87500db92f running Up 4 seconds (health: starting)
2026/10/14 15:28:46 [INFO] [gate] boot 1791991571-10166: live bind confirmed — recreating drive-backed app sonarr (state=starting) onto /mnt/felhom-drives/media_a
2026/10/14 15:28:51 [INFO] [gate] boot 1791991571-10166: live bind confirmed — recreating drive-backed app transmission (state=running) onto /mnt/felhom-drives/media_a
2026/10/14 15:28:58 [INFO] [gate] boot 1791991571-10166: live bind confirmed — recreating drive-backed app qbittorrent (state=running) onto /mnt/felhom-drives/media_a
2026/10/14 15:29:01 [INFO] [gate] boot 1791991571-10166: re-syncing FileBrowser mounts against the live binds
2026/10/14 15:29:01 [INFO] [stacks] ParseComposeHDDMounts: found 2 HDD mounts for /opt/docker/stacks/paperless-ngx/docker-compose.yml
2026/10/14 15:33:14 [INFO] [stacks] ParseComposeHDDMounts: found 2 HDD mounts for /opt/docker/stacks/paperless-ngx/docker-compose.yml
@@ -0,0 +1,3 @@
mirror main 0ad6383 DRILL media-all-drives: six media templates bind every test drive's userdata/media at the same path (Tester 1, 2026-10-14)
drill repo main: 0ad6383
@@ -0,0 +1,22 @@
24: repo_url: file:///opt/docker/felhom-controller/drill-catalog.git
24c24
< repo_url: https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
---
> repo_url: file:///opt/docker/felhom-controller/drill-catalog.git
2026/10/14 14:04:37 [INFO] [sync] Updated radarr/.felhom.yml
2026/10/14 14:04:37 [INFO] [sync] Updated sonarr/docker-compose.yml
2026/10/14 14:04:37 [INFO] [sync] Updated sonarr/.felhom.yml
2026/10/14 14:04:37 [INFO] [sync] Updated transmission/docker-compose.yml
2026/10/14 14:04:37 [INFO] [sync] Updated transmission/.felhom.yml
2026/10/14 14:04:37 [INFO] [sync] catalog moved 8ee687e544e0 -> 0ad638315272 (0 new, 6 stack dir(s) changed)
2026/10/14 14:04:37 [INFO] [sync] Catalog sync complete
2026/10/14 14:04:37 [INFO] [sync] Initial sync: Sablonok frissítve — frissítve: jellyfin, plex, qbittorrent, radarr, sonarr, transmission
24: repo_url: file:///opt/docker/felhom-controller/drill-catalog.git
25: sync_interval: 15m
-rw------- 1 root root 3044 Oct 4 19:41 /var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml.pre-media-2026-10-14
mirror main 0ad6383 DRILL media-all-drives: six media templates bind every test drive's userdata/media at the same path (Tester 1, 2026-10-14)
cache HEAD 0ad6383 DRILL media-all-drives: six media templates bind every test drive's userdata/media at the same path (Tester 1, 2026-10-14)
file:///opt/docker/felhom-controller/drill-catalog.git
@@ -0,0 +1,43 @@
2026-10-14T16:05:05+02:00
== control 1: Tester 1 cache carries the drill layout
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
LANGUAGE = (unset),
LC_ALL = (unset),
LC_CTYPE = "UTF-8",
LC_NUMERIC = (unset),
LC_COLLATE = (unset),
LC_TIME = (unset),
LC_MESSAGES = (unset),
LC_MONETARY = (unset),
LC_ADDRESS = (unset),
LC_IDENTIFICATION = (unset),
LC_MEASUREMENT = (unset),
LC_PAPER = (unset),
LC_TELEPHONE = (unset),
LC_NAME = (unset),
LANG = "en_US.UTF-8"
are supported and installed on your system.
perl: warning: Falling back to a fallback locale ("en_US.UTF-8").
1
0ad6383
== control 2: the other boxes' caches still follow the live catalog
-- demo-hp 9201:
8ee687e
https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
0
-- demo-hp 9202:
8ee687e
https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
0
-- N100 (felhom-pve) guest:
LANGUAGE = (unset),
LANG = "en_US.UTF-8"
guest 9201
LANGUAGE = (unset),
LANG = "en_US.UTF-8"
8ee687e
https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git
0
== control 3: live catalog main
8ee687e544e0bc52980458ebf3c86e9484dba1fa refs/heads/main
@@ -0,0 +1,38 @@
16:05:42 qbittorrent: POST deploy {"DOMAIN": "enkicsifelhom.hu", "SUBDOMAIN": "torrent", "HDD_PATH": "/mnt/felhom-drives/media_a", "QBT_ADMIN_PASSWORD": "<generated, kept in secrets.json>"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
16:05:57 qbittorrent: deployed, state=running, pinned={'qbittorrent': 'qbittorrentofficial/qbittorrent-nox:5.2.4-1'}
running /var/lib/docker/volumes/qbittorrent_qbittorrent_config/_data->/config:true /mnt/felhom-drives/media_a/userdata/media->/mnt/felhom-drives/media_a/userdata/media:true /mnt/felhom-drives/media_b/userdata/media->/mnt/felhom-drives/media_b/userdata/media:true
2026/10/14 14:05:42 [INFO] [api] Deploy requested for stack: qbittorrent
2026/10/14 14:05:42 [INFO] [stacks] Deploying stack qbittorrent with 4 env vars: [DOMAIN, SUBDOMAIN, HDD_PATH, QBT_ADMIN_PASSWORD]
2026/10/14 14:05:42 [INFO] Event pushed: app_deploy_started (info) [hu-only] — Alkalmazás telepítése elindult: qBittorrent
2026/10/14 14:05:47 [INFO] [stacks] Stack qbittorrent deployed successfully (took 5.5s)
2026/10/14 14:05:48 [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás telepítve: qBittorrent
2026/10/14 14:05:53 [INFO] [stacks] after_install qbittorrent: qbittorrent [/bin/sh -c curl -s -c /tmp/fc -o /dev/null -X POST -d "username=admin&password=${QBT_ADMIN_PASSWORD}" -H 'Referer: http://127.0.0.1:8080' http://127.0.0.1:8080/api/v2/auth/login && curl -s -b /tmp/fc -o /dev/null -X POST --data-urlencode 'json={"upnp":false,"web_ui_domain_list":"${SUBDOMAIN}.${DOMAIN};qbittorrent;localhost;127.0.0.1"}' -H 'Referer: http://127.0.0.1:8080' http://127.0.0.1:8080/api/v2/app/setPreferences && curl -s -b /tmp/fc http://127.0.0.1:8080/api/v2/app/preferences | grep -q '"upnp":false' && curl -s -o /dev/null -w '%{http_code}' -H 'Host: qbittorrent:8080' http://127.0.0.1:8080/ | grep -qE '^(200|401|403)$' && echo FELHOM_AFTER_INSTALL_OK
2026/10/14 14:05:53 [INFO] [stacks] qbittorrent: install hold OPENED by after_install — the app is reached as without a hold
16:06:01 transmission: POST deploy {"DOMAIN": "enkicsifelhom.hu", "SUBDOMAIN": "transmission", "HDD_PATH": "/mnt/felhom-drives/media_a", "TR_ADMIN_PASSWORD": "<generated, kept in secrets.json>"} -> 202 {"ok": true, "message": "Telepítés elindítva – az állapot a kártyán követhető"}
16:06:21 transmission: deployed, state=running, pinned={'transmission': 'lscr.io/linuxserver/transmission:version-4.1.3-r0'}
running /var/lib/docker/volumes/transmission_transmission_config/_data->/config:true /mnt/felhom-drives/media_a/userdata/media/transmission/watch->/watch:true /mnt/felhom-drives/media_a/userdata/media->/mnt/felhom-drives/media_a/userdata/media:true /mnt/felhom-drives/media_b/userdata/media->/mnt/felhom-drives/media_b/userdata/media:true
2026/10/14 14:06:01 [INFO] [api] Deploy requested for stack: transmission
2026/10/14 14:06:01 [INFO] [stacks] Deploying stack transmission with 4 env vars: [DOMAIN, SUBDOMAIN, HDD_PATH, TR_ADMIN_PASSWORD]
2026/10/14 14:06:01 [INFO] Event pushed: app_deploy_started (info) [hu-only] — Alkalmazás telepítése elindult: Transmission
2026/10/14 14:06:08 [INFO] [stacks] Stack transmission deployed successfully (took 6.7s)
2026/10/14 14:06:08 [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás telepítve: Transmission
16:06:26 sonarr: POST deploy {"DOMAIN": "enkicsifelhom.hu", "SUBDOMAIN": "sonarr", "HDD_PATH": "/mnt/felhom-drives/media_a"} -> 202 {"ok": true, "data": {"warning": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Normál használat mellett ez nem okoz problémát."}, "message": "Telepítés elindítva – az ál
16:07:01 sonarr: deployed, state=running, pinned={'sonarr': 'lscr.io/linuxserver/sonarr:4.0.20'}
running /var/lib/docker/volumes/sonarr_sonarr_config/_data->/config:true /mnt/felhom-drives/media_a/userdata/media->/mnt/felhom-drives/media_a/userdata/media:true /mnt/felhom-drives/media_b/userdata/media->/mnt/felhom-drives/media_b/userdata/media:true
2026/10/14 14:06:26 [INFO] [api] Deploy requested for stack: sonarr
2026/10/14 14:06:26 [INFO] [stacks] Deploying stack sonarr with 3 env vars: [DOMAIN, SUBDOMAIN, HDD_PATH]
2026/10/14 14:06:26 [INFO] Event pushed: app_deploy_started (info) [hu-only] — Alkalmazás telepítése elindult: Sonarr
2026/10/14 14:06:38 [INFO] [stacks] Stack sonarr deployed successfully (took 12.3s)
2026/10/14 14:06:39 [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás telepítve: Sonarr
16:07:05 radarr: POST deploy {"DOMAIN": "enkicsifelhom.hu", "SUBDOMAIN": "radarr", "HDD_PATH": "/mnt/felhom-drives/media_a"} -> 202 {"ok": true, "data": {"warning": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Normál használat mellett ez nem okoz problémát."}, "message": "Telepítés elindítva – az ál
16:07:30 radarr: deployed, state=running, pinned={'radarr': 'lscr.io/linuxserver/radarr:6.4.4'}
running /var/lib/docker/volumes/radarr_radarr_config/_data->/config:true /mnt/felhom-drives/media_a/userdata/media->/mnt/felhom-drives/media_a/userdata/media:true /mnt/felhom-drives/media_b/userdata/media->/mnt/felhom-drives/media_b/userdata/media:true
2026/10/14 14:07:05 [INFO] [api] Deploy requested for stack: radarr
2026/10/14 14:07:05 [INFO] [stacks] Deploying stack radarr with 3 env vars: [DOMAIN, SUBDOMAIN, HDD_PATH]
2026/10/14 14:07:05 [INFO] Event pushed: app_deploy_started (info) [hu-only] — Alkalmazás telepítése elindult: Radarr
2026/10/14 14:07:12 [INFO] [stacks] Stack radarr deployed successfully (took 6.6s)
2026/10/14 14:07:12 [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás telepítve: Radarr
@@ -0,0 +1,4 @@
sonarr: env SONARR__AUTH__APIKEY=<set> -> status with that key HTTP 200; with a wrong key HTTP 401; key written to config.xml: 0
radarr: env RADARR__AUTH__APIKEY=<set> -> status with that key HTTP 200; with a wrong key HTTP 401; key written to config.xml: 0
0
@@ -0,0 +1,86 @@
== qBittorrent http://172.18.0.8:8080 (password: the deploy secret, read from the container's environment)
POST http://172.18.0.8:8080/api/v2/auth/login -> 204 ''
session cookie name: QBT_SID_8080
GET http://172.18.0.8:8080/api/v2/app/preferences -> 200 {add_stopped_enabled, add_to_top_of_queue, add_trackers, add_trackers_enabled, add_trackers_from_url_enabled, add_trackers_url, add_trackers_url_list, alt_dl_limit, alt_up_limit, alternative_webui_enabled, alternative_webui_path, announce_ip, announce_port, announce_to_all_tiers, announce_to_all_trac}
before: auto_tmm_enabled=False save_path=/mnt/felhom-drives/media_a/userdata/media/torrents temp_path_enabled=True temp_path=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
POST http://172.18.0.8:8080/api/v2/app/setPreferences -> 200 ''
GET http://172.18.0.8:8080/api/v2/app/preferences -> 200 {add_stopped_enabled, add_to_top_of_queue, add_trackers, add_trackers_enabled, add_trackers_from_url_enabled, add_trackers_url, add_trackers_url_list, alt_dl_limit, alt_up_limit, alternative_webui_enabled, alternative_webui_path, announce_ip, announce_port, announce_to_all_tiers, announce_to_all_trac}
after : auto_tmm_enabled=True (a category's save path applies only in automatic mode)
GET http://172.18.0.8:8080/api/v2/torrents/categories -> 200 {}
POST http://172.18.0.8:8080/api/v2/torrents/createCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/createCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/createCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/createCategory -> 200 ''
GET http://172.18.0.8:8080/api/v2/torrents/categories -> 200 {movies-drive-a, movies-drive-b, tv-drive-a, tv-drive-b}
category movies-drive-a: savePath=/mnt/felhom-drives/media_a/userdata/media/torrents/movies downloadPath=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
category movies-drive-b: savePath=/mnt/felhom-drives/media_b/userdata/media/torrents/movies downloadPath=/mnt/felhom-drives/media_b/userdata/media/torrents/.temp
category tv-drive-a: savePath=/mnt/felhom-drives/media_a/userdata/media/torrents/tv downloadPath=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
category tv-drive-b: savePath=/mnt/felhom-drives/media_b/userdata/media/torrents/tv downloadPath=/mnt/felhom-drives/media_b/userdata/media/torrents/.temp
== Transmission http://172.18.0.9:9091/transmission/rpc (password: the deploy secret, read from the container's environment)
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
before: {"download-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/complete", "incomplete-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/incomplete", "incomplete-dir-enabled": true, "rename-partial-files": true}
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
after : {"download-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/complete", "incomplete-dir-enabled": false, "rename-partial-files": true} (one incomplete folder for all torrents would be on drive A)
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
probe Host: transmission:9091 -> HTTP 200 {'arguments': {'version': '4.1.3 (838877323f)'}, 'result': 'success'}
== sonarr http://172.18.0.10:8989/api/v3 (key: <ApiKey> in /config/config.xml, read from the config volume)
GET http://172.18.0.10:8989/api/v3/tag -> 200 [list of 0]
POST http://172.18.0.10:8989/api/v3/tag -> 201 {id, label}
POST http://172.18.0.10:8989/api/v3/tag -> 201 {id, label}
tags: {'drive-a': 1, 'drive-b': 2}
GET http://172.18.0.10:8989/api/v3/rootfolder -> 200 [list of 0]
POST http://172.18.0.10:8989/api/v3/rootfolder -> 201 {accessible, freeSpace, id, path, unmappedFolders}
POST http://172.18.0.10:8989/api/v3/rootfolder -> 201 {accessible, freeSpace, id, path, unmappedFolders}
GET http://172.18.0.10:8989/api/v3/rootfolder -> 200 [list of 2]
root folder: /mnt/felhom-drives/media_a/userdata/media/tv accessible=True freeSpace=99701432320
root folder: /mnt/felhom-drives/media_b/userdata/media/tv accessible=True freeSpace=99701567488
GET http://172.18.0.10:8989/api/v3/downloadclient/schema -> 200 [list of 18]
GET http://172.18.0.10:8989/api/v3/downloadclient -> 200 [list of 0]
POST http://172.18.0.10:8989/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
POST http://172.18.0.10:8989/api/v3/downloadclient -> 400 [list of 1]
client Transmission drive-a: REFUSED by sonarr's own connection test: [{"isWarning": false, "propertyName": "TvCategory", "errorMessage": "Cannot use Category and Directory", "attemptedValue": "tv-sonarr", "severity": "error"}]
POST http://172.18.0.10:8989/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
POST http://172.18.0.10:8989/api/v3/downloadclient -> 400 [list of 1]
client Transmission drive-b: REFUSED by sonarr's own connection test: [{"isWarning": false, "propertyName": "TvCategory", "errorMessage": "Cannot use Category and Directory", "attemptedValue": "tv-sonarr", "severity": "error"}]
GET http://172.18.0.10:8989/api/v3/downloadclient -> 200 [list of 2]
client qBittorrent drive-a: impl=QBittorrent enable=True tags=[1] host=qbittorrent category=tv-drive-a directory=None
client qBittorrent drive-b: impl=QBittorrent enable=True tags=[2] host=qbittorrent category=tv-drive-b directory=None
GET http://172.18.0.10:8989/api/v3/config/mediamanagement -> 200 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
before: minimumFreeSpaceWhenImporting=100 MB, copyUsingHardlinks=True
PUT http://172.18.0.10:8989/api/v3/config/mediamanagement/1 -> 202 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
GET http://172.18.0.10:8989/api/v3/config/mediamanagement -> 200 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
after : minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True (default kept)
== radarr http://172.18.0.11:7878/api/v3 (key: <ApiKey> in /config/config.xml, read from the config volume)
GET http://172.18.0.11:7878/api/v3/tag -> 200 [list of 0]
POST http://172.18.0.11:7878/api/v3/tag -> 201 {id, label}
POST http://172.18.0.11:7878/api/v3/tag -> 201 {id, label}
tags: {'drive-a': 1, 'drive-b': 2}
GET http://172.18.0.11:7878/api/v3/rootfolder -> 200 [list of 0]
POST http://172.18.0.11:7878/api/v3/rootfolder -> 201 {accessible, freeSpace, id, path, unmappedFolders}
POST http://172.18.0.11:7878/api/v3/rootfolder -> 201 {accessible, freeSpace, id, path, unmappedFolders}
GET http://172.18.0.11:7878/api/v3/rootfolder -> 200 [list of 2]
root folder: /mnt/felhom-drives/media_a/userdata/media/movies accessible=True freeSpace=99701432320
root folder: /mnt/felhom-drives/media_b/userdata/media/movies accessible=True freeSpace=99701567488
GET http://172.18.0.11:7878/api/v3/downloadclient/schema -> 200 [list of 19]
GET http://172.18.0.11:7878/api/v3/downloadclient -> 200 [list of 0]
POST http://172.18.0.11:7878/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
POST http://172.18.0.11:7878/api/v3/downloadclient -> 400 [list of 1]
client Transmission drive-a: REFUSED by radarr's own connection test: [{"isWarning": false, "propertyName": "MovieCategory", "errorMessage": "Cannot use Category and Directory", "attemptedValue": "radarr", "severity": "error"}]
POST http://172.18.0.11:7878/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
POST http://172.18.0.11:7878/api/v3/downloadclient -> 400 [list of 1]
client Transmission drive-b: REFUSED by radarr's own connection test: [{"isWarning": false, "propertyName": "MovieCategory", "errorMessage": "Cannot use Category and Directory", "attemptedValue": "radarr", "severity": "error"}]
GET http://172.18.0.11:7878/api/v3/downloadclient -> 200 [list of 2]
client qBittorrent drive-a: impl=QBittorrent enable=True tags=[1] host=qbittorrent category=movies-drive-a directory=None
client qBittorrent drive-b: impl=QBittorrent enable=True tags=[2] host=qbittorrent category=movies-drive-b directory=None
GET http://172.18.0.11:7878/api/v3/config/mediamanagement -> 200 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
before: minimumFreeSpaceWhenImporting=100 MB, copyUsingHardlinks=True
PUT http://172.18.0.11:7878/api/v3/config/mediamanagement/1 -> 202 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
GET http://172.18.0.11:7878/api/v3/config/mediamanagement -> 200 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
after : minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True (default kept)
== done; Transmission reachable by its container name: True
@@ -0,0 +1,78 @@
== qBittorrent http://172.18.0.8:8080 (password: the deploy secret, read from the container's environment)
POST http://172.18.0.8:8080/api/v2/auth/login -> 204 ''
session cookie name: QBT_SID_8080
GET http://172.18.0.8:8080/api/v2/app/preferences -> 200 {add_stopped_enabled, add_to_top_of_queue, add_trackers, add_trackers_enabled, add_trackers_from_url_enabled, add_trackers_url, add_trackers_url_list, alt_dl_limit, alt_up_limit, alternative_webui_enabled, alternative_webui_path, announce_ip, announce_port, announce_to_all_tiers, announce_to_all_trac}
before: auto_tmm_enabled=True save_path=/mnt/felhom-drives/media_a/userdata/media/torrents temp_path_enabled=True temp_path=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
POST http://172.18.0.8:8080/api/v2/app/setPreferences -> 200 ''
GET http://172.18.0.8:8080/api/v2/app/preferences -> 200 {add_stopped_enabled, add_to_top_of_queue, add_trackers, add_trackers_enabled, add_trackers_from_url_enabled, add_trackers_url, add_trackers_url_list, alt_dl_limit, alt_up_limit, alternative_webui_enabled, alternative_webui_path, announce_ip, announce_port, announce_to_all_tiers, announce_to_all_trac}
after : auto_tmm_enabled=True (a category's save path applies only in automatic mode)
GET http://172.18.0.8:8080/api/v2/torrents/categories -> 200 {movies-drive-a, movies-drive-b, tv-drive-a, tv-drive-b}
POST http://172.18.0.8:8080/api/v2/torrents/editCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/editCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/editCategory -> 200 ''
POST http://172.18.0.8:8080/api/v2/torrents/editCategory -> 200 ''
GET http://172.18.0.8:8080/api/v2/torrents/categories -> 200 {movies-drive-a, movies-drive-b, tv-drive-a, tv-drive-b}
category movies-drive-a: savePath=/mnt/felhom-drives/media_a/userdata/media/torrents/movies downloadPath=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
category movies-drive-b: savePath=/mnt/felhom-drives/media_b/userdata/media/torrents/movies downloadPath=/mnt/felhom-drives/media_b/userdata/media/torrents/.temp
category tv-drive-a: savePath=/mnt/felhom-drives/media_a/userdata/media/torrents/tv downloadPath=/mnt/felhom-drives/media_a/userdata/media/torrents/.temp
category tv-drive-b: savePath=/mnt/felhom-drives/media_b/userdata/media/torrents/tv downloadPath=/mnt/felhom-drives/media_b/userdata/media/torrents/.temp
== Transmission http://172.18.0.9:9091/transmission/rpc (password: the deploy secret, read from the container's environment)
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
before: {"download-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/complete", "incomplete-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/incomplete", "incomplete-dir-enabled": false, "rename-partial-files": true}
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
after : {"download-dir": "/mnt/felhom-drives/media_a/userdata/media/transmission/complete", "incomplete-dir-enabled": false, "rename-partial-files": true} (one incomplete folder for all torrents would be on drive A)
POST http://172.18.0.9:9091/transmission/rpc -> 409 '<h1>409: Conflict</h1><p>Your request had an invalid session_id header.</p><p>To fix this, follow these steps:<ol><li>
POST http://172.18.0.9:9091/transmission/rpc -> 200 {arguments, result}
probe Host: transmission:9091 -> HTTP 200 {'arguments': {'version': '4.1.3 (838877323f)'}, 'result': 'success'}
== sonarr http://172.18.0.10:8989/api/v3 (key: <ApiKey> in /config/config.xml, read from the config volume)
GET http://172.18.0.10:8989/api/v3/tag -> 200 [list of 2]
tags: {'drive-a': 1, 'drive-b': 2}
GET http://172.18.0.10:8989/api/v3/rootfolder -> 200 [list of 2]
GET http://172.18.0.10:8989/api/v3/rootfolder -> 200 [list of 2]
root folder: /mnt/felhom-drives/media_a/userdata/media/tv accessible=True freeSpace=99701432320
root folder: /mnt/felhom-drives/media_b/userdata/media/tv accessible=True freeSpace=99701567488
GET http://172.18.0.10:8989/api/v3/downloadclient/schema -> 200 [list of 18]
GET http://172.18.0.10:8989/api/v3/downloadclient -> 200 [list of 2]
client qBittorrent drive-a: exists — left as it is
POST http://172.18.0.10:8989/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
client qBittorrent drive-b: exists — left as it is
POST http://172.18.0.10:8989/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
GET http://172.18.0.10:8989/api/v3/downloadclient -> 200 [list of 4]
client qBittorrent drive-a: impl=QBittorrent enable=True tags=[1] host=qbittorrent category=tv-drive-a directory=None
client qBittorrent drive-b: impl=QBittorrent enable=True tags=[2] host=qbittorrent category=tv-drive-b directory=None
client Transmission drive-a: impl=Transmission enable=True tags=[1] host=transmission category= directory=/mnt/felhom-drives/media_a/userdata/media/transmission/tv
client Transmission drive-b: impl=Transmission enable=True tags=[2] host=transmission category= directory=/mnt/felhom-drives/media_b/userdata/media/transmission/tv
GET http://172.18.0.10:8989/api/v3/config/mediamanagement -> 200 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
before: minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True
PUT http://172.18.0.10:8989/api/v3/config/mediamanagement/1 -> 202 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
GET http://172.18.0.10:8989/api/v3/config/mediamanagement -> 200 {autoUnmonitorPreviouslyDownloadedEpisodes, chmodFolder, chownGroup, copyUsingHardlinks, createEmptySeriesFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, episodeTitleRequired, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, recycleBin, recy}
after : minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True (default kept)
== radarr http://172.18.0.11:7878/api/v3 (key: <ApiKey> in /config/config.xml, read from the config volume)
GET http://172.18.0.11:7878/api/v3/tag -> 200 [list of 2]
tags: {'drive-a': 1, 'drive-b': 2}
GET http://172.18.0.11:7878/api/v3/rootfolder -> 200 [list of 2]
GET http://172.18.0.11:7878/api/v3/rootfolder -> 200 [list of 2]
root folder: /mnt/felhom-drives/media_a/userdata/media/movies accessible=True freeSpace=99701432320
root folder: /mnt/felhom-drives/media_b/userdata/media/movies accessible=True freeSpace=99701567488
GET http://172.18.0.11:7878/api/v3/downloadclient/schema -> 200 [list of 19]
GET http://172.18.0.11:7878/api/v3/downloadclient -> 200 [list of 2]
client qBittorrent drive-a: exists — left as it is
POST http://172.18.0.11:7878/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
client qBittorrent drive-b: exists — left as it is
POST http://172.18.0.11:7878/api/v3/downloadclient -> 201 {configContract, enable, fields, id, implementation, implementationName, infoLink, name, priority, protocol, removeCompletedDownloads, removeFailedDownloads, tags}
GET http://172.18.0.11:7878/api/v3/downloadclient -> 200 [list of 4]
client qBittorrent drive-a: impl=QBittorrent enable=True tags=[1] host=qbittorrent category=movies-drive-a directory=None
client qBittorrent drive-b: impl=QBittorrent enable=True tags=[2] host=qbittorrent category=movies-drive-b directory=None
client Transmission drive-a: impl=Transmission enable=True tags=[1] host=transmission category= directory=/mnt/felhom-drives/media_a/userdata/media/transmission/movies
client Transmission drive-b: impl=Transmission enable=True tags=[2] host=transmission category= directory=/mnt/felhom-drives/media_b/userdata/media/transmission/movies
GET http://172.18.0.11:7878/api/v3/config/mediamanagement -> 200 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
before: minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True
PUT http://172.18.0.11:7878/api/v3/config/mediamanagement/1 -> 202 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
GET http://172.18.0.11:7878/api/v3/config/mediamanagement -> 200 {autoRenameFolders, autoUnmonitorPreviouslyDownloadedMovies, chmodFolder, chownGroup, copyUsingHardlinks, createEmptyMovieFolders, deleteEmptyFolders, downloadPropersAndRepacks, enableMediaInfo, extraFileExtensions, fileDate, id, importExtraFiles, minimumFreeSpaceWhenImporting, pathsDefaultStatic, re}
after : minimumFreeSpaceWhenImporting=10240 MB, copyUsingHardlinks=True (default kept)
== done; Transmission reachable by its container name: True
@@ -0,0 +1,22 @@
# Media apps on every drive — the rig on the Tester 1 box (2026-10-14)
Task: `TASK-media-all-drives` (operator rulings 2026-10-14, `09` §3). Box: Tester 1 (VM 341 on demo-hp, guest 9201,
`enkicsifelhom.hu`), controller 0.310.0, agent 0.156.1. Design written from this: `architecture/15-media-library.md`.
Predictions were written before each measurement: `A-drives/PREDICTIONS-A4-A6.md`, `S-PREDICTIONS.md`.
| Part | Result | Evidence |
|---|---|---|
| A1 | Tester 1 read first: one drive `adatlemez` (default; not the backup target — that is `local`), apps bookstack, paperless-ngx, privatebin; guest 4096 MB (max 5824), host VM 8 GB; off-site on; demo-hp `/mnt/hdd_1` 764 G free of 938 G | `A-drives/A1-*.txt` |
| A2 | Two 100 GB disks hot-added as `scsi2`/`scsi3`, `backup=0`, on `nvme-scratch` (`/mnt/hdd_1` root). Worst case (every sparse image full) leaves ≥ 391 G free = 42 %. Memory NOT raised: the six media apps' summed peaks are 1199 MB (`sizing.json`); with the box's three apps and the platform ~2.7 GB < 4096 MB | `A-drives/A2-disks-added.txt` |
| A4 | Both enrolled through `POST /api/storage/init` (the wizard's call), ~2 s each: `media_a` (sdc), `media_b` (sdd). Neither became the default (sent `set_default:false`; **the wizard's checkbox is ON by default**) nor the backup target (`target: local` before and after) | `A-drives/A4-*.txt` |
| A5 | Skeleton on both (`userdata/{documents,downloads,media/{movies,tv,torrents,transmission,photos,books,…},roms}`); FileBrowser recreated with both drives (`/srv/media_a`, `/srv/media_b`). The dashboard: „Aktív", „Nincs alkalmazás ezen a tárolón" — **nothing about apps that would need a restart** | `A-drives/A5-*.txt` |
| A6 | (1) demo-hp has no backup job at all; `backup=0` read back. (2) Tester 1's whole-guest backups — local AND off-site PBS — log „excluding bind mount point mp8 ('/mnt/felhom-drives')". (3) The controller's own classifier over the test templates: no media bind in any capture set (control: the live templates DO capture — and a decoy with unlisted writable binds is captured mandatory) | `A-drives/A6-*.txt` |
| B1 | Tester 1 points at a **local mirror** of the drill catalog (`file:///opt/docker/felhom-controller/drill-catalog.git`) — no credential on the box (R-992: a read-only token could not be minted, the 403 is quoted in `tools/repoint.py`). Three controls pass: Tester 1's cache = drill `0ad6383`; demo-hp 9201, 9202 and the N100 guest = live `8ee687e`; live `main` unchanged | `B1-*.txt` |
| B2 | Drill commit `0ad6383`: six templates bind `/mnt/felhom-drives/{media_a,media_b}/userdata/media` at the same path (`:ro` for Plex/Jellyfin); images unchanged | `tools/make_drill.py` |
| B4 | qBittorrent, Transmission, Sonarr, Radarr deployed through `POST /api/stacks/<app>/deploy`; plain host paths accepted, no warning | `B4-deploy.txt` |
| C | `tools/wire.py`: per drive a qBittorrent category, Sonarr/Radarr root folder + tag + two client entries; min free space 10 GB; hardlinks on (default) | `C-wire-run*.txt` |
| C | API key: read from `/config/config.xml`; it CAN be set at install: `SONARR__AUTH__APIKEY` / `RADARR__AUTH__APIKEY` env → 200 with that key, 401 with another | `C-apikey-at-install.txt` |
| S1 | Today's two-bind shape: `ln: … Cross-device link` (EXDEV) on one filesystem → copy (2 inodes). One bind per drive: link count 2, one inode | `S1-hardlink.txt` |
| S2 | One public-domain film (archive.org, CC PD Mark) on drive B: through Transmission and through qBittorrent → **hardlink** in `media_b/…/movies`. archive.org's web seed failed the last piece for qBittorrent; a local peer (the same torrent in Transmission) finished it — recorded | `S2-*.txt` |
| S3 | Tag drive-a, no client forced → „Transmission drive-a", hardlink on A. Mismatch (root B, tag A) → download on A, **copy** on B (link count 1 each) | `S3-*.txt` |
| — | **17:25 CEST: demo-hp was power-cut by another session's Immich drill** (`plugcut.py --plug hp`) — Tester 1 went down with it and came back by itself; the boot gate recreated the four media apps onto drive A; all test films then removed (0 files left) | `AFTER-power-cut-1725.txt` |
@@ -0,0 +1,14 @@
# Predictions — written before S2/S3 ran (2026-10-14 ~16:30 CEST)
S2 (root B, tag drive-b; once through qBittorrent drive-b, once through Transmission drive-b)
- Radarr approves the pushed release, sends it to the one enabled drive-b client.
- qBittorrent: the torrent lands in media_b/userdata/media/torrents/movies/<item>/ (category save path, automatic mode);
incomplete pieces in media_b/.../torrents/.temp. Transmission: in media_b/.../transmission/movies/<item>/.
- Radarr imports by HARDLINK into media_b/userdata/media/movies/Night of the Living Dead (1968)/: link count 2, same inode.
No remote path mapping is needed, because every container sees the same path.
S3
- tag routing: root A + tag drive-a, no client forced → one of the drive-a clients takes it (never a drive-b client);
download on A, hardlink in A's movies.
- the mismatch: root B + tag drive-a → download on A, import into B as a COPY (link count 1, two inodes); the A file
keeps seeding. Radarr does not refuse it.
@@ -0,0 +1,30 @@
### PREDICTION: old shape (two binds) -> ln fails EXDEV, mv falls back to copy (link count 1, two inodes); new shape (one bind per drive) -> hardlink, link count 2, same inode
### OLD SHAPE (live templates): -v A/media/movies:/media/movies -v A/downloads:/downloads
ln-exit=1
copied
/downloads/s1-probe/film.mkv links=1 inode=4194328 dev=2080
/media/movies/s1-probe/film.mkv links=1 inode=4194329 dev=2080
### guest view after OLD shape:
/mnt/felhom-drives/media_a/userdata/downloads/s1-probe/film.mkv links=1 inode=4194328
/mnt/felhom-drives/media_a/userdata/media/movies/s1-probe/film.mkv links=1 inode=4194329
### NEW SHAPE: -v A/media:A/media (same path)
ln-exit=0
/mnt/felhom-drives/media_a/userdata/media/torrents/s1-probe/film.mkv links=2 inode=4194328 dev=2080
/mnt/felhom-drives/media_a/userdata/media/movies/s1-probe/film.mkv links=2 inode=4194328 dev=2080
### guest view after NEW shape:
/mnt/felhom-drives/media_a/userdata/media/torrents/s1-probe/film.mkv links=2 inode=4194328
/mnt/felhom-drives/media_a/userdata/media/movies/s1-probe/film.mkv links=2 inode=4194328
### cleanup
/mnt/felhom-drives/media_a/userdata/downloads:
/mnt/felhom-drives/media_a/userdata/media/movies:
/mnt/felhom-drives/media_a/userdata/media/torrents:
ln: failed to create hard link '/media/movies/f' => '/downloads/s1-probe/f': Cross-device link
mv-exit=0
/media/movies/f-moved links=1 inode=4194328
/mnt/felhom-drives/media_a/userdata/downloads:
/mnt/felhom-drives/media_a/userdata/media/movies:
@@ -0,0 +1,57 @@
# ATTEMPT 1 (16:13–16:27 CEST) — stopped by CC. Radarr approved and grabbed (qBittorrent drive-b took it: category
# movies-drive-b, save path media_b/.../torrents/movies, incomplete path media_b/.../torrents/.temp — read from
# qBittorrent's own torrents/info). The download stalled at 6.09 % (32 MiB): 0 peers, and archive.org's web seed
# answered "500 Internal Server Error" (qBittorrent log, URL https://dn721601.ca.archive.org/); stop/start did not
# revive it. A plain curl range request to the same file from the same container answered 206 at the same time.
# So the source, not the chain, stalled. The run was stopped and retried (below).
# ATTEMPT 2 (16:38–17:09 CEST), item night-of-the-living-dead-1968_202508:
=== s2-qbit: root=/mnt/felhom-drives/media_b/userdata/media/movies tag=drive-b client=qBittorrent drive-b item=archive.org/night-of-the-living-dead-1968_202508
clients: [('qBittorrent drive-a', True, [1]), ('qBittorrent drive-b', True, [2]), ('Transmission drive-a', True, [1]), ('Transmission drive-b', False, [2])]
movie added: id=3 'Night of the Living Dead' (1968) path=/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968) tags=[2] profile=Any
release push -> 200 approved=True rejections=[] downloadAllowed=True
+11s grabbed: downloadClient=qBittorrent downloadClientName=qBittorrent drive-b downloadId=28766E9244F6920800B29298E9BBF00E41185CBA
+61s queue: status=warning trackedState=downloading left=596675329 client=qBittorrent drive-b output=None msgs=[]
+121s queue: status=downloading trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+181s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+241s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+302s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+362s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+422s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+482s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+542s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+602s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+662s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+722s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+782s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+842s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+902s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+962s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1022s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1082s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1142s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1202s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1262s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1322s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1382s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1442s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1502s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1562s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1622s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1682s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1742s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1802s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1862s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-b output=None msgs=[]
+1872s history downloadFolderImported: {"downloadClient": "qBittorrent", "downloadClientName": "qBittorrent drive-b", "droppedPath": "/mnt/felhom-drives/media_b/userdata/media/torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4", "importedPath": "/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4", "size": "596645320"}
download file: /mnt/felhom-drives/media_b/userdata/media/torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4 links=2 inode=131097 size=596645320
library file : /mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4 links=2 inode=131097 size=596645320
RESULT s2-qbit: HARDLINK (same inode); download on media_b, library on media_b
[finished]
# NOTE on attempt 2: qBittorrent reached 99.994 % at ~16:41 (7.7 MB/s from archive.org's web seed) and then stalled on
# the LAST piece (the film's tail + archive.org's three side files: __ia_thumb.jpg, _meta.sqlite, _meta.xml). Its log
# shows "Received error message from URL seed ... dn721505.ca.archive.org ... 500 Internal Server Error"; 8 stop/start
# retries over 6 min did not help, while curl range requests for the same three files from DooPlex answered 206. To
# finish, CC added the SAME public-domain torrent to Transmission (download dir media_b/.../transmission/s2-helper) and
# gave qBittorrent Transmission's container address as a peer (POST /api/v2/torrents/addPeers 172.18.0.9:51413) at
# 17:03; qBittorrent completed within 40 s and Radarr imported. The chain itself (grab by client, category save path,
# move from .temp, hardlink import) is unaffected; the source's web seed was the fault.
@@ -0,0 +1,16 @@
=== s2-trans: root=/mnt/felhom-drives/media_b/userdata/media/movies tag=drive-b client=Transmission drive-b item=archive.org/night-of-the-living-dead-1968_202508
clients: [('qBittorrent drive-a', True, [1]), ('qBittorrent drive-b', False, [2]), ('Transmission drive-a', True, [1]), ('Transmission drive-b', True, [2])]
movie added: id=2 'Night of the Living Dead' (1968) path=/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968) tags=[2] profile=Any
release push -> 200 approved=True rejections=[] downloadAllowed=True
+13s grabbed: downloadClient=Transmission downloadClientName=Transmission drive-b downloadId=28766E9244F6920800B29298E9BBF00E41185CBA
+63s queue: status=downloading trackedState=downloading left=595626753 client=Transmission drive-b output=/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+123s queue: status=downloading trackedState=downloading left=328141569 client=Transmission drive-b output=/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+183s queue: status=downloading trackedState=downloading left=50383617 client=Transmission drive-b output=/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+243s queue: status=downloading trackedState=downloading left=50383617 client=Transmission drive-b output=/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+303s queue: status=downloading trackedState=downloading left=35585 client=Transmission drive-b output=/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+353s history downloadFolderImported: {"downloadClient": "Transmission", "downloadClientName": "Transmission drive-b", "droppedPath": "/mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4", "importedPath": "/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4", "size": "596645320"}
download file: /mnt/felhom-drives/media_b/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4 links=2 inode=131097 size=596645320
library file : /mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4 links=2 inode=131097 size=596645320
RESULT s2-trans: HARDLINK (same inode); download on media_b, library on media_b
[finished]
@@ -0,0 +1,20 @@
== drive adatlemez (/mnt/felhom-drives/adatlemez/userdata/media)
== drive media_a (/mnt/felhom-drives/media_a/userdata/media)
== drive media_b (/mnt/felhom-drives/media_b/userdata/media)
-- movies/ (1 file(s))
596.6 MB links=2 inode=131097 movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4
-- torrents/ (2 file(s))
0.0 MB links=1 inode=131104 torrents/movies/.28766e9244f6920800b29298e9bbf00e41185cba.parts
596.6 MB links=2 inode=131097 torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4
-- transmission/ (4 file(s))
596.6 MB links=1 inode=131100 transmission/s2-helper/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4
0.0 MB links=1 inode=131101 transmission/s2-helper/night-of-the-living-dead-1968_202508/__ia_thumb.jpg
0.0 MB links=1 inode=131102 transmission/s2-helper/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.sqlite
0.0 MB links=1 inode=131103 transmission/s2-helper/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.xml
== 7 file(s) on 3 drive(s)
== ONE FILE, TWO NAMES (hardlink — no extra copy):
drive media_b inode 131097 (596.6 MB): movies/…/night-of-the-living-dead-1968_202508.mp4 <-> torrents/…/Night of the Living Dead 1968.mp4
1 pair(s)
== LIBRARY FILES WITH LINK COUNT 1 (a copy, or its download is gone):
@@ -0,0 +1,17 @@
== drive adatlemez (/mnt/felhom-drives/adatlemez/userdata/media)
== drive media_a (/mnt/felhom-drives/media_a/userdata/media)
== drive media_b (/mnt/felhom-drives/media_b/userdata/media)
-- movies/ (1 file(s))
596.6 MB links=2 inode=131097 movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4
-- transmission/ (4 file(s))
596.6 MB links=2 inode=131097 transmission/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4
0.0 MB links=1 inode=131098 transmission/movies/night-of-the-living-dead-1968_202508/__ia_thumb.jpg
0.0 MB links=1 inode=131099 transmission/movies/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.sqlite
0.0 MB links=1 inode=131100 transmission/movies/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.xml
== 5 file(s) on 3 drive(s)
== ONE FILE, TWO NAMES (hardlink — no extra copy):
drive media_b inode 131097 (596.6 MB): movies/…/night-of-the-living-dead-1968_202508.mp4 <-> transmission/…/Night of the Living Dead 1968.mp4
1 pair(s)
== LIBRARY FILES WITH LINK COUNT 1 (a copy, or its download is gone):
@@ -0,0 +1,29 @@
=== s3-mis: root=/mnt/felhom-drives/media_b/userdata/media/movies tag=drive-a client=(by tag) item=archive.org/night-of-the-living-dead-1968_202508
clients: [('qBittorrent drive-a', True, [1]), ('qBittorrent drive-b', True, [2]), ('Transmission drive-a', True, [1]), ('Transmission drive-b', True, [2])]
movie added: id=5 'Night of the Living Dead' (1968) path=/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968) tags=[1] profile=Any
release push -> 200 approved=True rejections=[] downloadAllowed=True
+14s grabbed: downloadClient=qBittorrent downloadClientName=qBittorrent drive-a downloadId=28766E9244F6920800B29298E9BBF00E41185CBA
+64s queue: status=downloading trackedState=downloading left=595528449 client=qBittorrent drive-a output=None msgs=[]
+124s queue: status=downloading trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+185s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+245s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+305s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+365s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+425s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+485s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+545s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+605s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+665s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+725s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+785s queue: status=warning trackedState=downloading left=35585 client=qBittorrent drive-a output=None msgs=[]
+805s history downloadFolderImported: {"downloadClient": "qBittorrent", "downloadClientName": "qBittorrent drive-a", "droppedPath": "/mnt/felhom-drives/media_a/userdata/media/torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4", "importedPath": "/mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4", "size": "596645320"}
download file: /mnt/felhom-drives/media_a/userdata/media/torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4 links=1 inode=4194355 size=596645320
library file : /mnt/felhom-drives/media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4 links=1 inode=131097 size=596645320
RESULT s3-mis: COPY (two inodes); download on media_a, library on media_b
[finished]
# NOTE: qBittorrent drive-a stalled on the LAST piece again (archive.org web seed, as in S2 attempt 2). At 17:21 CC added
# the same public-domain torrent to Transmission (media_a/.../transmission/s3-helper) and gave qBittorrent its address
# as a peer; qBittorrent completed at 17:23 and Radarr imported. Routing and import are unaffected by the helper.
# Radarr's own log around the import:
@@ -0,0 +1,16 @@
=== s3-tag: root=/mnt/felhom-drives/media_a/userdata/media/movies tag=drive-a client=(by tag) item=archive.org/night-of-the-living-dead-1968_202508
clients: [('qBittorrent drive-a', True, [1]), ('qBittorrent drive-b', True, [2]), ('Transmission drive-a', True, [1]), ('Transmission drive-b', True, [2])]
movie added: id=4 'Night of the Living Dead' (1968) path=/mnt/felhom-drives/media_a/userdata/media/movies/Night of the Living Dead (1968) tags=[1] profile=Any
release push -> 200 approved=True rejections=[] downloadAllowed=True
+11s grabbed: downloadClient=Transmission downloadClientName=Transmission drive-a downloadId=28766E9244F6920800B29298E9BBF00E41185CBA
+61s queue: status=downloading trackedState=downloading left=587385601 client=Transmission drive-a output=/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+122s queue: status=downloading trackedState=downloading left=309381889 client=Transmission drive-a output=/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+182s queue: status=downloading trackedState=downloading left=52677377 client=Transmission drive-a output=/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+242s queue: status=downloading trackedState=downloading left=52677377 client=Transmission drive-a output=/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+302s queue: status=downloading trackedState=downloading left=35585 client=Transmission drive-a output=/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508 msgs=[]
+362s history downloadFolderImported: {"downloadClient": "Transmission", "downloadClientName": "Transmission drive-a", "droppedPath": "/mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4", "importedPath": "/mnt/felhom-drives/media_a/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4", "size": "596645320"}
download file: /mnt/felhom-drives/media_a/userdata/media/transmission/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4 links=2 inode=4194354 size=596645320
library file : /mnt/felhom-drives/media_a/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4 links=2 inode=4194354 size=596645320
RESULT s3-tag: HARDLINK (same inode); download on media_a, library on media_a
[finished]
@@ -0,0 +1,22 @@
== drive adatlemez (/mnt/felhom-drives/adatlemez/userdata/media)
== drive media_a (/mnt/felhom-drives/media_a/userdata/media)
-- torrents/ (4 file(s))
596.6 MB links=1 inode=4194355 torrents/movies/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4
0.0 MB links=1 inode=4194364 torrents/movies/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.xml
0.0 MB links=1 inode=4194362 torrents/movies/night-of-the-living-dead-1968_202508/__ia_thumb.jpg
0.0 MB links=1 inode=4194363 torrents/movies/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.sqlite
-- transmission/ (4 file(s))
596.6 MB links=1 inode=4194358 transmission/s3-helper/night-of-the-living-dead-1968_202508/Night of the Living Dead 1968.mp4
0.0 MB links=1 inode=4194361 transmission/s3-helper/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.xml
0.0 MB links=1 inode=4194359 transmission/s3-helper/night-of-the-living-dead-1968_202508/__ia_thumb.jpg
0.0 MB links=1 inode=4194360 transmission/s3-helper/night-of-the-living-dead-1968_202508/night-of-the-living-dead-1968_202508_meta.sqlite
== drive media_b (/mnt/felhom-drives/media_b/userdata/media)
-- movies/ (1 file(s))
596.6 MB links=1 inode=131097 movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4
== 9 file(s) on 3 drive(s)
== ONE FILE, TWO NAMES (hardlink — no extra copy):
0 pair(s)
== LIBRARY FILES WITH LINK COUNT 1 (a copy, or its download is gone):
drive media_b: media_b/userdata/media/movies/Night of the Living Dead (1968)/night-of-the-living-dead-1968_202508.mp4 (596.6 MB)
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""chain.py <run> — S2/S3: one public-domain film through the WHOLE path, run INSIDE the guest (run_in_guest.py).
Radarr (movie added with a root folder + tag) -> release push API -> download client (picked by the tag; for S2 the
other same-tag client is switched off for the grab so the named one is used) -> download on a drive -> Radarr's
completed-download import -> library file. Then the evidence: download path, library path, link counts, inodes,
Radarr's history entry.
Content: „Night of the Living Dead" (1968), public domain (archive.org items carry the CC Public Domain Mark), < 1 GB.
runs:
s2-qbit root B, tag drive-b, only „qBittorrent drive-b" enabled among the drive-b clients
s2-trans root B, tag drive-b, only „Transmission drive-b" enabled among the drive-b clients
s3-tag root A, tag drive-a, both drive-a clients enabled (the tag alone picks)
s3-mis root B, tag drive-a (the mismatch)
cleanup delete the movie with its files in Radarr, remove every torrent with its data in both clients
Every key/password is read at runtime and redacted from the output.
"""
import base64, datetime, json, os, re, subprocess, sys, time, urllib.error, urllib.request
SECRETS = []
TMDB = 10331
ITEMS = {"s2-qbit": "night-of-the-living-dead-1968_202508", "s2-trans": "night-of-the-living-dead-1968_202508",
"s3-tag": "night-of-the-living-dead-1968_202508", "s3-mis": "night-of-the-living-dead-1968_202508"}
RUNS = {"s2-qbit": ("media_b", "drive-b", "qBittorrent drive-b"), "s2-trans": ("media_b", "drive-b", "Transmission drive-b"),
"s3-tag": ("media_a", "drive-a", None), "s3-mis": ("media_b", "drive-a", None)}
def red(s):
s = str(s)
for x in SECRETS:
if x:
s = s.replace(x, "<redacted>")
return s
def say(*a):
print(red(" ".join(str(x) for x in a)), flush=True)
def sh(c):
return subprocess.run(c, shell=True, capture_output=True, text=True).stdout.strip()
def ip(c):
return sh(f"docker inspect {c} -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}'").split()[0]
def call(method, url, headers=None, data=None, form=False):
h = dict(headers or {})
if data is not None:
if form:
data = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in data.items())
h["Content-Type"] = "application/x-www-form-urlencoded"
else:
data = json.dumps(data)
h["Content-Type"] = "application/json"
data = data.encode()
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as r:
code, txt, hd = r.status, r.read().decode("utf-8", "replace"), {k.lower(): v for k, v in r.headers.items()}
except urllib.error.HTTPError as e:
code, txt, hd = e.code, e.read().decode("utf-8", "replace"), {k.lower(): v for k, v in e.headers.items()}
try:
body = json.loads(txt) if txt else None
except ValueError:
body = txt
return code, body, hd
key = re.search(r"<ApiKey>([^<]+)</ApiKey>", open("/var/lib/docker/volumes/radarr_radarr_config/_data/config.xml").read()).group(1)
SECRETS.append(key)
RA = f"http://{ip('radarr')}:7878/api/v3"
H = {"X-Api-Key": key}
def ra(method, path, data=None):
c, b, _ = call(method, RA + path, H, data)
if c >= 300:
say(f" !! radarr {method} {path} -> {c} {red(json.dumps(b))[:500]}")
return c, b
def movie():
c, ms = ra("GET", "/movie")
return next((m for m in ms if m.get("tmdbId") == TMDB), None)
def clients():
return ra("GET", "/downloadclient")[1]
def set_enable(cl, on):
if cl["enable"] != on:
cl = dict(cl, enable=on)
ra("PUT", f"/downloadclient/{cl['id']}", cl)
def tag_id(label):
return next(t["id"] for t in ra("GET", "/tag")[1] if t["label"] == label)
def st(p):
try:
s = os.stat(p)
return f"links={s.st_nlink} inode={s.st_ino} size={s.st_size}"
except OSError as e:
return f"stat failed: {e}"
# ---------------------------------------------------------------- client side (for cleanup and the download path)
def qb():
QB = f"http://{ip('qbittorrent')}:8080"
pw = sh("docker exec qbittorrent printenv QBT_ADMIN_PASSWORD"); SECRETS.append(pw)
c, _, hd = call("POST", QB + "/api/v2/auth/login", {"Referer": QB}, {"username": "admin", "password": pw}, form=True)
m = re.search(r"((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)", hd.get("set-cookie", "")); SECRETS.append(m.group(2))
return QB, {"Referer": QB, "Cookie": f"{m.group(1)}={m.group(2)}"}
def tr(method, args=None):
TR = f"http://{ip('transmission')}:9091/transmission/rpc"
pw = sh("docker exec transmission printenv PASS"); SECRETS.append(pw)
h = {"Authorization": "Basic " + base64.b64encode(f"admin:{pw}".encode()).decode()}
SECRETS.append(h["Authorization"].split()[1])
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}})
if c == 409:
h["X-Transmission-Session-Id"] = hd.get("x-transmission-session-id", "")
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}})
return b
def cleanup():
m = movie()
if m:
ra("DELETE", f"/movie/{m['id']}?deleteFiles=true&addImportExclusion=false")
say(f"radarr: movie {m['id']} deleted with its files")
QB, QH = qb()
c, ts, _ = call("GET", QB + "/api/v2/torrents/info", QH)
for t in ts or []:
call("POST", QB + "/api/v2/torrents/delete", QH, {"hashes": t["hash"], "deleteFiles": "true"}, form=True)
say(f"qbittorrent: removed {t['name']} with its data ({t['content_path']})")
b = tr("torrent-get", {"fields": ["id", "name", "downloadDir"]})
for t in (b or {}).get("arguments", {}).get("torrents", []):
tr("torrent-remove", {"ids": [t["id"]], "delete-local-data": True})
say(f"transmission: removed {t['name']} with its data ({t['downloadDir']})")
for c in ra("GET", "/downloadclient")[1]:
set_enable(c, True)
say("all download clients enabled again")
def run(name):
drive, tag, only = RUNS[name]
item = ITEMS[name]
root = f"/mnt/felhom-drives/{drive}/userdata/media/movies"
say(f"=== {name}: root={root} tag={tag} client={'(by tag)' if not only else only} item=archive.org/{item}")
m = movie()
if m:
sys.exit(say("STOP: the film is already in Radarr — run cleanup first") or 1)
cls = clients()
if only:
for c in cls:
if tag_id(tag) in c["tags"]:
set_enable(c, c["name"] == only)
say(" clients:", [(c["name"], c["enable"], c["tags"]) for c in clients()])
c, lk = ra("GET", f"/movie/lookup/tmdb?tmdbId={TMDB}")
qp = ra("GET", "/qualityprofile")[1][0]
body = dict(lk, qualityProfileId=qp["id"], rootFolderPath=root, monitored=True, tags=[tag_id(tag)],
minimumAvailability="released", addOptions={"searchForMovie": False})
c, m = ra("POST", "/movie", body)
say(f" movie added: id={m['id']} '{m['title']}' ({m['year']}) path={m['path']} tags={m['tags']} profile={qp['name']}")
sz = {"night-of-the-living-dead-1968_202312": 550604180, "night-of-the-living-dead-1968_202508": 596600000,
"night-of-the-living-dead_202309": 598500000}[item]
rel = {"title": "Night.of.the.Living.Dead.1968.480p.WEB-DL.x264-ARCHIVEORG",
"downloadUrl": f"https://archive.org/download/{item}/{item}_archive.torrent",
"protocol": "torrent", "publishDate": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"size": sz, "indexer": "archive.org (release push)"}
t0 = time.time()
c, d = ra("POST", "/release/push", rel)
dd = d[0] if isinstance(d, list) else d
say(f" release push -> {c} approved={dd.get('approved')} rejections={dd.get('rejections')} downloadAllowed={dd.get('downloadAllowed')}")
grabbed = imported = None
for i in range(240):
time.sleep(10)
c, h = ra("GET", f"/history/movie?movieId={m['id']}")
for e in h or []:
if e["eventType"] == "grabbed" and not grabbed:
grabbed = e
say(f" +{int(time.time()-t0)}s grabbed: downloadClient={e['data'].get('downloadClient')} "
f"downloadClientName={e['data'].get('downloadClientName')} downloadId={e.get('downloadId')}")
if e["eventType"] == "downloadFolderImported":
imported = e
if imported:
break
if i % 6 == 5:
c, q = ra("GET", f"/queue?movieIds={m['id']}")
for r in (q or {}).get("records", []):
say(f" +{int(time.time()-t0)}s queue: status={r.get('status')} trackedState={r.get('trackedDownloadState')} "
f"left={r.get('sizeleft')} client={r.get('downloadClient')} output={r.get('outputPath')} "
f"msgs={[s.get('messages') for s in r.get('statusMessages', [])]}")
if not imported:
say(f" RESULT {name}: NOT imported within {int(time.time()-t0)}s")
return
e = imported["data"]
say(f" +{int(time.time()-t0)}s history downloadFolderImported: {json.dumps({k: e.get(k) for k in ('downloadClient', 'downloadClientName', 'droppedPath', 'importedPath', 'size')})}")
say(f" download file: {e['droppedPath']} {st(e['droppedPath'])}")
say(f" library file : {e['importedPath']} {st(e['importedPath'])}")
a, b = os.stat(e["droppedPath"]), os.stat(e["importedPath"])
say(f" RESULT {name}: {'HARDLINK (same inode)' if (a.st_ino, a.st_dev) == (b.st_ino, b.st_dev) else 'COPY (two inodes)'}"
f"; download on {e['droppedPath'].split('/')[3]}, library on {e['importedPath'].split('/')[3]}")
for c in clients():
set_enable(c, True)
if sys.argv[1] == "cleanup":
cleanup()
else:
run(sys.argv[1])
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""deploy.py <app> [<app> ...] — Part B4: install an app on the Tester 1 box the way the deploy wizard does:
GET /api/stacks/<app>/deploy-fields, POST /api/stacks/<app>/deploy, then wait for the deploy's own completion mark
(the pin in app.yaml), as box_walk.deploy does.
Values: DOMAIN, the app's default SUBDOMAIN, HDD_PATH = drive A (the drive the household would pick; the drill
templates bind both drives by plain path and do not read it), and a generated value for each password field — kept
ONLY in the 0600 file ~/.felhom-tester1/secrets.json (never printed, never in evidence). box_walk.deploy is not used
because its deploy_values makes a folder on the scratch box's drive path, which does not exist here.
Live-probes rule: never send a deploy for an app that is not meant to be installed. This script refuses an app that is
already installed and installs only the apps named on the command line.
"""
import json, os, secrets, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
HDD = "/mnt/felhom-drives/media_a"
SEC = os.path.expanduser("~/.felhom-tester1/secrets.json")
def load():
try:
return json.load(open(SEC))
except FileNotFoundError:
return {}
def save(d):
fd = os.open(SEC, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(d, f)
w.login()
for app in sys.argv[1:]:
st = w.stack(app)
if st.get("deployed"):
w.say(f"{app}: already installed — not touched")
continue
c, d = w.ctl("GET", f"/api/stacks/{app}/deploy-fields")
fields = (((d.get("data") or {}).get("metadata") or {}).get("deploy_fields")) or []
vals, gen = {"DOMAIN": w.DOMAIN}, []
for f in fields:
ev, ty = f.get("env_var"), f.get("type")
if ev == "DOMAIN":
continue
if ev == "SUBDOMAIN":
vals[ev] = f.get("default") or st.get("meta", {}).get("subdomain") or app
elif ev == "HDD_PATH":
vals[ev] = HDD
elif ty == "password":
vals[ev] = "T1-" + secrets.token_urlsafe(15)
gen.append(ev)
elif f.get("required"):
sys.exit(w.say(f"{app}: required field {ev} ({ty}) has no rule here — STOP, not guessing") or 1)
s = load(); s[app] = {k: vals[k] for k in gen}; save(s)
shown = {k: ("<generated, kept in secrets.json>" if k in gen else v) for k, v in vals.items()}
c, d = w.ctl("POST", f"/api/stacks/{app}/deploy", {"values": vals})
w.say(f"{app}: POST deploy {json.dumps(shown)} -> {c} {json.dumps(d, ensure_ascii=False)[:200]}")
if c != "202":
continue
for _ in range(120):
time.sleep(5)
st = w.stack(app)
pins = (st.get("app_config") or {}).get("pinned_images")
if st.get("deployed") and pins and st.get("state") in ("running", "unhealthy", "degraded"):
w.say(f"{app}: deployed, state={st.get('state')}, pinned={pins}")
break
else:
w.say(f"{app}: never became deployed (state={st.get('state')})")
print(w.guest(f"docker inspect {app} -f '{{{{.State.Status}}}} {{{{range .Mounts}}}}{{{{.Source}}}}->{{{{.Destination}}}}:{{{{.RW}}}} {{{{end}}}}'; "
f"docker logs --since 10m felhom-controller 2>&1 | grep -i '{app}' | grep -v DEBUG | grep -iE 'after_install|deploy|error|warn' | tail -8"))
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""enrol.py <device> <mount_name> <label> — Part A4: enrol one blank disk on the Tester 1 box through the SAME calls
the „Új meghajtó inicializálása" wizard makes (storage_init.html): GET /api/disks/candidates, POST /api/storage/init,
then poll GET /api/storage/init/status to a terminal phase. set_default is sent FALSE (the wizard's checkbox is
checked by default — recorded in the report; this run keeps Tester 1's default drive unchanged).
Run from DooPlex: SC=~/.felhom-tester1 TARGET=tester-1 python3 enrol.py /dev/sdc media_a "Media A"
"""
import json, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
dev, name, label = sys.argv[1], sys.argv[2], sys.argv[3]
w.login()
c, d = w.ctl("GET", "/api/disks/candidates")
cands = [x["device"] for x in ((d.get("data") or {}).get("initialize") or [])]
w.say(f"candidates -> {c} {cands}")
if dev not in cands:
sys.exit(w.say(f"STOP: {dev} is not offered for initialisation") or 1)
body = {"device": dev, "fstype": "ext4", "mount_name": name, "label": label,
"set_default": False, "confirmed": False, "durable_id": ""}
c, d = w.ctl("POST", "/api/storage/init", body)
w.say(f"POST /api/storage/init {json.dumps(body)} -> {c} {json.dumps(d, ensure_ascii=False)}")
if c != "200":
sys.exit(1)
last = None
for _ in range(600):
time.sleep(2)
c, d = w.ctl("GET", "/api/storage/init/status")
ph = (d.get("data") or {}).get("phase")
if ph != last:
w.say(f"status -> {c} {json.dumps(d.get('data'), ensure_ascii=False)}")
last = ph
if ph in ("done", "failed", "refused", "needs_confirmation"):
break
print("RESULT", last)
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""make_drill.py — Part B2: rewrite the six media templates IN THE DRILL CATALOG to the layout under test.
The layout (one bind per drive, the same path inside the container as in the guest, `userdata/media` only):
/mnt/felhom-drives/<drive>/userdata/media -> /mnt/felhom-drives/<drive>/userdata/media (rw; :ro for Plex/Jellyfin)
for <drive> in DRIVES. Images are NOT touched. Plain host paths on purpose: the controller's bind classifier knows only
the ${..._PATH} roots (stacks/classify_binds.go classifyRoot), so these binds are in no app capture set (A6).
Because the ${USERDATA_PATH} binds go away, the `.felhom.yml` `backup.userdata` entries and `data_paths` that named them
are removed too — appbackup.ValidateBackupSpec rejects an entry that matches no compose bind, and a rejected block
degrades the app to the legacy path. qBittorrent's and Transmission's `backup.volumes` (their config) stay.
Default save paths: drive A's `media/torrents` (qBittorrent, seeded on first boot) and `media/transmission/complete`
(Transmission, set by its entrypoint on a fresh config only). Everything else is unchanged.
python3 make_drill.py [--check] # edits /mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates in place
"""
import re, sys
D = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill/templates"
DRIVES = ("media_a", "media_b")
M = {d: f"/mnt/felhom-drives/{d}/userdata/media" for d in DRIVES}
A = M["media_a"]
def binds(indent, ro):
suf = ":ro" if ro else ""
return "".join(f"{indent}- {M[d]}:{M[d]}{suf}\n" for d in DRIVES)
def sub1(s, old, new, app):
if old not in s:
sys.exit(f"{app}: expected text not found: {old!r}")
return s.replace(old, new, 1)
def compose(app):
p = f"{D}/{app}/docker-compose.yml"
s = open(p).read()
if M["media_a"] in s:
return f"{app}: compose already in the new layout"
i = " "
if app == "sonarr":
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media/tv:/media/tv\n{i}- ${{USERDATA_PATH}}/downloads:/downloads\n", binds(i, False), app)
elif app == "radarr":
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media/movies:/media/movies\n{i}- ${{USERDATA_PATH}}/downloads:/downloads\n", binds(i, False), app)
elif app == "qbittorrent":
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media/torrents:/downloads\n", binds(i, False), app)
s = sub1(s, "'Session\\DefaultSavePath=/downloads'", f"'Session\\DefaultSavePath={A}/torrents'", app)
s = sub1(s, "'Session\\TempPath=/downloads/.temp'", f"'Session\\TempPath={A}/torrents/.temp'", app)
elif app == "transmission":
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media/transmission:/downloads\n", binds(i, False), app)
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media/transmission/watch:/watch\n", f"{i}- {A}/transmission/watch:/watch\n", app)
# the default download folders are set ONCE, on a fresh config (a household's later choice is kept)
s = sub1(s, """ cp /defaults/settings.json "$$CONF"\n""",
f""" cp /defaults/settings.json "$$CONF"\n jq '.["download-dir"] = "{A}/transmission/complete" | .["incomplete-dir"] = "{A}/transmission/incomplete"' "$$CONF" > /tmp/d.json && mv /tmp/d.json "$$CONF"\n""", app)
elif app in ("plex", "jellyfin"):
s = sub1(s, f"{i}- ${{USERDATA_PATH}}/media:/media:ro\n", binds(i, True), app)
if "${USERDATA_PATH}" in re.sub(r"^#.*$", "", s, flags=re.M):
sys.exit(f"{app}: a ${{USERDATA_PATH}} bind is left")
open(p, "w").write(s)
return f"{app}: compose rewritten"
def felhom_yml(app):
p = f"{D}/{app}/.felhom.yml"
s = open(p).read()
before = s
# drop backup.userdata (the list under it) — and the whole backup block when nothing else is left in it
s = re.sub(r"(^backup:\n(?: volumes:\n(?: .*\n| .*\n| .*\n)*)?) userdata:\n(?: .*\n| .*\n)+", r"\1", s, flags=re.M)
s = re.sub(r"^# --- Backup classification[^\n]*\n^backup:\n(?=\n|#|[a-z])", "", s, flags=re.M)
# drop data_paths (top level only; the i18n.en copy is display text and names no bind)
s = re.sub(r"^# --- Customer-facing folder annotation[^\n]*\n^data_paths:\n(?: .*\n)+", "", s, flags=re.M)
if s != before:
open(p, "w").write(s)
return f"{app}: .felhom.yml backup.userdata/data_paths removed"
return f"{app}: .felhom.yml unchanged"
for app in ("sonarr", "radarr", "qbittorrent", "transmission", "plex", "jellyfin"):
print(compose(app))
print(felhom_yml(app))
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""repoint.py mirror|drill|restore|status — point the Tester 1 box at the drill catalog WITHOUT a credential (R-992).
WHY A LOCAL MIRROR. `09` §6.5's route needs a catalog credential on the box: the drill repo is private (R-992). This
session could not mint a READ-ONLY one — the Gitea credential on DooPlex is itself a token, and Gitea answers 403 to a
token asking to list or create tokens (measured 2026-10-14) — and the night of 2026-10-06 put the admin WRITE token
into the box's controller.yaml, which this task forbids. The cache-staging workaround of 2026-10-12 does not hold for a
test that lasts days: every sync (and every controller start) re-applies the live catalog over the staged templates
and over the installed apps' compose files (Syncer.copyTemplates, no deployed check).
So the drill catalog is MIRRORED onto the box: a bare git repository inside the controller's own data volume, and
`git.repo_url: file:///opt/docker/felhom-controller/drill-catalog.git`. The controller's sync runs exactly as for a
remote (clone/fetch/reset, the R-615 re-clone on a changed URL); no secret is on the box. DooPlex updates the mirror
from the drill repo with a git bundle (`mirror`). The drill repo on Gitea stays the record.
SC=~/.felhom-tester1 TARGET=tester-1 python3 repoint.py mirror # (re)load the mirror from the drill repo main
... repoint.py drill # save controller.yaml (once), set repo_url to the mirror, restart, sync, rescan
... repoint.py restore # put the saved controller.yaml back (byte-identical), restart, sync, rescan
... repoint.py status
"""
import os, subprocess, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
DRILL = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill"
VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" # guest path of the controller's data volume
CVOL = "/opt/docker/felhom-controller" # the same, inside the controller container
SAVE = f"{VOL}/controller.yaml.pre-media-2026-10-14"
MIRROR_URL = f"file://{CVOL}/drill-catalog.git"
def status():
print(w.guest(f"""grep -nE 'repo_url|sync_interval' {VOL}/controller.yaml; ls -l {SAVE} 2>&1 | tail -1
docker exec felhom-controller sh -c 'git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main 2>&1;
git -C {CVOL}/data/catalog-cache log -1 --format="cache HEAD %h %s" 2>&1; git -C {CVOL}/data/catalog-cache remote get-url origin'"""))
def mirror():
b = "/tmp/drill-media.bundle"
subprocess.run(["git", "-C", DRILL, "bundle", "create", b, "main"], check=True, capture_output=True)
head = subprocess.run(["git", "-C", DRILL, "rev-parse", "--short", "main"], capture_output=True, text=True).stdout.strip()
ssh = w.ssh_args()
# bundle -> PVE host -> guest -> controller volume; the bare repo is made and fetched BY the controller's own user
subprocess.run(["scp", "-q", "-o", "StrictHostKeyChecking=accept-new"] + (["-J", w._T["jump"]] if w._T["jump"] else [])
+ [b, f"{w._T['ssh']}:{b}"], check=True)
subprocess.run(ssh + [f"pct push {w.GUEST} {b} {VOL}/drill-media.bundle && rm -f {b}"], check=True)
print(w.guest(f"""set -e
docker exec felhom-controller sh -c 'test -d {CVOL}/drill-catalog.git || git init -q --bare -b main {CVOL}/drill-catalog.git
git -C {CVOL}/drill-catalog.git fetch -q {CVOL}/drill-media.bundle +main:main && rm -f {CVOL}/drill-media.bundle
git -C {CVOL}/drill-catalog.git log -1 --format="mirror main %h %s" main'"""))
print("drill repo main:", head)
os.remove(b)
def restart_sync():
w.guest("docker restart felhom-controller >/dev/null")
for _ in range(60):
time.sleep(3)
try:
w.login()
break
except SystemExit:
continue
w.sync_rescan()
time.sleep(3)
print(w.guest("docker logs --since 3m felhom-controller 2>&1 | grep -E '\\[sync\\]' | grep -v DEBUG | tail -8"))
def drill():
print(w.guest(f"""set -e
test -f {SAVE} || cp -p {VOL}/controller.yaml {SAVE}
sed -i -E 's#^(\\s+repo_url: ).*$#\\1{MIRROR_URL}#' {VOL}/controller.yaml
grep -n 'repo_url' {VOL}/controller.yaml; diff {SAVE} {VOL}/controller.yaml || true"""))
restart_sync()
status()
def restore():
print(w.guest(f"""set -e
cp -p {SAVE} {VOL}/controller.yaml
cmp {SAVE} {VOL}/controller.yaml && echo RESTORED-IDENTICAL; grep -n repo_url {VOL}/controller.yaml"""))
restart_sync()
status()
{"mirror": mirror, "drill": drill, "restore": restore, "status": status}[sys.argv[1]]()
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""run_bg.py start <script.py> <arg> | log <arg> | stop <arg> — run a long chain step DETACHED inside the Tester 1 guest
(so a dropped SSH does not kill it), and read its log. The log lives in /root/media-<arg>.log until `stop` removes it
together with the script; the caller copies the log into the evidence folder first."""
import os, sys
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
op = sys.argv[1]
if op == "start":
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), sys.argv[2])).read()
a = sys.argv[3]
print(w.guest(f"cat > /root/.media-{a}.py <<'PYEOF'\n{src}\nPYEOF\n"
f"nohup python3 -u /root/.media-{a}.py {a} > /root/media-{a}.log 2>&1 < /dev/null &\necho started"))
elif op == "log":
print(w.guest(f"cat /root/media-{sys.argv[2]}.log; pgrep -f '/root/.media-{sys.argv[2]}.py' >/dev/null && echo '[still running]' || echo '[finished]'"))
elif op == "stop":
print(w.guest(f"pkill -f 'python3 -u /root/.media-{sys.argv[2]}.py'; rm -f /root/.media-{sys.argv[2]}.py /root/media-{sys.argv[2]}.log; echo removed"))
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""run_in_guest.py <script.py> [args] — run one of this folder's stdlib scripts INSIDE the Tester 1 guest
(SC=~/.felhom-tester1 TARGET=tester-1) and remove it afterwards. Output only; nothing is left on the box."""
import os, sys
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), sys.argv[1])).read()
args = " ".join(sys.argv[2:])
print(w.guest(f"cat > /root/.media-tool.py <<'PYEOF'\n{src}\nPYEOF\npython3 /root/.media-tool.py {args} 2>&1; rm -f /root/.media-tool.py", timeout=1800))
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""where.py — S4: where every media file sits, per drive. Run INSIDE the guest (run_in_guest.py where.py).
For each enrolled drive under /mnt/felhom-drives/*/userdata/media: every regular file (hidden temp folders included)
with its size, link count and inode, grouped by top folder (tv, movies, torrents, transmission, ...). Then the pairs
that are ONE file (same device + inode = a hardlink: no extra copy) and the library files that are NOT linked to any
download (link count 1 = a copy, or a download already removed). Stdlib only; reads, never writes.
"""
import collections, glob, os
ROOTS = sorted(glob.glob("/mnt/felhom-drives/*/userdata/media"))
by_ino = collections.defaultdict(list)
total = 0
for root in ROOTS:
drive = root.split("/")[3]
print(f"== drive {drive} ({root})")
for top in sorted(os.listdir(root)):
rows = []
for dp, dn, fn in os.walk(os.path.join(root, top)):
for f in fn:
p = os.path.join(dp, f)
try:
s = os.lstat(p)
except OSError as e:
rows.append(f" ?? {p}: {e}")
continue
if not os.path.isfile(p) or os.path.islink(p):
continue
total += 1
by_ino[(s.st_dev, s.st_ino)].append((drive, top, p, s.st_size, s.st_nlink))
rows.append(f" {s.st_size/1e6:10.1f} MB links={s.st_nlink} inode={s.st_ino:<10} {os.path.relpath(p, root)}")
if rows:
print(f" -- {top}/ ({len(rows)} file(s))")
print("\n".join(rows))
print(f"\n== {total} file(s) on {len(ROOTS)} drive(s)")
print("== ONE FILE, TWO NAMES (hardlink — no extra copy):")
n = 0
for k, v in by_ino.items():
if len(v) > 1:
n += 1
print(f" drive {v[0][0]} inode {k[1]} ({v[0][3]/1e6:.1f} MB): " + " <-> ".join(f"{x[1]}/…/{os.path.basename(x[2])}" for x in v))
print(f" {n} pair(s)")
print("== LIBRARY FILES WITH LINK COUNT 1 (a copy, or its download is gone):")
for k, v in by_ino.items():
for d, top, p, size, nl in v:
if top in ("tv", "movies") and nl == 1:
print(f" drive {d}: {os.path.relpath(p, '/mnt/felhom-drives')} ({size/1e6:.1f} MB)")
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""wire.py [-v] — Part C: connect Sonarr and Radarr to qBittorrent and Transmission, ONE ENTRY PER DRIVE, on the box.
Runs INSIDE the guest (it talks to the containers on the docker network and reads their keys from their own config
volumes / environment). Stdlib only. Every key and password is read at runtime and NEVER written to a file or a log:
every printed line passes through redact(). `-v` prints every call and the SHAPE of every answer (status, top-level
keys / list length) — this is the reference the later link code copies.
For each drive d in DRIVES, M = /mnt/felhom-drives/<d>/userdata/media (the same path in every container):
qBittorrent : categories tv-<d>, movies-<d> -> save path M/torrents/{tv,movies}, incomplete path M/torrents/.temp
Sonarr : root folder M/tv; tag <d>; „qBittorrent <d>" (category tv-<d>, tag <d>) and
„Transmission <d>" (directory M/transmission/tv, tag <d>)
Radarr : the same with movies
Sonarr/Radarr: minimum free space when importing = MIN_FREE_MB; „use hardlinks instead of copy" quoted
Box-wide, recorded with the reason in the output:
qBittorrent : auto_tmm_enabled = true (a category's save path applies ONLY in automatic torrent management;
qBittorrent's default is manual, which saves every torrent to the default
path = drive A, whatever the category says)
Transmission: incomplete-dir-enabled = false (Transmission has ONE incomplete folder for all torrents; on drive A it
would make every drive-B download a cross-drive copy at completion)
Idempotent: an existing tag / root folder / client / category of the same name is left as it is and reported.
"""
import base64, json, re, subprocess, sys, urllib.error, urllib.parse, urllib.request
V = "-v" in sys.argv
DRIVES = [("drive-a", "media_a"), ("drive-b", "media_b")]
MIN_FREE_MB = 10240
SECRETS = []
def redact(s):
s = str(s)
for x in SECRETS:
if x:
s = s.replace(x, "<redacted>")
return s
def say(*a):
print(redact(" ".join(str(x) for x in a)), flush=True)
def sh(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
def shape(body):
if isinstance(body, dict):
return "{" + ", ".join(sorted(body.keys()))[:300] + "}"
if isinstance(body, list):
return f"[list of {len(body)}]"
return repr(body)[:120]
def call(method, url, headers=None, data=None, form=False, raw=False, expect=(200, 201, 202, 204)):
h = dict(headers or {})
if data is not None and not isinstance(data, (bytes, str)):
data = urllib.parse.urlencode(data) if form else json.dumps(data)
h.setdefault("Content-Type", "application/x-www-form-urlencoded" if form else "application/json")
if isinstance(data, str):
data = data.encode()
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as r:
code, txt, hdrs = r.status, r.read().decode("utf-8", "replace"), {k.lower(): v for k, v in r.headers.items()}
except urllib.error.HTTPError as e:
code, txt, hdrs = e.code, e.read().decode("utf-8", "replace"), {k.lower(): v for k, v in e.headers.items()}
try:
body = json.loads(txt) if txt and not raw else txt
except ValueError:
body = txt
if V:
say(f" {method} {url} -> {code} {shape(body)}")
if code not in expect:
say(f" !! {method} {url} -> {code}: {redact(txt)[:600]}")
return code, body, hdrs
def ip(c):
return sh(f"docker inspect {c} -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}'").split()[0]
def apikey(app):
x = open(f"/var/lib/docker/volumes/{app}_{app}_config/_data/config.xml").read()
k = re.search(r"<ApiKey>([^<]+)</ApiKey>", x).group(1)
SECRETS.append(k)
return k
def env(c, var):
v = sh(f"docker exec {c} printenv {var}")
SECRETS.append(v)
return v
# ---------------------------------------------------------------- qBittorrent
QB = f"http://{ip('qbittorrent')}:8080"
qpw = env("qbittorrent", "QBT_ADMIN_PASSWORD")
say("== qBittorrent", QB, "(password: the deploy secret, read from the container's environment)")
c, _, hd = call("POST", QB + "/api/v2/auth/login", {"Referer": QB}, {"username": "admin", "password": qpw}, form=True, raw=True)
# qBittorrent 5.x names the session cookie QBT_SID_<port> (older versions: SID) — take whichever it set
m = re.search(r"((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)", hd.get("set-cookie", ""))
if not m:
sys.exit(say(f" !! no session cookie; answer headers: {sorted(hd)}") or 1)
say(f" session cookie name: {m.group(1)}")
SECRETS.append(m.group(2))
QH = {"Referer": QB, "Cookie": f"{m.group(1)}={m.group(2)}"}
c, prefs, _ = call("GET", QB + "/api/v2/app/preferences", QH)
say(f" before: auto_tmm_enabled={prefs.get('auto_tmm_enabled')} save_path={prefs.get('save_path')} "
f"temp_path_enabled={prefs.get('temp_path_enabled')} temp_path={prefs.get('temp_path')}")
call("POST", QB + "/api/v2/app/setPreferences", QH, {"json": json.dumps({"auto_tmm_enabled": True})}, form=True, raw=True)
c, prefs, _ = call("GET", QB + "/api/v2/app/preferences", QH)
say(f" after : auto_tmm_enabled={prefs.get('auto_tmm_enabled')} (a category's save path applies only in automatic mode)")
c, cats, _ = call("GET", QB + "/api/v2/torrents/categories", QH)
for tag, d in DRIVES:
M = f"/mnt/felhom-drives/{d}/userdata/media"
for kind in ("tv", "movies"):
name = f"{kind}-{tag}"
form = {"category": name, "savePath": f"{M}/torrents/{kind}", "downloadPathEnabled": "true",
"downloadPath": f"{M}/torrents/.temp"}
ep = "editCategory" if name in (cats or {}) else "createCategory"
call("POST", QB + f"/api/v2/torrents/{ep}", QH, form, form=True, raw=True)
c, cats, _ = call("GET", QB + "/api/v2/torrents/categories", QH)
for n, v in sorted((cats or {}).items()):
say(f" category {n}: savePath={v.get('savePath')} downloadPath={v.get('download_path', v.get('downloadPath'))}")
# ---------------------------------------------------------------- Transmission
TR = f"http://{ip('transmission')}:9091/transmission/rpc"
tpw = env("transmission", "PASS")
TA = "Basic " + base64.b64encode(f"admin:{tpw}".encode()).decode()
SECRETS.append(TA.split()[1])
say("== Transmission", TR, "(password: the deploy secret, read from the container's environment)")
def trpc(method, args=None, host=None):
h = {"Authorization": TA}
if host:
h["Host"] = host
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}}, expect=(200, 409))
if c == 409:
h["X-Transmission-Session-Id"] = hd.get("x-transmission-session-id", "")
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}}, expect=(200, 421))
return c, b
c, b = trpc("session-get", {"fields": ["download-dir", "incomplete-dir", "incomplete-dir-enabled", "rename-partial-files"]})
say(f" before: {json.dumps((b or {}).get('arguments'))}")
trpc("session-set", {"incomplete-dir-enabled": False})
c, b = trpc("session-get", {"fields": ["download-dir", "incomplete-dir-enabled", "rename-partial-files"]})
say(f" after : {json.dumps((b or {}).get('arguments'))} (one incomplete folder for all torrents would be on drive A)")
# The probe the link code needs: can another container reach Transmission BY NAME? (Host-header allow-list)
c, b = trpc("session-get", {"fields": ["version"]}, host="transmission:9091")
say(f" probe Host: transmission:9091 -> HTTP {c} {redact(str(b))[:160]}")
TR_BY_NAME = c == 200
# ---------------------------------------------------------------- Sonarr / Radarr
def arr(app, port, kind, root_kind):
base = f"http://{ip(app)}:{port}/api/v3"
H = {"X-Api-Key": apikey(app)}
say(f"== {app} {base} (key: <ApiKey> in /config/config.xml, read from the config volume)")
c, tags, _ = call("GET", base + "/tag", H)
have = {t["label"]: t["id"] for t in tags or []}
for tag, _d in DRIVES:
if tag not in have:
c, t, _ = call("POST", base + "/tag", H, {"label": tag})
have[tag] = t["id"]
say(f" tags: {have}")
c, roots, _ = call("GET", base + "/rootfolder", H)
rp = {r["path"].rstrip("/") for r in roots or []}
for tag, d in DRIVES:
p = f"/mnt/felhom-drives/{d}/userdata/media/{root_kind}"
if p not in rp:
call("POST", base + "/rootfolder", H, {"path": p})
c, roots, _ = call("GET", base + "/rootfolder", H)
for r in roots or []:
say(f" root folder: {r['path']} accessible={r.get('accessible')} freeSpace={r.get('freeSpace')}")
c, schema, _ = call("GET", base + "/downloadclient/schema", H)
sch = {s["implementation"]: s for s in schema}
c, clients, _ = call("GET", base + "/downloadclient", H)
names = {x["name"] for x in clients or []}
for tag, d in DRIVES:
M = f"/mnt/felhom-drives/{d}/userdata/media"
for impl, nm, vals in (
("QBittorrent", f"qBittorrent {tag}", {"host": "qbittorrent", "port": 8080, "username": "admin",
"password": qpw, f"{kind}Category": f"{'tv' if kind == 'tv' else 'movies'}-{tag}"}),
("Transmission", f"Transmission {tag}", {"host": "transmission", "port": 9091, "username": "admin",
"password": tpw, "urlBase": "/transmission/",
# the schema's default category must be CLEARED: Sonarr/Radarr
# refuse „Cannot use Category and Directory" (measured, run 1)
f"{kind}Category": "",
f"{kind}Directory": f"{M}/transmission/{root_kind}"})):
if nm in names:
say(f" client {nm}: exists — left as it is")
continue
body = json.loads(json.dumps(sch[impl]))
body.update({"name": nm, "enable": True, "tags": [have[tag]], "priority": 1})
for f in body["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
c, r, _ = call("POST", base + "/downloadclient", H, body, expect=(200, 201, 400))
if c == 400:
say(f" client {nm}: REFUSED by {app}'s own connection test: {redact(json.dumps(r))[:400]}")
c, clients, _ = call("GET", base + "/downloadclient", H)
for x in clients or []:
f = {k["name"]: k.get("value") for k in x["fields"]}
say(f" client {x['name']}: impl={x['implementation']} enable={x['enable']} tags={x['tags']} host={f.get('host')}"
f" category={f.get(kind + 'Category')} directory={f.get(kind + 'Directory')}")
c, mm, _ = call("GET", base + "/config/mediamanagement", H)
say(f" before: minimumFreeSpaceWhenImporting={mm.get('minimumFreeSpaceWhenImporting')} MB, "
f"copyUsingHardlinks={mm.get('copyUsingHardlinks')}")
mm["minimumFreeSpaceWhenImporting"] = MIN_FREE_MB
call("PUT", base + f"/config/mediamanagement/{mm['id']}", H, mm)
c, mm, _ = call("GET", base + "/config/mediamanagement", H)
say(f" after : minimumFreeSpaceWhenImporting={mm.get('minimumFreeSpaceWhenImporting')} MB, "
f"copyUsingHardlinks={mm.get('copyUsingHardlinks')} (default kept)")
arr("sonarr", 8989, "tv", "tv")
arr("radarr", 7878, "movie", "movies")
say("== done; Transmission reachable by its container name:", TR_BY_NAME)
+9 -3
View File
@@ -124,19 +124,23 @@ stopping line that lies.
| **R-494** | Install & onboarding | P4 | **NARROWED 2026-09-14 by operator ruling → [P3-LOW] the hub COULD create the tunnel at customer creation, for a domain already on Cloudflare. Not blocking: every customer has their own domain and the operator creates the tunnel per day-0 A.1 (`architecture/01-topology-and-trust.md`).** *Original finding, kept:* **[P1-HIGH] A new customer's dashboard has NO reachable address unless the operator hand-makes a Cloudflare tunnel — the link in the setup-code mail is dead.** MEASURED 2026-09-14 on a fresh install from the public ISO (drill intervention **I1**): the claim mail points at `https://felhom.drill0242.felhom.eu`; that name has **no A and no AAAA** record (`dig @1.1.1.1`, control `felhom.enkisfelhom.hu` resolves); the hub has **no tunnel- or DNS-creation code** (`hub/internal/cloudflare/` holds only geo-rule removal; `cf_tunnel_token` is a pasted, optional form field, `configs.go:1478`) — day-0 runbook A.1 makes it a manual Cloudflare-dashboard step that nothing on the customer-create page asks for; the box's own split-horizon resolver on the appliance LAN IP answered `google.com` but not the dashboard name at 13:27:39Z; the agent applied the record at **13:27:44Z** (`lanresolver: applied split-horizon record … ip=192.168.0.158`, 3 m 46 s after the controller started), so the box CAN answer the name — **but only to a device that uses the box as its DNS server, and no document, screen or mail tells a household to do that**; the router and the installer-offered DNS answer nothing. The page was reachable only at the guest's LAN address with the name forced (`curl --resolve …:443:192.168.0.158`). **A volunteer could not have done that.** **What it needs:** an operator ruling — the hub creates the tunnel and DNS at customer creation, or the product gives a household a LAN address that works with no DNS change. | **READY — rank P3-LOW; owner: CC** **Re-ranked 2026-10-03: P3→P4: operator-side automation; the operator creates the tunnel by hand per the day-0 runbook.** | — | — | CC |
| **R-504** | Install & onboarding | P4 | **[P3-LOW] `iso.felhom.eu` cannot show an index page on its own — its root returns 404, and the download page lives on the website instead.** MEASURED 2026-09-14: `https://iso.felhom.eu/` and `/index.html` → 404; only named objects answer. The host is an R2 bucket behind a custom domain; whether R2 would serve an uploaded `index.html` at `/` was **not measured** (uploading anything to the public bucket is a publication). The ISO v1.27.0 task puts the Hungarian download page at `felhom.eu/letoltes` (published with the ISO, after the operator's yes). **Remaining:** a redirect from `iso.felhom.eu/` to that page needs a Cloudflare rule the session has no credential for. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (Cloudflare rule)** **Re-ranked 2026-10-03: P3→P4: households are sent to the website's download page; the bare address is cosmetic.** | — | — | operator |
## Apps & catalog — 9 rows (P2 1, P3 4, P4 4)
## Apps & catalog — 13 rows (P2 2, P3 6, P4 5)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
| **R-1014** | Apps & catalog | P2 | **The media chain cannot work: the download clients save where Sonarr and Radarr never look, and the templates' hardlink promise is false.** Live catalog `main`: qBittorrent saves in `${USERDATA_PATH}/media/torrents`, Transmission in `media/transmission`; Sonarr and Radarr see `/downloads` = `${USERDATA_PATH}/downloads` and `/media/tv` or `/media/movies` — a download never reaches a library. The comment „SIBLING of media — same FS → hardlink/atomic move” (sonarr/radarr `docker-compose.yml` line 12) is FALSE: two bind mounts are two mount points, and `link(2)` answers EXDEV even on one filesystem. MEASURED 2026-10-14 on Tester 1 (S1): today's two-bind shape → `ln: … Cross-device link`, the import is a copy (link count 1, two inodes); one bind per drive at the same path → link count 2, one inode. S2: with that layout, one public-domain film went Radarr → Transmission → drive B → import as a hardlink. One fix closes both (the comment goes with the binds). Evidence `audits/media-all-drives-2026-10-14/``S1-hardlink.txt`, `S2-*.txt`; design `architecture/15-media-library.md`. | **READY** — filed 2026-10-14 | — | The live-fix task (`TASK-media-folder-fix-and-n100-2026-10-14.md`) moves the six templates to the layout of `15` §2 | CC |
| **R-981** | Apps & catalog | P3 | **The homepage app gets the Docker socket (that is root in the guest) and has no login, no setup gate and no family gate.** Fix: remove the socket mount, or put homepage behind the family gate. Security review 2026-10-12 (`audits/security-review-2026-10-12/`); detail held off-git for the operator (`/mnt/5_hdd/felhom.eu/evidence/security-review-2026-10-12-PRIVATE/`). | OPEN | — | Change the catalog template; prove on 9202 | CC |
| **R-971** | Apps & catalog | P2 | **A change pushed to the catalog's `main` reaches the installed apps on every box within about 15 minutes, and nothing on the box refuses dangerous container settings (privileged mode, the Docker socket, host mounts, host namespaces); so whoever can push to the catalog repository can take every guest.** Design-shaped: installed apps take catalog fixes on purpose. Decision for the operator in `STATUS.md`. Fix options: a privilege allow-list check on the box before any compose is written (and in catalog CI), and later signed catalog releases. Security review 2026-10-12 (`audits/security-review-2026-10-12/`); detail held off-git for the operator (`/mnt/5_hdd/felhom.eu/evidence/security-review-2026-10-12-PRIVATE/`). | WAITING-ON-OPERATOR — confirmed by a second reader | operator decision | Decide; then build the box-side check | operator |
| **R-676** | Apps & catalog | P3 | **[P3-LOW] Watch: immich's first start restarted 12 times — decision 28's crash-loop stop (6 in 10 min) would stop it.** From the 2026-09-17 chaos night (DB connection dropped during the first-start geocoding import on a 6 GB guest; it did not recover that night). No healthy app in any drill evidence restarts on a first start (1831 samples, 40 live containers), so the threshold stands; this row exists so the first immich install under v0.269.x is watched. `audits/night-2026-09-24/A3/40-first-start-restarts.txt` **2026-09-25 night (read from source, v0.271.0): a DEPLOY's first start is NOT covered by decision 28's suppression** — `Deploying` clears when `compose up -d` returns (`deploy.go` "Clear deploying flag"), and `ObserveUnhealthy` then samples the app; an automatic update's step, verify and undo ARE covered (`Updating`, pinned by `TestD28_NoCrashLoopStopDuringAnAutomaticStep`). So a first start that restarts ≥ 6 times in 10 min is stopped — which R-676 already accepts for a broken first start; a healthy slow first start would be stopped too. **-- 2026-09-30: the first-start restarts are explained.** immich's first-start geodata import OOM-kills its database at 512M on a guest with no swap (R-732, measured: 61–104 kills); the 2026-09-17 chaos-night case (DB connection dropped during the import on a 6 GB guest) fits it. Fixed in the catalog (`56c4888`, 768M). The watch itself (decision 28 on a DEPLOY's first start) is unchanged. | **OPEN — P3; owner: CC (watch)** | — | — | CC |
| **R-994** | Apps & catalog | P3 | **Seven apps peak above 80 % of a memory limit, and 23 apps' typical use is above their catalog `mem_request`.** MEASURED 2026-10-13 (`documentation/audits/sizing-2026-10-13/SIZING.md` §3, `evidence/catalog-check.json`). Near a limit — risk: the container is stopped for using too much memory: docmost (container 475 of 512 MB), romm (703 of 768), termix (117 of 128), uptime-kuma (227 of 256, tester-1 telemetry), grafana (435 of 512), immich (`immich-server` 1,665 of 2,048 during a 200-photo import), tandoor (827 of 1,024). No container was killed during the bench. Under-stated request (typical ÷ request): calcom ×4.0, adventurelog ×3.6, termix ×3.4, claper ×2.5, grafana ×2.2, komga ×2.2, docmost ×2.1, recipe-importer ×2.1, uptime-kuma ×2.1, zipline ×2.0, and 13 more ≤ ×1.8. Templates NOT changed (read-only run). | **READY** — filed 2026-10-13 | — | Raise the seven limits and the 23 requests in their templates, each through the catalog's harness; re-measure Immich with a larger library first | CC |
| **R-1015** | Apps & catalog | P3 | **Three apps tell the household to use a folder that does not exist: `/media/shows`.** Sonarr's first steps (`sonarr/.felhom.yml:74,109`), Jellyfin's (`jellyfin/.felhom.yml:77,83,112,117`) and Plex's (`plex/.felhom.yml:76,111`) name `/media/shows`; the folder every template binds and the skeleton makes is `media/tv`. Found 2026-10-14 (the task named Sonarr and Jellyfin; Plex has it too). | **READY** — filed 2026-10-14 | — | Change the copy (hu + en) to the folder the layout of `15` §2 gives; the live-fix task | CC |
| **R-1018** | Apps & catalog | P3 | **The OnlyOffice links (FileBrowser, Nextcloud) have no recorded live proof since the move to Proxmox.** `controller/internal/integrations/` holds two handlers; no audit after the Proxmox move shows a document opened from FileBrowser or Nextcloud in OnlyOffice (searched 2026-10-14). The media-app links of `15` §6 will copy this code, including its revoke-on-stop rule (`lifecycle.go:10-13`). | **OPEN** — filed 2026-10-14 | — | One live walk on a Tier-0 box: install OnlyOffice + FileBrowser, open a document, save, read it back; then stop one app and record what the link does | CC |
| **R-76** | Apps & catalog | P4 | **FileBrowser-created folders break the setgid chain, and a drop-zone's mode is not stable** **MIGRATED FROM `ROADMAP.md` 2026-08-22 (R-369) — originally filed 2026-07-26, size S, roadmap state `idea (surfaced by the R-75 spike, 2026-07-26)`.** Moved verbatim; nothing added or reinterpreted. The roadmap keeps its copy as history, marked moved. | **OPEN — migrated from ROADMAP 2026-08-22, rank unchanged** **2026-10-06 night: not a catalog fix** — the setgid chain is set by the controller's FileBrowser setup; a controller row. | — | Two related findings from `audits/SPIKE-catalog-data-paths-2026-07-26.md` P3/P5, both **pre-existing** and deliberately left alone by that spike. **(a)** FileBrowser Quantum 1.3.3 creates files `0644` and folders `0755` and does **not** propagate the setgid bit — even though the entrypoint wrapper's `umask 002` really is in effect (`/proc/1/status` `Umask: 0002`). Group inheritance itself works (a file uploaded into a 2775 group-100 dir landed group 100, not the process gid 1000), so the convention's *group* half holds and only its *mode* half is lost. The consequence is proven with a control: inside a UI-created `0755` folder a gid-1000 process's file landed group **1000**, while the identical write into the 2775 parent landed group **100**. So **any folder a customer creates through FileBrowser breaks the shared-group chain one level down.** Latent today — every userdata-touching catalog app that declares an identity declares uid/gid **1000**, the same uid FileBrowser runs as, so owner permissions mask it; it bites the day a content app runs as a different non-root uid with gid 1000. The comment at `infra/infra.go:156` is right that the image ignores `-e UMASK` but does not say t | CC |
| **R-707** | Apps & catalog | P4 | **[P2] 37 apps still start with a login a stranger can take (`09` §3 decision 45).** Audit of all 53 apps: `app-catalog-felhom.eu/FIRST-ADMIN.md` (class, fix route, status, measured or read). Open: **3 hard-coded defaults** — calibre-web (`admin / admin123`, measured working on demo-hp and 9202; its own `cps.py -s` route needs a generated password WITH a special character — our generator is letters+digits, a controller change), mealie (`changeme@example.com / MyPassword`), wger (`admin / adminadmin`); **34 open first-run screens** (the first visitor creates the admin: actualbudget, adventurelog, audiobookshelf, calcom, docmost, emby, ghost, gitea, gramps-web, home-assistant, homebox, immich, jellyfin, komga, n8n, navidrome, opengist, outline, papra, plant-it, radarr, rallly, recipe-importer, romm, seerr, sonarr, sparkyfitness, tandoor, termix, uptime-kuma, vikunja, wanderer, wishlist, zipline). **Stale notes:** romm's `default_creds` `admin / admin` answers 401 on demo-hp (like a wrong password) — the page now warns with a login that does not exist; zipline's looks stale too. **Measured on demo-hp 2026-09-28 (read-only):** bookstack's default still logs in on the INSTALLED app (the fix is for new installs; the page now warns). Each fix: route (a) env or (b) the app's own CLI/API via `after_install:`, proven on 9202 with the default failing and the generated password working; route (c) a page sentence. Several sessions (operator, 2026-09-28). **2026-09-29 (controller v0.280.0, catalog `d0e7e2e`):** every class-3 app fixed — mealie, wger, calibre-web by `after_install` (calibre-web with the new `password:24:special`), proven on 9202 fresh installs (`audits/login-gate-2026-09-29/D/`); the setup gate (decision 46, spike PASSED) built and live on immich, n8n, audiobookshelf (probes measured) and uptime-kuma (button) (`…/C/`); romm's and zipline's stale notes removed. **Left: 30 class-4 apps** — gate each (probe measured on 9202 where one exists — 11 upstream candidates listed in `…/B/B-VERDICT.md` §3; the button otherwise). **2026-09-29 afternoon (controller v0.281.0, catalog `6faf432`):** 28 more class-4 apps gated — 32 of 34 — each proven on 9202 (`audits/gate-rollout-2026-09-29/`B): stranger → gate page / 401, household reached the first-setup screen, the gate opened (9 by a measured probe, the rest by the press), the app answered after. seerr, outline, rallly: gated, their opening needs a media server / e-mail (not proven). **Left:** wanderer (R-714); plant-it is not installable. | **NARROWED** (2026-10-03 triage: the row's verdict was finished, but it names open work no other row carries — seerr, outline and rallly are gated, but the gate OPENING is not proven (needs a media server / e-mail)) — **CLOSED — 2026-09-29 (the rest → R-714)** | — | — | CC |
| **R-905** | Apps & catalog | P4 | **wger's collected style files (283 MB) go into every wger backup, though the app rebuilds them at every start.** Since R-762's fix (`DJANGO_DEBUG=False` + `collectstatic`, catalog `cf1ed43`) the static files sit in a named volume that the backup legs copy like data (measured 283 MB, `audits/design-build-2026-10-06/`); `collectstatic` regenerates them, so they are rebuildable bytes in every Tier-1 unit, Tier-2 copy and off-site snapshot. wger is `lifecycle: hidden` and no box runs it (hub read 2026-10-08). Options to weigh: a non-backed-up volume class for regenerable data, or an anonymous volume. Found by the R-762 helpers 2026-10-08. | **OPEN — needs a design before wger is shown** | — | Design the „regenerable volume" exclusion (catalog + controller backup legs) | CC |
| **R-995** | Apps & catalog | P4 | **Removing an app through the product can leave unnamed Docker volumes and an empty import folder behind.** MEASURED 2026-10-13 on demo-felhom after 61 removals (`documentation/audits/sizing-2026-10-13/METHOD.md` „Teardown”, `evidence/leftover-volumes-after-bench.txt`): 8 unnamed volumes (4–72 KB; immich 1, romm 2, privatebin 3, onlyoffice 2 — volumes the images declare themselves) and `userdata/import/{paperless,calibre,grimmory}` (empty, created by the deploy). The removal answered 200 and its own leftover check was clean, because it looks only at labelled volumes. No data risk; disk and tidiness only. Removed by name on the bench. | **READY** — filed 2026-10-13 | — | Make the removal account for the containers' unnamed volumes (and an empty import folder it created), with a test that deploys an image that declares a VOLUME | CC |
| **R-1007** | Apps & catalog | P3 | **After a power cut Immich shows photos as done that are broken, and nothing repairs them: of 101 photos uploaded in the last ~30 s before the cut, 81 kept their original but got an EMPTY thumbnail and preview (162 zero-byte files), and 1 — answered „201 Created" 0.3 s before the cut — has no original at all.** Immich's job queues are empty after the start, so it never redoes them (`audits/night-power-2026-10-13/` R2, `immich-check.txt`). Same ext4 effect on a plain 40-byte file. Other apps may do the same with files written just before a cut. | **OPEN** — 2026-10-14 spike (`audits/power-cut-fixes-2026-10-14/README.md` F, Immich v3.2.4): „thumbnails: missing" does not fix a zero-byte thumbnail (nor after deleting it — Immich decides from its database); „thumbnails: all" (force) fixes them; `integrity-missing-files` (`/api/admin/integrity/report?type=missing_file`) finds a lost original; nothing restores one but a backup. **Operator ruling 2026-10-14: fix only the broken photos** — after an unclean boot, find the photos with an empty thumbnail or preview, rebuild only those, run the missing-files integrity check, and tell the operator about lost originals. No „thumbnails: all". **Decision 236 (operator 2026-10-14, after spike A `audits/immich-repair-2026-10-14/`): the box asks Immich through Immich's own job queue (no key; every user's photos; the lost-file report read from the database); the catalog pins the proven Immich versions.** 2026-10-14 B2 spike (`audits/power-cut-fixes-2026-10-14/README.md` B2): **yes** — `POST /api/assets/jobs {assetIds, name: regenerate-thumbnail}` rebuilt exactly the 10 broken assets (1.55 s, 5.3 s CPU) and rewrote none of the other 20; an empty file maps to its asset by the first 36 characters of its name. | — | CC: build per decision 236 — catalog block `after_unclean_boot` (Immich), controller runner after an unclean boot, household note for lost photos, operator-only event; live proof on demo-hp 9201 (`audits/immich-repair-2026-10-14/`) | CC |
| **R-1016** | Apps & catalog | P4 | **qBittorrent's onboarding record names the wrong download folder.** `onboarding/qbittorrent.md` row 2.2 says `${USERDATA_PATH}/media/qbittorrent`; the template binds `media/torrents` (and the skeleton makes `media/torrents`). Record only — the app is right. Found 2026-10-14. | **READY** — filed 2026-10-14 | — | Correct the row with the live-fix task's catalog commit | CC |
## App updates — 4 rows (P3 4)
@@ -148,7 +152,7 @@ stopping line that lies.
| **R-683** | App updates | P3 | **[P3-LOW] Watch: after a power cut during an update's health check, the hold named an HOUR-OLD second-drive copy, not the one the update's own backup should have just made.** 2026-09-24 chaos round 3 (nextcloud, `backup_max_age: 1m`): no `backing-up` phase was seen and the hold named Tier 2 at 13:04 for an update pressed at 14:04; the pre-cut controller log was lost with the container (the runner now saves it at arm time — R-320). Round 11, the same action without a power cut, named a fresh 14:34 copy and logged the Tier-2 copy. The sentence was TRUE (it named the copy it offered); the question is why the update did not back up first. Not reproduced; watch the next power-cut drill. `audits/night-2026-09-24/E/round-03*.json`, `E/round-11-controller-pre.log` | **OPEN — P3; owner: CC (watch)** | — | — | CC |
| **R-785** | App updates | P3 | **[P3-LOW] SparkyFitness is pinned 11 releases and a major behind upstream (v0.17.3; upstream v1.7.3, v1.6.0 dated 2026-07-24).** READ 2026-10-01 (`audits/visitors-2026-10-01/C/bench/C1-previous-tag.txt`). **Needs:** an update walk 0.17 → 1.x through the ladder (bench + box), after R-784 is decided. | **OPEN — rank P3-LOW; owner: CC (after R-784)** | — | — | CC |
## Backup & restore — 29 rows (P2 3, P3 16, P4 10)
## Backup & restore — 30 rows (P2 3, P3 17, P4 10)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -171,6 +175,7 @@ stopping line that lies.
| **R-548** | Backup & restore | P3 | **[P3-LOW] The whole-guest backup’s LOCAL tier cannot fit on a small-system-disk box, and will retry on that tier for ever.** MEASURED 2026-09-17 (chaos night) on `tester-1-022354`: a whole-guest backup wrote a **~29 GB source** (`mp0` = `local-lvm:vm-9201-disk-1`, 70 G provisioned, 40.58 % used, `backup=1`) into `pve-root`, which on a 32 GB system disk is **14 GB total with ~4.9 GB free**. Two samples thirty seconds apart showed the archive growing ~497 MB while free space fell ~475 MB — **~16 MB/s, i.e. under four minutes to a full `/`** on the nested PVE. **The product’s behaviour is correct and legible throughout:** it failed the tier and said which one — `whole_guest_backup_failed` (error, operator-only): „Whole-guest backup FAILED on the local tier — retrying with backoff (next attempt in 15m0s)” — its status surface agreed (`target_id:"local"`, `success:false`, `size_bytes:0`), and the **off-site tier then ran from the same snapshot and succeeded in ~8½ minutes, encrypted to ep0, consuming no local disk at all**. So the data still left the house. What is filed is the loop: on a box shaped like this the local tier can **never** succeed, and it keeps retrying on a backoff for ever, burning I/O and risking `/` each time. **Honest caveat:** the 32 GB system disk is this drill’s fixture choice, so the row is conditional on disk size — but nothing in the product checks whether the local target could ever hold the source before trying. **Fix shape:** compare the source size against the target’s free space before starting the local tier and skip it with a clear reason, rather than discovering it at ~16 MB/s. Evidence: `audits/evidence-chaos-night-2026-09-17/round-6.txt`. **-- 2026-09-30, the class on a real box: demo-hp's whole-guest LOCAL tier was refused by the space preflight (R-685) 10 times since 2026-09-27** („local has 12.1 GiB free; the last archive of guest 9201 was 8.9 GiB, so a new one needs about 12.1 GiB") — its newest local archive was 2026-09-29 04:42, 30 h old at the day's read (the weekly PBS tier, last 2026-09-24, is its whole copy meanwhile). The refusal is correct and named; what is missing is that nothing gives the box the room back (the host's `local` holds the 8.9 GiB archive of the only guest plus templates on a 39 GB root). `audits/pg-last-six-2026-09-30/C/`. | **READY — rank P3-LOW; owner: CC** | — | — | CC |
| **R-921** | Backup & restore | P3 | **When the off-site tier answers BUSY, the household still gets a short stop of every app — with no copy.** MEASURED 2026-10-08 night on demo-hp (read-back 2026-10-09, `audits/dooplex-survival-2026-10-09/partE/R-518.txt`): local tier 20:20–20:25Z (own stop, < 2 min), then at 20:26:14Z the agent refused the off-site request (`backup refused — a heavy operation is already in flight`, `busy=backup:local` — the night OS step ran 20:27–20:29Z), yet the controller's metrics show all 15 app containers down at 20:26:19Z and back at 20:27:19Z; the retry ran the off-site tier at 20:34Z with its own short stop. So a two-tier night costs THREE stops, one of them for nothing. This was R-518's noted "unmeasured" risk. Fix shape: ask the agent whether it is free (or reserve the slot) BEFORE quiescing, and resume at once on BUSY. | **NARROWED 2026-10-09 — built; RELEASED 2026-10-10 (controller 0.305.0 on demo-hp, demo-felhom, Tester 1):** before stopping apps the controller asks the agent which tiers have a job in flight (`GET /backup/status`); another tier's job in flight → no stop, the tier stays due (red-proved: 3 apps stopped for a refused tier before); a refusal after the stop still resumes the apps at once (pinned). Also fixes a stop after a long upload or a restart mid-upload. **LEFT: the measured case itself** — the agent's host-wide busy lock (the night OS step, restore test, fstrim) is served on NO endpoint, so the controller cannot see it before stopping; needs one agent field (e.g. `busy` on `GET /backup/status`) + a controller pre-check behind a feature probe. `07` §6.4. | an agent change | Build the agent `busy` field + its controller check; read back a two-tier night | CC |
| **R-933** | Backup & restore | P3 | **Mirror the images of every catalog app we support into our own registry, before scale.** Operator, 2026-10-12 (S8, `09` §3 decision 203): *„Will need to mirror the apps we support later, just to be sure."* Today a restore re-pulls each image from its maker; a version the maker deleted cannot start (the accepted limit, `07` §6.6, R-698 closed). Size it first: the supported catalog's images (every ladder version a box can hold) × their size; then where the copy lives (the DooPlex registry is Tier 2, precious — the copy must not make DooPlex a single point of failure for every household's restore) and how a restore falls back to it. | **OPEN** — NEW 2026-10-12 | the size measurement | Measure the total size of the supported catalog's images; then a one-page design (where, how a restore falls back, cost) | CC (measure + design), operator (the go) |
| **R-1019** | Backup & restore | P3 | **The live download-client templates put downloads into two backup tiers nobody chose.** MEASURED 2026-10-14 with the controller's own classifier over the live templates (`audits/media-all-drives-2026-10-14/``A-drives/A6-3-capture-sets.txt`): Transmission's `${USERDATA_PATH}/media/transmission/watch` bind is not listed in its `backup:` block, so it is classed MANDATORY and goes into the OFF-SITE copy (a writable unlisted bind defaults to mandatory); and qBittorrent's `media/torrents` and Transmission's `media/transmission` are `optional`, so on a box with a second drive every download is mirrored there nightly (Tier 2 = mandatory + optional) — it can fill the backup drive with films. | **READY** — filed 2026-10-14 | — | Declare the media binds `excluded` (the watch folder too) in the live-fix task; `15` §7 states the rule for the per-drive bind | CC |
| **R-91** | Backup & restore | P4 | Old 13 GB datastore copy at `/srv/pbs-felhom` on ep0's root disk **Checked from source 2026-10-05 (burn-down round 2):** Gate is long past (row waits on demo-felhom's first post-migration PBS backup, migration 2026-07-27). Last positive record of the copy: audits/CAMPAIGN-9-restore-proof-2026-07-28.md:759 'ep0 : /srv/pbs-felhom rollback copy intact (13G)'; CONTEXT.md:3666 still says it is 13 G of dead weight awaiting R-91. No later record of deletion found (grep srv/pbs-felhom across felhom.eu). Deleting is on ep0 (protected) and needs an operator word. | WATCHING | demo-felhom's first **post-migration** PBS backup | Delete once it lands; fix `CONTEXT.md:1018` same commit | CC |
| **R-164** | Backup & restore | P4 | **C2's chain: the DB volume tar cannot be dropped until a SOUND dump predicate exists.** The unit carries both a volume tar and a SQL dump; the restore uses **both** — the dump is authoritative and replayed *after* the tar so it WINS (F17), with only the DB service up (R-47) — `internal/backup/restore_unit.go:262-266`. Dropping the DB container's tar would halve DB-app units **and** close the R-127(b) initdb-skip password trap (restored PGDATA ⇒ `POSTGRES_PASSWORD` ignored). | **BLOCKED** — on the predicate | a dump-validity predicate that is not `accounts has rows` | **The obvious gate is DEAD, measured:** `ValidateDump` warns when the `accounts` table is empty, and that warning was **correct** — the live DB genuinely had 0 accounts, and seeding one stopped the warning and put the row in the dump. But **a fresh appliance legitimately has zero accounts**, so promoting that predicate to a gate would **block every new customer's first backup**. Order: (1) a sound predicate — dump vs **live** per-table counts, not an absolute expectation; (2) warn→gate; (3) tar-drop. **Until (1), the tar is load-bearing** — not because dumps are bad, but because nothing can yet prove one is good. Pairs with **R-127** | CC |
| **R-213** | Backup & restore | P4 | **Putting files back in place — the half the recovery screen deliberately does not do.** The screen (R-193, controller v0.200.0) unlocks the repository and LISTS what is in it; restoring is per-app and lives in the backups area, and the operator ruled the two separate on 2026-08-05: *a screen that unlocks and then offers to overwrite is two decisions wearing one button*. What is missing is the step after the listing — a customer who can now SEE their files still has to work out, per app, which restore to choose. **The operator named its requirement: a live-versus-backup comparison** — the customer must be able to see what would change before anything is overwritten | **DEFERRED** — operator ruling 2026-10-12 (S18, `09` §3 decision 213): designed after the first customers. Stays P4; re-dated: look again when the first paying household has run 30 days. **OPEN — not started, deliberately** | the comparison design (nothing exists for it yet) | After the first customers | Operator + CC |
@@ -278,11 +283,12 @@ stopping line that lies.
| **R-989** | Business & legal | P4 | **Operator: read `documentation/audits/selfhst-survey-2026-10-12/FELHOM.md` §6 and rule on each recommendation.** READ-ONLY research 2026-10-12: the selfh.st self-hosting survey 2023–2025 (1,869 / 3,655 / 4,081 respondents, hobby users) against our catalog, features, pitch and hardware. Ten ranked recommendations, none acted on; the top three: a supported-hardware page (none exists — finding, §4), off-site backup as a *why* reason in the pitch (only 25.2 % of the crowd backs up off-site), Syncthing as the largest catalog gap (25.9 %). Tables: `TABLE.md`; method and limits: `METHOD.md`. | **WAITING-ON-OPERATOR** — filed 2026-10-12 | the operator's reading | Operator: rule on each of the ten (yes / no / later). If nothing is done: nothing changes; the research ages (the 2026 survey replaces it) | operator |
| **R-990** | Business & legal | P4 | **Operator: read `documentation/audits/forum-research-2026-10-12/FELHOM.md` §5 and rule on each recommendation.** READ-ONLY research 2026-10-12: three years (#7943–#11092, 2,908 posts, 195 people, 82 beginner threads) of the public PROHARDVER! topic „Home server / házi szerver építése”, tagged and counted by person (`FINDINGS.md`, method and spot-check in `METHOD.md`; no username or post text in git). Ten ranked recommendations, none acted on; the top three: a „which machine works” page (8 of 33 beginners who name their hardware own only a Synology, a router disk or a Raspberry Pi — the installer cannot run there; the same gap is R-989's first recommendation from a different source, and the shipped-page half is R-991), „you do not choose an operating system — we do” (the biggest beginner question, 20 of 82 threads), and „bring your old office PC” as the cheap entry. Catalog gap (≥ 3 people): a DLNA server (7), an ad/DNS blocker (5), a camera recorder (5). | **WAITING-ON-OPERATOR** — filed 2026-10-12 | the operator's reading | Operator: rule on each of the ten (yes / no / later). If nothing is done: nothing changes; the research ages | operator |
## Process & tooling — 21 rows (P3 7, P4 14)
## Process & tooling — 22 rows (P3 8, P4 14)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
| **R-992** | Process & tooling | P3 | **The drill-catalog route that `09` §6.5 documents cannot be followed: `admin/app-catalog-drill` is PRIVATE and no box has catalog credentials.** Measured 2026-10-12 while publishing the Usenet pair: the box's clone fails with `fatal: could not read Username for 'https://gitea.dooplex.hu'` (exit 128), the Gitea API reports the drill repo `private=true` and the live catalog `private=false`, and NEITHER the current controller.yaml on 9202 NOR any of its 30 saved copies has ever carried `git.username` or `git.token` (the controller supports both — `internal/config/config.go` GitConfig). The route worked for the campaigns of 2026-10-10 and the torrent pair earlier the same day, so the repository's visibility changed between then and now. **Worked around, not fixed:** that campaign staged its two templates in the box's own catalog cache (`audits/usenet-2026-10-12/tools/stage_in_cache.py`), which is where the sync copies a catalog change anyway, with the sync interval raised to 12h for the duration and rescan used in place of sync. The real fix is an operator decision: either a read-only catalog credential on the scratch box (a secret on a Tier-0 guest) or making the drill repository readable. Until then every new-app campaign hits this. | WAITING-ON-OPERATOR | the operator's choice between a box credential and a readable drill repo | Decide; then either set `git.username`/`git.token` on 9202 from the credentials store, or flip the drill repo, and correct `09` §6.5 either way | operator |
| **R-1017** | Process & tooling | P3 | **A comment names a guard test that does not exist.** `felhom-controller/controller/internal/appbackup/paths.go:58` says „`TestNoBareDrivePathToUserdataDir` is the guard that keeps the count from growing”; no test of that name (or `NoBareDrive*`) exists in the repo (grep 2026-10-14; positive control: `TestRemoveStack_R800_UserdataKeptIsNamed` is found). A comment asserting an invariant with no test pinning it — the class in the `felhom-testing` skill. | **READY** — filed 2026-10-14 | — | Write the guard (a test that fails when a bare drive path reaches `UserdataDir`), red-proof it, or correct the comment | CC |
| **R-987** | Process & tooling | P4 | **The 53 catalog app images have never been scanned for known vulnerabilities.** Fix: a monthly trivy pass over the images the catalog pins, reported, not gating. | OPEN | — | Add to the monthly re-test | CC |
| **R-986** | Process & tooling | P3 | **No dependency or secret scanner runs on any push, so a known-vulnerable library or a committed key is found only by a review like this one.** Proposed gate: govulncheck (called vulns only), gitleaks on the diff, trivy on our own images; start as WARN with an accepted-hits file, then FAIL. Tools and pinned versions: `audits/security-review-2026-10-12/SCANNERS.md`. Note: gitleaks' default rules did not find R-908's key. | OPEN | — | Build the gate, warn-first | CC |
| **R-733** | Process & tooling | P3 | **[P3-LOW] The test bench has NO swap and the boxes have 512 MiB — so a box proof can pass on swap where the bench fails, and nobody records whether a customer guest has swap.** MEASURED 2026-09-30 (R-732): immich's first start was OOM-killed 61–104 times on the bench (swap 0) and passed on 9202 by swapping ~108 MB; the bench given 512 MiB swap passed too. demo-hp 9201, 9202 and demo-felhom 9201 all read `swap: 512`; the golden's guest config is not recorded in its bake evidence, so a customer guest's swap is NOT measured. The harness's memory watch judges `anon` against the limit and never reads `memory.swap.current`. **Needs:** the golden's `swap` read and recorded; the box walk and the harness report `memory.swap.peak` beside `anon`; a decision whether proofs run with swap off (the stricter venue, as R-732's fix was proven). | **READY — rank P3-LOW; owner: CC (harness + golden evidence)** **2026-10-06 night: NARROWED** — the harness records each container's `swap_peak` and the venue's swap (catalog branch `night-held-2026-10-06`, `3f4611c`; SwapRecorded red-proved; bench SwapTotal 0 kB, 9202 524288 kB). LEFT: the golden's swap in its bake evidence, the box walk's record, and whether proofs run with swap off. | — | — | CC |