Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf5f3b93f | ||
|
|
9bd4b360cd | ||
|
|
e67e8d5d90 | ||
|
|
2ba4e1bb6f | ||
|
|
39ebaa84c5 | ||
|
|
1b587edaaa | ||
|
|
46035ccd5d | ||
|
|
b79d42d56d | ||
|
|
45e9b6054d | ||
|
|
2ce2e716c5 | ||
|
|
70ea164e31 | ||
|
|
6861111db4 | ||
|
|
aad5f2dfd3 | ||
|
|
a77d288eb9 | ||
|
|
f544736c66 | ||
|
|
a873f49587 | ||
|
|
2d1dfedacf | ||
|
|
3ddff796ad |
@@ -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
|
||||
|
||||
@@ -5,7 +5,9 @@ Erweiterung) mit:
|
||||
|
||||
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
||||
Hintergrund weiter wenn du zu Videos wechselst.
|
||||
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
|
||||
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
|
||||
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
|
||||
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
|
||||
Google anmelden reicht fuer beide Dienste.
|
||||
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
||||
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
||||
@@ -69,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
|
||||
|
||||
@@ -133,7 +143,8 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt
|
||||
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
||||
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
||||
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
||||
Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden.
|
||||
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
||||
Entwicklungsmodus) loeschen und neu anmelden.
|
||||
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
||||
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
||||
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"client_id": "1548023494976086127",
|
||||
"update_interval_seconds": 5,
|
||||
"update_interval_seconds": 15,
|
||||
"show_idle_presence": true
|
||||
},
|
||||
"updates": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "2.1.4"
|
||||
|
||||
+19
-2
@@ -10,6 +10,13 @@ from typing import Any
|
||||
APP_NAME = "Playtube"
|
||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
||||
|
||||
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||
# denselben Browser-Profil-Lock geteilt haben).
|
||||
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"app_name": APP_NAME,
|
||||
"discord": {
|
||||
@@ -32,13 +39,23 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
|
||||
|
||||
def _app_data_dir() -> Path:
|
||||
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil."""
|
||||
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe
|
||||
stabil. Entwicklungsmodus und gepackte .exe nutzen bewusst unterschiedliche
|
||||
Ordner (siehe _DATA_DIR_NAME)."""
|
||||
base = os.environ.get("APPDATA") or str(Path.home())
|
||||
d = Path(base) / APP_NAME
|
||||
d = Path(base) / _DATA_DIR_NAME
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def app_data_dir() -> Path:
|
||||
"""Oeffentlicher Zugriff auf den App-Datenordner, z.B. fuer Debug-Logs - die
|
||||
gepackte .exe laeuft ohne Konsolenfenster (console=False), print()-Debugging ist
|
||||
dort also unsichtbar; ein Log-File ist die einzige Moeglichkeit, dort etwas
|
||||
nachtraeglich einzusehen."""
|
||||
return _app_data_dir()
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
|
||||
# im gepackten Build im APPDATA-Ordner.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Sehr einfaches, immer aktives Datei-Log fuer schwer reproduzierbare Bugs.
|
||||
|
||||
Die gepackte .exe laeuft ohne Konsolenfenster (console=False in packaging/playtube.spec),
|
||||
also sind print()-Debugausgaben dort unsichtbar - selbst mit PLAYTUBE_DEBUG=1 sieht der
|
||||
Nutzer nichts. Dieses Modul schreibt stattdessen in eine kleine, automatisch gekappte
|
||||
Log-Datei im App-Datenordner, die sich jederzeit nachtraeglich auslesen laesst.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from .config import app_data_dir
|
||||
|
||||
_MAX_LINES = 500
|
||||
|
||||
|
||||
def _log_path() -> Path:
|
||||
d = app_data_dir() / "logs"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / "discord_rpc.log"
|
||||
|
||||
|
||||
def log_line(msg: str) -> None:
|
||||
"""Haengt eine Zeile mit Zeitstempel an - haelt die Datei klein, indem bei
|
||||
Ueberlaenge nur die letzten _MAX_LINES Zeilen behalten werden."""
|
||||
try:
|
||||
path = _log_path()
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{timestamp}] {msg}\n"
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if len(lines) > _MAX_LINES:
|
||||
path.write_text("\n".join(lines[-_MAX_LINES:]) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
# Logging darf niemals die App zum Absturz bringen.
|
||||
pass
|
||||
|
||||
|
||||
def log_exception(context: str, exc: BaseException) -> None:
|
||||
log_line(f"{context}: {exc!r}\n{''.join(traceback.format_exception(exc))}")
|
||||
+104
-13
@@ -9,12 +9,19 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from .config import APP_NAME
|
||||
from .debug_log import log_line
|
||||
|
||||
try:
|
||||
from pypresence.types import ActivityType
|
||||
except ImportError: # pypresence fehlt -> Discord-Feature bleibt einfach aus
|
||||
ActivityType = None
|
||||
|
||||
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
|
||||
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
|
||||
@@ -37,31 +44,55 @@ def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
|
||||
return text
|
||||
|
||||
|
||||
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
|
||||
_THUMBNAIL_SIZE_RE = re.compile(r"=w\d+-h\d+(-[a-z0-9-]*)?$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _upsize_thumbnail(url: str | None) -> str | None:
|
||||
"""YouTube-Music-Player-Bar-Thumbnails kommen sehr klein (z.B. '=w60-h60-l90-rj').
|
||||
Fuer eine scharfe Darstellung in Discord die Groesse im URL-Suffix hochsetzen."""
|
||||
if not url:
|
||||
return url
|
||||
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
|
||||
|
||||
|
||||
def build_presence_payload(
|
||||
info: dict[str, Any], session_start: int, start_ts: int, end_ts: int | None
|
||||
) -> dict[str, Any]:
|
||||
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
|
||||
Medien-Informationen."""
|
||||
Medien-Informationen. start_ts/end_ts werden vom DiscordRPCWorker mitgegeben (siehe
|
||||
dort _track_timestamps) statt hier direkt aus info["currentTime"] berechnet zu
|
||||
werden."""
|
||||
is_music = bool(info.get("isMusic"))
|
||||
playing = bool(info.get("playing"))
|
||||
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
|
||||
default_state = "YouTube Music" if is_music else "YouTube"
|
||||
state = _truncate(info.get("subtitle"), fallback=default_state)
|
||||
|
||||
thumbnail = info.get("thumbnail")
|
||||
if isinstance(thumbnail, str) and thumbnail.startswith("http"):
|
||||
# Discord akzeptiert fuer large_image auch direkte externe Bild-URLs (nicht nur
|
||||
# vorab hochgeladene Asset-Keys) - so zeigt Discord das echte Video-/Cover-Bild
|
||||
# statt eines statischen Logos.
|
||||
large_image = _upsize_thumbnail(thumbnail) if is_music else thumbnail
|
||||
else:
|
||||
large_image = ASSET_MUSIC if is_music else ASSET_YOUTUBE
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
# LISTENING = Hoert, WATCHING = Schaut - statt des Default-Typs PLAYING
|
||||
# (Spielt). Muss ein ActivityType-Enum-Member sein, kein rohes int - pypresence
|
||||
# ruft intern .value darauf auf.
|
||||
"activity_type": ActivityType.LISTENING if is_music else ActivityType.WATCHING,
|
||||
"instance": False,
|
||||
"details": title,
|
||||
"state": state,
|
||||
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE,
|
||||
"large_image": large_image,
|
||||
"large_text": "YouTube Music" if is_music else "YouTube",
|
||||
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
||||
"small_text": "Spielt" if playing else "Pausiert",
|
||||
"start": start_ts,
|
||||
}
|
||||
|
||||
duration = info.get("duration") or 0
|
||||
current_time = info.get("currentTime") or 0
|
||||
if playing:
|
||||
start_ts = int(time.time() - current_time)
|
||||
payload["start"] = start_ts
|
||||
if duration and duration > 0:
|
||||
payload["end"] = start_ts + int(duration)
|
||||
if end_ts:
|
||||
payload["end"] = end_ts
|
||||
|
||||
url = info.get("url")
|
||||
if url and isinstance(url, str) and url.startswith("http"):
|
||||
@@ -88,13 +119,25 @@ class DiscordRPCWorker(QThread):
|
||||
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
|
||||
super().__init__(parent)
|
||||
self._client_id = client_id
|
||||
self._interval = max(5.0, float(interval))
|
||||
# Discord ignoriert/verwirft Rich-Presence-Updates stillschweigend, wenn sie
|
||||
# haeufiger als ca. alle 15 Sekunden gesendet werden (offizielle Grenze fuer
|
||||
# SET_ACTIVITY). Wird diese Grenze unterschritten, landet zwar technisch jedes
|
||||
# Update im Code, aber Discord uebernimmt nur einen Teil davon - nach aussen
|
||||
# sieht das wie "eingefrorene" Bilder/Zeiten aus (Bild wechselt nicht, Fortschritt
|
||||
# "stackt" beim Songwechsel), weil zufaellig immer wieder ein veraltetes Update
|
||||
# durchkommt statt des aktuellen. Deshalb hartes Minimum von 15s, unabhaengig
|
||||
# davon, was in der Konfiguration steht.
|
||||
self._interval = max(15.0, float(interval))
|
||||
self._show_idle = show_idle
|
||||
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
|
||||
self._running = True
|
||||
self._connected = False
|
||||
self._presence = None
|
||||
self._session_start = int(time.time())
|
||||
# Anker fuer die Fortschrittsanzeige: wird NICHT mehr bei jedem Send aus
|
||||
# video.currentTime neu berechnet (siehe _track_timestamps).
|
||||
self._track_key: tuple | None = None
|
||||
self._track_start_ts: int | None = None
|
||||
|
||||
def submit_media_info(self, info: dict[str, Any] | None) -> None:
|
||||
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
||||
@@ -157,6 +200,7 @@ class DiscordRPCWorker(QThread):
|
||||
self._presence = Presence(self._client_id)
|
||||
self._presence.connect()
|
||||
self._connected = True
|
||||
log_line("[connect] verbunden")
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print("[discord-rpc] verbunden", flush=True)
|
||||
except Exception as exc:
|
||||
@@ -164,11 +208,46 @@ class DiscordRPCWorker(QThread):
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
|
||||
|
||||
def _track_timestamps(self, info: dict[str, Any]) -> tuple[int, int | None]:
|
||||
"""Liefert (start_ts, end_ts) fuer die Fortschrittsanzeige. Der Anker wird nur
|
||||
NEU gesetzt, wenn sich Titel/Untertitel aendern (= neuer Track) - nicht bei
|
||||
jedem Send aus video.currentTime neu berechnet. Grund: YouTube Music spielt
|
||||
beim Songwechsel oft nahtlos (gapless) aus einem durchgehenden Buffer weiter -
|
||||
video.currentTime springt dabei nicht zuverlaessig auf 0 zurueck, sondern kann
|
||||
einfach vom vorherigen Titel weiterzaehlen. Wuerde man start_ts jedes Mal aus
|
||||
currentTime neu ableiten, "stackt" die in Discord angezeigte Zeit ueber mehrere
|
||||
Songs hinweg, obwohl Titel/Bild laengst gewechselt haben."""
|
||||
title = info.get("title") or ""
|
||||
subtitle = info.get("subtitle") or ""
|
||||
is_music = bool(info.get("isMusic"))
|
||||
key = (is_music, title, subtitle)
|
||||
duration = info.get("duration") or 0
|
||||
current_time = info.get("currentTime") or 0
|
||||
now = time.time()
|
||||
|
||||
if key != self._track_key:
|
||||
old_key = self._track_key
|
||||
self._track_key = key
|
||||
# current_time nur als grobe Anfangs-Schaetzung verwenden (z.B. Programm
|
||||
# startet waehrend ein Titel schon laeuft) - plausibilisiert, damit ein
|
||||
# verlaesslicher Wert genau EINMAL beim Trackwechsel einfriert und danach
|
||||
# rein ueber die Systemzeit weiterlaeuft statt ueber currentTime.
|
||||
offset = current_time if (duration <= 0 or 0 <= current_time <= duration) else 0
|
||||
self._track_start_ts = int(now - offset)
|
||||
log_line(f"[track-change] alt={old_key!r} neu={key!r} offset={offset:.1f}s")
|
||||
|
||||
start_ts = self._track_start_ts if self._track_start_ts is not None else int(now)
|
||||
end_ts = start_ts + int(duration) if duration and duration > 0 else None
|
||||
return start_ts, end_ts
|
||||
|
||||
def _send(self, item) -> None:
|
||||
if self._presence is None:
|
||||
return
|
||||
try:
|
||||
if item is _IDLE_SENTINEL:
|
||||
# Naechster echter Track soll wieder einen frischen Zeit-Anker bekommen.
|
||||
self._track_key = None
|
||||
self._track_start_ts = None
|
||||
if self._show_idle:
|
||||
payload = build_idle_payload(self._session_start)
|
||||
self._presence.update(**payload)
|
||||
@@ -176,13 +255,25 @@ class DiscordRPCWorker(QThread):
|
||||
payload = None
|
||||
self._presence.clear()
|
||||
else:
|
||||
payload = build_presence_payload(item, self._session_start)
|
||||
start_ts, end_ts = self._track_timestamps(item)
|
||||
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
||||
self._presence.update(**payload)
|
||||
log_line(
|
||||
"[send] title=%r thumbnail=%r large_image=%r start=%s end=%s"
|
||||
% (
|
||||
item.get("title"),
|
||||
item.get("thumbnail"),
|
||||
payload.get("large_image"),
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
)
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
|
||||
except Exception as exc:
|
||||
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
||||
self._connected = False
|
||||
log_line(f"[send-error] {exc!r}")
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
|
||||
|
||||
|
||||
+37
-4
@@ -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:
|
||||
|
||||
+25
-3
@@ -7,15 +7,37 @@ MEDIA_PROBE_JS = r"""
|
||||
var el = document.querySelector(sel);
|
||||
return el ? el.textContent.trim() : null;
|
||||
}
|
||||
// Wichtig: getAttribute('src') statt .src - bei einem noch nicht (nach-)geladenen
|
||||
// <img> (leeres/fehlendes src-Attribut, z.B. waehrend Lazy-Loading) loest die .src
|
||||
// Property faelschlich auf die aktuelle Seiten-URL auf statt null/"" zu liefern.
|
||||
function imgSrc(sel) {
|
||||
var el = document.querySelector(sel);
|
||||
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');
|
||||
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
||||
thumbnail = imgM ? imgM.src : null;
|
||||
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
|
||||
} else {
|
||||
var t = document.title.replace(/ - YouTube$/, '');
|
||||
title = t || null;
|
||||
@@ -24,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
|
||||
|
||||
@@ -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")
|
||||
@@ -51,9 +60,11 @@ class SettingsTab(QWidget):
|
||||
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)
|
||||
# Minimum 15s: Discord ignoriert Rich-Presence-Updates, die haeufiger kommen,
|
||||
# stillschweigend (fuehrt zu "eingefrorenem" Bild/Fortschrittsbalken).
|
||||
self._discord_interval.setRange(15, 120)
|
||||
self._discord_interval.setSuffix(" s")
|
||||
self._discord_interval.setValue(int(discord_cfg.get("update_interval_seconds", 15)))
|
||||
self._discord_interval.setValue(max(15, 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)
|
||||
@@ -73,6 +84,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 +126,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 +154,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)
|
||||
|
||||
+129
-35
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user