Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
@@ -71,17 +71,25 @@ 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.
|
||||||
|
|
||||||
### 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": {
|
||||||
|
|||||||
@@ -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.1.2"
|
||||||
|
|||||||
+17
-2
@@ -88,7 +88,14 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
|
|||||||
|
|
||||||
duration = info.get("duration") or 0
|
duration = info.get("duration") or 0
|
||||||
current_time = info.get("currentTime") or 0
|
current_time = info.get("currentTime") or 0
|
||||||
if playing:
|
# IMMER start/end mitschicken, unabhaengig vom playing-Status - nicht nur wenn
|
||||||
|
# playing=true. Discord ersetzt "timestamps" bei einem SET_ACTIVITY-Update ohne
|
||||||
|
# diese Felder offenbar nicht sauber, sondern behaelt intern die zuletzt bekannten
|
||||||
|
# Werte bei ("stackt"). Waehrend eines Songwechsels ist "playing" durch das kurze
|
||||||
|
# Neuladen des <video>-Elements oft fuer 1-2 Polls faelschlich false - wurden
|
||||||
|
# start/end dann weggelassen, blieb Discords alte (viel zu weit zurueckliegende)
|
||||||
|
# Zeit einfach stehen, bis irgendwann wieder echte Werte kamen. Ein pausierter
|
||||||
|
# Titel zeigt so einfach einen eingefrorenen Fortschrittsbalken statt gar keinen.
|
||||||
start_ts = int(time.time() - current_time)
|
start_ts = int(time.time() - current_time)
|
||||||
payload["start"] = start_ts
|
payload["start"] = start_ts
|
||||||
if duration and duration > 0:
|
if duration and duration > 0:
|
||||||
@@ -119,7 +126,15 @@ 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
|
||||||
|
|||||||
+37
-4
@@ -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,16 +312,25 @@ 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.
|
||||||
@@ -314,6 +345,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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+123
-29
@@ -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,15 +81,25 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
name = asset.get("name", "").lower()
|
name = asset.get("name", "").lower()
|
||||||
if name.endswith(exts) and any(h in name for h in hints):
|
if not name.endswith(exts):
|
||||||
return asset
|
continue
|
||||||
for asset in assets:
|
if require_hint and not any(h in name for h in hints):
|
||||||
if asset.get("name", "").lower().endswith(exts):
|
continue
|
||||||
|
if ("patch" in name) != want_patch:
|
||||||
|
continue
|
||||||
return asset
|
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):
|
class UpdateChecker(QThread):
|
||||||
"""Prueft einmalig im Hintergrund auf eine neue Version."""
|
"""Prueft einmalig im Hintergrund auf eine neue Version."""
|
||||||
@@ -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,20 +193,69 @@ 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)
|
||||||
|
|
||||||
|
is_patch = "patch" in archive_name.lower()
|
||||||
|
self.progress.emit("Bereite Installation vor …")
|
||||||
|
|
||||||
|
if is_patch:
|
||||||
|
binary_name = _WINDOWS_BINARY_NAME if sys.platform == "win32" else _LINUX_BINARY_NAME
|
||||||
|
matches = list(extract_dir.rglob(binary_name))
|
||||||
|
if not matches:
|
||||||
|
# Unerwartet leeres/falsches Patch-Paket -> nicht kaputt installieren,
|
||||||
|
# lieber sauber fehlschlagen und den Nutzer auf das volle Paket
|
||||||
|
# hinweisen (naechster Check faellt automatisch darauf zurueck).
|
||||||
|
self.failed.emit(
|
||||||
|
f"Patch-Paket enthielt kein '{binary_name}'. Bitte erneut versuchen."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self._install_patch_windows(matches[0], install_dir, staging)
|
||||||
|
else:
|
||||||
|
self._install_patch_posix(matches[0], install_dir, staging)
|
||||||
|
else:
|
||||||
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
||||||
entries = list(extract_dir.iterdir())
|
entries = list(extract_dir.iterdir())
|
||||||
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
|
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
|
||||||
|
|
||||||
self.progress.emit("Bereite Installation vor …")
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
self._install_windows(source_dir, install_dir, staging)
|
self._install_full_windows(source_dir, install_dir, staging)
|
||||||
else:
|
else:
|
||||||
self._install_posix(source_dir, install_dir, staging)
|
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 --
|
# -- 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
|
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||||
script = f"""
|
script = f"""
|
||||||
$ErrorActionPreference = "SilentlyContinue"
|
$ErrorActionPreference = "SilentlyContinue"
|
||||||
@@ -183,16 +269,9 @@ Start-Process -FilePath "{exe_path}"
|
|||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||||
"""
|
"""
|
||||||
script_path = staging / "apply_update.ps1"
|
self._spawn_windows_script(script, staging)
|
||||||
script_path.write_text(script, encoding="utf-8")
|
|
||||||
subprocess.Popen(
|
|
||||||
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a --
|
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||||
|
|
||||||
def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
|
||||||
exe_path = install_dir / _LINUX_BINARY_NAME
|
exe_path = install_dir / _LINUX_BINARY_NAME
|
||||||
script = f"""#!/bin/sh
|
script = f"""#!/bin/sh
|
||||||
while kill -0 {os.getpid()} 2>/dev/null; do
|
while kill -0 {os.getpid()} 2>/dev/null; do
|
||||||
@@ -204,6 +283,21 @@ chmod +x "{exe_path}"
|
|||||||
nohup "{exe_path}" >/dev/null 2>&1 &
|
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||||
rm -rf "{staging}"
|
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 = 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