Initial tdarr-cli: Python CLI for the Tdarr API
All checks were successful
release / release (push) Successful in 1m4s
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:
65
.gitea/workflows/release.yaml
Normal file
65
.gitea/workflows/release.yaml
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
27
.vscode/tasks.json
vendored
Normal file
27
.vscode/tasks.json
vendored
Normal 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
64
README.md
Normal 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
40
doc/plan.md
Normal 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
18
install.sh
Executable 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
133
tdarrctl.py
Normal 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()
|
||||||
Reference in New Issue
Block a user