8 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
Fojadrachi 46035ccd5d Release v2.1.1
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:47:16 +02:00
FojadrachiandClaude Sonnet 5 b79d42d56d Fix: Discord-Fortschrittszeit stackt bei Autoplay-Songwechseln
Ursache: start/end wurden nur gesendet, wenn 'playing' gerade true war. Bei jedem
automatischen Songwechsel laedt das <video>-Element kurz neu, wodurch 'playing' fuer
1-2 Polls faelschlich false wird - in diesem Fenster fehlten start/end im Update
komplett. Discord ersetzt 'timestamps' bei einem unvollstaendigen SET_ACTIVITY-Update
offenbar nicht sauber, sondern behaelt die zuletzt bekannten (viel zu alten) Werte
bei, wodurch die angezeigte Zeit ueber mehrere Songs hinweg immer weiter waechst statt
pro Lied neu zu starten. Fix: start/end werden jetzt IMMER mitgeschickt, unabhaengig
vom playing-Status (ein pausierter Titel zeigt dann einfach einen eingefrorenen statt
gar keinen Fortschrittsbalken).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:47:02 +02:00
6 changed files with 130 additions and 16 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"discord": {
"enabled": true,
"client_id": "1548023494976086127",
"update_interval_seconds": 5,
"update_interval_seconds": 15,
"show_idle_presence": true
},
"updates": {
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube"
__version__ = "2.1.0"
__version__ = "2.1.4"
+8
View File
@@ -48,6 +48,14 @@ def _app_data_dir() -> Path:
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:
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
# 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 -12
View File
@@ -16,6 +16,7 @@ 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
@@ -54,9 +55,13 @@ def _upsize_thumbnail(url: str | None) -> str | None:
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
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"))
playing = bool(info.get("playing"))
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
@@ -84,15 +89,10 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
"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,
}
duration = info.get("duration") or 0
current_time = info.get("currentTime") or 0
if playing:
start_ts = int(time.time() - current_time)
payload["start"] = start_ts
if duration and duration > 0:
payload["end"] = start_ts + int(duration)
if end_ts:
payload["end"] = end_ts
url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"):
@@ -119,13 +119,25 @@ class DiscordRPCWorker(QThread):
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
super().__init__(parent)
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._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)."""
@@ -188,6 +200,7 @@ class DiscordRPCWorker(QThread):
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:
@@ -195,11 +208,46 @@ class DiscordRPCWorker(QThread):
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)
@@ -207,13 +255,25 @@ class DiscordRPCWorker(QThread):
payload = None
self._presence.clear()
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)
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"):
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)
+4 -2
View File
@@ -60,9 +60,11 @@ class SettingsTab(QWidget):
self._client_id = QLineEdit(str(discord_cfg.get("client_id", "")))
self._client_id.setPlaceholderText("Discord Application Client-ID")
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.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.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
discord_form.addRow(self._discord_enabled)