Initial Tdarr-tray: KDE systray applet for the Tdarr workstation node
All checks were successful
release / release (push) Successful in 16s

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

View File

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

4
.gitignore vendored Normal file
View File

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

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

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

63
README.md Normal file
View File

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

34
doc/plan.md Normal file
View File

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

33
install.sh Executable file
View File

@@ -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" <<DESK
[Desktop Entry]
Type=Application
Name=Tdarr Tray
Comment=Styr Tdarr workstation-noden från systemfältet
Exec=$BIN
Icon=applications-multimedia
Terminal=false
Categories=Utility;
X-GNOME-Autostart-enabled=true
DESK
pkill -f "$BIN" 2>/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)."

191
tdarr-tray.py Normal file
View File

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