Files
Playtube/playtube/updater.py
T
FojadrachiandClaude Sonnet 5 771c438257 Eigene .play-Dateiendung fuer Windows-Patchdateien + manuelle Installation per Doppelklick
Windows-Patch-Pakete heissen jetzt Playtube-vX.Y.Z-win64-patch.play statt .zip
(technisch weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim
Entpacken auf die Magic Bytes, nicht auf die Endung). Playtube registriert .play
beim ersten Start als Windows-Dateizuordnung (HKCU, keine Admin-Rechte noetig),
sodass eine manuell heruntergeladene Patchdatei per Doppelklick installiert
werden kann, ohne dass Playtube selbst etwas herunterladen muss:

- shortcuts.py: ensure_play_file_association() registriert ProgID +
  DefaultIcon + shell/open/command und stoesst SHChangeNotify an, damit die
  Zuordnung sofort greift.
- updater.py: UpdateInstaller akzeptiert jetzt optional local_archive_path
  statt einer download_url und ueberspringt dann den Download komplett.
- main.py: erkennt eine .play-Datei als Kommandozeilenargument (Doppelklick-
  Start) und uebergibt sie an MainWindow.install_local_patch().
- release.yml: Windows-Patch wird als .zip gepackt und anschliessend zu .play
  umbenannt; _find_platform_asset() sucht fuer Windows-Patches jetzt nach .play
  statt .zip.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 14:03:23 +02:00

448 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
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
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
Windows-Patch-Pakete tragen die eigene Dateiendung ".play" statt ".zip" (technisch
weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die
Magic Bytes, nicht auf die Endung). shortcuts.py registriert ".play" beim ersten Start
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann so
auch per Doppelklick installiert werden, ohne dass Playtube selbst etwas herunterladen
muss (siehe main.py: wird eine .play-Datei als Kommandozeilenargument uebergeben,
installiert MainWindow.install_local_patch() sie direkt ueber UpdateInstaller).
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
import tarfile
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"
_LINUX_BINARY_NAME = "Playtube"
_WINDOWS_BINARY_NAME = "Playtube.exe"
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_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
Moduldoku. Windows: volles Paket -> *.zip, Patch -> *.play (unsere eigene
Dateiendung, technisch ein ganz normales .zip - siehe Moduldoku), beide mit "win" im
Namen. Linux -> *.tar.gz mit "linux"."""
assets = release.get("assets", [])
if sys.platform == "win32":
hints = ("win",)
patch_exts, full_exts = (".play",), (".zip",)
elif sys.platform.startswith("linux"):
hints = ("linux",)
patch_exts = full_exts = (".tar.gz", ".tgz")
else:
return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
exts = patch_exts if want_patch else full_exts
for asset in assets:
name = asset.get("name", "").lower()
if not name.endswith(exts):
continue
if require_hint and not any(h in name for h in hints):
continue
if ("patch" in name) != want_patch:
continue
return asset
return None
return (
find(True, True)
or find(True, False)
or find(False, True)
or find(False, False)
)
class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version."""
updateAvailable = Signal(str, str, str) # version, release_notes, 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_platform_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)
# 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()
failed = Signal(str)
def __init__(
self,
download_url: str = "",
local_archive_path: str | None = None,
parent=None,
):
super().__init__(parent)
self._download_url = download_url
# Gesetzt, wenn der Nutzer eine bereits heruntergeladene .play-Patchdatei per
# Doppelklick geoeffnet hat (siehe shortcuts.py-Dateiverknuepfung) - dann wird
# nichts heruntergeladen, sondern direkt diese lokale Datei installiert.
self._local_archive_path = local_archive_path
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:
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
extract_dir = staging / "extracted"
if self._local_archive_path:
archive_path = Path(self._local_archive_path)
if not archive_path.exists():
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
return
archive_name = archive_path.name
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
self.progress_percent.emit(-1)
else:
if not self._download_url:
self.failed.emit(
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
)
return
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp:
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 …")
# 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")):
with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir)
else:
# .zip UND .play (unsere eigene Dateiendung fuer per Doppelklick startbare
# Patchdateien - technisch ein ganz normales .zip, siehe Moduldoku) werden
# beide als ZIP entpackt.
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir)
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
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/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
if sys.platform == "win32":
self._install_full_windows(source_dir, install_dir, staging)
else:
self._install_full_posix(source_dir, install_dir, staging)
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 = 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 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 -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 = 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.write_text(script, encoding="utf-8")
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
def _spawn_posix_script(self, script: str, staging: Path) -> None:
script_path = staging / "apply_update.sh"
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)
subprocess.Popen(
["/bin/sh", str(script_path)],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# ------------------------------------------------------------- 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()