Compare commits
5 Commits
458989e6cd
...
4d78dd4524
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d78dd4524 | ||
|
|
f7001d5130 | ||
|
|
ff2e13a16f | ||
|
|
5783963816 | ||
|
|
837d6e5866 |
73
automation/bin/pd_backup_lib.sh
Executable file
73
automation/bin/pd_backup_lib.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
AUTOMATION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$AUTOMATION_DIR/.env}"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing env file: $ENV_FILE" >&2
|
||||||
|
echo "Copy automation/.env.example to automation/.env and fill in real values first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
required_vars=(
|
||||||
|
SERENITY_BACKUP_HOST
|
||||||
|
SERENITY_BACKUP_ROOT
|
||||||
|
PD_APPDATA_ROOT
|
||||||
|
PD_DATABASES_ROOT
|
||||||
|
PD_TANK_DOCKER_ROOT
|
||||||
|
PD_DB_DUMP_ROOT
|
||||||
|
POSTGRES_CONTAINER
|
||||||
|
)
|
||||||
|
|
||||||
|
for var in "${required_vars[@]}"; do
|
||||||
|
if [[ -z "${!var:-}" ]]; then
|
||||||
|
echo "Required variable $var is empty in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$PD_DB_DUMP_ROOT"
|
||||||
|
|
||||||
|
DOCKER_BIN="${DOCKER_BIN:-/usr/bin/docker}"
|
||||||
|
SUDO_BIN="${SUDO_BIN:-/usr/bin/sudo}"
|
||||||
|
USE_SUDO_FOR_DOCKER="${USE_SUDO_FOR_DOCKER:-true}"
|
||||||
|
BACKUP_KNOWN_HOSTS="${BACKUP_KNOWN_HOSTS:-${HOME:-/root}/.ssh/known_hosts}"
|
||||||
|
|
||||||
|
if [[ ! -x "$DOCKER_BIN" ]]; then
|
||||||
|
echo "Docker binary not executable: $DOCKER_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_cmd() {
|
||||||
|
if [[ "${EUID:-$(id -u)}" == "0" ]]; then
|
||||||
|
"$DOCKER_BIN" "$@"
|
||||||
|
elif [[ "$USE_SUDO_FOR_DOCKER" == "true" ]]; then
|
||||||
|
"$SUDO_BIN" -n "$DOCKER_BIN" "$@"
|
||||||
|
else
|
||||||
|
"$DOCKER_BIN" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp_utc() {
|
||||||
|
date -u +"%Y%m%dT%H%M%SZ"
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_args() {
|
||||||
|
local opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$BACKUP_KNOWN_HOSTS"
|
||||||
|
if [[ -n "${BACKUP_SSH_KEY:-}" ]]; then
|
||||||
|
printf '%s' "-i $BACKUP_SSH_KEY $opts"
|
||||||
|
else
|
||||||
|
printf '%s' "$opts"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
printf 'ssh %s' "$(ssh_args)"
|
||||||
|
}
|
||||||
22
automation/bin/pd_backup_postgres.sh
Executable file
22
automation/bin/pd_backup_postgres.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||||
|
|
||||||
|
stamp="$(timestamp_utc)"
|
||||||
|
dbs="${POSTGRES_DB_LIST:-}"
|
||||||
|
|
||||||
|
if [[ -z "$dbs" ]]; then
|
||||||
|
echo "POSTGRES_DB_LIST is empty; nothing to dump." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for db in $dbs; do
|
||||||
|
out="$PD_DB_DUMP_ROOT/${db}_${stamp}.sql.gz"
|
||||||
|
echo "Dumping Postgres database '$db' to $out"
|
||||||
|
docker_cmd exec "$POSTGRES_CONTAINER" pg_dump -U postgres "$db" | gzip -9 > "$out"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Postgres dumps complete: $PD_DB_DUMP_ROOT"
|
||||||
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
51
automation/bin/pd_backup_sync_to_serenity.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/pd_backup_lib.sh"
|
||||||
|
|
||||||
|
ssh_opts="$(ssh_args)"
|
||||||
|
remote_root="$SERENITY_BACKUP_HOST:$SERENITY_BACKUP_ROOT"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
ssh $ssh_opts "$SERENITY_BACKUP_HOST" mkdir -p \
|
||||||
|
"$SERENITY_BACKUP_ROOT/appdata" \
|
||||||
|
"$SERENITY_BACKUP_ROOT/databases" \
|
||||||
|
"$SERENITY_BACKUP_ROOT/tank-docker" \
|
||||||
|
"$SERENITY_BACKUP_ROOT/db-dumps"
|
||||||
|
|
||||||
|
rsync_cmd=(
|
||||||
|
rsync -a --delete
|
||||||
|
--exclude '*.sqlite-journal'
|
||||||
|
--exclude '*.db-journal'
|
||||||
|
--exclude '*.sqlite-wal'
|
||||||
|
--exclude '*.db-wal'
|
||||||
|
--exclude '*.tmp'
|
||||||
|
)
|
||||||
|
rsync_cmd+=( -e "$(ssh_cmd)" )
|
||||||
|
|
||||||
|
run_rsync() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
echo "$label"
|
||||||
|
set +e
|
||||||
|
"${rsync_cmd[@]}" "$@"
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ $rc -eq 24 ]]; then
|
||||||
|
echo "rsync completed with vanished-file warning (code 24); treating as acceptable for live appdata."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
run_rsync "Syncing appdata to Serenity" "$PD_APPDATA_ROOT/" "$remote_root/appdata/"
|
||||||
|
|
||||||
|
run_rsync "Syncing database volume roots to Serenity" "$PD_DATABASES_ROOT/" "$remote_root/databases/"
|
||||||
|
|
||||||
|
run_rsync "Syncing selected tank docker data to Serenity" "$PD_TANK_DOCKER_ROOT/" "$remote_root/tank-docker/"
|
||||||
|
|
||||||
|
run_rsync "Syncing DB dumps to Serenity" "$PD_DB_DUMP_ROOT/" "$remote_root/db-dumps/"
|
||||||
|
|
||||||
|
echo "Serenity sync complete: $remote_root"
|
||||||
9
automation/bin/run_pd_backups.sh
Executable file
9
automation/bin/run_pd_backups.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/pd_backup_postgres.sh"
|
||||||
|
"$SCRIPT_DIR/pd_backup_sync_to_serenity.sh"
|
||||||
|
|
||||||
|
echo "PD backup run complete."
|
||||||
@@ -26,6 +26,16 @@ Back up at minimum:
|
|||||||
|
|
||||||
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
Recommended dump locations: `/mnt/tank/docker/backups/db-dumps/`
|
||||||
|
|
||||||
|
Repo-side scaffolding now lives under `automation/`:
|
||||||
|
|
||||||
|
- `automation/bin/pd_backup_postgres.sh`
|
||||||
|
- `automation/bin/pd_backup_sync_to_serenity.sh`
|
||||||
|
- `automation/bin/run_pd_backups.sh`
|
||||||
|
|
||||||
|
These are staged helper scripts only until they are deployed with a real `.env`, SSH trust to Serenity, and a live scheduler on PD.
|
||||||
|
|
||||||
|
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
|
||||||
|
|
||||||
## .env File Backup Options
|
## .env File Backup Options
|
||||||
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
1. **Private Gitea repo** — short term, push all .env files to a private repo on your local Gitea
|
||||||
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
2. **Encrypted restic** — include .env files in restic backup with encryption, push offsite (Backblaze B2)
|
||||||
|
|||||||
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
85
docs/operations/PD_BACKUP_DEPLOYMENT.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# PD Backup Deployment
|
||||||
|
|
||||||
|
Deploy the PD → Serenity backup runner from the real compose path on PD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/mnt/docker-ssd/docker/compose/automation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this model
|
||||||
|
|
||||||
|
- PD is the compose/data host, so backups should originate there.
|
||||||
|
- TrueNAS SCALE is happier with plain cron + shell than extra appliance-fighting service glue.
|
||||||
|
- Root cron on PD avoids the common `sudo`/Docker socket permission mess.
|
||||||
|
- Backups should still run even if n8n is down.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Repo content present on PD at `/mnt/docker-ssd/docker/compose/automation`
|
||||||
|
2. Writable dump directory:
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||||
|
```
|
||||||
|
3. SSH trust from PD to Serenity for the selected account
|
||||||
|
4. Real `.env` created from `.env.example`
|
||||||
|
5. Preferred: install the cron job in root's crontab on PD
|
||||||
|
6. If using a non-root PD account instead, `sudo -n /usr/bin/docker` must work
|
||||||
|
|
||||||
|
## Suggested `.env` values to review
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SERENITY_BACKUP_HOST=root@10.5.1.5
|
||||||
|
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
|
||||||
|
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
|
||||||
|
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases
|
||||||
|
PD_TANK_DOCKER_ROOT=/mnt/tank/docker
|
||||||
|
PD_DB_DUMP_ROOT=/mnt/tank/docker/backups/db-dumps
|
||||||
|
POSTGRES_CONTAINER=shared-postgres
|
||||||
|
POSTGRES_DB_LIST="n8n paperless"
|
||||||
|
BACKUP_SSH_KEY=/home/truenas_admin/.ssh/serenity_backup_ed25519
|
||||||
|
DOCKER_BIN=/usr/bin/docker
|
||||||
|
SUDO_BIN=/usr/bin/sudo
|
||||||
|
USE_SUDO_FOR_DOCKER=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual first run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/docker-ssd/docker/compose/automation
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
mkdir -p /mnt/tank/docker/backups/db-dumps
|
||||||
|
/usr/bin/bash bin/run_pd_backups.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
- fresh `*.sql.gz` files appear in `/mnt/tank/docker/backups/db-dumps`
|
||||||
|
- appdata/database/tank-docker content lands under the chosen Serenity backup root
|
||||||
|
- no sudo prompt appeared during `docker exec`
|
||||||
|
|
||||||
|
## Cron install
|
||||||
|
|
||||||
|
Preferred: install in **root's crontab** on PD.
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# BEGIN PD BACKUPS
|
||||||
|
15 2 * * * cd /mnt/docker-ssd/docker/compose/automation && /usr/bin/bash bin/run_pd_backups.sh >> /mnt/tank/docker/backups/pd-backups.log 2>&1
|
||||||
|
# END PD BACKUPS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-deploy checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -100 /mnt/tank/docker/backups/pd-backups.log
|
||||||
|
ls -lh /mnt/tank/docker/backups/db-dumps
|
||||||
|
ssh root@10.5.1.5 'find /mnt/user/backups/plausible-deniability -maxdepth 2 -type d | sort | head -40'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important limitations
|
||||||
|
|
||||||
|
- Do **not** use `systemctl restart docker` on PD.
|
||||||
|
- Keep source/destination paths under `/mnt/...`.
|
||||||
|
- `rsync --delete` is intentional; use a dedicated destination root, not a shared miscellaneous folder.
|
||||||
|
- This scaffolding does not yet back up `.env` files here because those are already handled by the separate encrypted/private-repo process.
|
||||||
|
- If you insist on a non-root cron user on PD, you will need passwordless Docker access (`sudo -n /usr/bin/docker ...`) and write access to the chosen dump directory.
|
||||||
@@ -19,6 +19,16 @@ Each stack contains:
|
|||||||
- `.env.example` — committed with placeholder values
|
- `.env.example` — committed with placeholder values
|
||||||
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
- optional init scripts (e.g. `initdb/`, `initdb-mariadb/`)
|
||||||
|
|
||||||
|
## Host-Run Services
|
||||||
|
If something on NOMAD is not deployed as a Docker compose stack, its live runtime belongs under `/opt/<service>`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `/opt/project-nomad`
|
||||||
|
- `/opt/newt`
|
||||||
|
- `/opt/doris-dashboard`
|
||||||
|
|
||||||
|
Use the repo for source/staging, but do not run production host services from an agent workspace or random checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||||
|
|
||||||
## Deployment Order
|
## Deployment Order
|
||||||
1. `databases/` — must be up before anything else
|
1. `databases/` — must be up before anything else
|
||||||
2. `infrastructure/` — newt creates the pangolin network
|
2. `infrastructure/` — newt creates the pangolin network
|
||||||
|
|||||||
@@ -1,91 +1,125 @@
|
|||||||
# Kima-Hub Deployment Plan
|
# Kima-Hub Deployment / Repair Reference
|
||||||
|
|
||||||
**Host:** PlausibleDeniability (PD)
|
**Host:** PlausibleDeniability (PD)
|
||||||
**Port:** 3333
|
**Stack dir in repo:** `media/kima-hub/`
|
||||||
**Stack dir:** `/mnt/docker-ssd/docker/compose/media-extra/`
|
**Live stack dir on host:** `/mnt/docker-ssd/docker/compose/media/kima-hub/`
|
||||||
**Status:** Pending
|
**Status:** Repo stack corrected to match upstream all-in-one Kima layout and live service verified healthy on PD
|
||||||
|
|
||||||
## What It Is
|
## Current canonical stack
|
||||||
|
|
||||||
Kima-Hub is a self-hosted music streaming platform — think personal Spotify. Point it at your music library and it handles cataloging, artist discovery, AI-powered playlists, podcast subscriptions, and seamless integration with Lidarr and Audiobookshelf.
|
The upstream all-in-one Kima image expects this shape:
|
||||||
|
|
||||||
## Pre-Deploy Checklist
|
- **Image:** `ghcr.io/chevron7locked/kima:latest`
|
||||||
|
- **Container name:** `kima`
|
||||||
|
- **Host port:** `3333`
|
||||||
|
- **Container port:** `3030`
|
||||||
|
- **Persistent data:**
|
||||||
|
- `/mnt/docker-ssd/docker/appdata/kima-hub/data:/data`
|
||||||
|
- **Media mount:**
|
||||||
|
- `/mnt/unraid/data/media/music:/music`
|
||||||
|
- **Network:** `pangolin`
|
||||||
|
- **Host gateway alias:** `host.docker.internal:host-gateway`
|
||||||
|
|
||||||
- [ ] Confirm music library path on Unraid NFS (`/mnt/unraid/data/media/music` or similar)
|
## Canonical live `.env` shape
|
||||||
- [ ] Create appdata directory: `sudo mkdir -p /mnt/tank/docker/appdata/kima-hub`
|
|
||||||
- [ ] Create stack directory: `sudo mkdir -p /mnt/docker-ssd/docker/compose/media-extra`
|
|
||||||
|
|
||||||
## docker-compose.yaml
|
```env
|
||||||
|
TZ=America/New_York
|
||||||
|
SESSION_SECRET=
|
||||||
|
SETTINGS_ENCRYPTION_KEY=
|
||||||
|
KIMA_CALLBACK_URL=http://host.docker.internal:3333
|
||||||
|
DISABLE_CLAP=
|
||||||
|
LIDARR_API_KEY=
|
||||||
|
FANART_API_KEY=
|
||||||
|
LASTFM_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
SOULSEEK_USERNAME=
|
||||||
|
SOULSEEK_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `SESSION_SECRET` and `SETTINGS_ENCRYPTION_KEY` can be left blank; current upstream all-in-one image will generate and persist them under `/data/secrets` on first start.
|
||||||
|
- `DISABLE_CLAP=true` is the low-memory / no-AI-vibe fallback if the analyzer proves too heavy.
|
||||||
|
- `KIMA_CALLBACK_URL` should point at the host-facing Kima URL Lidarr can reach.
|
||||||
|
|
||||||
|
## Canonical compose file
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima-hub
|
container_name: kima
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3333:3030"
|
|
||||||
volumes:
|
|
||||||
- /mnt/tank/docker/appdata/kima-hub:/data
|
|
||||||
- /mnt/unraid/data/media/music:/music:ro
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
healthcheck:
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q 0BD6 || exit 1"]
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
interval: 30s
|
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||||
timeout: 10s
|
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||||
retries: 3
|
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
|
||||||
|
- FANART_API_KEY=${FANART_API_KEY:-}
|
||||||
|
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
|
||||||
|
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||||
|
- /mnt/unraid/data/media/music:/music
|
||||||
|
ports:
|
||||||
|
- "3333:3030"
|
||||||
networks:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pangolin:
|
pangolin:
|
||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Internal port is `3030`, mapped to `3333` on the host. Hex `0BD6` = port 3030 for healthcheck.
|
## Validate before deploy
|
||||||
> **Music path:** Adjust `/mnt/unraid/data/media/music` to match your actual Unraid music library path.
|
|
||||||
> **Read-only mount:** Music library is mounted `:ro` — Kima-Hub should not need write access to source files.
|
|
||||||
|
|
||||||
## .env.example
|
|
||||||
|
|
||||||
```env
|
|
||||||
TZ=America/New_York
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/docker-ssd/docker/compose/media-extra
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
sudo docker compose --env-file .env config # validate
|
sudo docker compose --env-file .env config
|
||||||
sudo docker compose --env-file .env up -d
|
|
||||||
sudo docker logs kima-hub --tail 30
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Post-Deploy
|
## Deploy / repair
|
||||||
|
|
||||||
1. Open `http://10.5.1.6:3333` and create your account
|
```bash
|
||||||
2. Point Kima at your music directory (`/music`)
|
cd /mnt/docker-ssd/docker/compose/media/kima-hub
|
||||||
3. In Lidarr: Settings → Connect → add Kima-Hub webhook
|
sudo docker compose --env-file .env up -d
|
||||||
4. In Audiobookshelf: verify Kima can reach it at `http://10.5.1.6:13358`
|
sudo docker compose --env-file .env ps
|
||||||
5. Add Pangolin resource: `kima.paccoco.com` → port 3333
|
sudo docker logs --tail 60 kima
|
||||||
|
```
|
||||||
|
|
||||||
## Pangolin Resource
|
## Post-deploy checks
|
||||||
|
|
||||||
Add in Pangolin dashboard:
|
1. Open `http://10.5.1.6:3333`
|
||||||
- **Subdomain:** `kima.paccoco.com`
|
2. Confirm the app starts and initial setup loads
|
||||||
- **Target:** `http://10.5.1.6:3333`
|
3. Confirm `/music` is visible to Kima
|
||||||
- **Auth:** Enable (personal use only)
|
4. Confirm Kima creates and uses `/data` persistence correctly
|
||||||
|
5. Add Pangolin resource for `kima.paccoco.com` if desired
|
||||||
|
6. Confirm the NFS-backed Serenity media mount at `/mnt/unraid/data/media` is present on PD and contains the music library Kima should scan
|
||||||
|
|
||||||
## Storage Decision
|
Verified on 2026-05-15:
|
||||||
|
- container `kima` running and healthy
|
||||||
|
- `http://10.5.1.6:3333/api/health` returned status OK
|
||||||
|
- live mount narrowed to `/mnt/unraid/data/media/music:/music`
|
||||||
|
|
||||||
| Path | Tier | Reason |
|
## Optional integrations / secrets
|
||||||
|------|------|--------|
|
|
||||||
| `/mnt/tank/docker/appdata/kima-hub` | tank | Config, DB, playlists — not write-heavy |
|
Not required just to boot:
|
||||||
| `/mnt/unraid/data/media/music` | Unraid NFS (read-only) | Source library stays on Serenity |
|
|
||||||
|
- **Lidarr API key** — for Lidarr integration
|
||||||
|
- **Fanart API key** — for artist images
|
||||||
|
- **Last.fm API key** — for enrichment/recommendations
|
||||||
|
- **OpenAI API key** — optional AI features documented upstream; current upstream docs only explicitly mention OpenAI for that part
|
||||||
|
- **Soulseek username/password** — if Soulseek features are used
|
||||||
|
|
||||||
|
Do **not** commit live secrets into the repo copy of `.env`.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- NFS mount must be up before starting (`sudo bash /mnt/tank/docker/scripts/mount-unraid-nfs.sh`)
|
- The previous repo/live shape using `/app/config`, `/app/data`, `PUID`, `PGID`, `KIMA_MUSIC_PATH`, and `3333:3333` did not match current upstream all-in-one docs.
|
||||||
- If you transcode on the fly, CPU load on PD will spike; Kima-Hub is CPU-bound for transcoding
|
- KitchenOwl already lives in the `home/` stack; Kima lives in its own `media/kima-hub/` stack.
|
||||||
- Kima-Hub has no GPU acceleration — pure CPU transcoding
|
- Keep compose-managed runtime under `/mnt/docker-ssd/docker/compose/...` per SOP.
|
||||||
|
|||||||
@@ -185,10 +185,11 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
|
|||||||
- Project N.O.M.A.D.-managed app containers stay exactly as-is and are **hands-off** unless John explicitly says otherwise.
|
- Project N.O.M.A.D.-managed app containers stay exactly as-is and are **hands-off** unless John explicitly says otherwise.
|
||||||
- `whisper` and `node-exporter` are not protected services; they can be removed, but their exact runtime has been documented above for rebuild/recovery.
|
- `whisper` and `node-exporter` are not protected services; they can be removed, but their exact runtime has been documented above for rebuild/recovery.
|
||||||
- Pelican/Wings-managed game containers are part of the permanent game-server stack on NOMAD and stay in place.
|
- Pelican/Wings-managed game containers are part of the permanent game-server stack on NOMAD and stay in place.
|
||||||
- Doris Dashboard source now lives in the canonical homelab repo at `/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard` rather than the old workspace-only repo.
|
- Doris Dashboard source belongs in the homelab repo, and the live NOMAD runtime belongs at `/opt/doris-dashboard`.
|
||||||
|
|
||||||
### Doris Dashboard (local NOMAD homepage)
|
### Doris Dashboard (local NOMAD homepage)
|
||||||
- Canonical repo path: `/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard`
|
- Live runtime path: `/opt/doris-dashboard`
|
||||||
|
- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard`
|
||||||
- Purpose: local-only dashboard for briefing/news/weather/homelab pulse/Paperless review on NOMAD
|
- Purpose: local-only dashboard for briefing/news/weather/homelab pulse/Paperless review on NOMAD
|
||||||
- Generator: `home/doris-dashboard/bin/generate_dashboard.py`
|
- Generator: `home/doris-dashboard/bin/generate_dashboard.py`
|
||||||
- Interactive LAN server: `home/doris-dashboard/bin/dashboard_server.py`
|
- Interactive LAN server: `home/doris-dashboard/bin/dashboard_server.py`
|
||||||
@@ -196,7 +197,12 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi
|
|||||||
- Runtime notes:
|
- Runtime notes:
|
||||||
- Generated output and queue snapshots are runtime data; source/config lives in git, generated JSON/HTML does not need to be treated as canonical.
|
- Generated output and queue snapshots are runtime data; source/config lives in git, generated JSON/HTML does not need to be treated as canonical.
|
||||||
- Topic thumbs write back to `doris-digest/data/feedback.json`.
|
- Topic thumbs write back to `doris-digest/data/feedback.json`.
|
||||||
- Paperless acknowledge/dismiss state writes to `home/doris-dashboard/data/paperless_review_state.json` on the local checkout.
|
- Paperless acknowledge/dismiss state writes to `/opt/doris-dashboard/data/paperless_review_state.json` on the live host.
|
||||||
|
|
||||||
|
### Host-run service path rule
|
||||||
|
- Docker/compose-managed stacks live under `/mnt/docker-ssd/docker/compose`.
|
||||||
|
- Host-run services that are not compose stacks live under `/opt/<service>`.
|
||||||
|
- Do not run production services from any agent workspace or ad-hoc checkout under `/home/fizzlepoof/.openclaw/workspace`.
|
||||||
|
|
||||||
### Safe to remove / how to restore
|
### Safe to remove / how to restore
|
||||||
#### Safe to remove now
|
#### Safe to remove now
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: documents
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
paperless:
|
paperless:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:2.16
|
||||||
container_name: paperless
|
container_name: paperless
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
- pangolin
|
- pangolin
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: apache/tika:latest
|
image: apache/tika:3.1.0.0
|
||||||
container_name: paperless-tika
|
container_name: paperless-tika
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
- paperless-internal
|
- paperless-internal
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: gotenberg/gotenberg:latest
|
image: gotenberg/gotenberg:8.17
|
||||||
container_name: paperless-gotenberg
|
container_name: paperless-gotenberg
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 256m
|
mem_limit: 256m
|
||||||
|
|||||||
@@ -2,16 +2,22 @@
|
|||||||
|
|
||||||
Local static homepage for John’s morning briefing and peek-anytime news dashboard.
|
Local static homepage for John’s morning briefing and peek-anytime news dashboard.
|
||||||
|
|
||||||
Canonical path:
|
Live runtime path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
/opt/doris-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Source path in the homelab repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/fizzlepoof/repos/truenas-stacks/home/doris-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated page:
|
Generated page:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
/opt/doris-dashboard/public/index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it shows
|
## What it shows
|
||||||
@@ -29,14 +35,14 @@ Generated page:
|
|||||||
## Generate manually
|
## Generate manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
cd /opt/doris-dashboard
|
||||||
bin/generate_dashboard.py
|
bin/generate_dashboard.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Preview locally
|
## Preview locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public
|
cd /opt/doris-dashboard/public
|
||||||
python3 -m http.server 8787
|
python3 -m http.server 8787
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,9 +60,10 @@ Installed in `fizzlepoof`’s crontab:
|
|||||||
|
|
||||||
```cron
|
```cron
|
||||||
# BEGIN DORIS DASHBOARD
|
# BEGIN DORIS DASHBOARD
|
||||||
DORIS_DASHBOARD_DIR=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
DORIS_DASHBOARD_DIR=/opt/doris-dashboard
|
||||||
# Refresh local dashboard after digest collection windows.
|
DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
|
||||||
5,35 6-23 * * * cd $DORIS_DASHBOARD_DIR && bin/generate_dashboard.py >> logs/generate.log 2>&1
|
# Refresh local dashboard after digest collection windows; wrapper enforces Chicago daytime.
|
||||||
|
5,35 * * * * cd $DORIS_DASHBOARD_DIR && bin/cron_generate_dashboard.sh >> logs/generate.log 2>&1
|
||||||
# END DORIS DASHBOARD
|
# END DORIS DASHBOARD
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,7 +146,7 @@ Added sections/data:
|
|||||||
The generated page still lives at:
|
The generated page still lives at:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html
|
/opt/doris-dashboard/public/index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
And the LAN service remains:
|
And the LAN service remains:
|
||||||
@@ -151,7 +158,7 @@ http://10.5.1.16:8787/
|
|||||||
Verification commands:
|
Verification commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
cd /opt/doris-dashboard
|
||||||
python3 -m py_compile bin/generate_dashboard.py
|
python3 -m py_compile bin/generate_dashboard.py
|
||||||
bin/generate_dashboard.py
|
bin/generate_dashboard.py
|
||||||
curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard'
|
curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard'
|
||||||
@@ -190,7 +197,7 @@ Implementation notes:
|
|||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
cd /opt/doris-dashboard
|
||||||
bin/generate_dashboard.py
|
bin/generate_dashboard.py
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -219,6 +226,16 @@ Important note:
|
|||||||
|
|
||||||
- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`.
|
- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`.
|
||||||
|
|
||||||
|
## Runtime path sanity
|
||||||
|
|
||||||
|
The live systemd unit and crontab must point only at:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/doris-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not run the dashboard from an agent workspace. Agent workspaces are for editing and staging; NOMAD host-run services belong under `/opt/<service>`.
|
||||||
|
|
||||||
## Cron fix — 2026-05-13
|
## Cron fix — 2026-05-13
|
||||||
|
|
||||||
The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently.
|
The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
export DORIS_DASHBOARD_DIR="${DORIS_DASHBOARD_DIR:-$(pwd)}"
|
||||||
|
export DORIS_DIGEST_DIR="${DORIS_DIGEST_DIR:-/home/fizzlepoof/.openclaw/workspace/doris-digest}"
|
||||||
|
|
||||||
hour="$(TZ=America/Chicago date +%H)"
|
hour="$(TZ=America/Chicago date +%H)"
|
||||||
if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi
|
if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi
|
||||||
bin/generate_dashboard.py
|
bin/generate_dashboard.py
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=fizzlepoof
|
User=fizzlepoof
|
||||||
Group=fizzlepoof
|
Group=fizzlepoof
|
||||||
WorkingDirectory=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard
|
Environment=DORIS_DASHBOARD_DIR=/opt/doris-dashboard
|
||||||
ExecStartPre=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/generate_dashboard.py
|
Environment=DORIS_DIGEST_DIR=/home/fizzlepoof/.openclaw/workspace/doris-digest
|
||||||
ExecStart=/usr/bin/python3 /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/dashboard_server.py
|
WorkingDirectory=/opt/doris-dashboard
|
||||||
|
ExecStartPre=/opt/doris-dashboard/bin/generate_dashboard.py
|
||||||
|
ExecStart=/usr/bin/python3 /opt/doris-dashboard/bin/dashboard_server.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
services:
|
services:
|
||||||
kima-hub:
|
kima:
|
||||||
image: ghcr.io/chevron7locked/kima:latest
|
image: ghcr.io/chevron7locked/kima:latest
|
||||||
container_name: kima
|
container_name: kima
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
- PUID=${PUID}
|
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||||
- PGID=${PGID}
|
- SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:-}
|
||||||
- KIMA_MUSIC_PATH=/music
|
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3333}
|
||||||
|
- DISABLE_CLAP=${DISABLE_CLAP:-}
|
||||||
|
- LIDARR_API_KEY=${LIDARR_API_KEY:-}
|
||||||
|
- FANART_API_KEY=${FANART_API_KEY:-}
|
||||||
|
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- SOULSEEK_USERNAME=${SOULSEEK_USERNAME:-}
|
||||||
|
- SOULSEEK_PASSWORD=${SOULSEEK_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/config:/app/config
|
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/data
|
||||||
- /mnt/docker-ssd/docker/appdata/kima-hub/data:/app/data
|
- /mnt/unraid/data/media/music:/music
|
||||||
- /mnt/unraid/data/media:/music
|
|
||||||
ports:
|
ports:
|
||||||
- "3333:3333"
|
- "3333:3030"
|
||||||
networks:
|
networks:
|
||||||
- pangolin
|
- pangolin
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Content-Type",
|
"name": "Content-Type",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = 'https://paperless.paccoco.com';\nconst PAPERLESS_TOKEN = '4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d';\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = {\n method,\n url,\n headers,\n json: true,\n };\n\n if (body !== undefined) {\n options.body = body;\n }\n\n return await this.helpers.httpRequest(options);\n}\n\n// Fetch all existing tags. Handles pagination.\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\n\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n\n if (page.next) {\n tagsUrl = page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`;\n } else {\n tagsUrl = null;\n }\n}\n\nconst tagsByName = new Map(\n existingTags.map(tag => [normalizeTagName(tag.name), tag])\n);\n\nconst appliedTags = [];\n\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, {\n name: tagName,\n });\n tagsByName.set(tagName, tag);\n }\n\n if (tag?.id) {\n appliedTags.push(tag);\n }\n}\n\n// Fetch current document so we merge tags instead of overwriting existing ones.\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, {\n tags: mergedTagIds,\n});\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = ($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '');\nconst PAPERLESS_TOKEN = $env.PAPERLESS_API_TOKEN;\nif (!PAPERLESS_TOKEN) throw new Error('PAPERLESS_API_TOKEN is not set.');\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = { method, url, headers, json: true };\n if (body !== undefined) options.body = body;\n return await this.helpers.httpRequest(options);\n}\n\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n tagsUrl = page.next ? (page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`) : null;\n}\n\nconst tagsByName = new Map(existingTags.map(tag => [normalizeTagName(tag.name), tag]));\nconst appliedTags = [];\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, { name: tagName });\n tagsByName.set(tagName, tag);\n }\n if (tag?.id) appliedTags.push(tag);\n}\n\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, { tags: mergedTagIds });\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||||
},
|
},
|
||||||
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
||||||
"name": "Apply Suggested Tags to Paperless",
|
"name": "Apply Suggested Tags to Paperless",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Token 4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d"
|
"value": "={{'Token ' + $env.PAPERLESS_API_TOKEN}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Content-Type",
|
"name": "Content-Type",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = 'https://paperless.paccoco.com';\nconst PAPERLESS_TOKEN = '4fb34ba35b4ab76a0715e8c56faf00a31de4fb5d';\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = {\n method,\n url,\n headers,\n json: true,\n };\n\n if (body !== undefined) {\n options.body = body;\n }\n\n return await this.helpers.httpRequest(options);\n}\n\n// Fetch all existing tags. Handles pagination.\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\n\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n\n if (page.next) {\n tagsUrl = page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`;\n } else {\n tagsUrl = null;\n }\n}\n\nconst tagsByName = new Map(\n existingTags.map(tag => [normalizeTagName(tag.name), tag])\n);\n\nconst appliedTags = [];\n\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, {\n name: tagName,\n });\n tagsByName.set(tagName, tag);\n }\n\n if (tag?.id) {\n appliedTags.push(tag);\n }\n}\n\n// Fetch current document so we merge tags instead of overwriting existing ones.\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, {\n tags: mergedTagIds,\n});\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
"jsCode": "const data = $input.first().json;\n\nconst PAPERLESS_BASE_URL = ($env.PAPERLESS_BASE_URL || 'https://paperless.paccoco.com').replace(/\\/$/, '');\nconst PAPERLESS_TOKEN = $env.PAPERLESS_API_TOKEN;\nif (!PAPERLESS_TOKEN) throw new Error('PAPERLESS_API_TOKEN is not set.');\n\nconst headers = {\n Authorization: `Token ${PAPERLESS_TOKEN}`,\n 'Content-Type': 'application/json',\n};\n\nfunction normalizeTagName(tag) {\n return String(tag || '')\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9 _-]/gi, '')\n .replace(/\\s+/g, ' ')\n .slice(0, 64);\n}\n\nconst suggestedTags = Array.isArray(data.suggested_tags)\n ? [...new Set(data.suggested_tags.map(normalizeTagName).filter(Boolean))].slice(0, 5)\n : [];\n\nif (!data.documentId) {\n throw new Error('No documentId found; cannot apply tags.');\n}\n\nif (suggestedTags.length === 0) {\n return [{\n json: {\n ...data,\n tag_apply_status: 'skipped_no_suggested_tags',\n applied_tag_names: [],\n applied_tag_ids: []\n }\n }];\n}\n\nasync function request(method, url, body) {\n const options = { method, url, headers, json: true };\n if (body !== undefined) options.body = body;\n return await this.helpers.httpRequest(options);\n}\n\nconst existingTags = [];\nlet tagsUrl = `${PAPERLESS_BASE_URL}/api/tags/?page_size=100`;\nwhile (tagsUrl) {\n const page = await request.call(this, 'GET', tagsUrl);\n const results = Array.isArray(page.results) ? page.results : (Array.isArray(page) ? page : []);\n existingTags.push(...results);\n tagsUrl = page.next ? (page.next.startsWith('http') ? page.next : `${PAPERLESS_BASE_URL}${page.next}`) : null;\n}\n\nconst tagsByName = new Map(existingTags.map(tag => [normalizeTagName(tag.name), tag]));\nconst appliedTags = [];\nfor (const tagName of suggestedTags) {\n let tag = tagsByName.get(tagName);\n if (!tag) {\n tag = await request.call(this, 'POST', `${PAPERLESS_BASE_URL}/api/tags/`, { name: tagName });\n tagsByName.set(tagName, tag);\n }\n if (tag?.id) appliedTags.push(tag);\n}\n\nconst doc = await request.call(this, 'GET', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`);\nconst currentTagIds = Array.isArray(doc.tags) ? doc.tags : [];\nconst appliedTagIds = appliedTags.map(tag => tag.id);\nconst mergedTagIds = [...new Set([...currentTagIds, ...appliedTagIds])];\n\nawait request.call(this, 'PATCH', `${PAPERLESS_BASE_URL}/api/documents/${data.documentId}/`, { tags: mergedTagIds });\n\nreturn [{\n json: {\n ...data,\n tag_apply_status: 'updated',\n applied_tag_names: appliedTags.map(tag => tag.name),\n applied_tag_ids: appliedTagIds,\n final_tag_ids: mergedTagIds,\n }\n}];"
|
||||||
},
|
},
|
||||||
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
"id": "b0d5423b-dc83-4adf-98c6-d95afa3e5c1b",
|
||||||
"name": "Apply Suggested Tags to Paperless",
|
"name": "Apply Suggested Tags to Paperless",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "\nconst body = $json.body || $json;\nlet id = body.document_id || body.id || body.doc_id || body.pk || body.document;\nif (!id || (typeof id === 'string' && id.includes('{'))) {\n id = null;\n}\nreturn [{ json: { document_id: id ? String(id) : null, received_at: new Date().toISOString(), webhook_payload_keys: Object.keys(body) } }];\n"
|
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: String(documentId), received_at: new Date().toISOString(), webhook_payload_keys: Object.keys(body) } }];\n"
|
||||||
},
|
},
|
||||||
"id": "extract-doc-id",
|
"id": "extract-doc-id",
|
||||||
"name": "Extract Document ID",
|
"name": "Extract Document ID",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'gpt-4o-mini', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}",
|
"jsonBody": "={{ JSON.stringify({ model: $env.PAPERLESS_TRIAGE_MODEL || 'medium', temperature: 0.1, response_format: { type: 'json_object' }, messages: $json.llm_messages }) }}",
|
||||||
"options": {
|
"options": {
|
||||||
"timeout": 60000
|
"timeout": 60000
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "\n// Requires n8n Code node filesystem access: NODE_FUNCTION_ALLOW_BUILTIN=fs,path\nconst fs = require('fs');\nconst path = require('path');\nconst queuePath = $env.PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH || '/data/paperless_review.json';\nconst maxItems = Number($env.PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS || 200);\nfs.mkdirSync(path.dirname(queuePath), { recursive: true });\nlet queue = [];\ntry { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch (_) { queue = []; }\nif (!Array.isArray(queue)) queue = [];\nconst item = $json.review_item;\nqueue = [item, ...queue.filter(x => String(x.id) !== String(item.id))].slice(0, maxItems);\nconst tmp = queuePath + '.tmp';\nfs.writeFileSync(tmp, JSON.stringify(queue, null, 2) + '\n', 'utf8');\nfs.renameSync(tmp, queuePath);\nreturn [{ json: { ...$json, queue_path: queuePath, queue_count: queue.length } }];\n"
|
"jsCode": "\n// Requires n8n Code node filesystem access: NODE_FUNCTION_ALLOW_BUILTIN=fs,path\nconst fs = require('fs');\nconst path = require('path');\nconst queuePath = $env.PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH || '/data/paperless_review.json';\nconst maxItems = Number($env.PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS || 200);\nfs.mkdirSync(path.dirname(queuePath), { recursive: true });\nlet queue = [];\ntry { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch (_) { queue = []; }\nif (!Array.isArray(queue)) queue = [];\nconst item = $json.review_item;\nqueue = [item, ...queue.filter(x => String(x.id) !== String(item.id))].slice(0, maxItems);\nconst tmp = queuePath + '.tmp';\nfs.writeFileSync(tmp, JSON.stringify(queue, null, 2) + '\\n', 'utf8');\nfs.renameSync(tmp, queuePath);\nreturn [{ json: { ...$json, queue_path: queuePath, queue_count: queue.length } }];\n"
|
||||||
},
|
},
|
||||||
"id": "write-queue",
|
"id": "write-queue",
|
||||||
"name": "Write Doris Review Queue JSON",
|
"name": "Write Doris Review Queue JSON",
|
||||||
@@ -313,11 +313,6 @@
|
|||||||
"node": "Apply Safe Updates Enabled?",
|
"node": "Apply Safe Updates Enabled?",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
},
|
|
||||||
{
|
|
||||||
"node": "Urgent Notification Configured?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || input;\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document\n].filter(v => v !== undefined && v !== null);\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||||
},
|
},
|
||||||
"id": "normalize-paperless-webhook",
|
"id": "normalize-paperless-webhook",
|
||||||
"name": "Normalize Paperless Webhook",
|
"name": "Normalize Paperless Webhook",
|
||||||
@@ -152,13 +152,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"={{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "school-ai-summary",
|
"id": "school-ai-summary",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || input;\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document\n].filter(v => v !== undefined && v !== null);\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
"jsCode": "\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst query = input.query || {};\nconst params = input.params || {};\nconst headers = input.headers || {};\nconst candidates = [\n body.document_id,\n body.id,\n body.doc_id,\n body.pk,\n body.document_id?.id,\n body.document?.id,\n body.document,\n query.document_id,\n query.id,\n query.doc_id,\n params.document_id,\n params.id,\n headers['x-paperless-document-id'],\n input.document_id,\n input.id\n].filter(v => v !== undefined && v !== null && v !== '');\nlet raw = candidates[0];\nif (typeof raw === 'object' && raw !== null) raw = raw.id ?? raw.document_id ?? raw.pk ?? null;\nif (typeof raw === 'string' && /\\{.+\\}/.test(raw)) {\n throw new Error('Received unresolved template string instead of document id.');\n}\nconst documentId = Number(raw);\nif (!Number.isFinite(documentId) || documentId <= 0) {\n throw new Error('Could not resolve Paperless document id from webhook payload.');\n}\nreturn [{ json: { document_id: documentId } }];"
|
||||||
},
|
},
|
||||||
"id": "normalize-paperless-webhook",
|
"id": "normalize-paperless-webhook",
|
||||||
"name": "Normalize Paperless Webhook",
|
"name": "Normalize Paperless Webhook",
|
||||||
@@ -152,13 +152,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "Bearer sk-a95bd142d43d175b6476ebc862e81aa1f6ef34b1153f839698d07541d2f55308"
|
"value": "={{'Bearer ' + $env.LITELLM_API_KEY}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"specifyBody": "json",
|
||||||
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
"jsonBody": "={\n \"model\": \"medium\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You write concise, useful summaries for school assignments stored in Paperless. Return ONLY valid JSON with this exact shape: {\\\"summary\\\": string, \\\"course_context\\\": [string], \\\"suggested_tags\\\": [string]}. Summary should be 2-4 sentences, name the argument or subject clearly, and avoid fluff. course_context should contain short bullets like assignment type, score if known, or class framing when inferable from the provided metadata. suggested_tags should be 3-6 lowercase tags focused on subject matter, not generic words. No markdown, no code fences.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"={{ JSON.stringify('Title: ' + ($json.title || '') + '\\nClass: ' + ($json.class_name || '') + '\\nAssignment: ' + ($json.assignment_name || '') + '\\nSubmission kind: ' + ($json.submission_kind || '') + '\\nOriginal filename: ' + ($json.original_filename || '') + '\\n\\nDocument content:\\n' + ($json.content || '')).slice(1, -1) }}\"\n }\n ],\n \"max_tokens\": 500,\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" }\n}",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "school-ai-summary",
|
"id": "school-ai-summary",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| 14 | Paperless Inbox Reminder | Scheduled (Monday 9 AM) | Queries Paperless for untagged documents; sends Gotify reminder with oldest items listed, or a ✅ clear if inbox is empty |
|
| 14 | Paperless Inbox Reminder | Scheduled (Monday 9 AM) | Queries Paperless for untagged documents; sends Gotify reminder with oldest items listed, or a ✅ clear if inbox is empty |
|
||||||
| 15 | n8n Self-Health Monitor | Scheduled (every 15 min) | Checks n8n execution history for failures in the past hour; deduplicates via Postgres; pushes Gotify alert with workflow name and error summary |
|
| 15 | n8n Self-Health Monitor | Scheduled (every 15 min) | Checks n8n execution history for failures in the past hour; deduplicates via Postgres; pushes Gotify alert with workflow name and error summary |
|
||||||
| 16 | NFS Mount Watchdog | Scheduled (every 5 min) | Probes NFS mount points on PD; if missing: runs mount script, re-probes, restarts affected containers (Plex, Audiobookshelf, Immich); notifies via Gotify on heal or escalation |
|
| 16 | NFS Mount Watchdog | Scheduled (every 5 min) | Probes NFS mount points on PD; if missing: runs mount script, re-probes, restarts affected containers (Plex, Audiobookshelf, Immich); notifies via Gotify on heal or escalation |
|
||||||
|
| 17 | Paperless Intake Triage → Doris Review Queue | `POST /webhook/paperless/intake-triage` | Fetches a Paperless document by `document_id`, runs conservative AI triage, writes a Doris Dashboard review queue JSON file, and keeps safe auto-apply disabled unless explicitly enabled |
|
||||||
| 18 | Telegram School Intake → Postgres → Paperless v1.2 | `POST /webhook/school/intake/upload` | Accepts multipart schoolwork uploads plus class/assignment metadata, uploads into Paperless with deterministic `intake_id` in the filename/title, and returns the resulting task/document context |
|
| 18 | Telegram School Intake → Postgres → Paperless v1.2 | `POST /webhook/school/intake/upload` | Accepts multipart schoolwork uploads plus class/assignment metadata, uploads into Paperless with deterministic `intake_id` in the filename/title, and returns the resulting task/document context |
|
||||||
| 19 | Paperless School Intake Metadata Enrichment v1.1 | `POST /webhook/school/intake/paperless-enrich` | Handles Paperless post-consumption webhooks, resolves metadata from deterministic filename/title, then applies deterministic title/tags and optional correspondent mapping |
|
| 19 | Paperless School Intake Metadata Enrichment v1.1 | `POST /webhook/school/intake/paperless-enrich` | Handles Paperless post-consumption webhooks, resolves metadata from deterministic filename/title, then applies deterministic title/tags and optional correspondent mapping |
|
||||||
|
|
||||||
@@ -79,6 +80,10 @@ Two steps required — both the `.env` file AND the `docker-compose.yaml` `envir
|
|||||||
| `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON` | 19 | JSON object mapping submission kind → Paperless tag ID |
|
| `PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON` | 19 | JSON object mapping submission kind → Paperless tag ID |
|
||||||
| `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON` | 19 | JSON object mapping paper kind → Paperless document type ID |
|
| `PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON` | 19 | JSON object mapping paper kind → Paperless document type ID |
|
||||||
| `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` | 19 | JSON object mapping class name → Paperless correspondent ID |
|
| `PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON` | 19 | JSON object mapping class name → Paperless correspondent ID |
|
||||||
|
| `PAPERLESS_TRIAGE_MODEL` | 17 | Optional LiteLLM model override; defaults to `medium` |
|
||||||
|
| `PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH` | 17 | Writable queue path inside the n8n container, e.g. `/data/paperless-triage/paperless_review.json` |
|
||||||
|
| `PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS` | 17 | Optional queue size cap; defaults to `200` |
|
||||||
|
| `PAPERLESS_TRIAGE_APPLY_SAFE` | 17 | Keep `false` unless you intentionally want title-only safe auto-apply enabled |
|
||||||
| `N8N_API_KEY` | 15 | n8n → Settings → API → Create API Key |
|
| `N8N_API_KEY` | 15 | n8n → Settings → API → Create API Key |
|
||||||
| `LITELLM_API_KEY` | 01, 04, 08 | LiteLLM virtual key (already in .env) |
|
| `LITELLM_API_KEY` | 01, 04, 08 | LiteLLM virtual key (already in .env) |
|
||||||
|
|
||||||
@@ -97,6 +102,10 @@ Two steps required — both the `.env` file AND the `docker-compose.yaml` `envir
|
|||||||
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
|
PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON: ${PAPERLESS_TAG_SUBMISSION_KIND_MAP_JSON}
|
||||||
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
|
PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON: ${PAPERLESS_DOCUMENT_TYPE_PAPER_KIND_MAP_JSON}
|
||||||
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
|
PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON: ${PAPERLESS_CORRESPONDENT_CLASS_MAP_JSON}
|
||||||
|
PAPERLESS_TRIAGE_MODEL: ${PAPERLESS_TRIAGE_MODEL}
|
||||||
|
PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH: ${PAPERLESS_TRIAGE_REVIEW_QUEUE_PATH}
|
||||||
|
PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS: ${PAPERLESS_TRIAGE_REVIEW_MAX_ITEMS}
|
||||||
|
PAPERLESS_TRIAGE_APPLY_SAFE: ${PAPERLESS_TRIAGE_APPLY_SAFE}
|
||||||
N8N_API_KEY: ${N8N_API_KEY}
|
N8N_API_KEY: ${N8N_API_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,17 +155,18 @@ curl -X PUT http://10.5.1.6:6333/collections/knowledge_base \
|
|||||||
curl http://10.5.1.6:11434/api/pull -d '{"name": "nomic-embed-text"}'
|
curl http://10.5.1.6:11434/api/pull -d '{"name": "nomic-embed-text"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Paperless Webhook (required for workflows 03 and 05)
|
### Paperless Webhook (required for workflows 03, 05, 17, and 19)
|
||||||
|
|
||||||
In Paperless-NGX, set up post-consumption webhooks to POST to:
|
In Paperless-NGX, set up post-consumption webhooks to POST to:
|
||||||
```
|
```
|
||||||
https://n8n.paccoco.com/webhook/paperless/new-document
|
https://n8n.paccoco.com/webhook/paperless/new-document
|
||||||
https://n8n.paccoco.com/webhook/paperless/rag-ingest
|
https://n8n.paccoco.com/webhook/paperless/rag-ingest
|
||||||
|
https://n8n.paccoco.com/webhook/paperless/intake-triage
|
||||||
https://n8n.paccoco.com/webhook/school/intake/paperless-enrich
|
https://n8n.paccoco.com/webhook/school/intake/paperless-enrich
|
||||||
```
|
```
|
||||||
with body: `{"document_id": <id>}`
|
with body: `{"document_id": <id>}`
|
||||||
|
|
||||||
You can trigger all three from the same Paperless event. Workflow 03 handles AI summary/tagging, workflow 05 handles RAG ingest, and workflow 19 re-applies deterministic school metadata for intake-managed documents.
|
You can trigger all four from the same Paperless event. Workflow 03 handles general AI summary/tagging, workflow 05 handles RAG ingest, workflow 17 feeds the Doris Dashboard review queue, and workflow 19 re-applies deterministic school metadata for intake-managed documents.
|
||||||
|
|
||||||
### Grafana Webhook (already configured)
|
### Grafana Webhook (already configured)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user