diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf09900..86a906a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - name: Playtube.exe bauen 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 - uses: actions/upload-artifact@v4 @@ -41,6 +41,14 @@ jobs: name: windows-build 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: runs-on: ubuntu-latest steps: @@ -69,7 +77,7 @@ jobs: - name: Playtube-Binary bauen 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 - uses: actions/upload-artifact@v4 @@ -77,6 +85,14 @@ jobs: name: linux-build 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: needs: [build-windows, build-linux] runs-on: ubuntu-latest diff --git a/README.md b/README.md index c7a6c97..d295dff 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,25 @@ o.ae. umgehen). ## Automatische Updates 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) -des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie -installiert werden soll: +(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die +[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im +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 - nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App - danach selbst neu. +- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket** + herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt + ~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 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 diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index 2c2aec2..5f193ee 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -52,6 +52,11 @@ class MainWindow(QMainWindow): self._tabs.addTab(self._settings_tab, "Einstellungen") self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1) 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._music_tab.mediaInfoChanged.connect(self._on_media_info) @@ -263,13 +268,30 @@ class MainWindow(QMainWindow): def _check_for_updates(self) -> None: if self._update_checker is not None and self._update_checker.isRunning(): return + self._settings_tab.set_checking() self._update_checker = UpdateChecker(self) 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() + 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: + 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: - 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._show_and_raise() @@ -290,16 +312,25 @@ class MainWindow(QMainWindow): self._start_update_install(download_url) 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._update_installer = UpdateInstaller(download_url, self) - self._update_installer.progress.connect( - lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}") - ) + self._update_installer.progress.connect(self._on_install_progress_text) + 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.failed.connect(self._on_update_failed) 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: + self._settings_tab.set_download_progress(100) if getattr(sys, "frozen", False): # Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess # beendet wird, tauscht dann die Dateien aus und startet die App neu. @@ -314,6 +345,8 @@ class MainWindow(QMainWindow): def _on_update_failed(self, error: str) -> None: 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) def _restart_dev_process(self) -> None: diff --git a/playtube/settings_tab.py b/playtube/settings_tab.py index 52735f9..fd1f59e 100644 --- a/playtube/settings_tab.py +++ b/playtube/settings_tab.py @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QMessageBox, + QProgressBar, QPushButton, QSpinBox, QVBoxLayout, @@ -30,6 +31,8 @@ class SettingsTab(QWidget): MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden.""" settingsSaved = Signal(dict) + checkUpdatesRequested = Signal() + installUpdateRequested = Signal() def __init__(self, config: dict[str, Any], parent=None): super().__init__(parent) @@ -79,6 +82,29 @@ class SettingsTab(QWidget): self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6))) update_form.addRow(self._updates_enabled) 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) general_box = QGroupBox("Allgemein") @@ -126,3 +152,39 @@ class SettingsTab(QWidget): "Einstellungen gespeichert und übernommen (Discord-Verbindung wurde " "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) diff --git a/playtube/updater.py b/playtube/updater.py index 0a3a87e..f25449c 100644 --- a/playtube/updater.py +++ b/playtube/updater.py @@ -5,11 +5,16 @@ Ablauf: 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 das - zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es - und laesst ein kurzes Skript (nach Prozessende) den Installationsordner - ersetzen und die App neu starten - PowerShell+robocopy unter Windows, - ein Shell-Skript unter Linux. + - 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). """ @@ -17,7 +22,6 @@ from __future__ import annotations import json import os -import shutil import stat import subprocess 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: - """Sucht das zur laufenden Plattform passende Release-Paket: - Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen.""" + """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 -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux".""" assets = release.get("assets", []) if sys.platform == "win32": hints, exts = ("win",), (".zip",) @@ -76,14 +81,24 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None: else: return None - for asset in assets: - name = asset.get("name", "").lower() - if name.endswith(exts) and any(h in name for h in hints): + def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None: + 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 - for asset in assets: - if asset.get("name", "").lower().endswith(exts): - 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): @@ -112,6 +127,9 @@ 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) @@ -138,6 +156,7 @@ class UpdateInstaller(QThread): return self.progress.emit("Lade Update herunter …") + self.progress_percent.emit(0) install_dir = Path(sys.executable).resolve().parent staging = Path(tempfile.mkdtemp(prefix="playtube_update_")) archive_name = self._download_url.rsplit("/", 1)[-1] @@ -145,10 +164,28 @@ class UpdateInstaller(QThread): 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(archive_path, "wb") as out: - shutil.copyfileobj(resp, out) + 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) @@ -156,20 +193,69 @@ class UpdateInstaller(QThread): with zipfile.ZipFile(archive_path) as zf: zf.extractall(extract_dir) - # 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 - + is_patch = "patch" in archive_name.lower() self.progress.emit("Bereite Installation vor …") - if sys.platform == "win32": - self._install_windows(source_dir, install_dir, staging) + + 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: - self._install_posix(source_dir, install_dir, staging) + # 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() - # -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy -- + # -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) -- - def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: + def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None: + exe_path = install_dir / _WINDOWS_BINARY_NAME + script = f""" +$ErrorActionPreference = "SilentlyContinue" +Start-Sleep -Seconds 1 +$targetPid = {os.getpid()} +while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{ + Start-Sleep -Milliseconds 500 +}} +Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force +Start-Process -FilePath "{exe_path}" +Start-Sleep -Seconds 2 +Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue +""" + self._spawn_windows_script(script, staging) + + def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None: + exe_path = install_dir / _LINUX_BINARY_NAME + script = f"""#!/bin/sh +while kill -0 {os.getpid()} 2>/dev/null; do + sleep 0.5 +done +cp -f "{new_binary}" "{exe_path}" +chmod +x "{exe_path}" +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 = f""" $ErrorActionPreference = "SilentlyContinue" @@ -183,16 +269,9 @@ 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") - subprocess.Popen( - ["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], - creationflags=subprocess.CREATE_NO_WINDOW, - ) + self._spawn_windows_script(script, staging) - # -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a -- - - def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None: + def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None: exe_path = install_dir / _LINUX_BINARY_NAME script = f"""#!/bin/sh while kill -0 {os.getpid()} 2>/dev/null; do @@ -204,6 +283,21 @@ chmod +x "{exe_path}" 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)