9 Commits
Author SHA1 Message Date
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
8 changed files with 299 additions and 58 deletions
+18 -2
View File
@@ -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
+15 -7
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube"
__version__ = "1.0.2"
__version__ = "2.1.1"
+8 -1
View File
@@ -88,7 +88,14 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
duration = info.get("duration") or 0
current_time = info.get("currentTime") or 0
if playing:
# IMMER start/end mitschicken, unabhaengig vom playing-Status - nicht nur wenn
# playing=true. Discord ersetzt "timestamps" bei einem SET_ACTIVITY-Update ohne
# diese Felder offenbar nicht sauber, sondern behaelt intern die zuletzt bekannten
# Werte bei ("stackt"). Waehrend eines Songwechsels ist "playing" durch das kurze
# Neuladen des <video>-Elements oft fuer 1-2 Polls faelschlich false - wurden
# start/end dann weggelassen, blieb Discords alte (viel zu weit zurueckliegende)
# Zeit einfach stehen, bis irgendwann wieder echte Werte kamen. Ein pausierter
# Titel zeigt so einfach einen eingefrorenen Fortschrittsbalken statt gar keinen.
start_ts = int(time.time() - current_time)
payload["start"] = start_ts
if duration and duration > 0:
+37 -4
View File
@@ -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:
+17 -2
View File
@@ -15,14 +15,29 @@ MEDIA_PROBE_JS = r"""
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 isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null;
if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
} else {
var t = document.title.replace(/ - YouTube$/, '');
title = t || null;
@@ -31,7 +46,7 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a');
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
+70 -2
View File
@@ -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)
@@ -39,9 +42,15 @@ class SettingsTab(QWidget):
outer.setContentsMargins(32, 28, 32, 28)
outer.setSpacing(18)
header = QVBoxLayout()
header.setSpacing(2)
title = QLabel(f"{APP_NAME}-Einstellungen")
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_box = QGroupBox("Discord Rich Presence")
@@ -73,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")
@@ -92,7 +124,7 @@ class SettingsTab(QWidget):
button_row.addStretch(1)
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);")
outer.addWidget(info)
@@ -120,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)
+123 -29
View File
@@ -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,15 +81,25 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
else:
return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
for asset in assets:
name = asset.get("name", "").lower()
if name.endswith(exts) and any(h in name for h in hints):
return asset
for asset in assets:
if asset.get("name", "").lower().endswith(exts):
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."""
@@ -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)
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/").
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 …")
if sys.platform == "win32":
self._install_windows(source_dir, install_dir, staging)
self._install_full_windows(source_dir, install_dir, staging)
else:
self._install_posix(source_dir, install_dir, staging)
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)