Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5379be6c11 | ||
|
|
c6fe895334 | ||
|
|
7c202600cb | ||
|
|
7c37868cd1 | ||
|
|
d63b6b36d4 | ||
|
|
06e88ced19 | ||
|
|
fbf5f3b93f | ||
|
|
9bd4b360cd | ||
|
|
e67e8d5d90 | ||
|
|
2ba4e1bb6f | ||
|
|
39ebaa84c5 | ||
|
|
1b587edaaa | ||
|
|
46035ccd5d | ||
|
|
b79d42d56d | ||
|
|
45e9b6054d | ||
|
|
2ce2e716c5 | ||
|
|
70ea164e31 | ||
|
|
6861111db4 |
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Playtube.exe bauen
|
- name: Playtube.exe bauen
|
||||||
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
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
|
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
@@ -41,6 +41,14 @@ jobs:
|
|||||||
name: windows-build
|
name: windows-build
|
||||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
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:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -69,7 +77,7 @@ jobs:
|
|||||||
- name: Playtube-Binary bauen
|
- name: Playtube-Binary bauen
|
||||||
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
|
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
|
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
@@ -77,6 +85,14 @@ jobs:
|
|||||||
name: linux-build
|
name: linux-build
|
||||||
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
|
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:
|
publish-release:
|
||||||
needs: [build-windows, build-linux]
|
needs: [build-windows, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae
|
|||||||
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt
|
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser
|
||||||
|
`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube
|
||||||
|
wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es
|
||||||
|
sonst keinen Startmenue-Eintrag).
|
||||||
|
|
||||||
|
Das Build-Skript benennt
|
||||||
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
||||||
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
||||||
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
|
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
|
||||||
@@ -71,17 +76,31 @@ o.ae. umgehen).
|
|||||||
## Automatische Updates
|
## Automatische Updates
|
||||||
|
|
||||||
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
|
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)
|
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
|
||||||
des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie
|
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
|
||||||
installiert werden soll:
|
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
|
- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket**
|
||||||
nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App
|
herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt
|
||||||
danach selbst neu.
|
~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
|
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
|
||||||
requirements.txt` aus und startet den Python-Prozess neu.
|
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.
|
||||||
|
|
||||||
|
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine-
|
||||||
|
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr
|
||||||
|
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon
|
||||||
|
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
|
||||||
|
(`webprofile/storage`) liegen.
|
||||||
|
|
||||||
### Eine neue Version veroeffentlichen
|
### Eine neue Version veroeffentlichen
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"client_id": "1548023494976086127",
|
"client_id": "1548023494976086127",
|
||||||
"update_interval_seconds": 5,
|
"update_interval_seconds": 15,
|
||||||
"show_idle_presence": true
|
"show_idle_presence": true
|
||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
|
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from playtube import app_id # noqa: E402
|
from playtube import __version__, app_id # noqa: E402
|
||||||
from playtube.config import APP_NAME, load_config # noqa: E402
|
from playtube.config import APP_NAME, clear_cache_on_update, load_config # noqa: E402
|
||||||
|
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
|
||||||
|
|
||||||
app_id.set_app_user_model_id()
|
app_id.set_app_user_model_id()
|
||||||
app_id.configure_webengine_process_path()
|
app_id.configure_webengine_process_path()
|
||||||
@@ -45,6 +46,12 @@ def main() -> int:
|
|||||||
app.setWindowIcon(QIcon(str(icon_path)))
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
# Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login
|
||||||
|
# bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst
|
||||||
|
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
|
||||||
|
clear_cache_on_update(__version__)
|
||||||
|
ensure_start_menu_shortcut(APP_NAME)
|
||||||
|
|
||||||
window = MainWindow(config)
|
window = MainWindow(config)
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||||
|
|
||||||
__app_name__ = "Playtube"
|
__app_name__ = "Playtube"
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.2.1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -48,6 +49,14 @@ def _app_data_dir() -> Path:
|
|||||||
return d
|
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:
|
def _config_path() -> Path:
|
||||||
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
|
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
|
||||||
# im gepackten Build im APPDATA-Ordner.
|
# im gepackten Build im APPDATA-Ordner.
|
||||||
@@ -93,3 +102,28 @@ def profile_dir() -> Path:
|
|||||||
d = _app_data_dir() / "webprofile"
|
d = _app_data_dir() / "webprofile"
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache_on_update(current_version: str) -> None:
|
||||||
|
"""Loescht den QtWebEngine-HTTP-Cache (webprofile/cache), wenn seit dem letzten
|
||||||
|
Start ein Update installiert wurde - der Login (Cookies/LocalStorage liegen in
|
||||||
|
webprofile/storage, einem komplett getrennten Ordner) bleibt dabei unangetastet.
|
||||||
|
Alte Cache-Eintraege (z.B. Icon-Sprites, Skripte) koennen nach einem Update nicht
|
||||||
|
mehr zum neuen Code passen - frueher Ursache fuer fehlende Icons nach einem
|
||||||
|
beschaedigten Cache, siehe README."""
|
||||||
|
marker = _app_data_dir() / "installed_version.txt"
|
||||||
|
previous = None
|
||||||
|
if marker.exists():
|
||||||
|
try:
|
||||||
|
previous = marker.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
previous = None
|
||||||
|
|
||||||
|
if previous != current_version:
|
||||||
|
cache_dir = profile_dir() / "cache"
|
||||||
|
if cache_dir.exists():
|
||||||
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
marker.write_text(current_version, encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -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))}")
|
||||||
+73
-12
@@ -16,6 +16,7 @@ from typing import Any
|
|||||||
from PySide6.QtCore import QThread
|
from PySide6.QtCore import QThread
|
||||||
|
|
||||||
from .config import APP_NAME
|
from .config import APP_NAME
|
||||||
|
from .debug_log import log_line
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pypresence.types import ActivityType
|
from pypresence.types import ActivityType
|
||||||
@@ -54,9 +55,13 @@ def _upsize_thumbnail(url: str | None) -> str | None:
|
|||||||
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
|
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
|
||||||
|
|
||||||
|
|
||||||
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
|
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
|
"""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"))
|
is_music = bool(info.get("isMusic"))
|
||||||
playing = bool(info.get("playing"))
|
playing = bool(info.get("playing"))
|
||||||
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
|
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
|
||||||
@@ -84,15 +89,10 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
|
|||||||
"large_text": "YouTube Music" if is_music else "YouTube",
|
"large_text": "YouTube Music" if is_music else "YouTube",
|
||||||
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
||||||
"small_text": "Spielt" if playing else "Pausiert",
|
"small_text": "Spielt" if playing else "Pausiert",
|
||||||
|
"start": start_ts,
|
||||||
}
|
}
|
||||||
|
if end_ts:
|
||||||
duration = info.get("duration") or 0
|
payload["end"] = end_ts
|
||||||
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)
|
|
||||||
|
|
||||||
url = info.get("url")
|
url = info.get("url")
|
||||||
if url and isinstance(url, str) and url.startswith("http"):
|
if url and isinstance(url, str) and url.startswith("http"):
|
||||||
@@ -119,13 +119,25 @@ class DiscordRPCWorker(QThread):
|
|||||||
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
|
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._client_id = client_id
|
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._show_idle = show_idle
|
||||||
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
|
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
|
||||||
self._running = True
|
self._running = True
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._presence = None
|
self._presence = None
|
||||||
self._session_start = int(time.time())
|
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:
|
def submit_media_info(self, info: dict[str, Any] | None) -> None:
|
||||||
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
||||||
@@ -188,6 +200,7 @@ class DiscordRPCWorker(QThread):
|
|||||||
self._presence = Presence(self._client_id)
|
self._presence = Presence(self._client_id)
|
||||||
self._presence.connect()
|
self._presence.connect()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
log_line("[connect] verbunden")
|
||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
print("[discord-rpc] verbunden", flush=True)
|
print("[discord-rpc] verbunden", flush=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -195,11 +208,46 @@ class DiscordRPCWorker(QThread):
|
|||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
|
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:
|
def _send(self, item) -> None:
|
||||||
if self._presence is None:
|
if self._presence is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if item is _IDLE_SENTINEL:
|
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:
|
if self._show_idle:
|
||||||
payload = build_idle_payload(self._session_start)
|
payload = build_idle_payload(self._session_start)
|
||||||
self._presence.update(**payload)
|
self._presence.update(**payload)
|
||||||
@@ -207,13 +255,26 @@ class DiscordRPCWorker(QThread):
|
|||||||
payload = None
|
payload = None
|
||||||
self._presence.clear()
|
self._presence.clear()
|
||||||
else:
|
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)
|
self._presence.update(**payload)
|
||||||
|
log_line(
|
||||||
|
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
|
||||||
|
% (
|
||||||
|
item.get("title"),
|
||||||
|
item.get("url"),
|
||||||
|
item.get("thumbnail"),
|
||||||
|
payload.get("large_image"),
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
|
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
log_line(f"[send-error] {exc!r}")
|
||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
|
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
|
||||||
|
|
||||||
|
|||||||
+45
-5
@@ -52,6 +52,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
||||||
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
||||||
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
|
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._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
|
||||||
self._music_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:
|
def _check_for_updates(self) -> None:
|
||||||
if self._update_checker is not None and self._update_checker.isRunning():
|
if self._update_checker is not None and self._update_checker.isRunning():
|
||||||
return
|
return
|
||||||
|
self._settings_tab.set_checking()
|
||||||
self._update_checker = UpdateChecker(self)
|
self._update_checker = UpdateChecker(self)
|
||||||
self._update_checker.updateAvailable.connect(self._on_update_available)
|
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()
|
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:
|
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:
|
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._pending_update_version = version
|
||||||
|
|
||||||
self._show_and_raise()
|
self._show_and_raise()
|
||||||
@@ -290,19 +312,35 @@ class MainWindow(QMainWindow):
|
|||||||
self._start_update_install(download_url)
|
self._start_update_install(download_url)
|
||||||
|
|
||||||
def _start_update_install(self, download_url: str) -> None:
|
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._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
|
||||||
self._update_installer = UpdateInstaller(download_url, self)
|
self._update_installer = UpdateInstaller(download_url, self)
|
||||||
self._update_installer.progress.connect(
|
self._update_installer.progress.connect(self._on_install_progress_text)
|
||||||
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
|
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.finished_ok.connect(self._on_update_finished)
|
||||||
self._update_installer.failed.connect(self._on_update_failed)
|
self._update_installer.failed.connect(self._on_update_failed)
|
||||||
self._update_installer.start()
|
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:
|
def _on_update_finished(self) -> None:
|
||||||
|
self._settings_tab.set_download_progress(100)
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
|
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
|
||||||
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
|
# beendet wird, tauscht dann die Dateien aus und startet die App neu -
|
||||||
|
# es zeigt dabei selbst ein kleines Fortschrittsfenster an (siehe
|
||||||
|
# updater.py), damit der Nutzer zwischen "App schliesst sich" und
|
||||||
|
# "neue App startet" nicht denkt, etwas sei abgestuerzt.
|
||||||
|
self._settings_tab.set_update_status(
|
||||||
|
"Installation abgeschlossen. Playtube wird neu gestartet …"
|
||||||
|
)
|
||||||
|
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
|
||||||
self._quit()
|
self._quit()
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
@@ -314,6 +352,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _on_update_failed(self, error: str) -> None:
|
def _on_update_failed(self, error: str) -> None:
|
||||||
self._tray.setToolTip(APP_NAME)
|
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)
|
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
|
||||||
|
|
||||||
def _restart_dev_process(self) -> None:
|
def _restart_dev_process(self) -> None:
|
||||||
|
|||||||
+64
-4
@@ -28,16 +28,72 @@ MEDIA_PROBE_JS = r"""
|
|||||||
function ytThumbnailUrl(videoId) {
|
function ytThumbnailUrl(videoId) {
|
||||||
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
|
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
|
||||||
}
|
}
|
||||||
|
// "1:23" oder "1:02:03" -> Sekunden.
|
||||||
|
function parseClock(str) {
|
||||||
|
if (!str) { return null; }
|
||||||
|
var parts = String(str).trim().split(':').map(function(p) { return parseInt(p, 10); });
|
||||||
|
if (!parts.length) { return null; }
|
||||||
|
for (var i = 0; i < parts.length; i++) { if (isNaN(parts[i])) { return null; } }
|
||||||
|
var seconds = 0;
|
||||||
|
for (var j = 0; j < parts.length; j++) { seconds = seconds * 60 + parts[j]; }
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
function numAttr(el, name) {
|
||||||
|
if (!el) { return null; }
|
||||||
|
var raw = el.getAttribute(name);
|
||||||
|
var n = raw !== null ? parseFloat(raw) : NaN;
|
||||||
|
return isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
// WICHTIG: video.currentTime/video.duration sind bei YouTube Music NICHT
|
||||||
|
// verlaesslich pro Titel - bei nahtlosem (gapless) Songwechsel laeuft darunter
|
||||||
|
// teils ein durchgehender Buffer weiter, dessen currentTime/duration beim
|
||||||
|
// Songwechsel NICHT auf den neuen Titel zurueckgesetzt wird, sondern einfach ueber
|
||||||
|
// mehrere Songs hinweg weiterzaehlt (empirisch bestaetigt: nach einem Songwechsel
|
||||||
|
// wurde die verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des NEUEN
|
||||||
|
// uebernommen, und die "Dauer" wuchs bei jedem Poll weiter statt konstant zu
|
||||||
|
// bleiben). Deshalb wird die sichtbar angezeigte Fortschrittsanzeige ausgelesen
|
||||||
|
// (ARIA-Attribute des Sliders in Sekunden, sonst der Zeit-Text im Player) - die
|
||||||
|
// zeigt garantiert den Fortschritt des GERADE LAUFENDEN Titels, weil der Nutzer sie
|
||||||
|
// ja selbst so sieht.
|
||||||
|
function musicTimeInfo() {
|
||||||
|
var slider = document.querySelector('#progress-bar, tp-yt-paper-slider#progress-bar');
|
||||||
|
var current = numAttr(slider, 'aria-valuenow');
|
||||||
|
var total = numAttr(slider, 'aria-valuemax');
|
||||||
|
if (current !== null && total !== null && total > 0) {
|
||||||
|
return { current: current, total: total };
|
||||||
|
}
|
||||||
|
var el = document.querySelector('.time-info.ytmusic-player-bar, ytmusic-player-bar .time-info');
|
||||||
|
var parts = el ? (el.textContent || '').split('/') : [];
|
||||||
|
if (parts.length === 2) {
|
||||||
|
var c = parseClock(parts[0]), t = parseClock(parts[1]);
|
||||||
|
if (c !== null && t !== null) { return { current: c, total: t }; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function youtubeTimeInfo() {
|
||||||
|
var current = parseClock(txt('.ytp-time-current'));
|
||||||
|
var total = parseClock(txt('.ytp-time-duration'));
|
||||||
|
if (current !== null && total !== null) { return { current: current, total: total }; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var video = document.querySelector('video');
|
var video = document.querySelector('video');
|
||||||
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
||||||
var videoId = videoIdFromUrl(location.href);
|
var videoId = videoIdFromUrl(location.href);
|
||||||
var title = null, subtitle = null, thumbnail = null;
|
var title = null, subtitle = null, thumbnail = null;
|
||||||
|
|
||||||
|
// WICHTIG: die Video-ID aus der URL ist die zuverlaessigste Thumbnail-Quelle - sie
|
||||||
|
// wechselt garantiert synchron mit dem Titel. Das <img> in der YT-Music-Playerleiste
|
||||||
|
// (fruehere Praeferenz) wird von YouTube per Crossfade/Shadow-DOM animiert und
|
||||||
|
// aktualisiert sein src-Attribut dabei nachweislich NICHT zuverlaessig pro Titel -
|
||||||
|
// empirisch bestaetigt: dieselbe Thumbnail-URL blieb ueber mehrere komplett
|
||||||
|
// unterschiedliche Songs hinweg stehen, obwohl Titel/Zeit schon laengst gewechselt
|
||||||
|
// hatten. Das DOM-<img> dient nur noch als Fallback, falls keine Video-ID in der
|
||||||
|
// URL steckt.
|
||||||
if (isMusic) {
|
if (isMusic) {
|
||||||
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
||||||
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
||||||
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
|
thumbnail = ytThumbnailUrl(videoId) || imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
||||||
} else {
|
} else {
|
||||||
var t = document.title.replace(/ - YouTube$/, '');
|
var t = document.title.replace(/ - YouTube$/, '');
|
||||||
title = t || null;
|
title = t || null;
|
||||||
@@ -46,9 +102,13 @@ MEDIA_PROBE_JS = r"""
|
|||||||
|| txt('#channel-name a')
|
|| txt('#channel-name a')
|
||||||
|| txt('ytd-channel-name#channel-name a');
|
|| txt('ytd-channel-name#channel-name a');
|
||||||
var imgY = document.querySelector('link[rel="image_src"]');
|
var imgY = document.querySelector('link[rel="image_src"]');
|
||||||
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
|
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
|
||||||
|
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
|
||||||
|
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
|
||||||
|
|
||||||
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
|
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
|
||||||
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
|
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
|
||||||
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
|
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
|
||||||
@@ -61,8 +121,8 @@ MEDIA_PROBE_JS = r"""
|
|||||||
thumbnail: thumbnail,
|
thumbnail: thumbnail,
|
||||||
url: location.href,
|
url: location.href,
|
||||||
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
|
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
|
||||||
currentTime: video ? video.currentTime : 0,
|
currentTime: currentTime,
|
||||||
duration: (video && isFinite(video.duration)) ? video.duration : 0,
|
duration: duration,
|
||||||
hasVideo: !!video
|
hasVideo: !!video
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -30,6 +31,8 @@ class SettingsTab(QWidget):
|
|||||||
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
|
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
|
||||||
|
|
||||||
settingsSaved = Signal(dict)
|
settingsSaved = Signal(dict)
|
||||||
|
checkUpdatesRequested = Signal()
|
||||||
|
installUpdateRequested = Signal()
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any], parent=None):
|
def __init__(self, config: dict[str, Any], parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -39,9 +42,15 @@ class SettingsTab(QWidget):
|
|||||||
outer.setContentsMargins(32, 28, 32, 28)
|
outer.setContentsMargins(32, 28, 32, 28)
|
||||||
outer.setSpacing(18)
|
outer.setSpacing(18)
|
||||||
|
|
||||||
|
header = QVBoxLayout()
|
||||||
|
header.setSpacing(2)
|
||||||
title = QLabel(f"{APP_NAME}-Einstellungen")
|
title = QLabel(f"{APP_NAME}-Einstellungen")
|
||||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
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_cfg = config.get("discord", {})
|
||||||
discord_box = QGroupBox("Discord Rich Presence")
|
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 = QLineEdit(str(discord_cfg.get("client_id", "")))
|
||||||
self._client_id.setPlaceholderText("Discord Application Client-ID")
|
self._client_id.setPlaceholderText("Discord Application Client-ID")
|
||||||
self._discord_interval = QSpinBox()
|
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.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 = QCheckBox("Status anzeigen, wenn gerade nichts läuft")
|
||||||
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
|
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
|
||||||
discord_form.addRow(self._discord_enabled)
|
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)))
|
self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6)))
|
||||||
update_form.addRow(self._updates_enabled)
|
update_form.addRow(self._updates_enabled)
|
||||||
update_form.addRow("Prüfintervall:", self._check_interval)
|
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)
|
outer.addWidget(update_box)
|
||||||
|
|
||||||
general_box = QGroupBox("Allgemein")
|
general_box = QGroupBox("Allgemein")
|
||||||
@@ -92,7 +126,7 @@ class SettingsTab(QWidget):
|
|||||||
button_row.addStretch(1)
|
button_row.addStretch(1)
|
||||||
outer.addLayout(button_row)
|
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);")
|
info.setStyleSheet("color: palette(mid);")
|
||||||
outer.addWidget(info)
|
outer.addWidget(info)
|
||||||
|
|
||||||
@@ -120,3 +154,39 @@ class SettingsTab(QWidget):
|
|||||||
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
|
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
|
||||||
"mit den neuen Werten neu gestartet).",
|
"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)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung.
|
||||||
|
|
||||||
|
Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es
|
||||||
|
also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten"
|
||||||
|
Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent -
|
||||||
|
prueft vorher, ob die Verknuepfung schon existiert)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _find_icon(exe_dir: Path) -> Path | None:
|
||||||
|
matches = list(exe_dir.rglob("icon.ico"))
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_start_menu_shortcut(app_name: str) -> None:
|
||||||
|
if sys.platform != "win32" or not getattr(sys, "frozen", False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if not appdata:
|
||||||
|
return
|
||||||
|
start_menu = Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
||||||
|
shortcut_path = start_menu / f"{app_name}.lnk"
|
||||||
|
if shortcut_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
exe_path = Path(sys.executable).resolve()
|
||||||
|
icon_path = _find_icon(exe_path.parent) or exe_path
|
||||||
|
|
||||||
|
ps_script = (
|
||||||
|
'$WshShell = New-Object -ComObject WScript.Shell\n'
|
||||||
|
f'$Shortcut = $WshShell.CreateShortcut("{shortcut_path}")\n'
|
||||||
|
f'$Shortcut.TargetPath = "{exe_path}"\n'
|
||||||
|
f'$Shortcut.WorkingDirectory = "{exe_path.parent}"\n'
|
||||||
|
f'$Shortcut.IconLocation = "{icon_path}"\n'
|
||||||
|
f'$Shortcut.Description = "{app_name}"\n'
|
||||||
|
'$Shortcut.Save()\n'
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_script],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=15,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
|
||||||
|
pass
|
||||||
+211
-45
@@ -5,11 +5,16 @@ Ablauf:
|
|||||||
Version als die aktuell laufende (playtube.__version__).
|
Version als die aktuell laufende (playtube.__version__).
|
||||||
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
|
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
|
||||||
3. UpdateInstaller installiert das Update:
|
3. UpdateInstaller installiert das Update:
|
||||||
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt das
|
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
|
||||||
zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es
|
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
|
||||||
und laesst ein kurzes Skript (nach Prozessende) den Installationsordner
|
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
|
||||||
ersetzen und die App neu starten - PowerShell+robocopy unter Windows,
|
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
|
||||||
ein Shell-Skript unter Linux.
|
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
|
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
|
||||||
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
||||||
"""
|
"""
|
||||||
@@ -17,7 +22,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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:
|
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""Sucht das zur laufenden Plattform passende Release-Paket:
|
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
|
||||||
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen."""
|
"-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", [])
|
assets = release.get("assets", [])
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
hints, exts = ("win",), (".zip",)
|
hints, exts = ("win",), (".zip",)
|
||||||
@@ -76,14 +81,24 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for asset in assets:
|
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
|
||||||
name = asset.get("name", "").lower()
|
for asset in assets:
|
||||||
if name.endswith(exts) and any(h in name for h in hints):
|
name = asset.get("name", "").lower()
|
||||||
|
if not name.endswith(exts):
|
||||||
|
continue
|
||||||
|
if require_hint and not any(h in name for h in hints):
|
||||||
|
continue
|
||||||
|
if ("patch" in name) != want_patch:
|
||||||
|
continue
|
||||||
return asset
|
return asset
|
||||||
for asset in assets:
|
return None
|
||||||
if asset.get("name", "").lower().endswith(exts):
|
|
||||||
return asset
|
return (
|
||||||
return None
|
find(True, True)
|
||||||
|
or find(True, False)
|
||||||
|
or find(False, True)
|
||||||
|
or find(False, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UpdateChecker(QThread):
|
class UpdateChecker(QThread):
|
||||||
@@ -112,6 +127,9 @@ class UpdateInstaller(QThread):
|
|||||||
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
|
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
|
||||||
|
|
||||||
progress = Signal(str)
|
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()
|
finished_ok = Signal()
|
||||||
failed = Signal(str)
|
failed = Signal(str)
|
||||||
|
|
||||||
@@ -138,6 +156,7 @@ class UpdateInstaller(QThread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.progress.emit("Lade Update herunter …")
|
self.progress.emit("Lade Update herunter …")
|
||||||
|
self.progress_percent.emit(0)
|
||||||
install_dir = Path(sys.executable).resolve().parent
|
install_dir = Path(sys.executable).resolve().parent
|
||||||
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
||||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||||
@@ -145,10 +164,28 @@ class UpdateInstaller(QThread):
|
|||||||
extract_dir = staging / "extracted"
|
extract_dir = staging / "extracted"
|
||||||
|
|
||||||
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
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:
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
shutil.copyfileobj(resp, out)
|
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 …")
|
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")):
|
if archive_name.endswith((".tar.gz", ".tgz")):
|
||||||
with tarfile.open(archive_path) as tf:
|
with tarfile.open(archive_path) as tf:
|
||||||
tf.extractall(extract_dir)
|
tf.extractall(extract_dir)
|
||||||
@@ -156,33 +193,175 @@ class UpdateInstaller(QThread):
|
|||||||
with zipfile.ZipFile(archive_path) as zf:
|
with zipfile.ZipFile(archive_path) as zf:
|
||||||
zf.extractall(extract_dir)
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
is_patch = "patch" in archive_name.lower()
|
||||||
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 …")
|
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:
|
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()
|
self.finished_ok.emit()
|
||||||
|
|
||||||
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy --
|
# Kleines, immer sichtbares Fortschrittsfenster fuer den Windows-Installationsschritt.
|
||||||
|
# Der Hauptprozess ist zu diesem Zeitpunkt schon beendet (Datei-Sperren!), daher
|
||||||
|
# laeuft das komplett im separaten PowerShell-Skript - ohne dieses Fenster wuerde
|
||||||
|
# der Nutzer nach dem Schliessen der App fuer die Dauer der Installation (bei
|
||||||
|
# einem vollen Paket ggf. mehrere Sekunden) gar nichts sehen, was wie ein Absturz
|
||||||
|
# oder Haenger wirkt.
|
||||||
|
_WINDOWS_PROGRESS_FORM_PS = """
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.Text = "Playtube-Update"
|
||||||
|
$form.Size = New-Object System.Drawing.Size(380,130)
|
||||||
|
$form.StartPosition = "CenterScreen"
|
||||||
|
$form.FormBorderStyle = "FixedDialog"
|
||||||
|
$form.ControlBox = $false
|
||||||
|
$form.TopMost = $true
|
||||||
|
$label = New-Object System.Windows.Forms.Label
|
||||||
|
$label.Text = "Playtube wird aktualisiert – bitte warten …"
|
||||||
|
$label.AutoSize = $false
|
||||||
|
$label.Size = New-Object System.Drawing.Size(340,20)
|
||||||
|
$label.Location = New-Object System.Drawing.Point(20,15)
|
||||||
|
$form.Controls.Add($label)
|
||||||
|
$bar = New-Object System.Windows.Forms.ProgressBar
|
||||||
|
$bar.Style = "Marquee"
|
||||||
|
$bar.MarqueeAnimationSpeed = 30
|
||||||
|
$bar.Size = New-Object System.Drawing.Size(340,20)
|
||||||
|
$bar.Location = New-Object System.Drawing.Point(20,50)
|
||||||
|
$form.Controls.Add($bar)
|
||||||
|
$form.Show()
|
||||||
|
$form.Refresh()
|
||||||
|
"""
|
||||||
|
|
||||||
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
|
||||||
|
|
||||||
|
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
|
||||||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||||
script = f"""
|
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||||||
$ErrorActionPreference = "SilentlyContinue"
|
$ErrorActionPreference = "SilentlyContinue"
|
||||||
Start-Sleep -Seconds 1
|
|
||||||
$targetPid = {os.getpid()}
|
$targetPid = {os.getpid()}
|
||||||
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 300
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
}}
|
}}
|
||||||
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null
|
$label.Text = "Kopiere aktualisierte Datei …"
|
||||||
|
$form.Refresh()
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
|
||||||
|
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||||||
|
$form.Refresh()
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
Start-Process -FilePath "{exe_path}"
|
Start-Process -FilePath "{exe_path}"
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Milliseconds 800
|
||||||
|
$form.Close()
|
||||||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||||
"""
|
"""
|
||||||
|
self._spawn_windows_script(script, staging)
|
||||||
|
|
||||||
|
# Falls zenity installiert ist (auf den meisten Desktop-Distros vorhanden), waehrend
|
||||||
|
# Wartezeit/Installation ein pulsierendes Fortschrittsfenster zeigen - rein optisch,
|
||||||
|
# das Update funktioniert auch ohne (dann passiert der Neustart einfach unsichtbar).
|
||||||
|
_POSIX_PROGRESS_HEADER = """#!/bin/sh
|
||||||
|
ZPID=""
|
||||||
|
if command -v zenity >/dev/null 2>&1; then
|
||||||
|
tail -f /dev/null | zenity --progress --title="Playtube-Update" \
|
||||||
|
--text="Playtube wird aktualisiert - bitte warten ..." --pulsate --no-cancel \
|
||||||
|
>/dev/null 2>&1 &
|
||||||
|
ZPID=$!
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
_POSIX_PROGRESS_FOOTER = """
|
||||||
|
[ -n "$ZPID" ] && kill "$ZPID" 2>/dev/null
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
|
||||||
|
exe_path = install_dir / _LINUX_BINARY_NAME
|
||||||
|
script = self._POSIX_PROGRESS_HEADER + f"""
|
||||||
|
while kill -0 {os.getpid()} 2>/dev/null; do
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
cp -f "{new_binary}" "{exe_path}"
|
||||||
|
chmod +x "{exe_path}"
|
||||||
|
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||||||
|
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||||
|
rm -rf "{staging}"
|
||||||
|
"""
|
||||||
|
self._spawn_posix_script(script, staging)
|
||||||
|
|
||||||
|
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
|
||||||
|
|
||||||
|
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||||
|
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||||
|
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||||||
|
$ErrorActionPreference = "SilentlyContinue"
|
||||||
|
$targetPid = {os.getpid()}
|
||||||
|
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
}}
|
||||||
|
$label.Text = "Kopiere Programmdateien … (kann etwas dauern)"
|
||||||
|
$form.Refresh()
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
|
||||||
|
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
|
||||||
|
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
|
||||||
|
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
||||||
|
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
||||||
|
while (-not $roboProc.HasExited) {{
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
}}
|
||||||
|
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||||||
|
$form.Refresh()
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
Start-Process -FilePath "{exe_path}"
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
$form.Close()
|
||||||
|
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||||
|
"""
|
||||||
|
self._spawn_windows_script(script, staging)
|
||||||
|
|
||||||
|
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||||
|
exe_path = install_dir / _LINUX_BINARY_NAME
|
||||||
|
script = self._POSIX_PROGRESS_HEADER + f"""
|
||||||
|
while kill -0 {os.getpid()} 2>/dev/null; do
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
rm -rf "{install_dir}"/*
|
||||||
|
cp -a "{source_dir}"/. "{install_dir}"/
|
||||||
|
chmod +x "{exe_path}"
|
||||||
|
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||||||
|
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||||
|
rm -rf "{staging}"
|
||||||
|
"""
|
||||||
|
self._spawn_posix_script(script, staging)
|
||||||
|
|
||||||
|
# -- Skript-Ausfuehrung: PowerShell (wartet+robocopy/Copy) unter Windows, Shell
|
||||||
|
# (wartet+cp) unter Linux; wird detached gestartet, damit es diesen Prozess
|
||||||
|
# ueberlebt, wenn dieser sich gleich beendet. --
|
||||||
|
|
||||||
|
def _spawn_windows_script(self, script: str, staging: Path) -> None:
|
||||||
script_path = staging / "apply_update.ps1"
|
script_path = staging / "apply_update.ps1"
|
||||||
script_path.write_text(script, encoding="utf-8")
|
script_path.write_text(script, encoding="utf-8")
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
@@ -190,20 +369,7 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
|||||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a --
|
def _spawn_posix_script(self, script: str, staging: Path) -> None:
|
||||||
|
|
||||||
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 = staging / "apply_update.sh"
|
||||||
script_path.write_text(script, encoding="utf-8")
|
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)
|
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|||||||
Reference in New Issue
Block a user