7 Commits
Author SHA1 Message Date
Fojadrachi a77d288eb9 Release v1.0.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-11 20:54:08 +02:00
FojadrachiandClaude Sonnet 5 f544736c66 Thumbnail-Fix: Video-ID aus URL statt fragilem <link rel=image_src>
Auf vielen aktuellen YouTube-Seiten fehlt das <link rel="image_src"> Tag, auf das
sich die Thumbnail-Erkennung bisher verlassen hat - large_image fiel dadurch staendig
auf das statische Logo zurueck. Jetzt wird die Video-ID aus der URL (?v=...) gelesen
und die Thumbnail-URL direkt ueber YouTubes CDN gebaut (i.ytimg.com/vi/<id>/hqdefault.jpg),
funktioniert zuverlaessig unabhaengig vom Seiten-Markup. Per Live-Test verifiziert.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:52:54 +02:00
Fojadrachi a873f49587 Release v1.0.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 20:49:54 +02:00
FojadrachiandClaude Sonnet 5 2d1dfedacf Discord RPC: echter Fund - activity_type falsch typisiert + Cover-Bild statt Logo
Der entscheidende verbleibende Bug: 'activity_type' wurde als rohes int gesetzt,
pypresence.Presence.update() ruft aber intern .value darauf auf und wirft
AttributeError - jedes Update mit echten Mediendaten schlug dadurch fehl (nur die
generische Idle-Presence ohne activity_type kam durch). Fix: ActivityType-Enum
verwenden (LISTENING/WATCHING statt Default PLAYING), zusammen mit 'instance: False'
nach Vorbild einer frueheren, funktionierenden Electron-Implementierung. Per
Discord-Screenshot verifiziert: RPC erscheint jetzt korrekt.

Zusaetzlich:
- large_image nutzt jetzt die echte Video-/Cover-Thumbnail-URL (Discord akzeptiert
  externe Bild-URLs, nicht nur hochgeladene Asset-Keys) statt des statischen Logos,
  mit Hochskalierung fuer die kleinen YouTube-Music-Player-Bar-Thumbnails.
- media_probe.py: Thumbnail-Erkennung nutzt jetzt getAttribute('src') statt .src -
  bei einem noch nicht geladenen <img> (leeres src-Attribut) loeste .src faelschlich
  auf die aktuelle Seiten-URL auf statt null zu liefern.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:49:41 +02:00
FojadrachiandClaude Sonnet 5 3ddff796ad Entwicklungsmodus nutzt eigenen APPDATA-Ordner (PlaytubeDev statt Playtube)
Verhindert, dass ein lokaler Test-/Dev-Build (python main.py) sich Login-Profil und
Discord-Konfiguration mit einer installierten/gepackten Playtube.exe teilt - genau das
hatte zuletzt zur falschen Discord-Client-ID in der echten Installation gefuehrt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:24:19 +02:00
Fojadrachi 04a12c2426 Release v1.0.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 19:46:45 +02:00
FojadrachiandClaude Sonnet 5 cb1e13b0fc Fix: Discord RPC hat nie Daten empfangen (runJavaScript-Bug)
Wurzelursache gefunden: page().runJavaScript() liefert bei einem direkt
zurueckgegebenen JS-Objekt in dieser PySide6/QtWebEngine-Version zuverlaessig nur
einen leeren String statt das Objekt (Zahlen/Strings funktionieren, Objekte nicht).
Dadurch schlug 'isinstance(result, dict)' in BrowserTab._on_media_probe_result IMMER
fehl, mediaInfoChanged wurde nie emittiert, MainWindow._on_media_info nie aufgerufen
und der DiscordRPCWorker damit nie mit echten Daten gefuettert (Warteschlange blieb
fuer immer leer, der Worker hat sich nie mal mit Discord verbunden).

Fix: media_probe.py gibt jetzt JSON.stringify(...) zurueck, browser.py parst den
String mit json.loads(). Per isoliertem Test verifiziert (Songtitel erscheint jetzt
korrekt im Fenstertitel, Discord-Update wird erfolgreich gesendet).

Ausserdem: PLAYTUBE_DEBUG=1 Env-Var fuer Debug-Logging in browser.py/discord_rpc.py,
Exceptions in discord_rpc.py werden jetzt sichtbar geloggt statt verschluckt,
neue Discord Client-ID.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 19:46:32 +02:00
6 changed files with 117 additions and 18 deletions
+5 -2
View File
@@ -5,7 +5,9 @@ Erweiterung) mit:
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im - **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
Hintergrund weiter wenn du zu Videos wechselst. Hintergrund weiter wenn du zu Videos wechselst.
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei - **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
Google anmelden reicht fuer beide Dienste. Google anmelden reicht fuer beide Dienste.
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und - **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft. einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
@@ -133,7 +135,8 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden. Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
Entwicklungsmodus) loeschen und neu anmelden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von - 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py` - Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
+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__ = "1.0.0" __version__ = "1.0.3"
+19 -2
View File
@@ -3,6 +3,8 @@ Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Ausles
der aktuellen Wiedergabe fuer Discord Rich Presence.""" der aktuellen Wiedergabe fuer Discord Rich Presence."""
from __future__ import annotations from __future__ import annotations
import json
import os
import sys import sys
from PySide6.QtCore import QTimer, Signal, QUrl from PySide6.QtCore import QTimer, Signal, QUrl
@@ -162,5 +164,20 @@ class BrowserTab(QWebEngineView):
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result) self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
def _on_media_probe_result(self, result) -> None: def _on_media_probe_result(self, result) -> None:
if isinstance(result, dict): # runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig
self.mediaInfoChanged.emit(result) # als dict (siehe media_probe.py) - das Probe-Skript gibt daher einen
# JSON-String zurueck, der hier geparst wird.
info = None
if isinstance(result, str) and result:
try:
info = json.loads(result)
except json.JSONDecodeError:
info = None
elif isinstance(result, dict):
info = result
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[media-probe:{self._home_url}] roh={result!r} geparst={info!r}", flush=True)
if isinstance(info, dict):
self.mediaInfoChanged.emit(info)
+12 -3
View File
@@ -10,12 +10,19 @@ from typing import Any
APP_NAME = "Playtube" APP_NAME = "Playtube"
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
# denselben Browser-Profil-Lock geteilt haben).
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"app_name": APP_NAME, "app_name": APP_NAME,
"discord": { "discord": {
"enabled": True, "enabled": True,
# Deine Discord Application Client-ID (discord.com/developers/applications). # Deine Discord Application Client-ID (discord.com/developers/applications).
"client_id": "1544058029190938774", "client_id": "1548023494976086127",
"update_interval_seconds": 15, "update_interval_seconds": 15,
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren. # Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
"show_idle_presence": True, "show_idle_presence": True,
@@ -32,9 +39,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _app_data_dir() -> Path: def _app_data_dir() -> Path:
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil.""" """Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe
stabil. Entwicklungsmodus und gepackte .exe nutzen bewusst unterschiedliche
Ordner (siehe _DATA_DIR_NAME)."""
base = os.environ.get("APPDATA") or str(Path.home()) base = os.environ.get("APPDATA") or str(Path.home())
d = Path(base) / APP_NAME d = Path(base) / _DATA_DIR_NAME
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
+48 -5
View File
@@ -7,7 +7,9 @@ versucht, ohne die App zu beeintraechtigen.
""" """
from __future__ import annotations from __future__ import annotations
import os
import queue import queue
import re
import time import time
from typing import Any from typing import Any
@@ -15,6 +17,11 @@ from PySide6.QtCore import QThread
from .config import APP_NAME from .config import APP_NAME
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 # Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord # Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
# einfach kein Bild an - es gibt keinen Fehler. # einfach kein Bild an - es gibt keinen Fehler.
@@ -36,6 +43,17 @@ def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
return text 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) -> dict[str, Any]: def build_presence_payload(info: dict[str, Any], session_start: int) -> 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."""
@@ -45,10 +63,24 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
default_state = "YouTube Music" if is_music else "YouTube" default_state = "YouTube Music" if is_music else "YouTube"
state = _truncate(info.get("subtitle"), fallback=default_state) 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] = { 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, "details": title,
"state": state, "state": state,
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE, "large_image": large_image,
"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",
@@ -156,8 +188,12 @@ 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
except Exception: if os.environ.get("PLAYTUBE_DEBUG"):
print("[discord-rpc] verbunden", flush=True)
except Exception as exc:
self._connected = False self._connected = False
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
def _send(self, item) -> None: def _send(self, item) -> None:
if self._presence is None: if self._presence is None:
@@ -165,14 +201,21 @@ class DiscordRPCWorker(QThread):
try: try:
if item is _IDLE_SENTINEL: if item is _IDLE_SENTINEL:
if self._show_idle: if self._show_idle:
self._presence.update(**build_idle_payload(self._session_start)) payload = build_idle_payload(self._session_start)
self._presence.update(**payload)
else: else:
payload = None
self._presence.clear() self._presence.clear()
else: else:
self._presence.update(**build_presence_payload(item, self._session_start)) payload = build_presence_payload(item, self._session_start)
except Exception: self._presence.update(**payload)
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. # Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False self._connected = False
if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
def _cleanup(self) -> None: def _cleanup(self) -> None:
if self._presence is not None: if self._presence is not None:
+32 -5
View File
@@ -7,15 +7,37 @@ MEDIA_PROBE_JS = r"""
var el = document.querySelector(sel); var el = document.querySelector(sel);
return el ? el.textContent.trim() : null; return el ? el.textContent.trim() : null;
} }
// Wichtig: getAttribute('src') statt .src - bei einem noch nicht (nach-)geladenen
// <img> (leeres/fehlendes src-Attribut, z.B. waehrend Lazy-Loading) loest die .src
// Property faelschlich auf die aktuelle Seiten-URL auf statt null/"" zu liefern.
function imgSrc(sel) {
var el = document.querySelector(sel);
var raw = el ? el.getAttribute('src') : null;
return (raw && /^https?:\/\//i.test(raw)) ? raw : null;
}
// Video-ID aus der URL (?v=... Parameter) - damit laesst sich die Thumbnail-URL
// ueber YouTubes CDN immer zuverlaessig selbst bauen, unabhaengig davon, ob
// gerade ein passendes <img>/<link> im DOM zu finden ist (das <link
// rel="image_src">, auf das sich der Code frueher verlassen hat, fehlt auf
// vielen aktuellen YouTube-Seiten schlicht).
function videoIdFromUrl(url) {
try {
return new URL(url).searchParams.get('v');
} catch (e) { return null; }
}
function ytThumbnailUrl(videoId) {
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
}
var video = document.querySelector('video'); var video = document.querySelector('video');
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1; var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null; var title = null, subtitle = null, thumbnail = null;
if (isMusic) { if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title'); title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline'); subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img'); thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
thumbnail = imgM ? imgM.src : null;
} else { } else {
var t = document.title.replace(/ - YouTube$/, ''); var t = document.title.replace(/ - YouTube$/, '');
title = t || null; title = t || null;
@@ -24,10 +46,15 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a') || txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a'); || txt('ytd-channel-name#channel-name a');
var imgY = document.querySelector('link[rel="image_src"]'); var imgY = document.querySelector('link[rel="image_src"]');
thumbnail = imgY ? imgY.href : null; thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
} }
return { // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
// JSON-String zurueckgeben und in Python mit json.loads() wieder parsen
// (siehe BrowserTab._on_media_probe_result in browser.py).
return JSON.stringify({
isMusic: isMusic, isMusic: isMusic,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
@@ -37,7 +64,7 @@ MEDIA_PROBE_JS = r"""
currentTime: video ? video.currentTime : 0, currentTime: video ? video.currentTime : 0,
duration: (video && isFinite(video.duration)) ? video.duration : 0, duration: (video && isFinite(video.duration)) ? video.duration : 0,
hasVideo: !!video hasVideo: !!video
}; });
})(); })();
""" """