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:
@@ -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()
|
||||
Reference in New Issue
Block a user