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
192 lines
7.2 KiB
Python
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())
|