commit 81ff5c7fea1b70ac58f9792d2c6ad2522e98a92a Author: claude Date: Sun Jul 5 20:34:35 2026 +0200 Initial Tdarr-tray: KDE systray applet for the Tdarr workstation node PyQt6 QSystemTrayIcon: green=running, red=stopped, yellow=transition. Pause/resume the node via the API (frees/reclaims the GPU, no sudo) so you can free the 4060 Ti before gaming. Playbook standard: README (per-OS prereqs + build/run/test), doc/plan.md, .gitignore (build/ first), VS Code build task -> ./build, install.sh + one-line curl (clone or release), 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..3348079 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,65 @@ +name: release + +# Publicerar tray-appen (tdarr-tray.py) + 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 tdarr-tray.py + mkdir -p dist + cp tdarr-tray.py dist/tdarr-tray.py + cp install.sh dist/install.sh + cd dist && sha256sum tdarr-tray.py 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-tray $TAG — systray-applet för Tdarr-noden (KDE/Plasma). + + Installera (en rad): + curl -fsSL https://gitea.brasse-pc.eu/brasse/Tdarr-tray/raw/branch/master/install.sh | sh + + Kräver python3 + PyQt6. Se wiki." + 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 tdarr-tray.py 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..2cafe87 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,40 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "detail": "Bygger en körbar tdarr-tray till ./build/", + "type": "shell", + "command": "mkdir -p build && install -m 0755 tdarr-tray.py build/tdarr-tray && echo '-> build/tdarr-tray'", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "Installera + autostart", + "type": "shell", + "command": "./install.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [] + }, + { + "label": "Kör (förgrund)", + "type": "shell", + "command": "python3 tdarr-tray.py", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Status (--check)", + "type": "shell", + "command": "python3 tdarr-tray.py --check", + "group": "test", + "problemMatcher": [] + }, + { + "label": "Stoppa tray", + "type": "shell", + "command": "pkill -f tdarr-tray && echo stoppad || echo 'ingen instans'", + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a768f1c --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Tdarr-tray + +En **systray-applet** (KDE/Plasma, Wayland) för att styra Tdarr-noden på +workstationen — se om den transkoderar och pausa/återuppta den med ett klick +(t.ex. innan du spelar: paus frigör GPU:n direkt). + +## Färger +| Ikon | Betydelse | +|------|-----------| +| 🟢 grön | noden **körs** (ansluten, ej pausad — transkoderar eller redo) | +| 🔴 röd | noden **körs inte** (pausad eller offline) | +| 🟡 gul | **övergång** — håller på att pausas/återupptas | + +Symbol i ikonen: ▶ (körs), ❚❚ (stoppad), ⋯ (övergång). + +## Installera + +En rad (laddar ner appen, sätter upp autostart, startar den): + +```sh +curl -fsSL https://gitea.brasse-pc.eu/brasse/Tdarr-tray/raw/branch/master/install.sh | sh +``` + +Från en klon: `./install.sh`. Installerar till `~/.local/bin/tdarr-tray` + +autostart i `~/.config/autostart/`. + +## Prerequisites (per OS) + +Python ≥ 3.8 + **PyQt6**: + +| OS | Kommando | +|----|----------| +| Garuda/Arch (dev-boxen) | `sudo pacman -S --needed python python-pyqt6` | +| Debian/Ubuntu | `sudo apt install python3 python3-pyqt6` | + +Kräver en systray (KDE/Plasma har det inbyggt). + +## Meny (högerklick på ikonen) +Status · **⏸ Pausa / ▶ Återuppta nod** (via Tdarrs API, frigör/återtar GPU:n) · +Uppdatera nu · Öppna Tdarr i webbläsaren · Avsluta. + +## Konfiguration (miljövariabler) +| Var | Standard | Beskrivning | +|-----|----------|-------------| +| `TDARR_URL` | `http://192.168.0.19:8266` | Tdarr-serverns API | +| `TDARR_NODE` | `Workstation-4060Ti` | nodnamn att styra | +| `TDARR_API_KEY` | (läses ur `~/tdarr-node/.env`, rad `apiKey=`) | API-nyckel (`x-api-key`) | +| `TDARR_POLL_MS` | `5000` | polling-intervall | + +## Bygg (till ./build) / test + +VS Code: **Run Build Task** (`Ctrl+Shift+B`) → `build`-tasken lägger en körbar +`tdarr-tray` i `build/` (gitignoread). Manuellt / rök-koll: + +```sh +mkdir -p build && install -m 0755 tdarr-tray.py build/tdarr-tray +python3 -m py_compile tdarr-tray.py # syntax +python3 tdarr-tray.py --check # headless statuskoll +``` + +## Mer +- Plan & designbeslut: [`doc/plan.md`](doc/plan.md). +- Arkitektur / hur den funkar: repots **wiki**. diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..6487277 --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,34 @@ +# Tdarr-tray — plan + +## Goal +En systray-applet på workstationen som visar om Tdarr-noden (den som kör NVENC på +4060 Ti) transkoderar, och låter dig pausa/återuppta den med ett klick — så du +snabbt kan frigöra GPU:n innan du spelar och återuppta efteråt. + +## Stack +- Python 3 + **PyQt6** (`QSystemTrayIcon`). KDE/Plasma, Wayland. +- Pratar med Tdarrs API (`x-api-key`), samma auth som tdarr-cli. + +## Deploy target +Pattern A (installerbar). `install.sh` → `~/.local/bin/tdarr-tray` + autostart +(`~/.config/autostart/`). Release-CI publicerar `tdarr-tray.py` + `install.sh` till +rullande `latest`; en-rads `curl … | sh`. + +## Design +- Pollar `GET /api/v2/get-nodes` var 5:e sekund, matchar noden på **namn** (id + ändras vid varje nod-restart). +- Läge → ikonfärg: körs (grön) / körs inte = pausad|offline (röd) / övergång (gul). +- Aktiva transkodningar = `workers` med `idle:false`. +- Paus/återuppta: `POST /api/v2/update-node {nodeUpdates:{nodePaused:…}}` (ingen sudo; + paus dödar ffmpeg → encoder 0 %). + +## Roadmap / expansions +- Notis (ntfy/knotify) när kön är klar eller noden felar. +- Visa kö-längd / sparat diskutrymme i tooltip. +- Snabbval: pausa i X minuter, eller auto-paus när ett spel körs (t.ex. via + gamemode/process-watch). +- Stöd att styra flera noder (inte bara workstationen). + +## Open questions +- Ska "stäng av" även kunna stoppa container (kräver docker/sudo) eller räcker + paus (frigör GPU:n redan)? Nuläge: paus räcker. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..329b5ba --- /dev/null +++ b/install.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Installerar tray-appen till ~/.local/bin + autostart, och startar den. +# Från clone: ./install.sh +# En rad: curl -fsSL https://gitea.brasse-pc.eu/brasse/Tdarr-tray/raw/branch/master/install.sh | sh +set -eu +REPO="https://gitea.brasse-pc.eu/brasse/Tdarr-tray" +BIN="$HOME/.local/bin/tdarr-tray" +DESKTOP="$HOME/.config/autostart/tdarr-tray.desktop" +mkdir -p "$HOME/.local/bin" "$HOME/.config/autostart" +SRC="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)" +if [ -n "${SRC:-}" ] && [ -f "$SRC/tdarr-tray.py" ]; then + install -m 0755 "$SRC/tdarr-tray.py" "$BIN" +else + if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$BIN" "$REPO/releases/download/latest/tdarr-tray.py"; else wget -qO "$BIN" "$REPO/releases/download/latest/tdarr-tray.py"; fi + chmod +x "$BIN" +fi +cat > "$DESKTOP" </dev/null || true +sleep 1 +nohup "$BIN" >/dev/null 2>&1 & disown 2>/dev/null || true +echo "Installerat: $BIN" +echo "Autostart: $DESKTOP" +echo "Startad (kolla systemfältet)." diff --git a/tdarr-tray.py b/tdarr-tray.py new file mode 100644 index 0000000..2558a37 --- /dev/null +++ b/tdarr-tray.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""tdarr-tray — KDE/systray-applet för Tdarr workstation-noden. + +Tray-ikonen visar nodens läge och du kan pausa/återuppta den (t.ex. innan du +spelar — paus frigör GPU:n direkt). + +Färger: + 🟢 grön = noden körs (aktiv/redo att transkoda) + 🔴 röd = noden körs inte (pausad eller offline) + 🟡 gul = håller på att starta/stänga ner (övergång) + +Konfig (env, med rimliga standardvärden): + TDARR_URL standard http://192.168.0.19:8266 + TDARR_NODE nodnamn, standard Workstation-4060Ti + TDARR_API_KEY annars läses apiKey=... ur ~/tdarr-node/.env + +`tdarr-tray.py --check` = headless statuskoll (ingen GUI). +""" +import sys, os, json, urllib.request + +API = os.environ.get("TDARR_URL", "http://192.168.0.19:8266").rstrip("/") +NODE_NAME = os.environ.get("TDARR_NODE", "Workstation-4060Ti") +POLL_MS = int(os.environ.get("TDARR_POLL_MS", "5000")) + + +def load_key(): + k = os.environ.get("TDARR_API_KEY") + if k: + return k.strip() + p = os.path.expanduser("~/tdarr-node/.env") + if os.path.exists(p): + for line in open(p): + if line.strip().startswith("apiKey="): + return line.split("=", 1)[1].strip() + return "" + + +KEY = load_key() + + +def api(method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(API + path, data=data, method=method, + headers={"x-api-key": KEY, "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as r: + raw = r.read().decode() + try: + return json.loads(raw) + except Exception: + return raw + + +def get_node(): + """Nod-objektet (med _id) eller None om offline/saknas.""" + try: + d = api("GET", "/api/v2/get-nodes") + except Exception: + return None + if not isinstance(d, dict): + return None + for k, n in d.items(): + if n.get("nodeName") == NODE_NAME: + n["_id"] = k + return n + return None + + +def set_paused(node_id, paused): + api("POST", "/api/v2/update-node", + {"data": {"nodeID": node_id, "nodeUpdates": {"nodePaused": bool(paused)}}}) + + +def node_detail(n): + """Detaljtext för status/tooltip.""" + if n is None: + return "Offline" + if n.get("nodePaused"): + return "Pausad" + workers = n.get("workers") or {} + active = [w for w in workers.values() if not w.get("idle")] + return f"Transkoderar {len(active)} fil(er)" if active else "Ledig (väntar på jobb)" + + +def is_running(n): + """True om noden körs (ansluten + ej pausad).""" + return n is not None and not n.get("nodePaused") + + +# ---------------- headless-läge ---------------- +def check(): + n = get_node() + print(f"{NODE_NAME}: {'RUNNING' if is_running(n) else 'STOPPED'} — {node_detail(n)}") + return 0 + + +# ---------------- GUI ---------------- +def run_gui(): + from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu + from PyQt6.QtGui import QIcon, QPixmap, QPainter, QColor, QAction, QPolygonF + from PyQt6.QtCore import QTimer, Qt, QPointF, QRectF + + COLORS = {"running": "#2ecc40", "stopped": "#ff4136", "transition": "#ffca28"} + + def make_icon(state): + pm = QPixmap(64, 64) + pm.fill(Qt.GlobalColor.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + # färgad badge + p.setBrush(QColor(COLORS[state])) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(4, 4, 56, 56) + # vit symbol + p.setBrush(QColor("white")) + if state == "running": # play-triangel ▶ + tri = QPolygonF([QPointF(26, 20), QPointF(26, 44), QPointF(46, 32)]) + p.drawPolygon(tri) + elif state == "stopped": # paus-staplar ❚❚ + p.drawRoundedRect(QRectF(23, 21, 7, 22), 2, 2) + p.drawRoundedRect(QRectF(34, 21, 7, 22), 2, 2) + else: # övergång: tre prickar … + for cx in (22, 32, 42): + p.drawEllipse(QPointF(cx, 32), 3.2, 3.2) + p.end() + return QIcon(pm) + + class Tray: + def __init__(self, app): + self.app = app + self.node = None + self.pending = None # måltillstånd (paused True/False) under övergång + self.icons = {k: make_icon(k) for k in COLORS} + self.tray = QSystemTrayIcon() + self.menu = QMenu() + self.status_act = QAction("…"); self.status_act.setEnabled(False) + self.toggle_act = QAction("Pausa nod"); self.toggle_act.triggered.connect(self.toggle) + self.refresh_act = QAction("Uppdatera nu"); self.refresh_act.triggered.connect(self.refresh) + self.open_act = QAction("Öppna Tdarr i webbläsaren") + self.open_act.triggered.connect(lambda: os.system("xdg-open https://tdarr.brasse-pc.eu >/dev/null 2>&1 &")) + self.quit_act = QAction("Avsluta"); self.quit_act.triggered.connect(app.quit) + for a in [self.status_act, None, self.toggle_act, self.refresh_act, self.open_act, None, self.quit_act]: + self.menu.addSeparator() if a is None else self.menu.addAction(a) + self.tray.setContextMenu(self.menu) + self.tray.activated.connect(lambda *_: self.refresh()) + self.tray.setIcon(self.icons["transition"]) + self.tray.show() + self.timer = QTimer(); self.timer.timeout.connect(self.refresh); self.timer.start(POLL_MS) + self.refresh() + + def refresh(self): + self.node = get_node() + detail = node_detail(self.node) + # övergång klar när verkligt läge når målet + if self.pending is not None and self.node is not None and \ + bool(self.node.get("nodePaused")) == self.pending: + self.pending = None + if self.pending is not None: + state = "transition" + else: + state = "running" if is_running(self.node) else "stopped" + self.tray.setIcon(self.icons[state]) + self.status_act.setText(f"{NODE_NAME}: {detail}") + self.tray.setToolTip(f"Tdarr — {NODE_NAME}\n{detail}") + self.toggle_act.setEnabled(self.node is not None) + self.toggle_act.setText("▶ Återuppta nod" if (self.node and self.node.get("nodePaused")) else "⏸ Pausa nod") + + def toggle(self): + if not self.node: + return + target = not self.node.get("nodePaused") # target = ny paused-status + self.pending = target + self.tray.setIcon(self.icons["transition"]) # gul direkt + try: + set_paused(self.node["_id"], target) + except Exception: + self.pending = None + QTimer.singleShot(700, self.refresh) + + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + if not QSystemTrayIcon.isSystemTrayAvailable(): + print("Ingen systray tillgänglig", file=sys.stderr) + return 1 + Tray(app) + return app.exec() + + +if __name__ == "__main__": + if not KEY: + print("VARNING: ingen API-nyckel (TDARR_API_KEY eller ~/tdarr-node/.env)", file=sys.stderr) + sys.exit(check() if "--check" in sys.argv else run_gui())