Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ CF_ACCESS_HOSTS=
# detection keeps protecting the PC. Same token on both sides (16+ chars).
# Allow PC_AGENT_PORT in the PC's firewall for the NAS address only.
# On the PC:
#PC_AGENT_ONLY=true # once Curatarr runs on the NAS: the tray runs only the agent
#PC_AGENT_TOKEN=
#PC_AGENT_PORT=11435
#PC_AGENT_NOTIFY_URL=http://<nas>:<port> # this Curatarr, for the shutdown notice
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,13 @@ embedding weights the stored vectors come from. A host where the same
model name carries other weights is reported before it can write vectors
that do not compare.

**The PC after the move.** `PC_AGENT_ONLY=true` makes the PC's tray run
only the agent: no server and no database. "Open Curatarr" opens the NAS,
and a logoff, shutdown or Quit tells the NAS first. A Curatarr started on
that PC anyway refuses to run, so the same Plex and arrs are never
synced twice. `docs/NAS_MOVE.md` walks the move step by step, rollback
included.

**Docs.** README and USAGE walk through a reproducible first start (the
prerequisites, the Windows install in three steps and the line that proves
Curatarr is up), troubleshooting is named by symptom (LAN access, the Plex
Expand Down
96 changes: 96 additions & 0 deletions docs/NAS_MOVE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Moving Curatarr onto a NAS, with the gaming PC as a second host

The end state: Curatarr runs around the clock in a container on the NAS.
The gaming PC's card does the heavy work while the PC is on and nobody is
playing; the PC's tray only reports, it no longer runs Curatarr itself.
Without the PC, chat and enrichment run on the NAS's own Ollama, and
deletion runs wait (their verdicts never come from the smaller model).

Placeholders: `<nas>` and `<pc>` are LAN addresses, `<nas-ollama>` the NAS
Ollama's port (TrueNAS apps live in 30xxx), `<port>` the container's host
port, `<token>` one random secret of 32+ characters. Generate it like this:

```powershell
-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 40 | ForEach-Object { [char]$_ })
```

## A. Before the move (Curatarr still on the PC)

1. **Record the embedding weights.** Run `python build_models.py --check`
on the PC (start.bat does it on every start). It notes which weights
the stored vectors were made with, and later compares the NAS's copy
with them.
2. **The NAS's Ollama.** In the TrueNAS Ollama app, set the server
environment that `python build_models.py --suggest <GB>` prints for the
card. For 16 GB that is `OLLAMA_KV_CACHE_TYPE=q8_0`,
`OLLAMA_FLASH_ATTENTION=1`, `OLLAMA_NUM_PARALLEL=1` and
`OLLAMA_MAX_LOADED_MODELS=1`.
3. **The PC's Ollama on the LAN.** Set `OLLAMA_HOST=0.0.0.0:11434`, then
quit Ollama and start it from the Start menu. Allow ports 11434 (Ollama)
and 11435 (the agent) for the NAS only:

```powershell
New-NetFirewallRule -DisplayName "Ollama from NAS" -Direction Inbound -Protocol TCP -LocalPort 11434 -RemoteAddress <nas> -Action Allow
New-NetFirewallRule -DisplayName "Curatarr agent from NAS" -Direction Inbound -Protocol TCP -LocalPort 11435 -RemoteAddress <nas> -Action Allow
```

4. **The container is deployed** (`deploy/docker-compose.yml`) and answers
on `http://<nas>:<port>`, still in the setup wizard. Do not run that
setup against the real Plex: two installs would sync and write twice.

## B. The move

1. **Stop Curatarr on the PC** (tray: Shutdown, or close the start.bat
console). A clean stop checkpoints the database, so
`curatarr.db-wal` ends up empty.
2. **Keep a copy** of the PC's `data/` folder and `.env` (a zip) until the
NAS has run for a week.
3. **Stop the container.** Copy the *contents* of the PC's `data/` into the
container's volume, and the `.env` into it as `.env` (the container
reads `/app/data/.env`). Then give the files to the apps user:
`chown -R 568:568 <dataset>`.
4. **Edit the `.env` in the volume.** Change or add these:

| key | value |
|---|---|
| `OLLAMA_ENDPOINT` | `http://<nas>:<nas-ollama>` |
| `BASE_CURATOR_MODEL` | the `--suggest` base, e.g. `gemma4:26b-a4b-it-qat` |
| `LLM_PROFILE`, `CURATOR_NUM_CTX`, `CURATOR_NUM_GPU` | the `--suggest` profile, window and num_gpu |
| `OLLAMA_PC_ENDPOINT` | `http://<pc>:11434` |
| `PC_BASE_CURATOR_MODEL` (and `_SUMMARIZER_`, `_PITCHER_`) | what the PC ran before (its old `BASE_*`, or the defaults) |
| `PC_AGENT_URL` | `http://<pc>:11435` |
| `PC_AGENT_TOKEN` | `<token>` |
| `PLEX_REDIRECT_URI` | `http://<nas>:<port>` |

Everything else stays: Plex, the arrs, API keys, Cloudflare Access.
5. **The PC's `.env`.** Add `PC_AGENT_ONLY=true`, `PC_AGENT_TOKEN=<token>`
and `PC_AGENT_NOTIFY_URL=http://<nas>:<port>`. Then start the tray
(`start_tray.bat`) and keep "Start with Windows" on. The tray now runs
only the agent; start.bat refuses to start a second Curatarr there.
6. **Update the container** (pull the latest image, redeploy), then in its
shell:

```bash
python build_models.py --host main
python build_models.py --check
```

The first command pulls the NAS's bases and the embedding model and
builds the bakes there. The second must show both hosts' models present
and **"Embedding weights match"**. On a mismatch, stop: the NAS has
other weights under the same name, and new vectors would not compare
with the stored ones.
7. **Start the container and watch its log.** Expect
`[pc] available (agent answered)` and no errors.
- Open `http://<nas>:<port>` and sign in.
- Ask the curator something: with the PC on, it answers from the PC.
- Quit the PC's agent and ask again: now the NAS answers.
8. **Point the reverse proxy** (NPM) for the public name at
`http://<nas>:<port>`, then check the public address.

## Rolling back

Stop the container. On the PC, remove `PC_AGENT_ONLY` (or set it to false)
and start start.bat. Point the proxy back at the PC. The PC's `data/` is
exactly as it was when you stopped it in B.1, so nothing done on the NAS
carries over.
5 changes: 4 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ Apps → Custom App → Install via YAML.
household watch, principle or model update means a fresh verdict.
- **Moving an existing install:** stop the old one, then copy its `data/`
folder and its `.env` into the volume. Never run two instances against the
same Plex and arrs.
same Plex and arrs. [NAS_MOVE.md](NAS_MOVE.md) walks the whole move,
rollback included, for a NAS with the gaming PC as a second host. That PC
then runs only the agent (`PC_AGENT_ONLY=true`), and its start.bat refuses
to start a second Curatarr.
- **Updates** come with a new image (pull and redeploy). The in-app restart
exits the process, and `restart: unless-stopped` brings it back.

Expand Down
3 changes: 3 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ def _blank_url_falls_back_to_default(cls, v, info):
# Curatarr's LAN address, for the shutdown notice).
# On the NAS: PC_AGENT_TOKEN, PC_AGENT_URL (http://<pc>:<port>).
PC_AGENT_TOKEN: Optional[SecretStr] = None
# On the PC once Curatarr runs on the NAS: the tray starts only the agent
# (no server, no database) and "Open Curatarr" opens PC_AGENT_NOTIFY_URL.
PC_AGENT_ONLY: bool = False
PC_AGENT_PORT: int = 11435
PC_AGENT_NOTIFY_URL: str = ""
PC_AGENT_URL: str = ""
Expand Down
9 changes: 9 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@

@asynccontextmanager
async def lifespan(app: FastAPI):
# A PC set up as the NAS's card lender (PC_AGENT_ONLY) must not run a
# second Curatarr: two schedulers against one Plex and the same arrs
# write playlists, watchlists and deletions twice. Refused before
# anything opens - start.bat on that PC fails loudly instead.
if getattr(settings, "PC_AGENT_ONLY", False):
logger.error("PC_AGENT_ONLY is set: this PC only lends its card to the Curatarr "
"on the NAS (%s). Not starting a second Curatarr here - the tray "
"runs the agent.", settings.PC_AGENT_NOTIFY_URL or "PC_AGENT_NOTIFY_URL")
raise SystemExit(1)
# Pass 100: stop Syncthing from touching the live SQLite DB. Syncing an
# open WAL-mode database causes "database is locked" errors and risks
# corruption. The guard excludes data/ in the enclosing Syncthing
Expand Down
66 changes: 66 additions & 0 deletions src/tray_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,70 @@ def _toggle_autostart(icon, _item):

# ── main ──────────────────────────────────────────────────────────────────────

def _agent_only(settings, mutex) -> None:
"""PC_AGENT_ONLY: this PC no longer runs Curatarr - the NAS does - and
lends it its card. The tray keeps only the PC agent (src/pc_agent.py):
no server, no database, no scheduler. "Open Curatarr" opens the NAS's
address (PC_AGENT_NOTIFY_URL); a logoff, a shutdown or Quit tells the
NAS first, so nothing more is sent to this card."""
import pystray
from src import pc_agent
from src.services.model_check import ollama_answers

agent = pc_agent.start_from_settings(settings)
if agent is None:
logger.error("PC_AGENT_ONLY is set but the agent did not start "
"(PC_AGENT_TOKEN missing or shorter than 16, or the port is taken).")
if mutex:
ctypes.windll.kernel32.CloseHandle(mutex)
sys.exit(1)
nas = (settings.PC_AGENT_NOTIFY_URL or "").strip().rstrip("/")
warning = (None if ollama_answers(creationflags=_CREATE_NO_WINDOW) else
"Ollama is not answering - Curatarr on the NAS cannot use this PC's card.")
icon = None

def _stop_for_good():
agent.announce_shutdown()
if icon is not None:
icon.stop()

icon = pystray.Icon(
"curatarr", _draw_eye(64), "Curatarr agent (lends this PC's card)",
menu=pystray.Menu(
pystray.MenuItem("Open Curatarr", lambda: webbrowser.open(nas),
default=True, enabled=bool(nas)),
pystray.MenuItem("Open logs", lambda: os.startfile(LOG_DIR)),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Start with Windows", _toggle_autostart,
checked=_autostart_enabled),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit agent", lambda: _stop_for_good()),
),
)
# The session hook joins a "server" before Windows may end the session;
# there is none here, so a thread that has already finished.
nothing = threading.Thread(target=lambda: None, name="curatarr-no-server")
nothing.start()
nothing.join()
_install_endsession_hook(_stop_for_good, nothing)
logger.info("Agent-only mode: reporting to %s", nas or "(no PC_AGENT_NOTIFY_URL)")
if warning:
def _late_notify(i):
i.visible = True
try:
i.notify(warning, "Curatarr")
except Exception as e: # noqa: BLE001 - toast only; the warning is logged too
logger.warning("%s (tray notification failed: %s)", warning, e)
logger.warning(warning)
icon.run(setup=_late_notify)
else:
icon.run()
agent.stop()
if mutex:
ctypes.windll.kernel32.CloseHandle(mutex)
logger.info("Agent-only tray exiting.")


def main() -> None:
os.chdir(ROOT) # belt-and-braces on top of src/paths anchoring

Expand All @@ -365,6 +429,8 @@ def main() -> None:
from src.config import settings

mutex = _acquire_mutex()
if getattr(settings, "PC_AGENT_ONLY", False) and mutex is not None:
return _agent_only(settings, mutex)
if mutex is None or _port_in_use(settings.PORT):
# Another tray instance or a dev console already serves — focus that.
logger.info("Curatarr already running — opening browser instead.")
Expand Down
94 changes: 94 additions & 0 deletions tests/test_agent_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""PC_AGENT_ONLY: the PC after the move (2026-10-23, NAS move step 6).

Once Curatarr runs on the NAS, the gaming PC only lends its card. Its tray
runs just the agent - no server, no database - and a second Curatarr there
(start.bat out of habit) would sync the same Plex and arrs twice, so the
server refuses to start. docs/NAS_MOVE.md walks the move.

python tests/test_agent_only.py
"""
import asyncio
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from src.config import settings # noqa: E402

PASS = FAIL = 0


def check(name, cond):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name}")


check("off by default: every install that is not the PC of a split runs as before",
settings.PC_AGENT_ONLY is False)

# ── the server refuses on an agent-only PC ──────────────────────────────────
import src.main as app_main # noqa: E402
opened = []
saved = (settings.PC_AGENT_ONLY, app_main.logger.error)
settings.PC_AGENT_ONLY = True
app_main.logger.error = lambda *a, **k: opened.append(a[0])


async def _boot():
async with app_main.lifespan(app_main.app):
opened.append("STARTED")

try:
asyncio.run(_boot())
check("an agent-only PC does not start a second Curatarr", False)
except SystemExit as e:
check("an agent-only PC does not start a second Curatarr (start.bat fails loudly)",
e.code == 1 and "STARTED" not in opened and any("PC_AGENT_ONLY" in m for m in opened))
finally:
settings.PC_AGENT_ONLY, app_main.logger.error = saved

main_src = (ROOT / "src/main.py").read_text(encoding="utf-8")
life = main_src[main_src.index("async def lifespan("):]
check("...refused before anything opens (Syncthing guard, database)",
life.index("PC_AGENT_ONLY") < life.index("sync_guard"))

# ── the tray in agent-only mode ─────────────────────────────────────────────
tray = (ROOT / "src/tray_app.py").read_text(encoding="utf-8")
agent_only = tray[tray.index("def _agent_only("):tray.index("def main(")]
main_fn = tray[tray.index("def main("):]
check("the tray branches to agent-only before it checks or opens Curatarr's port",
main_fn.index("return _agent_only(settings, mutex)") < main_fn.index("_port_in_use(settings.PORT)"))
check("agent-only starts no server and opens no database",
"uvicorn" not in agent_only and "src.main" not in agent_only and "get_db_session" not in agent_only)
check("...it starts the agent, or exits saying why",
"pc_agent.start_from_settings(settings)" in agent_only and "sys.exit(1)" in agent_only)
check("...a logoff, a shutdown or Quit tells the NAS first",
"_install_endsession_hook(_stop_for_good, nothing)" in agent_only
and agent_only.index("agent.announce_shutdown()") < agent_only.index("icon.stop()")
and 'MenuItem("Quit agent", lambda: _stop_for_good())' in agent_only)
check("...'Open Curatarr' opens the NAS", "webbrowser.open(nas)" in agent_only
and "PC_AGENT_NOTIFY_URL" in agent_only)
check("...and it warns when this PC's Ollama is not answering (the NAS needs it)",
"ollama_answers(" in agent_only)

# ── the runbook ─────────────────────────────────────────────────────────────
doc = (ROOT / "docs" / "NAS_MOVE.md").read_text(encoding="utf-8")
check("the runbook records the embedding weights before the move and checks them after",
doc.index("Record the embedding weights") < doc.index("Embedding weights match"))
check("...stops the PC's Curatarr before copying, and keeps a copy to roll back to",
doc.index("Stop Curatarr on the PC") < doc.index("Copy the *contents*")
and "Rolling back" in doc)
import re # noqa: E402
check("...and names no real address: placeholders, no LAN IPs, no domains",
"<nas>" in doc and "<pc>" in doc
and not re.search(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", doc.replace("0.0.0.0", ""))
and not re.search(r"https?://[a-z0-9-]+\.[a-z0-9.-]+\.(?:de|com|net|org)\b", doc))

print(f"\n{PASS} passed, {FAIL} failed")
sys.exit(1 if FAIL else 0)
Loading