14 Commits
Author SHA1 Message Date
Fojadrachi 5379be6c11 Release v2.2.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-12 13:44:47 +02:00
FojadrachiandClaude Sonnet 5 c6fe895334 Fix: Discord-Thumbnail bleibt ueber mehrere Songs stehen (YT Music)
Log-Beweis: dieselbe Thumbnail-URL blieb ueber mehrere komplett verschiedene
Songs (unterschiedliche Titel/Interpreten) unveraendert stehen, waehrend Titel
und Zeit bereits korrekt aktualisiert wurden. Das bisher bevorzugte <img> in
der YT-Music-Playerleiste wird von YouTube per Crossfade/Shadow-DOM animiert
und aktualisiert sein src-Attribut dabei nicht zuverlaessig pro Titel.

Thumbnail wird jetzt primaer aus der Video-ID in der URL abgeleitet (wechselt
garantiert synchron mit dem Titel, dieselbe Quelle die schon zuverlaessig als
Fallback diente) statt aus dem unzuverlaessigen Player-Leisten-<img>, das nur
noch als letzter Fallback dient. Debug-Log zeigt jetzt zusaetzlich die URL pro
Send fuer weitere Verifikation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 13:44:41 +02:00
Fojadrachi 7c202600cb Release v2.2.0
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 13:09:59 +02:00
FojadrachiandClaude Sonnet 5 7c37868cd1 Echter Discord-Zeit-/Bild-Fix, Cache-Reset nach Update, Windows-Startmenue-Icon
Anhand der neuen Debug-Logs (siehe vorheriger Commit) konnte der Bug endlich anhand
echter Daten diagnostiziert werden: video.currentTime/video.duration sind bei YouTube
Music beim Songwechsel NICHT verlaesslich - wegen nahtloser (gapless) Wiedergabe laeuft
darunter ein durchgehender Buffer, dessen currentTime/duration nicht pro Titel
zurueckgesetzt wird. Log-Beweis: beim Wechsel zu einem neuen Titel wurde die
verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des neuen uebernommen,
und die Dauer wuchs bei jedem Poll weiter statt konstant zu bleiben.

- media_probe.py: liest Fortschritt/Gesamtlaenge jetzt aus der sichtbar angezeigten
  Fortschrittsanzeige (ARIA-Attribute des YT-Music-Sliders in Sekunden, sonst
  Zeit-Text im Player; bei YouTube .ytp-time-current/.ytp-time-duration) statt aus den
  unzuverlaessigen <video>-Eigenschaften - diese Anzeige MUSS pro Titel stimmen, weil
  der Nutzer sie selbst so sieht.
- config.py/main.py: Cache (webprofile/cache) wird automatisch geleert, wenn seit dem
  letzten Start ein Update installiert wurde - Login (webprofile/storage) bleibt
  unangetastet.
- shortcuts.py/main.py: legt beim ersten Start der gepackten .exe automatisch eine
  Windows-Startmenue-Verknuepfung an (Playtube wird als portables ZIP ohne Installer
  ausgeliefert, haette sonst nie einen Startmenue-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 13:09:54 +02:00
Fojadrachi d63b6b36d4 Release v2.1.5
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:34:00 +02:00
FojadrachiandClaude Sonnet 5 06e88ced19 Sichtbarer Fortschritt + garantierter Neustart waehrend der Update-Installation
Bisher lief der Datei-Austausch (Skript wartet auf Prozessende, kopiert/robocopy,
startet App neu) nach dem Schliessen des Hauptfensters komplett unsichtbar im
Hintergrund - bei einem vollen Paket-Update konnte das mehrere Sekunden dauern,
in denen es aussah, als waere die App abgestuerzt oder haenge.

- Windows: PowerShell-Skript zeigt jetzt ein kleines WinForms-Fenster mit
  Marquee-Fortschrittsbalken und Statustext (Warten auf Prozessende / Kopieren /
  Fertig - Neustart), robocopy laeuft dafuer per Start-Process+Polling statt
  -Wait, damit die Fensternachrichtenschleife per DoEvents() nicht einfriert.
- Linux: optional dasselbe ueber zenity --progress (best effort, falls
  installiert - ohne zenity laeuft es wie bisher unsichtbar durch).
- App zeigt vor dem Neustart noch kurz eine Statusmeldung im Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 12:33:56 +02:00
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
12 changed files with 385 additions and 35 deletions
+12 -1
View File
@@ -55,7 +55,12 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae
powershell -ExecutionPolicy Bypass -File packaging\build.ps1 powershell -ExecutionPolicy Bypass -File packaging\build.ps1
``` ```
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser
`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube
wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es
sonst keinen Startmenue-Eintrag).
Das Build-Skript benennt
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess` `PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
@@ -91,6 +96,12 @@ fragt ein Dialog, ob sie installiert werden soll:
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren. `updates.enabled` deaktivieren.
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
(`webprofile/storage`) liegen.
### Eine neue Version veroeffentlichen ### Eine neue Version veroeffentlichen
```powershell ```powershell
+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": {
+9 -2
View File
@@ -10,8 +10,9 @@ from pathlib import Path
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren. # Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import app_id # noqa: E402 from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, load_config # noqa: E402 from playtube.config import APP_NAME, clear_cache_on_update, load_config # noqa: E402
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
app_id.set_app_user_model_id() app_id.set_app_user_model_id()
app_id.configure_webengine_process_path() app_id.configure_webengine_process_path()
@@ -45,6 +46,12 @@ def main() -> int:
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
config = load_config() config = load_config()
# Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login
# bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME)
window = MainWindow(config) window = MainWindow(config)
window.show() window.show()
+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.0" __version__ = "2.2.1"
+34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -48,6 +49,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.
@@ -93,3 +102,28 @@ def profile_dir() -> Path:
d = _app_data_dir() / "webprofile" d = _app_data_dir() / "webprofile"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
def clear_cache_on_update(current_version: str) -> None:
"""Loescht den QtWebEngine-HTTP-Cache (webprofile/cache), wenn seit dem letzten
Start ein Update installiert wurde - der Login (Cookies/LocalStorage liegen in
webprofile/storage, einem komplett getrennten Ordner) bleibt dabei unangetastet.
Alte Cache-Eintraege (z.B. Icon-Sprites, Skripte) koennen nach einem Update nicht
mehr zum neuen Code passen - frueher Ursache fuer fehlende Icons nach einem
beschaedigten Cache, siehe README."""
marker = _app_data_dir() / "installed_version.txt"
previous = None
if marker.exists():
try:
previous = marker.read_text(encoding="utf-8").strip()
except OSError:
previous = None
if previous != current_version:
cache_dir = profile_dir() / "cache"
if cache_dir.exists():
shutil.rmtree(cache_dir, ignore_errors=True)
try:
marker.write_text(current_version, encoding="utf-8")
except OSError:
pass
+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))}")
+73 -12
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,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", "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
if playing:
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"):
@@ -119,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)."""
@@ -188,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:
@@ -195,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)
@@ -207,13 +255,26 @@ 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 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"): 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)
+8 -1
View File
@@ -333,7 +333,14 @@ class MainWindow(QMainWindow):
self._settings_tab.set_download_progress(100) self._settings_tab.set_download_progress(100)
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess # Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
# beendet wird, tauscht dann die Dateien aus und startet die App neu. # beendet wird, tauscht dann die Dateien aus und startet die App neu -
# es zeigt dabei selbst ein kleines Fortschrittsfenster an (siehe
# updater.py), damit der Nutzer zwischen "App schliesst sich" und
# "neue App startet" nicht denkt, etwas sei abgestuerzt.
self._settings_tab.set_update_status(
"Installation abgeschlossen. Playtube wird neu gestartet …"
)
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
self._quit() self._quit()
else: else:
QMessageBox.information( QMessageBox.information(
+64 -4
View File
@@ -28,16 +28,72 @@ MEDIA_PROBE_JS = r"""
function ytThumbnailUrl(videoId) { function ytThumbnailUrl(videoId) {
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null; return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
} }
// "1:23" oder "1:02:03" -> Sekunden.
function parseClock(str) {
if (!str) { return null; }
var parts = String(str).trim().split(':').map(function(p) { return parseInt(p, 10); });
if (!parts.length) { return null; }
for (var i = 0; i < parts.length; i++) { if (isNaN(parts[i])) { return null; } }
var seconds = 0;
for (var j = 0; j < parts.length; j++) { seconds = seconds * 60 + parts[j]; }
return seconds;
}
function numAttr(el, name) {
if (!el) { return null; }
var raw = el.getAttribute(name);
var n = raw !== null ? parseFloat(raw) : NaN;
return isFinite(n) ? n : null;
}
// WICHTIG: video.currentTime/video.duration sind bei YouTube Music NICHT
// verlaesslich pro Titel - bei nahtlosem (gapless) Songwechsel laeuft darunter
// teils ein durchgehender Buffer weiter, dessen currentTime/duration beim
// Songwechsel NICHT auf den neuen Titel zurueckgesetzt wird, sondern einfach ueber
// mehrere Songs hinweg weiterzaehlt (empirisch bestaetigt: nach einem Songwechsel
// wurde die verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des NEUEN
// uebernommen, und die "Dauer" wuchs bei jedem Poll weiter statt konstant zu
// bleiben). Deshalb wird die sichtbar angezeigte Fortschrittsanzeige ausgelesen
// (ARIA-Attribute des Sliders in Sekunden, sonst der Zeit-Text im Player) - die
// zeigt garantiert den Fortschritt des GERADE LAUFENDEN Titels, weil der Nutzer sie
// ja selbst so sieht.
function musicTimeInfo() {
var slider = document.querySelector('#progress-bar, tp-yt-paper-slider#progress-bar');
var current = numAttr(slider, 'aria-valuenow');
var total = numAttr(slider, 'aria-valuemax');
if (current !== null && total !== null && total > 0) {
return { current: current, total: total };
}
var el = document.querySelector('.time-info.ytmusic-player-bar, ytmusic-player-bar .time-info');
var parts = el ? (el.textContent || '').split('/') : [];
if (parts.length === 2) {
var c = parseClock(parts[0]), t = parseClock(parts[1]);
if (c !== null && t !== null) { return { current: c, total: t }; }
}
return null;
}
function youtubeTimeInfo() {
var current = parseClock(txt('.ytp-time-current'));
var total = parseClock(txt('.ytp-time-duration'));
if (current !== null && total !== null) { return { current: current, total: total }; }
return 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 videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null; var title = null, subtitle = null, thumbnail = null;
// WICHTIG: die Video-ID aus der URL ist die zuverlaessigste Thumbnail-Quelle - sie
// wechselt garantiert synchron mit dem Titel. Das <img> in der YT-Music-Playerleiste
// (fruehere Praeferenz) wird von YouTube per Crossfade/Shadow-DOM animiert und
// aktualisiert sein src-Attribut dabei nachweislich NICHT zuverlaessig pro Titel -
// empirisch bestaetigt: dieselbe Thumbnail-URL blieb ueber mehrere komplett
// unterschiedliche Songs hinweg stehen, obwohl Titel/Zeit schon laengst gewechselt
// hatten. Das DOM-<img> dient nur noch als Fallback, falls keine Video-ID in der
// URL steckt.
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');
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId); thumbnail = ytThumbnailUrl(videoId) || imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
} else { } else {
var t = document.title.replace(/ - YouTube$/, ''); var t = document.title.replace(/ - YouTube$/, '');
title = t || null; title = t || null;
@@ -46,9 +102,13 @@ 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) || ytThumbnailUrl(videoId); thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
} }
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des // zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als // Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
@@ -61,8 +121,8 @@ MEDIA_PROBE_JS = r"""
thumbnail: thumbnail, thumbnail: thumbnail,
url: location.href, url: location.href,
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: video ? video.currentTime : 0, currentTime: currentTime,
duration: (video && isFinite(video.duration)) ? video.duration : 0, duration: duration,
hasVideo: !!video hasVideo: !!video
}); });
})(); })();
+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)
+52
View File
@@ -0,0 +1,52 @@
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung.
Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es
also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten"
Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent -
prueft vorher, ob die Verknuepfung schon existiert)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
def _find_icon(exe_dir: Path) -> Path | None:
matches = list(exe_dir.rglob("icon.ico"))
return matches[0] if matches else None
def ensure_start_menu_shortcut(app_name: str) -> None:
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
try:
appdata = os.environ.get("APPDATA")
if not appdata:
return
start_menu = Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
shortcut_path = start_menu / f"{app_name}.lnk"
if shortcut_path.exists():
return
exe_path = Path(sys.executable).resolve()
icon_path = _find_icon(exe_path.parent) or exe_path
ps_script = (
'$WshShell = New-Object -ComObject WScript.Shell\n'
f'$Shortcut = $WshShell.CreateShortcut("{shortcut_path}")\n'
f'$Shortcut.TargetPath = "{exe_path}"\n'
f'$Shortcut.WorkingDirectory = "{exe_path.parent}"\n'
f'$Shortcut.IconLocation = "{icon_path}"\n'
f'$Shortcut.Description = "{app_name}"\n'
'$Shortcut.Save()\n'
)
subprocess.run(
["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_script],
capture_output=True,
timeout=15,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except Exception:
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
pass
+83 -11
View File
@@ -222,32 +222,88 @@ class UpdateInstaller(QThread):
self.finished_ok.emit() self.finished_ok.emit()
# Kleines, immer sichtbares Fortschrittsfenster fuer den Windows-Installationsschritt.
# Der Hauptprozess ist zu diesem Zeitpunkt schon beendet (Datei-Sperren!), daher
# laeuft das komplett im separaten PowerShell-Skript - ohne dieses Fenster wuerde
# der Nutzer nach dem Schliessen der App fuer die Dauer der Installation (bei
# einem vollen Paket ggf. mehrere Sekunden) gar nichts sehen, was wie ein Absturz
# oder Haenger wirkt.
_WINDOWS_PROGRESS_FORM_PS = """
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text = "Playtube-Update"
$form.Size = New-Object System.Drawing.Size(380,130)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.ControlBox = $false
$form.TopMost = $true
$label = New-Object System.Windows.Forms.Label
$label.Text = "Playtube wird aktualisiert – bitte warten …"
$label.AutoSize = $false
$label.Size = New-Object System.Drawing.Size(340,20)
$label.Location = New-Object System.Drawing.Point(20,15)
$form.Controls.Add($label)
$bar = New-Object System.Windows.Forms.ProgressBar
$bar.Style = "Marquee"
$bar.MarqueeAnimationSpeed = 30
$bar.Size = New-Object System.Drawing.Size(340,20)
$bar.Location = New-Object System.Drawing.Point(20,50)
$form.Controls.Add($bar)
$form.Show()
$form.Refresh()
"""
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) -- # -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None: def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f""" script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()} $targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{ while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 300
[System.Windows.Forms.Application]::DoEvents()
}} }}
$label.Text = "Kopiere aktualisierte Datei …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Start-Process -FilePath "{exe_path}" Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2 Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
""" """
self._spawn_windows_script(script, staging) self._spawn_windows_script(script, staging)
# Falls zenity installiert ist (auf den meisten Desktop-Distros vorhanden), waehrend
# Wartezeit/Installation ein pulsierendes Fortschrittsfenster zeigen - rein optisch,
# das Update funktioniert auch ohne (dann passiert der Neustart einfach unsichtbar).
_POSIX_PROGRESS_HEADER = """#!/bin/sh
ZPID=""
if command -v zenity >/dev/null 2>&1; then
tail -f /dev/null | zenity --progress --title="Playtube-Update" \
--text="Playtube wird aktualisiert - bitte warten ..." --pulsate --no-cancel \
>/dev/null 2>&1 &
ZPID=$!
fi
"""
_POSIX_PROGRESS_FOOTER = """
[ -n "$ZPID" ] && kill "$ZPID" 2>/dev/null
"""
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None: def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh script = self._POSIX_PROGRESS_HEADER + f"""
while kill -0 {os.getpid()} 2>/dev/null; do while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5 sleep 0.5
done done
cp -f "{new_binary}" "{exe_path}" cp -f "{new_binary}" "{exe_path}"
chmod +x "{exe_path}" chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 & nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}" rm -rf "{staging}"
""" """
@@ -257,29 +313,45 @@ rm -rf "{staging}"
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f""" script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()} $targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{ while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 300
[System.Windows.Forms.Application]::DoEvents()
}} }}
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null $label.Text = "Kopiere Programmdateien … (kann etwas dauern)"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
while (-not $roboProc.HasExited) {{
Start-Sleep -Milliseconds 200
[System.Windows.Forms.Application]::DoEvents()
}}
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Start-Process -FilePath "{exe_path}" Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2 Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
""" """
self._spawn_windows_script(script, staging) self._spawn_windows_script(script, staging)
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None: def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh script = self._POSIX_PROGRESS_HEADER + f"""
while kill -0 {os.getpid()} 2>/dev/null; do while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5 sleep 0.5
done done
rm -rf "{install_dir}"/* rm -rf "{install_dir}"/*
cp -a "{source_dir}"/. "{install_dir}"/ cp -a "{source_dir}"/. "{install_dir}"/
chmod +x "{exe_path}" chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 & nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}" rm -rf "{staging}"
""" """