Files
Tdarr-tray/tdarr-tray.py
claude 81ff5c7fea
All checks were successful
release / release (push) Successful in 16s
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPrEZVBpPVoCRyJz2exkZ
2026-07-05 20:34:35 +02:00

192 lines
7.2 KiB
Python

#!/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())