Initial Tdarr-tray: KDE systray applet for the Tdarr workstation node
All checks were successful
release / release (push) Successful in 16s
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:
65
.gitea/workflows/release.yaml
Normal file
65
.gitea/workflows/release.yaml
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal 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
63
README.md
Normal 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
34
doc/plan.md
Normal 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
33
install.sh
Executable 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
191
tdarr-tray.py
Normal 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())
|
||||
Reference in New Issue
Block a user