commit 7c0c55bceaea984667d8605050a8a549b0576a1f Author: claude Date: Sun Jul 5 20:31:39 2026 +0200 Initial tdarr-cli: Python CLI for the Tdarr API status/nodes/worker/cruddb/insert/api commands (stdlib only). Cracked Tdarr's x-api-key auth (seededApiKey), alter-worker-limit and update-node formats. Used to set up 2-node transcoding (workstation NVENC + Pi5 CPU fallback). Playbook standard: README (per-OS prereqs), doc/plan.md, .gitignore (build/ first), VS Code build task, install.sh + one-line curl, Gitea Actions rolling-latest release. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011YPrEZVBpPVoCRyJz2exkZ diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..08d03dc --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,65 @@ +name: release + +# Publicerar tdarrctl (arkitektur-oberoende Python-stdlib-skript) + install.sh +# till en rullande 'latest'-release; en vX.Y.Z-tag ger en versionerad release. +# Runnern använder sin egen GITHUB_TOKEN mot Gitea-API:t. +on: + push: + branches: [master, main] + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release-tag (tom = 'latest')" + required: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Bestäm tag + id: t + run: | + case "$GITHUB_REF" in + refs/tags/*) T="$GITHUB_REF_NAME" ;; + *) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;; + esac + echo "tag=$T" >> "$GITHUB_OUTPUT" + + - name: Paketera + syntaxkoll + run: | + set -eux + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo + $SUDO apt-get update && $SUDO apt-get install -y --no-install-recommends jq curl ca-certificates python3 || true + python3 -m py_compile tdarrctl.py + mkdir -p dist + install -m 0755 tdarrctl.py dist/tdarrctl + cp install.sh dist/install.sh + cd dist && sha256sum tdarrctl install.sh > checksums.txt && ls -la + + - name: Skapa release + ladda upp assets + env: + API: http://gitea-d:3000/api/v1 + REPO: ${{ github.repository }} + TAG: ${{ steps.t.outputs.tag }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + BODY="tdarr-cli $TAG — CLI för Tdarrs API (Python, stdlib). + + Installera (en rad): + curl -fsSL https://gitea.brasse-pc.eu/brasse/tdarr-cli/raw/branch/master/install.sh | sh + + Kräver python3. Se wiki för användning." + payload=$(jq -n --arg tag "$TAG" --arg body "$BODY" '{tag_name:$tag,name:$tag,body:$body}') + rid=$(curl -s -X POST "$API/repos/$REPO/releases" -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d "$payload" | jq -r '.id // empty') + [ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" -H "Authorization: token $TOKEN" | jq -r '.id') + curl -s -X PATCH "$API/repos/$REPO/releases/$rid" -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" '{name:$tag,body:$body}')" -o /dev/null + for f in tdarrctl install.sh checksums.txt; do + aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" -H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id") + [ -n "$aid" ] && [ "$aid" != "null" ] && curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" -H "Authorization: token $TOKEN" -o /dev/null + curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" -H "Authorization: token $TOKEN" -F "attachment=@dist/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c30f69e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +__pycache__/ +*.pyc +.env diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fee77db --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "detail": "Bygger en fristående tdarrctl till ./build/", + "type": "shell", + "command": "mkdir -p build && install -m 0755 tdarrctl.py build/tdarrctl && echo '-> build/tdarrctl'", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "nodes", + "detail": "Kör: tdarrctl nodes", + "type": "shell", + "command": "python3 tdarrctl.py nodes", + "group": "test", + "problemMatcher": [] + }, + { + "label": "syntaxkoll", + "type": "shell", + "command": "python3 -m py_compile tdarrctl.py && echo OK", + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfcbee9 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# tdarr-cli (`tdarrctl`) + +En liten CLI mot **Tdarrs API** — som `npmctl` fast för Tdarr. Byggd för att +konfigurera/styra Tdarr-servern (bibliotek, flows, noder, worker-gränser) utan +att klicka i webb-UI:t. Ren Python (bara stdlib). + +## Installera + +En rad (laddar ner senaste `tdarrctl` och lägger den i PATH): + +```sh +curl -fsSL https://gitea.brasse-pc.eu/brasse/tdarr-cli/raw/branch/master/install.sh | sh +``` + +Miljövariabler: `TDARRCTL_INSTALL_DIR` (installkatalog), `TDARRCTL_VERSION` (release-tag). + +## Prerequisites (per OS) + +Endast **Python ≥ 3.8** krävs (inga externa paket — bara standardbiblioteket). + +| OS | Kommando | +|----|----------| +| Garuda/Arch (dev-boxen) | `sudo pacman -S --needed python` | +| Debian/Ubuntu | `sudo apt install python3` | + +## Konfiguration + +- Bas-URL: `TDARR_URL` (standard `http://192.168.0.19:8266`). +- API-nyckel: `TDARR_API_KEY`, annars läses `apiKey=...` ur `~/tdarr-node/.env`. + Skickas som `x-api-key`; skrivs aldrig ut. + +## Kör + +```sh +python3 tdarrctl.py status # server-status/version +python3 tdarrctl.py nodes # noder + worker-gränser +python3 tdarrctl.py worker transcodegpu increase [n] # ändra workers +python3 tdarrctl.py libraries # lista bibliotek +python3 tdarrctl.py cruddb [--obj f.json] [--docID id] +python3 tdarrctl.py insert [--mode insert|update|removeOne] +python3 tdarrctl.py api [--data f.json|-] # generiskt +``` + +## Bygg (till ./build) + +VS Code: **Run Build Task** (`Ctrl+Shift+B`) → `build`-tasken lägger en +körbar `tdarrctl` i `build/` (gitignoread). Manuellt: + +```sh +mkdir -p build && install -m 0755 tdarrctl.py build/tdarrctl +./build/tdarrctl status +``` + +## Test / rök-koll + +```sh +python3 -m py_compile tdarrctl.py # syntax +python3 tdarrctl.py status # når servern? +``` + +## Mer + +- Plan & designbeslut: [`doc/plan.md`](doc/plan.md). +- Fördjupning (API-format, collections, exempel): repots **wiki**. diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..ec725ee --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,40 @@ +# tdarr-cli — plan + +## Goal +En liten, skriptbar CLI mot Tdarrs (odokumenterade) HTTP-API så att Tdarr-servern +på Pi5 kan konfigureras/styras utan webb-UI:t — noder, worker-gränser, bibliotek +och generiska anrop. Ursprungligen byggd för att sätta upp 2-nod-transkodning +(workstation NVENC + Pi5 CPU-fallback). + +## Stack +- Python 3, endast standardbibliotek (`urllib`, `json`, `argparse`). Ingen build. +- Arkitektur-oberoende (körs på både x64 och arm64 med python3). + +## Deploy target +Pattern A — installerbar CLI. Release-CI publicerar `tdarrctl` (+ `install.sh`, +`checksums.txt`) till en rullande `latest`-release; en-rads `curl … | sh`-install. + +## Auth +`x-api-key` (Tdarr `seededApiKey`). Nyckeln läses ur env eller `~/tdarr-node/.env`, +skrivs aldrig ut. + +## Kommandon (nuläge) +`status`, `nodes`, `worker`, `libraries`, `cruddb` (generisk collection-CRUD), +`insert` (insert/update/removeOne), `api` (generiskt HTTP). + +Kartlagda API-detaljer: +- `alter-worker-limit`: `{data:{nodeID, workerType:, process:}}`. +- `update-node`: `{data:{nodeID, nodeUpdates:{…}}}` (t.ex. `nodePaused`). +- cruddb-collections: `LibrarySettingsJSONDB`, `FlowsJSONDB`, `NodeJSONDB`, `PluginJSONDB`, m.fl. + modes: `getAll`, `insert`, `update`, `removeOne`. + +## Roadmap / expansions +- Bekvämlighetskommandon: `pause ` / `resume ` (wrappar `update-node`). +- `create-library` / `set-flow` som bygger korrekta objekt (Tdarr-objekten är + stora/ömtåliga via rå cruddb — bibliotek/flow görs idag bäst i UI:t). +- Konfigfil (`~/.config/tdarrctl/config.json`) för URL + nyckel-referens. +- `--json`-utdata för skriptning. + +## Open questions +- Vilka fler high-level-kommandon är värda att curera (vs generisk `cruddb`/`api`)? +- Ska den slås ihop med/ersättas av tdarr-tray för icke-CLI-styrning? diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ec5ed6b --- /dev/null +++ b/install.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# En-rads-install: +# curl -fsSL https://gitea.brasse-pc.eu/brasse/tdarr-cli/raw/branch/master/install.sh | sh +set -eu +REPO="https://gitea.brasse-pc.eu/brasse/tdarr-cli" +VER="${TDARRCTL_VERSION:-latest}" +command -v python3 >/dev/null 2>&1 || { echo "fel: python3 krävs"; exit 1; } +if command -v curl >/dev/null 2>&1; then DL="curl -fsSL -o"; else DL="wget -qO"; fi +DEST="${TDARRCTL_INSTALL_DIR:-}" +[ -z "$DEST" ] && { if [ "$(id -u)" -eq 0 ] || [ -w /usr/local/bin ]; then DEST=/usr/local/bin; else DEST="$HOME/.local/bin"; fi; } +mkdir -p "$DEST" +tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT +$DL "$tmp" "$REPO/releases/download/$VER/tdarrctl" +chmod +x "$tmp" +mv "$tmp" "$DEST/tdarrctl" 2>/dev/null || { command -v sudo >/dev/null && sudo mv "$tmp" "$DEST/tdarrctl"; } +trap - EXIT +echo "Installerat: $DEST/tdarrctl" +case ":$PATH:" in *":$DEST:"*) ;; *) echo "OBS: lägg $DEST i PATH";; esac diff --git a/tdarrctl.py b/tdarrctl.py new file mode 100644 index 0000000..d0a4db3 --- /dev/null +++ b/tdarrctl.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""tdarrctl — liten temporär CLI mot Tdarrs API (som npmctl fast för Tdarr). + +Auth: x-api-key. Nyckeln läses från $TDARR_API_KEY, annars ur en .env-fil +(rad `apiKey=...`), t.ex. ~/tdarr-node/.env. Nyckeln skrivs aldrig ut. + +Bas-URL: $TDARR_URL (standard http://192.168.0.19:8266). +""" +import argparse, json, os, sys, urllib.request, urllib.error, glob + +URL = os.environ.get("TDARR_URL", "http://192.168.0.19:8266").rstrip("/") + +def load_key(): + k = os.environ.get("TDARR_API_KEY") + if k: + return k.strip() + for p in [os.path.expanduser("~/tdarr-node/.env"), + os.path.expanduser("~/fileflows-node/.env")]: + if os.path.exists(p): + for line in open(p): + line = line.strip() + if line.startswith("apiKey="): + return line.split("=", 1)[1].strip() + sys.exit("fel: ingen API-nyckel (sätt TDARR_API_KEY eller apiKey=... i ~/tdarr-node/.env)") + +KEY = None + +def req(method, path, body=None): + url = path if path.startswith("http") else URL + path + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(url, data=data, method=method, + headers={"x-api-key": KEY, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, timeout=30) as resp: + raw = resp.read().decode() + code = resp.status + except urllib.error.HTTPError as e: + raw = e.read().decode(); code = e.code + try: + return code, json.loads(raw) + except Exception: + return code, raw + +def cruddb(collection, mode, obj=None, docID=""): + return req("POST", "/api/v2/cruddb", + {"data": {"collection": collection, "mode": mode, "docID": docID, "obj": obj or {}}}) + +def pp(x): + print(json.dumps(x, indent=2, ensure_ascii=False) if not isinstance(x, str) else x) + +# ---- kommandon ---- +def cmd_status(a): + pp(req("GET", "/api/v2/status")[1]) + +def cmd_nodes(a): + code, d = req("GET", "/api/v2/get-nodes") + if not isinstance(d, dict): + return pp(d) + for k, n in d.items(): + wl = n.get("workerLimits") or {} + print(f"{n.get('nodeName'):24} id={k} paused={n.get('nodePaused')} " + f"cpu={wl.get('transcodecpu')} gpu={wl.get('transcodegpu')}") + +def cmd_worker(a): + # hitta nod-id via namn-delsträng + _, d = req("GET", "/api/v2/get-nodes") + nid = next((k for k, n in d.items() if a.node.lower() in (n.get("nodeName") or "").lower()), None) + if not nid: + sys.exit(f"ingen nod matchar '{a.node}'") + for _ in range(a.count): + code, r = req("POST", "/api/v2/alter-worker-limit", + {"data": {"nodeID": nid, "workerType": a.type, "process": a.direction}}) + print(code, r) + +def cmd_cruddb(a): + obj = None + if a.obj: + obj = json.load(open(a.obj)) if a.obj != "-" else json.load(sys.stdin) + pp(cruddb(a.collection, a.mode, obj, a.docID)[1]) + +def cmd_api(a): + body = None + if a.data: + body = json.load(open(a.data)) if a.data != "-" else json.load(sys.stdin) + code, r = req(a.method.upper(), a.path, body) + print("HTTP", code) + pp(r) + +def cmd_libraries(a): + pp(cruddb("LibrarySettingsJSONDB", "getAll")[1]) + +def cmd_insert(a): + # generisk insert/update i en collection från en JSON-fil + obj = json.load(open(a.file)) if a.file != "-" else json.load(sys.stdin) + pp(cruddb(a.collection, a.mode, obj, obj.get("_id", ""))[1]) + +def main(): + global KEY + KEY = load_key() + p = argparse.ArgumentParser(prog="tdarrctl", description="Temporär Tdarr-API-CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status").set_defaults(f=cmd_status) + sub.add_parser("nodes").set_defaults(f=cmd_nodes) + + w = sub.add_parser("worker", help="ändra worker-gräns") + w.add_argument("node"); w.add_argument("type", + choices=["transcodecpu", "transcodegpu", "healthcheckcpu", "healthcheckgpu"]) + w.add_argument("direction", choices=["increase", "decrease"]) + w.add_argument("count", nargs="?", type=int, default=1) + w.set_defaults(f=cmd_worker) + + c = sub.add_parser("cruddb", help="generisk cruddb") + c.add_argument("collection"); c.add_argument("mode") + c.add_argument("--obj"); c.add_argument("--docID", default="") + c.set_defaults(f=cmd_cruddb) + + ap = sub.add_parser("api", help="generisk HTTP") + ap.add_argument("method"); ap.add_argument("path"); ap.add_argument("--data") + ap.set_defaults(f=cmd_api) + + sub.add_parser("libraries").set_defaults(f=cmd_libraries) + + ins = sub.add_parser("insert", help="insert/update obj (JSON-fil) i collection") + ins.add_argument("collection"); ins.add_argument("file") + ins.add_argument("--mode", default="insert") + ins.set_defaults(f=cmd_insert) + + a = p.parse_args() + a.f(a) + +if __name__ == "__main__": + main()