10 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
10 changed files with 371 additions and 38 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
```
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
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
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
`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
```powershell
+9 -2
View File
@@ -10,8 +10,9 @@ from pathlib import Path
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import app_id # noqa: E402
from playtube.config import APP_NAME, load_config # noqa: E402
from playtube import __version__, app_id # 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.configure_webengine_process_path()
@@ -45,6 +46,12 @@ def main() -> int:
app.setWindowIcon(QIcon(str(icon_path)))
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.show()
+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.2"
__version__ = "2.2.1"
+34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any
@@ -48,6 +49,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.
@@ -93,3 +102,28 @@ def profile_dir() -> Path:
d = _app_data_dir() / "webprofile"
d.mkdir(parents=True, exist_ok=True)
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))}")
+64 -18
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,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",
"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
# 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)
if end_ts:
payload["end"] = end_ts
url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"):
@@ -141,6 +134,10 @@ class DiscordRPCWorker(QThread):
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)."""
@@ -203,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:
@@ -210,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)
@@ -222,13 +255,26 @@ 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 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)
+8 -1
View File
@@ -333,7 +333,14 @@ class MainWindow(QMainWindow):
self._settings_tab.set_download_progress(100)
if getattr(sys, "frozen", False):
# 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()
else:
QMessageBox.information(
+64 -4
View File
@@ -28,16 +28,72 @@ MEDIA_PROBE_JS = r"""
function ytThumbnailUrl(videoId) {
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 isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var videoId = videoIdFromUrl(location.href);
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) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
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 {
var t = document.title.replace(/ - YouTube$/, '');
title = t || null;
@@ -46,9 +102,13 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a');
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
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
@@ -61,8 +121,8 @@ MEDIA_PROBE_JS = r"""
thumbnail: thumbnail,
url: location.href,
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: video ? video.currentTime : 0,
duration: (video && isFinite(video.duration)) ? video.duration : 0,
currentTime: currentTime,
duration: duration,
hasVideo: !!video
});
})();
+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()
# 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) --
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f"""
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()}
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
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2
Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
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:
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
sleep 0.5
done
cp -f "{new_binary}" "{exe_path}"
chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
@@ -257,29 +313,45 @@ rm -rf "{staging}"
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f"""
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()}
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-Sleep -Seconds 2
Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
self._spawn_windows_script(script, staging)
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
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
sleep 0.5
done
rm -rf "{install_dir}"/*
cp -a "{source_dir}"/. "{install_dir}"/
chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""