6 Commits
Author SHA1 Message Date
Fojadrachi fbf5f3b93f Release v2.1.4
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 12:05:34 +02:00
FojadrachiandClaude Sonnet 5 9bd4b360cd Debug-Log fuer Discord-RPC-Sends (gepackte .exe laeuft ohne Konsole)
console=False im PyInstaller-Build bedeutet: print()/PLAYTUBE_DEBUG-Ausgaben
sind fuer den Nutzer komplett unsichtbar - alle bisherigen Fixes basierten
daher auf Symptombeschreibungen statt echten Daten. Schreibt jetzt bei jedem
RPC-Send (Titel, Thumbnail, large_image, start/end, Track-Wechsel-Erkennung)
eine Zeile in %APPDATA%/Playtube/logs/discord_rpc.log (auf 500 Zeilen
gekappt), damit sich das Bild-Problem anhand echter Daten diagnostizieren
laesst statt weiter zu raten.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 12:05:29 +02:00
Fojadrachi e67e8d5d90 Release v2.1.3
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 00:08:37 +02:00
FojadrachiandClaude Sonnet 5 2ba4e1bb6f Fix: Discord-Fortschrittszeit stackt weiterhin bei YT-Music-Songwechsel (gapless playback)
video.currentTime wird bei YouTube Music beim Songwechsel wegen nahtloser
(gapless) Wiedergabe aus einem durchgehenden Buffer nicht zuverlaessig auf 0
zurueckgesetzt. Da start/end bisher bei JEDEM RPC-Update frisch aus
currentTime berechnet wurden, blieb die in Discord angezeigte Zeit ueber
Songwechsel hinweg stehen bzw. stackte sich, obwohl Titel/Bild laengst
gewechselt hatten (v2.1.2 hat nur das Rate-Limit-Problem fuer das Bild
behoben, nicht dieses).

Der Zeit-Anker wird jetzt im DiscordRPCWorker anhand von Titel-/Untertitel-
Aenderung gesetzt (_track_timestamps) und bleibt danach stabil ueber die
Systemzeit, statt bei jedem Send erneut aus currentTime abgeleitet zu werden.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 00:08:32 +02:00
Fojadrachi 39ebaa84c5 Release v2.1.2
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 23:57:48 +02:00
FojadrachiandClaude Sonnet 5 1b587edaaa Fix: Discord-RPC-Updates unterhalb 15s werden von Discord verworfen (eingefrorenes Bild/Zeit)
Discord ignoriert Rich-Presence-Updates stillschweigend, wenn sie haeufiger als
etwa alle 15 Sekunden gesendet werden. Das update_interval_seconds war auf 5s
konfigurierbar/konfiguriert, wodurch ein Teil der Updates (inkl. neuem
Thumbnail und neuem Start-Zeitstempel bei Songwechsel) verworfen wurde -
sichtbar als eingefrorenes altes Bild und 'stackende' Zeit bei Autoplay.
Hartes Minimum von 15s jetzt sowohl im Worker als auch im Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:57:43 +02:00
6 changed files with 130 additions and 23 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"discord": { "discord": {
"enabled": true, "enabled": true,
"client_id": "1548023494976086127", "client_id": "1548023494976086127",
"update_interval_seconds": 5, "update_interval_seconds": 15,
"show_idle_presence": true "show_idle_presence": true
}, },
"updates": { "updates": {
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube"
__version__ = "2.1.1" __version__ = "2.1.4"
+8
View File
@@ -48,6 +48,14 @@ def _app_data_dir() -> Path:
return d return d
def app_data_dir() -> Path:
"""Oeffentlicher Zugriff auf den App-Datenordner, z.B. fuer Debug-Logs - die
gepackte .exe laeuft ohne Konsolenfenster (console=False), print()-Debugging ist
dort also unsichtbar; ein Log-File ist die einzige Moeglichkeit, dort etwas
nachtraeglich einzusehen."""
return _app_data_dir()
def _config_path() -> Path: def _config_path() -> Path:
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar), # Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
# im gepackten Build im APPDATA-Ordner. # im gepackten Build im APPDATA-Ordner.
+44
View File
@@ -0,0 +1,44 @@
"""Sehr einfaches, immer aktives Datei-Log fuer schwer reproduzierbare Bugs.
Die gepackte .exe laeuft ohne Konsolenfenster (console=False in packaging/playtube.spec),
also sind print()-Debugausgaben dort unsichtbar - selbst mit PLAYTUBE_DEBUG=1 sieht der
Nutzer nichts. Dieses Modul schreibt stattdessen in eine kleine, automatisch gekappte
Log-Datei im App-Datenordner, die sich jederzeit nachtraeglich auslesen laesst.
"""
from __future__ import annotations
import time
import traceback
from pathlib import Path
from .config import app_data_dir
_MAX_LINES = 500
def _log_path() -> Path:
d = app_data_dir() / "logs"
d.mkdir(parents=True, exist_ok=True)
return d / "discord_rpc.log"
def log_line(msg: str) -> None:
"""Haengt eine Zeile mit Zeitstempel an - haelt die Datei klein, indem bei
Ueberlaenge nur die letzten _MAX_LINES Zeilen behalten werden."""
try:
path = _log_path()
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
if len(lines) > _MAX_LINES:
path.write_text("\n".join(lines[-_MAX_LINES:]) + "\n", encoding="utf-8")
except Exception:
# Logging darf niemals die App zum Absturz bringen.
pass
def log_exception(context: str, exc: BaseException) -> None:
log_line(f"{context}: {exc!r}\n{''.join(traceback.format_exception(exc))}")
+72 -19
View File
@@ -16,6 +16,7 @@ from typing import Any
from PySide6.QtCore import QThread from PySide6.QtCore import QThread
from .config import APP_NAME from .config import APP_NAME
from .debug_log import log_line
try: try:
from pypresence.types import ActivityType from pypresence.types import ActivityType
@@ -54,9 +55,13 @@ def _upsize_thumbnail(url: str | None) -> str | None:
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url) return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]: 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 """Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
Medien-Informationen.""" 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")) is_music = bool(info.get("isMusic"))
playing = bool(info.get("playing")) playing = bool(info.get("playing"))
title = _truncate(info.get("title"), fallback="Unbekannter Titel") title = _truncate(info.get("title"), fallback="Unbekannter Titel")
@@ -84,22 +89,10 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
"large_text": "YouTube Music" if is_music else "YouTube", "large_text": "YouTube Music" if is_music else "YouTube",
"small_image": ASSET_PLAY if playing else ASSET_PAUSE, "small_image": ASSET_PLAY if playing else ASSET_PAUSE,
"small_text": "Spielt" if playing else "Pausiert", "small_text": "Spielt" if playing else "Pausiert",
"start": start_ts,
} }
if end_ts:
duration = info.get("duration") or 0 payload["end"] = end_ts
current_time = info.get("currentTime") or 0
# IMMER start/end mitschicken, unabhaengig vom playing-Status - nicht nur wenn
# playing=true. Discord ersetzt "timestamps" bei einem SET_ACTIVITY-Update ohne
# diese Felder offenbar nicht sauber, sondern behaelt intern die zuletzt bekannten
# Werte bei ("stackt"). Waehrend eines Songwechsels ist "playing" durch das kurze
# Neuladen des <video>-Elements oft fuer 1-2 Polls faelschlich false - wurden
# start/end dann weggelassen, blieb Discords alte (viel zu weit zurueckliegende)
# Zeit einfach stehen, bis irgendwann wieder echte Werte kamen. Ein pausierter
# Titel zeigt so einfach einen eingefrorenen Fortschrittsbalken statt gar keinen.
start_ts = int(time.time() - current_time)
payload["start"] = start_ts
if duration and duration > 0:
payload["end"] = start_ts + int(duration)
url = info.get("url") url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"): if url and isinstance(url, str) and url.startswith("http"):
@@ -126,13 +119,25 @@ class DiscordRPCWorker(QThread):
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None): def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
super().__init__(parent) super().__init__(parent)
self._client_id = client_id self._client_id = client_id
self._interval = max(5.0, float(interval)) # 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._show_idle = show_idle
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1) self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
self._running = True self._running = True
self._connected = False self._connected = False
self._presence = None self._presence = None
self._session_start = int(time.time()) 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: def submit_media_info(self, info: dict[str, Any] | None) -> None:
"""Neuester bekannter Zustand (None = nichts spielt / idle).""" """Neuester bekannter Zustand (None = nichts spielt / idle)."""
@@ -195,6 +200,7 @@ class DiscordRPCWorker(QThread):
self._presence = Presence(self._client_id) self._presence = Presence(self._client_id)
self._presence.connect() self._presence.connect()
self._connected = True self._connected = True
log_line("[connect] verbunden")
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print("[discord-rpc] verbunden", flush=True) print("[discord-rpc] verbunden", flush=True)
except Exception as exc: except Exception as exc:
@@ -202,11 +208,46 @@ class DiscordRPCWorker(QThread):
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True) 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: def _send(self, item) -> None:
if self._presence is None: if self._presence is None:
return return
try: try:
if item is _IDLE_SENTINEL: 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: if self._show_idle:
payload = build_idle_payload(self._session_start) payload = build_idle_payload(self._session_start)
self._presence.update(**payload) self._presence.update(**payload)
@@ -214,13 +255,25 @@ class DiscordRPCWorker(QThread):
payload = None payload = None
self._presence.clear() self._presence.clear()
else: else:
payload = build_presence_payload(item, self._session_start) start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload) self._presence.update(**payload)
log_line(
"[send] title=%r thumbnail=%r large_image=%r start=%s end=%s"
% (
item.get("title"),
item.get("thumbnail"),
payload.get("large_image"),
start_ts,
end_ts,
)
)
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] gesendet: {payload!r}", flush=True) print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
except Exception as exc: except Exception as exc:
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden. # Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False self._connected = False
log_line(f"[send-error] {exc!r}")
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True) print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
+4 -2
View File
@@ -60,9 +60,11 @@ class SettingsTab(QWidget):
self._client_id = QLineEdit(str(discord_cfg.get("client_id", ""))) self._client_id = QLineEdit(str(discord_cfg.get("client_id", "")))
self._client_id.setPlaceholderText("Discord Application Client-ID") self._client_id.setPlaceholderText("Discord Application Client-ID")
self._discord_interval = QSpinBox() self._discord_interval = QSpinBox()
self._discord_interval.setRange(5, 120) # Minimum 15s: Discord ignoriert Rich-Presence-Updates, die haeufiger kommen,
# stillschweigend (fuehrt zu "eingefrorenem" Bild/Fortschrittsbalken).
self._discord_interval.setRange(15, 120)
self._discord_interval.setSuffix(" s") self._discord_interval.setSuffix(" s")
self._discord_interval.setValue(int(discord_cfg.get("update_interval_seconds", 15))) self._discord_interval.setValue(max(15, int(discord_cfg.get("update_interval_seconds", 15))))
self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft") self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft")
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True))) self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
discord_form.addRow(self._discord_enabled) discord_form.addRow(self._discord_enabled)