Playtube: eigenstaendiger YouTube/YouTube-Music-Player mit Discord RPC und Auto-Update

- PySide6/QtWebEngine mit persistentem Login-Profil (YouTube + YouTube Music Tabs)
- Google-Login-Fix per Sec-CH-UA-Headern + Chrome-JS-Shim
- Discord Rich Presence (eigener Thread, Titel/Fortschritt/Buttons)
- Eigenes Branding (Taskmanager/Taskleiste) per AppUserModelID + PyInstaller-Packaging
- Auto-Update ueber GitHub Releases (fojadrachi/Playtube)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-11 19:13:13 +02:00
co-authored by Claude Sonnet 5
commit 578cda499a
21 changed files with 1640 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube).
Ablauf:
1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
- Gepackte Playtube.exe: laedt das Windows-Release-Zip herunter, entpackt es
und laesst ein kurzes PowerShell-Skript (nach Prozessende) den Installations-
ordner per robocopy /MIR ersetzen und die App neu starten.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Any
from PySide6.QtCore import QThread, Signal
from . import __version__ as CURRENT_VERSION
GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
_USER_AGENT = "Playtube-Updater"
def _parse_version(v: str) -> tuple[int, ...]:
v = v.strip().lstrip("vV")
parts: list[int] = []
for p in v.split("."):
digits = "".join(ch for ch in p if ch.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts) or (0,)
def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
return _parse_version(remote) > _parse_version(local)
def fetch_latest_release() -> dict[str, Any] | None:
req = urllib.request.Request(
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
return None
def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
assets = release.get("assets", [])
for asset in assets:
name = asset.get("name", "").lower()
if name.endswith(".zip") and ("win" in name or "windows" in name):
return asset
for asset in assets:
if asset.get("name", "").lower().endswith(".zip"):
return asset
return None
class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version."""
updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url
checkFailed = Signal()
upToDate = Signal()
def run(self) -> None:
release = fetch_latest_release()
if not release:
self.checkFailed.emit()
return
tag = release.get("tag_name") or release.get("name") or ""
if not tag or not is_newer(tag):
self.upToDate.emit()
return
asset = _find_windows_zip_asset(release)
download_url = asset["browser_download_url"] if asset else ""
notes = (release.get("body") or "").strip()
self.updateAvailable.emit(tag, notes, download_url)
class UpdateInstaller(QThread):
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
progress = Signal(str)
finished_ok = Signal()
failed = Signal(str)
def __init__(self, download_url: str, parent=None):
super().__init__(parent)
self._download_url = download_url
def run(self) -> None:
try:
if getattr(sys, "frozen", False):
self._run_packaged_update()
else:
self._run_dev_update()
except Exception as exc: # noqa: BLE001 - Fehler soll in der UI landen, nicht crashen
self.failed.emit(str(exc))
# ---------------------------------------------------------- gepackter Modus
def _run_packaged_update(self) -> None:
if not self._download_url:
self.failed.emit(
"Kein Windows-Release-Paket (.zip) im neuesten Release gefunden."
)
return
self.progress.emit("Lade Update herunter …")
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
zip_path = staging / "update.zip"
extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, open(zip_path, "wb") as out:
shutil.copyfileobj(resp, out)
self.progress.emit("Entpacke Update …")
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(extract_dir)
# Manche Release-Zips enthalten einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
self.progress.emit("Bereite Installation vor …")
script_path = self._write_apply_script(source_dir, install_dir, staging)
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
self.finished_ok.emit()
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path:
exe_path = install_dir / "Playtube.exe"
script = f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500
}}
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null
Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8")
return script_path
# ------------------------------------------------------------- Entwicklungsmodus
def _run_dev_update(self) -> None:
project_root = Path(__file__).resolve().parent.parent
if not (project_root / ".git").exists():
self.failed.emit(
"Kein Git-Repository gefunden - im Entwicklungsmodus bitte manuell "
"'git pull' im Projektordner ausfuehren."
)
return
self.progress.emit("Lade Aenderungen per git pull …")
result = subprocess.run(
["git", "pull", "--ff-only"], cwd=project_root, capture_output=True, text=True
)
if result.returncode != 0:
self.failed.emit(f"git pull fehlgeschlagen:\n{result.stderr.strip()}")
return
self.progress.emit("Installiere Abhaengigkeiten …")
pip_result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "-r", str(project_root / "requirements.txt")],
capture_output=True,
text=True,
)
if pip_result.returncode != 0:
self.failed.emit(f"pip install fehlgeschlagen:\n{pip_result.stderr.strip()}")
return
self.finished_ok.emit()