Einstellungen-Tab, plattformuebergreifendes Update/Branding, CI-Release-Pipeline
- Settings-Tab in der App (Discord/Update/Start-Tab live editierbar, kein config.json-Handbearbeiten noetig) - updater.py: plattformabhaengige Asset-Auswahl + Installation (Windows .zip/robocopy, Linux .tar.gz/Shell-Skript) - browser.py/chrome_shim.py: Sec-CH-UA + navigator.userAgentData jetzt je nach sys.platform (Windows/Linux/macOS) - app_id.py: robustere Suche nach umbenanntem QtWebEngine-Hilfsprozess (rglob statt fixer Pfade) - packaging/playtube.spec: icon/version nur unter Windows setzen (Linux-Build faehig) - packaging/install-linux.sh: Installationsskript fuer native Linux-Version (Desktop-Eintrag, Icon, PATH) - .github/workflows/release.yml: baut bei Tag-Push automatisch Windows-.exe UND Linux-Binary und veroeffentlicht beide als GitHub Release Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
+7
-8
@@ -47,11 +47,10 @@ def configure_webengine_process_path() -> None:
|
||||
return
|
||||
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
candidates = [
|
||||
exe_dir / "PlaytubeHelper.exe",
|
||||
exe_dir / "_internal" / "PlaytubeHelper.exe",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
|
||||
return
|
||||
# PyInstaller legt PySide6 je nach Version unterschiedlich tief in _internal ab
|
||||
# (z.B. _internal\PySide6\), daher rekursiv suchen statt feste Pfade anzunehmen.
|
||||
try:
|
||||
candidate = next(exe_dir.rglob("PlaytubeHelper.exe"))
|
||||
except StopIteration:
|
||||
return
|
||||
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
|
||||
|
||||
+18
-3
@@ -3,6 +3,8 @@ Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Ausles
|
||||
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage,
|
||||
@@ -27,8 +29,21 @@ from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PL
|
||||
_CHROME_VERSION = CHROME_FULL
|
||||
_CHROME_MAJOR = CHROME_MAJOR
|
||||
|
||||
if sys.platform == "win32":
|
||||
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Windows"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
|
||||
elif sys.platform.startswith("linux"):
|
||||
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Linux"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
|
||||
else:
|
||||
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
|
||||
_SEC_CH_UA_PLATFORM = b'"macOS"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
|
||||
|
||||
_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 "
|
||||
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
|
||||
)
|
||||
|
||||
@@ -52,8 +67,8 @@ class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
|
||||
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA)
|
||||
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST)
|
||||
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0")
|
||||
info.setHttpHeader(b"sec-ch-ua-platform", b'"Windows"')
|
||||
info.setHttpHeader(b"sec-ch-ua-platform-version", b'"15.0.0"')
|
||||
info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM)
|
||||
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
|
||||
|
||||
|
||||
_shared_profile: QWebEngineProfile | None = None
|
||||
|
||||
+15
-3
@@ -21,9 +21,21 @@ Nutzer lediglich, sich in der eigenen App mit dem eigenen Google-Konto anzumelde
|
||||
so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
CHROME_MAJOR = "140"
|
||||
CHROME_FULL = "140.0.0.0"
|
||||
|
||||
if sys.platform == "win32":
|
||||
_JS_PLATFORM = "Windows"
|
||||
_JS_PLATFORM_VERSION = "15.0.0"
|
||||
elif sys.platform.startswith("linux"):
|
||||
_JS_PLATFORM = "Linux"
|
||||
_JS_PLATFORM_VERSION = "6.0.0"
|
||||
else:
|
||||
_JS_PLATFORM = "macOS"
|
||||
_JS_PLATFORM_VERSION = "14.0.0"
|
||||
|
||||
CHROME_SHIM_JS = f"""
|
||||
(function() {{
|
||||
try {{
|
||||
@@ -69,12 +81,12 @@ CHROME_SHIM_JS = f"""
|
||||
var uaData = {{
|
||||
brands: brands,
|
||||
mobile: false,
|
||||
platform: 'Windows',
|
||||
platform: '{_JS_PLATFORM}',
|
||||
getHighEntropyValues: function(hints) {{
|
||||
var full = {{
|
||||
architecture: 'x86', bitness: '64', brands: brands,
|
||||
fullVersionList: fullBrands, mobile: false, model: '',
|
||||
platform: 'Windows', platformVersion: '15.0.0',
|
||||
platform: '{_JS_PLATFORM}', platformVersion: '{_JS_PLATFORM_VERSION}',
|
||||
uaFullVersion: '{CHROME_FULL}', wow64: false
|
||||
}};
|
||||
var result = {{}};
|
||||
@@ -83,7 +95,7 @@ CHROME_SHIM_JS = f"""
|
||||
}});
|
||||
return Promise.resolve(result);
|
||||
}},
|
||||
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: 'Windows' }}; }}
|
||||
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: '{_JS_PLATFORM}' }}; }}
|
||||
}};
|
||||
Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }});
|
||||
}} catch (e) {{}}
|
||||
|
||||
+58
-19
@@ -22,6 +22,7 @@ from . import __version__ as APP_VERSION
|
||||
from .browser import BrowserTab
|
||||
from .config import APP_NAME
|
||||
from .discord_rpc import DiscordRPCWorker
|
||||
from .settings_tab import SettingsTab
|
||||
from .updater import UpdateChecker, UpdateInstaller
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||
@@ -45,9 +46,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||
self._music_tab = BrowserTab(config["home_music"], self)
|
||||
self._settings_tab = SettingsTab(config, self)
|
||||
self._tabs.addTab(self._youtube_tab, "YouTube")
|
||||
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||||
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._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
|
||||
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
|
||||
@@ -58,14 +62,7 @@ class MainWindow(QMainWindow):
|
||||
self._build_tray()
|
||||
|
||||
self._rpc_worker: DiscordRPCWorker | None = None
|
||||
discord_cfg = config.get("discord", {})
|
||||
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
|
||||
self._rpc_worker = DiscordRPCWorker(
|
||||
client_id=str(discord_cfg["client_id"]),
|
||||
interval=discord_cfg.get("update_interval_seconds", 15),
|
||||
show_idle=discord_cfg.get("show_idle_presence", True),
|
||||
)
|
||||
self._rpc_worker.start()
|
||||
self._start_discord_worker()
|
||||
|
||||
self._update_checker: UpdateChecker | None = None
|
||||
self._update_installer: UpdateInstaller | None = None
|
||||
@@ -80,19 +77,19 @@ class MainWindow(QMainWindow):
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
back_action = QAction("◀", self)
|
||||
back_action.triggered.connect(lambda: self._current_tab().back())
|
||||
back_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.back()))
|
||||
toolbar.addAction(back_action)
|
||||
|
||||
forward_action = QAction("▶", self)
|
||||
forward_action.triggered.connect(lambda: self._current_tab().forward())
|
||||
forward_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.forward()))
|
||||
toolbar.addAction(forward_action)
|
||||
|
||||
reload_action = QAction("⟳", self)
|
||||
reload_action.triggered.connect(lambda: self._current_tab().reload())
|
||||
reload_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.reload()))
|
||||
toolbar.addAction(reload_action)
|
||||
|
||||
home_action = QAction("⌂", self)
|
||||
home_action.triggered.connect(lambda: self._current_tab().go_home())
|
||||
home_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.go_home()))
|
||||
toolbar.addAction(home_action)
|
||||
|
||||
toolbar.addSeparator()
|
||||
@@ -117,13 +114,13 @@ class MainWindow(QMainWindow):
|
||||
show_action.triggered.connect(self._show_and_raise)
|
||||
|
||||
play_pause_action = menu.addAction("Wiedergabe umschalten")
|
||||
play_pause_action.triggered.connect(lambda: self._current_tab().toggle_playback())
|
||||
play_pause_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.toggle_playback()))
|
||||
|
||||
next_action = menu.addAction("Nächster Titel")
|
||||
next_action.triggered.connect(lambda: self._current_tab().next_track())
|
||||
next_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.next_track()))
|
||||
|
||||
prev_action = menu.addAction("Vorheriger Titel")
|
||||
prev_action.triggered.connect(lambda: self._current_tab().previous_track())
|
||||
prev_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.previous_track()))
|
||||
|
||||
menu.addSeparator()
|
||||
quit_action = menu.addAction("Beenden")
|
||||
@@ -135,25 +132,39 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# --------------------------------------------------------------- Slots
|
||||
|
||||
def _current_tab(self) -> BrowserTab:
|
||||
def _current_tab(self) -> QWidget:
|
||||
return self._tabs.currentWidget()
|
||||
|
||||
def _current_browser_tab(self) -> BrowserTab | None:
|
||||
tab = self._tabs.currentWidget()
|
||||
return tab if isinstance(tab, BrowserTab) else None
|
||||
|
||||
def _with_browser_tab(self, fn) -> None:
|
||||
"""Fuehrt fn(tab) nur aus, wenn der aktive Tab ein Browser-Tab ist (nicht
|
||||
die Einstellungen)."""
|
||||
tab = self._current_browser_tab()
|
||||
if tab is not None:
|
||||
fn(tab)
|
||||
|
||||
def _navigate_to_url_bar(self) -> None:
|
||||
text = self._url_bar.text().strip()
|
||||
if not text:
|
||||
tab = self._current_browser_tab()
|
||||
if not text or tab is None:
|
||||
return
|
||||
if " " in text or ("." not in text and "://" not in text):
|
||||
url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode())
|
||||
else:
|
||||
url = QUrl.fromUserInput(text)
|
||||
self._current_tab().setUrl(url)
|
||||
tab.setUrl(url)
|
||||
|
||||
def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None:
|
||||
if self._current_tab() is tab:
|
||||
self._url_bar.setText(url.toString())
|
||||
|
||||
def _on_tab_changed(self, index: int) -> None:
|
||||
self._url_bar.setText(self._current_tab().url().toString())
|
||||
tab = self._current_browser_tab()
|
||||
self._url_bar.setEnabled(tab is not None)
|
||||
self._url_bar.setText(tab.url().toString() if tab is not None else "")
|
||||
|
||||
def _on_media_info(self, info: dict) -> None:
|
||||
sender = self.sender()
|
||||
@@ -175,6 +186,27 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._rpc_worker.submit_media_info(None)
|
||||
|
||||
def _start_discord_worker(self) -> None:
|
||||
discord_cfg = self._config.get("discord", {})
|
||||
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
|
||||
self._rpc_worker = DiscordRPCWorker(
|
||||
client_id=str(discord_cfg["client_id"]),
|
||||
interval=discord_cfg.get("update_interval_seconds", 15),
|
||||
show_idle=discord_cfg.get("show_idle_presence", True),
|
||||
)
|
||||
self._rpc_worker.start()
|
||||
|
||||
def _on_settings_saved(self, new_config: dict) -> None:
|
||||
self._config = new_config
|
||||
|
||||
if self._rpc_worker is not None:
|
||||
self._rpc_worker.stop()
|
||||
self._rpc_worker.wait(2000)
|
||||
self._rpc_worker = None
|
||||
self._start_discord_worker()
|
||||
|
||||
self._setup_auto_update()
|
||||
|
||||
def _on_tray_activated(self, reason) -> None:
|
||||
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||
self._show_and_raise()
|
||||
@@ -209,6 +241,13 @@ class MainWindow(QMainWindow):
|
||||
# -------------------------------------------------------------- Auto-Update
|
||||
|
||||
def _setup_auto_update(self) -> None:
|
||||
"""(Re-)Konfiguriert die Auto-Update-Pruefung - sicher mehrfach aufrufbar,
|
||||
z.B. nach Aenderungen im Einstellungen-Tab."""
|
||||
if getattr(self, "_update_timer", None) is not None:
|
||||
self._update_timer.stop()
|
||||
self._update_timer.deleteLater()
|
||||
self._update_timer = None
|
||||
|
||||
updates_cfg = self._config.get("updates", {})
|
||||
if not updates_cfg.get("enabled", True):
|
||||
return
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Einstellungen-Tab: Discord-RPC, Auto-Update und Start-Tab direkt in der App
|
||||
bearbeitbar, ohne config.json von Hand anfassen zu muessen."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .config import APP_NAME, save_config
|
||||
from .updater import GITHUB_REPO
|
||||
|
||||
|
||||
class SettingsTab(QWidget):
|
||||
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
||||
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
|
||||
|
||||
settingsSaved = Signal(dict)
|
||||
|
||||
def __init__(self, config: dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
self._config = config
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(32, 28, 32, 28)
|
||||
outer.setSpacing(18)
|
||||
|
||||
title = QLabel(f"{APP_NAME}-Einstellungen")
|
||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||
outer.addWidget(title)
|
||||
|
||||
discord_cfg = config.get("discord", {})
|
||||
discord_box = QGroupBox("Discord Rich Presence")
|
||||
discord_form = QFormLayout(discord_box)
|
||||
self._discord_enabled = QCheckBox("Aktiviert")
|
||||
self._discord_enabled.setChecked(bool(discord_cfg.get("enabled", True)))
|
||||
self._client_id = QLineEdit(str(discord_cfg.get("client_id", "")))
|
||||
self._client_id.setPlaceholderText("Discord Application Client-ID")
|
||||
self._discord_interval = QSpinBox()
|
||||
self._discord_interval.setRange(5, 120)
|
||||
self._discord_interval.setSuffix(" s")
|
||||
self._discord_interval.setValue(int(discord_cfg.get("update_interval_seconds", 15)))
|
||||
self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft")
|
||||
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
|
||||
discord_form.addRow(self._discord_enabled)
|
||||
discord_form.addRow("Client-ID:", self._client_id)
|
||||
discord_form.addRow("Update-Intervall:", self._discord_interval)
|
||||
discord_form.addRow(self._show_idle)
|
||||
outer.addWidget(discord_box)
|
||||
|
||||
updates_cfg = config.get("updates", {})
|
||||
update_box = QGroupBox("Automatische Updates")
|
||||
update_form = QFormLayout(update_box)
|
||||
self._updates_enabled = QCheckBox("Aktiviert")
|
||||
self._updates_enabled.setChecked(bool(updates_cfg.get("enabled", True)))
|
||||
self._check_interval = QSpinBox()
|
||||
self._check_interval.setRange(1, 168)
|
||||
self._check_interval.setSuffix(" h")
|
||||
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)
|
||||
outer.addWidget(update_box)
|
||||
|
||||
general_box = QGroupBox("Allgemein")
|
||||
general_form = QFormLayout(general_box)
|
||||
self._start_tab = QComboBox()
|
||||
self._start_tab.addItem("YouTube", "youtube")
|
||||
self._start_tab.addItem("YouTube Music", "music")
|
||||
idx = self._start_tab.findData(config.get("start_tab", "youtube"))
|
||||
self._start_tab.setCurrentIndex(max(0, idx))
|
||||
general_form.addRow("Beim Start öffnen:", self._start_tab)
|
||||
outer.addWidget(general_box)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
save_btn = QPushButton("Speichern")
|
||||
save_btn.clicked.connect(self._on_save)
|
||||
button_row.addWidget(save_btn)
|
||||
button_row.addStretch(1)
|
||||
outer.addLayout(button_row)
|
||||
|
||||
info = QLabel(f"{APP_NAME} v{APP_VERSION} · github.com/{GITHUB_REPO}")
|
||||
info.setStyleSheet("color: palette(mid);")
|
||||
outer.addWidget(info)
|
||||
|
||||
outer.addStretch(1)
|
||||
|
||||
def _on_save(self) -> None:
|
||||
self._config.setdefault("discord", {})
|
||||
self._config.setdefault("updates", {})
|
||||
|
||||
self._config["discord"]["enabled"] = self._discord_enabled.isChecked()
|
||||
self._config["discord"]["client_id"] = self._client_id.text().strip()
|
||||
self._config["discord"]["update_interval_seconds"] = self._discord_interval.value()
|
||||
self._config["discord"]["show_idle_presence"] = self._show_idle.isChecked()
|
||||
|
||||
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
||||
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
||||
|
||||
self._config["start_tab"] = self._start_tab.currentData()
|
||||
|
||||
save_config(self._config)
|
||||
self.settingsSaved.emit(self._config)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Gespeichert",
|
||||
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
|
||||
"mit den neuen Werten neu gestartet).",
|
||||
)
|
||||
+71
-22
@@ -5,9 +5,11 @@ 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 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.
|
||||
- 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.
|
||||
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
|
||||
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
||||
"""
|
||||
@@ -16,8 +18,10 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -33,6 +37,9 @@ 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")
|
||||
@@ -58,14 +65,23 @@ def fetch_latest_release() -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_windows_zip_asset(release: dict[str, Any]) -> 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."""
|
||||
assets = release.get("assets", [])
|
||||
if sys.platform == "win32":
|
||||
hints, exts = ("win",), (".zip",)
|
||||
elif sys.platform.startswith("linux"):
|
||||
hints, exts = ("linux",), (".tar.gz", ".tgz")
|
||||
else:
|
||||
return None
|
||||
|
||||
for asset in assets:
|
||||
name = asset.get("name", "").lower()
|
||||
if name.endswith(".zip") and ("win" in name or "windows" in name):
|
||||
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(".zip"):
|
||||
if asset.get("name", "").lower().endswith(exts):
|
||||
return asset
|
||||
return None
|
||||
|
||||
@@ -73,7 +89,7 @@ def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||||
class UpdateChecker(QThread):
|
||||
"""Prueft einmalig im Hintergrund auf eine neue Version."""
|
||||
|
||||
updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url
|
||||
updateAvailable = Signal(str, str, str) # version, release_notes, download_url
|
||||
checkFailed = Signal()
|
||||
upToDate = Signal()
|
||||
|
||||
@@ -86,7 +102,7 @@ class UpdateChecker(QThread):
|
||||
if not tag or not is_newer(tag):
|
||||
self.upToDate.emit()
|
||||
return
|
||||
asset = _find_windows_zip_asset(release)
|
||||
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)
|
||||
@@ -117,38 +133,44 @@ class UpdateInstaller(QThread):
|
||||
def _run_packaged_update(self) -> None:
|
||||
if not self._download_url:
|
||||
self.failed.emit(
|
||||
"Kein Windows-Release-Paket (.zip) im neuesten Release gefunden."
|
||||
"Kein passendes Release-Paket fuer dieses Betriebssystem 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"
|
||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||
archive_path = staging / archive_name
|
||||
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:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out:
|
||||
shutil.copyfileobj(resp, out)
|
||||
|
||||
self.progress.emit("Entpacke Update …")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
if archive_name.endswith((".tar.gz", ".tgz")):
|
||||
with tarfile.open(archive_path) as tf:
|
||||
tf.extractall(extract_dir)
|
||||
else:
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
# Manche Release-Zips enthalten einen einzelnen Unterordner (z.B. "Playtube/").
|
||||
# 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 …")
|
||||
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,
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
self._install_windows(source_dir, install_dir, staging)
|
||||
else:
|
||||
self._install_posix(source_dir, install_dir, staging)
|
||||
self.finished_ok.emit()
|
||||
|
||||
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path:
|
||||
exe_path = install_dir / "Playtube.exe"
|
||||
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy --
|
||||
|
||||
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||
script = f"""
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
Start-Sleep -Seconds 1
|
||||
@@ -163,7 +185,34 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||
"""
|
||||
script_path = staging / "apply_update.ps1"
|
||||
script_path.write_text(script, encoding="utf-8")
|
||||
return script_path
|
||||
subprocess.Popen(
|
||||
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
|
||||
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a --
|
||||
|
||||
def _install_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
|
||||
sleep 0.5
|
||||
done
|
||||
rm -rf "{install_dir}"/*
|
||||
cp -a "{source_dir}"/. "{install_dir}"/
|
||||
chmod +x "{exe_path}"
|
||||
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||
rm -rf "{staging}"
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user