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