Initial tdarr-cli: Python CLI for the Tdarr API
All checks were successful
release / release (push) Successful in 1m4s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPrEZVBpPVoCRyJz2exkZ
This commit is contained in:
2026-07-05 20:31:39 +02:00
commit 7c0c55bcea
7 changed files with 351 additions and 0 deletions

View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
build/
__pycache__/
*.pyc
.env

27
.vscode/tasks.json vendored Normal file
View File

@@ -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": []
}
]
}

64
README.md Normal file
View File

@@ -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 <namn> transcodegpu increase [n] # ändra workers
python3 tdarrctl.py libraries # lista bibliotek
python3 tdarrctl.py cruddb <COLLECTION> <mode> [--obj f.json] [--docID id]
python3 tdarrctl.py insert <COLLECTION> <f.json> [--mode insert|update|removeOne]
python3 tdarrctl.py api <METHOD> <path> [--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**.

40
doc/plan.md Normal file
View File

@@ -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:<transcodegpu|…>, process:<increase|decrease>}}`.
- `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 <nod>` / `resume <nod>` (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?

18
install.sh Executable file
View File

@@ -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

133
tdarrctl.py Normal file
View File

@@ -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()