Files
Playtube/playtube/discord_rpc.py
T
Fojadrachi d28b5d05da Discord-Presence: zeigt "In den Einstellungen" und das gerade bearbeitete Feld
Solange der Einstellungen-Tab offen ist, zeigt Discord "In den Einstellungen" mit dem
Feld, das gerade den Fokus hat (z.B. "Bearbeitet: Discord Rich Presence > Client-ID").
Es werden nur Feldnamen uebermittelt, nie Inhalte (z.B. nicht die Client-ID selbst).

- discord_rpc: build_settings_payload + DiscordRPCWorker.submit_settings_activity
- settings_tab: Signal activityChanged, verfolgt den Fokus (auch bei Spinner/Combobox,
  deren Fokus auf einem internen Kind-Widget liegt)
- mainwindow: _update_discord_presence waehlt zwischen Einstellungen, Wiedergabe und
  Leerlauf; Einstellungs-Status folgt der Option "Status anzeigen, wenn gerade nichts
  laeuft" (show_idle_presence); bei Tab-Wechsel und nach dem Speichern aktualisiert
- README: Hinweis zur neuen Anzeige
2026-09-19 20:30:33 +02:00

336 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Discord Rich Presence Integration.
Laeuft in einem eigenen Thread (pypresence macht blockierende IPC-Aufrufe), damit die
Oberflaeche nie haengt, egal ob Discord laeuft, gerade startet oder gar nicht
installiert ist. Verbindungsfehler werden abgefangen und in Intervallen erneut
versucht, ohne die App zu beeintraechtigen.
"""
from __future__ import annotations
import os
import queue
import re
import time
from typing import Any
from PySide6.QtCore import QThread
from .config import APP_NAME
from .debug_log import log_line
try:
from pypresence.types import ActivityType
except ImportError: # pypresence fehlt -> Discord-Feature bleibt einfach aus
ActivityType = None
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
# einfach kein Bild an - es gibt keinen Fehler.
ASSET_YOUTUBE = "youtube_logo"
ASSET_MUSIC = "music_logo"
ASSET_DEFAULT = "logo"
ASSET_PLAY = "play_icon"
ASSET_PAUSE = "pause_icon"
_RECONNECT_DELAY_SECONDS = 10
def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
text = (text or fallback).strip()
if not text:
text = fallback
if len(text) > limit:
text = text[: limit - 1].rstrip() + "…"
return text
_THUMBNAIL_SIZE_RE = re.compile(r"=w\d+-h\d+(-[a-z0-9-]*)?$", re.IGNORECASE)
def _upsize_thumbnail(url: str | None) -> str | None:
"""YouTube-Music-Player-Bar-Thumbnails kommen sehr klein (z.B. '=w60-h60-l90-rj').
Fuer eine scharfe Darstellung in Discord die Groesse im URL-Suffix hochsetzen."""
if not url:
return url
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
def build_presence_payload(
info: dict[str, Any], session_start: int, start_ts: int, end_ts: int | None
) -> dict[str, Any]:
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
Medien-Informationen. start_ts/end_ts werden vom DiscordRPCWorker mitgegeben (siehe
dort _track_timestamps) statt hier direkt aus info["currentTime"] berechnet zu
werden."""
is_music = bool(info.get("isMusic"))
playing = bool(info.get("playing"))
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
default_state = "YouTube Music" if is_music else "YouTube"
state = _truncate(info.get("subtitle"), fallback=default_state)
thumbnail = info.get("thumbnail")
if isinstance(thumbnail, str) and thumbnail.startswith("http"):
# Discord akzeptiert fuer large_image auch direkte externe Bild-URLs (nicht nur
# vorab hochgeladene Asset-Keys) - so zeigt Discord das echte Video-/Cover-Bild
# statt eines statischen Logos.
large_image = _upsize_thumbnail(thumbnail) if is_music else thumbnail
else:
large_image = ASSET_MUSIC if is_music else ASSET_YOUTUBE
payload: dict[str, Any] = {
# LISTENING = Hoert, WATCHING = Schaut - statt des Default-Typs PLAYING
# (Spielt). Muss ein ActivityType-Enum-Member sein, kein rohes int - pypresence
# ruft intern .value darauf auf.
"activity_type": ActivityType.LISTENING if is_music else ActivityType.WATCHING,
"instance": False,
"details": title,
"state": state,
"large_image": large_image,
"large_text": "YouTube Music" if is_music else "YouTube",
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
"small_text": "Spielt" if playing else "Pausiert",
"start": start_ts,
}
if end_ts:
payload["end"] = end_ts
url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"):
label = "In YouTube Music öffnen" if is_music else "Auf YouTube ansehen"
payload["buttons"] = [{"label": label, "url": url}]
return payload
def build_idle_payload(session_start: int) -> dict[str, Any]:
return {
"details": f"Bei {APP_NAME}",
"state": "Stöbert gerade",
"large_image": ASSET_DEFAULT,
"large_text": APP_NAME,
"start": session_start,
}
SETTINGS_DETAILS = "In den Einstellungen"
SETTINGS_DEFAULT_STATE = "Schaut sich die Einstellungen an"
def build_settings_payload(session_start: int, activity: str | None) -> dict[str, Any]:
"""Presence, solange der Einstellungen-Tab offen ist. `activity` beschreibt, was
gerade bearbeitet wird (z.B. "Bearbeitet: Discord Rich Presence › Client-ID") - es
wird bewusst nur der NAME des Feldes uebergeben, nie sein Inhalt (sonst wuerde
z.B. die eingetragene Client-ID oeffentlich im Discord-Profil stehen)."""
return {
"details": SETTINGS_DETAILS,
"state": _truncate(activity, fallback=SETTINGS_DEFAULT_STATE),
"large_image": ASSET_DEFAULT,
"large_text": APP_NAME,
"start": session_start,
}
class DiscordRPCWorker(QThread):
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
super().__init__(parent)
self._client_id = client_id
# Discord ignoriert/verwirft Rich-Presence-Updates stillschweigend, wenn sie
# haeufiger als ca. alle 15 Sekunden gesendet werden (offizielle Grenze fuer
# SET_ACTIVITY). Wird diese Grenze unterschritten, landet zwar technisch jedes
# Update im Code, aber Discord uebernimmt nur einen Teil davon - nach aussen
# sieht das wie "eingefrorene" Bilder/Zeiten aus (Bild wechselt nicht, Fortschritt
# "stackt" beim Songwechsel), weil zufaellig immer wieder ein veraltetes Update
# durchkommt statt des aktuellen. Deshalb hartes Minimum von 15s, unabhaengig
# davon, was in der Konfiguration steht.
self._interval = max(15.0, float(interval))
self._show_idle = show_idle
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
self._running = True
self._connected = False
self._presence = None
self._session_start = int(time.time())
# Anker fuer die Fortschrittsanzeige: wird NICHT mehr bei jedem Send aus
# video.currentTime neu berechnet (siehe _track_timestamps).
self._track_key: tuple | None = None
self._track_start_ts: int | None = None
def submit_media_info(self, info: dict[str, Any] | None) -> None:
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
def submit_settings_activity(self, activity: str | None) -> None:
"""Nutzer ist im Einstellungen-Tab; `activity` = was gerade bearbeitet wird
(None = nur "schaut sich die Einstellungen an")."""
self._replace_queue(_SettingsActivity(activity))
def _replace_queue(self, item) -> None:
try:
self._queue.get_nowait()
except queue.Empty:
pass
try:
self._queue.put_nowait(item)
except queue.Full:
pass
def stop(self) -> None:
self._running = False
self._replace_queue(_STOP_SENTINEL)
def run(self) -> None:
last_sent = 0.0
while self._running:
try:
item = self._queue.get()
except Exception:
break
if item is _STOP_SENTINEL or not self._running:
break
wait = self._interval - (time.time() - last_sent)
if wait > 0:
time.sleep(wait)
# Waehrend des Wartens evtl. neuere Daten uebernehmen (debounce).
try:
while True:
item = self._queue.get_nowait()
except queue.Empty:
pass
if item is _STOP_SENTINEL or not self._running:
break
self._ensure_connected()
if self._connected:
self._send(item)
last_sent = time.time()
else:
# Nicht verbunden -> nicht sinnlos oft versuchen.
time.sleep(_RECONNECT_DELAY_SECONDS)
self._cleanup()
def _ensure_connected(self) -> None:
if self._connected:
return
try:
from pypresence import Presence
self._presence = Presence(self._client_id)
self._presence.connect()
self._connected = True
log_line("[connect] verbunden")
if os.environ.get("PLAYTUBE_DEBUG"):
print("[discord-rpc] verbunden", flush=True)
except Exception as exc:
self._connected = False
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
def _track_timestamps(self, info: dict[str, Any]) -> tuple[int, int | None]:
"""Liefert (start_ts, end_ts) fuer die Fortschrittsanzeige. Der Anker wird nur
NEU gesetzt, wenn sich Titel/Untertitel aendern (= neuer Track) - nicht bei
jedem Send aus video.currentTime neu berechnet. Grund: YouTube Music spielt
beim Songwechsel oft nahtlos (gapless) aus einem durchgehenden Buffer weiter -
video.currentTime springt dabei nicht zuverlaessig auf 0 zurueck, sondern kann
einfach vom vorherigen Titel weiterzaehlen. Wuerde man start_ts jedes Mal aus
currentTime neu ableiten, "stackt" die in Discord angezeigte Zeit ueber mehrere
Songs hinweg, obwohl Titel/Bild laengst gewechselt haben."""
title = info.get("title") or ""
subtitle = info.get("subtitle") or ""
is_music = bool(info.get("isMusic"))
key = (is_music, title, subtitle)
duration = info.get("duration") or 0
current_time = info.get("currentTime") or 0
now = time.time()
if key != self._track_key:
old_key = self._track_key
self._track_key = key
# current_time nur als grobe Anfangs-Schaetzung verwenden (z.B. Programm
# startet waehrend ein Titel schon laeuft) - plausibilisiert, damit ein
# verlaesslicher Wert genau EINMAL beim Trackwechsel einfriert und danach
# rein ueber die Systemzeit weiterlaeuft statt ueber currentTime.
offset = current_time if (duration <= 0 or 0 <= current_time <= duration) else 0
self._track_start_ts = int(now - offset)
log_line(f"[track-change] alt={old_key!r} neu={key!r} offset={offset:.1f}s")
start_ts = self._track_start_ts if self._track_start_ts is not None else int(now)
end_ts = start_ts + int(duration) if duration and duration > 0 else None
return start_ts, end_ts
def _send(self, item) -> None:
if self._presence is None:
return
try:
if item is _IDLE_SENTINEL:
# Naechster echter Track soll wieder einen frischen Zeit-Anker bekommen.
self._track_key = None
self._track_start_ts = None
if self._show_idle:
payload = build_idle_payload(self._session_start)
self._presence.update(**payload)
else:
payload = None
self._presence.clear()
elif isinstance(item, _SettingsActivity):
# Wie beim Leerlauf: der naechste echte Track bekommt einen frischen
# Zeit-Anker.
self._track_key = None
self._track_start_ts = None
payload = build_settings_payload(self._session_start, item.activity)
self._presence.update(**payload)
log_line("[send-settings] state=%r" % (payload["state"],))
else:
start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload)
log_line(
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
% (
item.get("title"),
item.get("url"),
item.get("thumbnail"),
payload.get("large_image"),
start_ts,
end_ts,
)
)
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
except Exception as exc:
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False
log_line(f"[send-error] {exc!r}")
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
def _cleanup(self) -> None:
if self._presence is not None:
try:
self._presence.clear()
self._presence.close()
except Exception:
pass
class _SettingsActivity:
"""Queue-Eintrag: Nutzer ist in den Einstellungen und bearbeitet `activity`."""
__slots__ = ("activity",)
def __init__(self, activity: str | None) -> None:
self.activity = activity
class _Sentinel:
__slots__ = ()
_IDLE_SENTINEL = _Sentinel()
_STOP_SENTINEL = _Sentinel()