20 Commits
Author SHA1 Message Date
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
Fojadrachi 45e9b6054d Release v2.1.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-11 21:11:44 +02:00
FojadrachiandClaude Sonnet 5 2ce2e716c5 Update-UI im Einstellungen-Tab + schnelle Patch-Updates statt Komplettinstallation
- Einstellungen-Tab: 'Jetzt nach Updates suchen'-Button, Status-Text (aktuell/verfuegbar/
  fehlgeschlagen), Fortschrittsbalken fuer den Download (Prozent bei bekannter Groesse,
  unbestimmt waehrend Entpacken/Installieren), 'Update installieren'-Button erscheint bei Fund.
- updater.py/UpdateInstaller: laedt Download jetzt in Chunks (statt copyfileobj) und meldet
  echten Fortschritt in Prozent ueber ein neues progress_percent-Signal.
- Patch-Updates: CI baut zusaetzlich zum vollen Release-Paket ein kleines '-patch'-Paket,
  das nur die ausfuehrbare Datei (Playtube.exe/Playtube, ~2-3 MB) enthaelt - der riesige
  PySide6/QtWebEngine-Laufzeitordner _internal/ (~200 MB) bleibt unangetastet, da er sich
  zwischen normalen Code-Patches nicht aendert. updater.py bevorzugt das Patch-Paket und
  faellt nur auf das volle Paket zurueck, wenn kein Patch-Asset gefunden wird.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 21:11:31 +02:00
Fojadrachi 70ea164e31 Release v2.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 21:03:04 +02:00
FojadrachiandClaude Sonnet 5 6861111db4 Einstellungen: Versionsnummer sichtbar unter dem Titel anzeigen
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 21:02:51 +02:00
Fojadrachi aad5f2dfd3 Release v2.0.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-11 20:55:25 +02:00
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
11 changed files with 562 additions and 85 deletions
+18 -2
View File
@@ -33,7 +33,7 @@ jobs:
- name: Playtube.exe bauen - name: Playtube.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .zip packen - name: Als .zip packen (volles Paket)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -41,6 +41,14 @@ jobs:
name: windows-build name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-${{ github.ref_name }}-win64-patch.zip
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -69,7 +77,7 @@ jobs:
- name: Playtube-Binary bauen - name: Playtube-Binary bauen
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .tar.gz packen - name: Als .tar.gz packen (volles Paket)
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -77,6 +85,14 @@ jobs:
name: linux-build name: linux-build
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-patch
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
publish-release: publish-release:
needs: [build-windows, build-linux] needs: [build-windows, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
+20 -9
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.
@@ -69,17 +71,25 @@ o.ae. umgehen).
## Automatische Updates ## Automatische Updates
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
(Standard 6, in `config.json` einstellbar) die [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) (Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
installiert werden soll: Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll:
- **Gepackte `Playtube.exe`**: laedt das `.zip`-Asset des Release herunter und ersetzt - **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket**
nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt
danach selbst neu. ~200 MB) und ersetzt ausschliesslich diese - der riesige PySide6/QtWebEngine-
Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen normalen
Patch-Releases nicht aendert. Nur wenn kein Patch-Paket verfuegbar ist (z.B. beim
allerersten Release oder nach einem Wechsel der PySide6-Version), wird automatisch
auf das volle Release-Paket zurueckgefallen und der komplette Installationsordner
ersetzt. Die App startet sich in beiden Faellen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r - **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
requirements.txt` aus und startet den Python-Prozess neu. requirements.txt` aus und startet den Python-Prozess neu.
Auto-Update laesst sich in `config.json` unter `updates.enabled` deaktivieren. Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren.
### Eine neue Version veroeffentlichen ### Eine neue Version veroeffentlichen
@@ -133,7 +143,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
@@ -3,7 +3,7 @@
"discord": { "discord": {
"enabled": true, "enabled": true,
"client_id": "1548023494976086127", "client_id": "1548023494976086127",
"update_interval_seconds": 5, "update_interval_seconds": 15,
"show_idle_presence": true "show_idle_presence": true
}, },
"updates": { "updates": {
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube"
__version__ = "1.0.1" __version__ = "2.1.5"
+19 -2
View File
@@ -10,6 +10,13 @@ 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": {
@@ -32,13 +39,23 @@ 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
def app_data_dir() -> Path:
"""Oeffentlicher Zugriff auf den App-Datenordner, z.B. fuer Debug-Logs - die
gepackte .exe laeuft ohne Konsolenfenster (console=False), print()-Debugging ist
dort also unsichtbar; ein Log-File ist die einzige Moeglichkeit, dort etwas
nachtraeglich einzusehen."""
return _app_data_dir()
def _config_path() -> Path: def _config_path() -> Path:
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar), # Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
# im gepackten Build im APPDATA-Ordner. # im gepackten Build im APPDATA-Ordner.
+44
View File
@@ -0,0 +1,44 @@
"""Sehr einfaches, immer aktives Datei-Log fuer schwer reproduzierbare Bugs.
Die gepackte .exe laeuft ohne Konsolenfenster (console=False in packaging/playtube.spec),
also sind print()-Debugausgaben dort unsichtbar - selbst mit PLAYTUBE_DEBUG=1 sieht der
Nutzer nichts. Dieses Modul schreibt stattdessen in eine kleine, automatisch gekappte
Log-Datei im App-Datenordner, die sich jederzeit nachtraeglich auslesen laesst.
"""
from __future__ import annotations
import time
import traceback
from pathlib import Path
from .config import app_data_dir
_MAX_LINES = 500
def _log_path() -> Path:
d = app_data_dir() / "logs"
d.mkdir(parents=True, exist_ok=True)
return d / "discord_rpc.log"
def log_line(msg: str) -> None:
"""Haengt eine Zeile mit Zeitstempel an - haelt die Datei klein, indem bei
Ueberlaenge nur die letzten _MAX_LINES Zeilen behalten werden."""
try:
path = _log_path()
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
if len(lines) > _MAX_LINES:
path.write_text("\n".join(lines[-_MAX_LINES:]) + "\n", encoding="utf-8")
except Exception:
# Logging darf niemals die App zum Absturz bringen.
pass
def log_exception(context: str, exc: BaseException) -> None:
log_line(f"{context}: {exc!r}\n{''.join(traceback.format_exception(exc))}")
+104 -13
View File
@@ -9,12 +9,19 @@ from __future__ import annotations
import os import os
import queue import queue
import re
import time import time
from typing import Any 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:
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
@@ -37,31 +44,55 @@ def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
return text return text
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]: _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, 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")
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",
"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"):
@@ -88,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)."""
@@ -157,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:
@@ -164,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)
@@ -176,13 +255,25 @@ class DiscordRPCWorker(QThread):
payload = None payload = None
self._presence.clear() self._presence.clear()
else: else:
payload = build_presence_payload(item, self._session_start) start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload) self._presence.update(**payload)
log_line(
"[send] title=%r thumbnail=%r large_image=%r start=%s end=%s"
% (
item.get("title"),
item.get("thumbnail"),
payload.get("large_image"),
start_ts,
end_ts,
)
)
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] gesendet: {payload!r}", flush=True) print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
except Exception as exc: except Exception as exc:
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden. # Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False self._connected = False
log_line(f"[send-error] {exc!r}")
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True) print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
+45 -5
View File
@@ -52,6 +52,11 @@ class MainWindow(QMainWindow):
self._tabs.addTab(self._settings_tab, "Einstellungen") self._tabs.addTab(self._settings_tab, "Einstellungen")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1) self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._settings_tab.settingsSaved.connect(self._on_settings_saved) self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
self._settings_tab.installUpdateRequested.connect(
lambda: self._start_update_install(self._pending_download_url)
)
self._pending_download_url = ""
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info) self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info) self._music_tab.mediaInfoChanged.connect(self._on_media_info)
@@ -263,13 +268,30 @@ class MainWindow(QMainWindow):
def _check_for_updates(self) -> None: def _check_for_updates(self) -> None:
if self._update_checker is not None and self._update_checker.isRunning(): if self._update_checker is not None and self._update_checker.isRunning():
return return
self._settings_tab.set_checking()
self._update_checker = UpdateChecker(self) self._update_checker = UpdateChecker(self)
self._update_checker.updateAvailable.connect(self._on_update_available) self._update_checker.updateAvailable.connect(self._on_update_available)
self._update_checker.upToDate.connect(self._on_update_up_to_date)
self._update_checker.checkFailed.connect(self._on_update_check_failed)
self._update_checker.start() self._update_checker.start()
def _on_update_up_to_date(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(f"Du hast bereits die neueste Version (v{APP_VERSION}).")
def _on_update_check_failed(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(
"Update-Prüfung fehlgeschlagen (keine Internetverbindung oder GitHub nicht erreichbar)."
)
def _on_update_available(self, version: str, notes: str, download_url: str) -> None: def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
self._settings_tab.set_check_done()
self._pending_download_url = download_url
self._settings_tab.show_update_available(version)
if self._pending_update_version == version: if self._pending_update_version == version:
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt return # Dialog fuer diese Version wurde schon einmal gezeigt
self._pending_update_version = version self._pending_update_version = version
self._show_and_raise() self._show_and_raise()
@@ -290,19 +312,35 @@ class MainWindow(QMainWindow):
self._start_update_install(download_url) self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None: def _start_update_install(self, download_url: str) -> None:
if not download_url:
return
self._settings_tab.hide_install_button()
self._settings_tab.set_update_status("Lade Update herunter …")
self._settings_tab.set_download_progress(0)
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …") self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self) self._update_installer = UpdateInstaller(download_url, self)
self._update_installer.progress.connect( self._update_installer.progress.connect(self._on_install_progress_text)
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}") self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
)
self._update_installer.finished_ok.connect(self._on_update_finished) self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed) self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start() self._update_installer.start()
def _on_install_progress_text(self, msg: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg)
def _on_update_finished(self) -> None: def _on_update_finished(self) -> None:
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(
@@ -314,6 +352,8 @@ class MainWindow(QMainWindow):
def _on_update_failed(self, error: str) -> None: def _on_update_failed(self, error: str) -> None:
self._tray.setToolTip(APP_NAME) self._tray.setToolTip(APP_NAME)
self._settings_tab.set_progress_hidden()
self._settings_tab.set_update_status(f"Update fehlgeschlagen: {error}")
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error) QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
def _restart_dev_process(self) -> None: def _restart_dev_process(self) -> None:
+25 -3
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,7 +46,7 @@ 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);
} }
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
+74 -4
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QProgressBar,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QVBoxLayout, QVBoxLayout,
@@ -30,6 +31,8 @@ class SettingsTab(QWidget):
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden.""" MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
settingsSaved = Signal(dict) settingsSaved = Signal(dict)
checkUpdatesRequested = Signal()
installUpdateRequested = Signal()
def __init__(self, config: dict[str, Any], parent=None): def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent) super().__init__(parent)
@@ -39,9 +42,15 @@ class SettingsTab(QWidget):
outer.setContentsMargins(32, 28, 32, 28) outer.setContentsMargins(32, 28, 32, 28)
outer.setSpacing(18) outer.setSpacing(18)
header = QVBoxLayout()
header.setSpacing(2)
title = QLabel(f"{APP_NAME}-Einstellungen") title = QLabel(f"{APP_NAME}-Einstellungen")
title.setStyleSheet("font-size: 20px; font-weight: 600;") title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title) header.addWidget(title)
version_label = QLabel(f"Version {APP_VERSION}")
version_label.setStyleSheet("color: palette(mid);")
header.addWidget(version_label)
outer.addLayout(header)
discord_cfg = config.get("discord", {}) discord_cfg = config.get("discord", {})
discord_box = QGroupBox("Discord Rich Presence") discord_box = QGroupBox("Discord Rich Presence")
@@ -51,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)
@@ -73,6 +84,29 @@ class SettingsTab(QWidget):
self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6))) self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6)))
update_form.addRow(self._updates_enabled) update_form.addRow(self._updates_enabled)
update_form.addRow("Prüfintervall:", self._check_interval) update_form.addRow("Prüfintervall:", self._check_interval)
update_actions = QHBoxLayout()
self._check_updates_btn = QPushButton("Jetzt nach Updates suchen")
self._check_updates_btn.clicked.connect(self.checkUpdatesRequested.emit)
update_actions.addWidget(self._check_updates_btn)
self._install_update_btn = QPushButton("Update installieren")
self._install_update_btn.setVisible(False)
self._install_update_btn.clicked.connect(self.installUpdateRequested.emit)
update_actions.addWidget(self._install_update_btn)
update_actions.addStretch(1)
update_form.addRow(update_actions)
self._update_status_label = QLabel("")
self._update_status_label.setStyleSheet("color: palette(mid);")
self._update_status_label.setWordWrap(True)
update_form.addRow(self._update_status_label)
self._update_progress = QProgressBar()
self._update_progress.setRange(0, 100)
self._update_progress.setTextVisible(True)
self._update_progress.setVisible(False)
update_form.addRow(self._update_progress)
outer.addWidget(update_box) outer.addWidget(update_box)
general_box = QGroupBox("Allgemein") general_box = QGroupBox("Allgemein")
@@ -92,7 +126,7 @@ class SettingsTab(QWidget):
button_row.addStretch(1) button_row.addStretch(1)
outer.addLayout(button_row) outer.addLayout(button_row)
info = QLabel(f"{APP_NAME} v{APP_VERSION} · github.com/{GITHUB_REPO}") info = QLabel(f"github.com/{GITHUB_REPO}")
info.setStyleSheet("color: palette(mid);") info.setStyleSheet("color: palette(mid);")
outer.addWidget(info) outer.addWidget(info)
@@ -120,3 +154,39 @@ class SettingsTab(QWidget):
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde " "Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
"mit den neuen Werten neu gestartet).", "mit den neuen Werten neu gestartet).",
) )
# ----------------------------------------------------- Update-Status (von MainWindow)
def set_checking(self) -> None:
self._check_updates_btn.setEnabled(False)
self._install_update_btn.setVisible(False)
self._update_progress.setVisible(False)
self._update_status_label.setText("Suche nach Updates …")
def set_check_done(self) -> None:
self._check_updates_btn.setEnabled(True)
def set_update_status(self, text: str) -> None:
self._update_status_label.setText(text)
def show_update_available(self, version: str) -> None:
self._update_status_label.setText(f"Neue Version verfügbar: {version}")
self._install_update_btn.setVisible(True)
def hide_install_button(self) -> None:
self._install_update_btn.setVisible(False)
def set_download_progress(self, percent: int) -> None:
"""percent: 0-100 fuer einen konkreten Fortschritt, -1 fuer unbestimmt
(z.B. waehrend Entpacken/Installieren), negativ->versteckt den Balken nicht
automatisch - dafuer set_progress_hidden() aufrufen."""
if percent < 0:
self._update_progress.setRange(0, 0) # "laufender" Balken ohne Prozentzahl
else:
self._update_progress.setRange(0, 100)
self._update_progress.setValue(percent)
self._update_progress.setVisible(True)
def set_progress_hidden(self) -> None:
self._update_progress.setVisible(False)
self._update_progress.setRange(0, 100)
+205 -39
View File
@@ -5,11 +5,16 @@ Ablauf:
Version als die aktuell laufende (playtube.__version__). Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung. 2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update: 3. UpdateInstaller installiert das Update:
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt das - Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
und laesst ein kurzes Skript (nach Prozessende) den Installationsordner .exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
ersetzen und die App neu starten - PowerShell+robocopy unter Windows, ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
ein Shell-Skript unter Linux. Ordner (_internal/) bleibt unangetastet, da er sich zwischen Patch-Releases
normalerweise nicht aendert. Gibt es kein Patch-Paket (z.B. beim allerersten
Release oder wenn CI es nicht gebaut hat), faellt es automatisch auf das
volle Release-Paket zurueck und ersetzt den kompletten Installationsordner.
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu -
PowerShell unter Windows, ein Shell-Skript unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r - Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv). requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
""" """
@@ -17,7 +22,6 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
@@ -66,8 +70,9 @@ def fetch_latest_release() -> dict[str, Any] | None:
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None: def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket: """Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen.""" "-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
assets = release.get("assets", []) assets = release.get("assets", [])
if sys.platform == "win32": if sys.platform == "win32":
hints, exts = ("win",), (".zip",) hints, exts = ("win",), (".zip",)
@@ -76,15 +81,25 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
else: else:
return None return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
for asset in assets: for asset in assets:
name = asset.get("name", "").lower() name = asset.get("name", "").lower()
if name.endswith(exts) and any(h in name for h in hints): if not name.endswith(exts):
return asset continue
for asset in assets: if require_hint and not any(h in name for h in hints):
if asset.get("name", "").lower().endswith(exts): continue
if ("patch" in name) != want_patch:
continue
return asset return asset
return None return None
return (
find(True, True)
or find(True, False)
or find(False, True)
or find(False, False)
)
class UpdateChecker(QThread): class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version.""" """Prueft einmalig im Hintergrund auf eine neue Version."""
@@ -112,6 +127,9 @@ class UpdateInstaller(QThread):
"""Laedt ein Release herunter und installiert es (siehe Moduldoku).""" """Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
progress = Signal(str) progress = Signal(str)
# Download-Fortschritt in Prozent (0-100); -1 = unbestimmt (z.B. waehrend
# Entpacken/Installieren, wo sich kein Prozentsatz sinnvoll angeben laesst).
progress_percent = Signal(int)
finished_ok = Signal() finished_ok = Signal()
failed = Signal(str) failed = Signal(str)
@@ -138,6 +156,7 @@ class UpdateInstaller(QThread):
return return
self.progress.emit("Lade Update herunter …") self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
install_dir = Path(sys.executable).resolve().parent install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_")) staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
archive_name = self._download_url.rsplit("/", 1)[-1] archive_name = self._download_url.rsplit("/", 1)[-1]
@@ -145,10 +164,28 @@ class UpdateInstaller(QThread):
extract_dir = staging / "extracted" extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT}) req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out: with urllib.request.urlopen(req, timeout=120) as resp:
shutil.copyfileobj(resp, out) total = int(resp.headers.get("Content-Length") or 0)
downloaded = 0
last_emitted = -1
with open(archive_path, "wb") as out:
while True:
chunk = resp.read(256 * 1024)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
if total:
percent = int(downloaded * 100 / total)
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
self.progress.emit("Entpacke Update …") self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
self.progress_percent.emit(-1)
if archive_name.endswith((".tar.gz", ".tgz")): if archive_name.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path) as tf: with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir) tf.extractall(extract_dir)
@@ -156,33 +193,175 @@ class UpdateInstaller(QThread):
with zipfile.ZipFile(archive_path) as zf: with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir) zf.extractall(extract_dir)
is_patch = "patch" in archive_name.lower()
self.progress.emit("Bereite Installation vor …")
if is_patch:
binary_name = _WINDOWS_BINARY_NAME if sys.platform == "win32" else _LINUX_BINARY_NAME
matches = list(extract_dir.rglob(binary_name))
if not matches:
# Unerwartet leeres/falsches Patch-Paket -> nicht kaputt installieren,
# lieber sauber fehlschlagen und den Nutzer auf das volle Paket
# hinweisen (naechster Check faellt automatisch darauf zurueck).
self.failed.emit(
f"Patch-Paket enthielt kein '{binary_name}'. Bitte erneut versuchen."
)
return
if sys.platform == "win32":
self._install_patch_windows(matches[0], install_dir, staging)
else:
self._install_patch_posix(matches[0], install_dir, staging)
else:
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/"). # Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir()) entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
self.progress.emit("Bereite Installation vor …")
if sys.platform == "win32": if sys.platform == "win32":
self._install_windows(source_dir, install_dir, staging) self._install_full_windows(source_dir, install_dir, staging)
else: else:
self._install_posix(source_dir, install_dir, staging) self._install_full_posix(source_dir, install_dir, staging)
self.finished_ok.emit() self.finished_ok.emit()
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy -- # 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()
"""
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: # -- 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 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 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-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)
# 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 = 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}"
"""
self._spawn_posix_script(script, staging)
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue"
$targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 300
[System.Windows.Forms.Application]::DoEvents()
}}
$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 -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 = 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}"
"""
self._spawn_posix_script(script, staging)
# -- Skript-Ausfuehrung: PowerShell (wartet+robocopy/Copy) unter Windows, Shell
# (wartet+cp) unter Linux; wird detached gestartet, damit es diesen Prozess
# ueberlebt, wenn dieser sich gleich beendet. --
def _spawn_windows_script(self, script: str, staging: Path) -> None:
script_path = staging / "apply_update.ps1" script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8") script_path.write_text(script, encoding="utf-8")
subprocess.Popen( subprocess.Popen(
@@ -190,20 +369,7 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
creationflags=subprocess.CREATE_NO_WINDOW, creationflags=subprocess.CREATE_NO_WINDOW,
) )
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a -- def _spawn_posix_script(self, script: str, staging: Path) -> None:
def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh
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}"
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
script_path = staging / "apply_update.sh" script_path = staging / "apply_update.sh"
script_path.write_text(script, encoding="utf-8") script_path.write_text(script, encoding="utf-8")
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)