10 Commits
Author SHA1 Message Date
Fojadrachi 45e9b6054d Release v2.1.0
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 21:11:44 +02:00
FojadrachiandClaude Sonnet 5 2ce2e716c5 Update-UI im Einstellungen-Tab + schnelle Patch-Updates statt Komplettinstallation
- Einstellungen-Tab: 'Jetzt nach Updates suchen'-Button, Status-Text (aktuell/verfuegbar/
  fehlgeschlagen), Fortschrittsbalken fuer den Download (Prozent bei bekannter Groesse,
  unbestimmt waehrend Entpacken/Installieren), 'Update installieren'-Button erscheint bei Fund.
- updater.py/UpdateInstaller: laedt Download jetzt in Chunks (statt copyfileobj) und meldet
  echten Fortschritt in Prozent ueber ein neues progress_percent-Signal.
- Patch-Updates: CI baut zusaetzlich zum vollen Release-Paket ein kleines '-patch'-Paket,
  das nur die ausfuehrbare Datei (Playtube.exe/Playtube, ~2-3 MB) enthaelt - der riesige
  PySide6/QtWebEngine-Laufzeitordner _internal/ (~200 MB) bleibt unangetastet, da er sich
  zwischen normalen Code-Patches nicht aendert. updater.py bevorzugt das Patch-Paket und
  faellt nur auf das volle Paket zurueck, wenn kein Patch-Asset gefunden wird.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 21:11:31 +02:00
Fojadrachi 70ea164e31 Release v2.0.1
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 21:03:04 +02:00
FojadrachiandClaude Sonnet 5 6861111db4 Einstellungen: Versionsnummer sichtbar unter dem Titel anzeigen
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 21:02:51 +02:00
Fojadrachi aad5f2dfd3 Release v2.0.0
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 20:55:25 +02:00
Fojadrachi a77d288eb9 Release v1.0.3
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 20:54:08 +02:00
FojadrachiandClaude Sonnet 5 f544736c66 Thumbnail-Fix: Video-ID aus URL statt fragilem <link rel=image_src>
Auf vielen aktuellen YouTube-Seiten fehlt das <link rel="image_src"> Tag, auf das
sich die Thumbnail-Erkennung bisher verlassen hat - large_image fiel dadurch staendig
auf das statische Logo zurueck. Jetzt wird die Video-ID aus der URL (?v=...) gelesen
und die Thumbnail-URL direkt ueber YouTubes CDN gebaut (i.ytimg.com/vi/<id>/hqdefault.jpg),
funktioniert zuverlaessig unabhaengig vom Seiten-Markup. Per Live-Test verifiziert.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:52:54 +02:00
Fojadrachi a873f49587 Release v1.0.2
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-11 20:49:54 +02:00
FojadrachiandClaude Sonnet 5 2d1dfedacf Discord RPC: echter Fund - activity_type falsch typisiert + Cover-Bild statt Logo
Der entscheidende verbleibende Bug: 'activity_type' wurde als rohes int gesetzt,
pypresence.Presence.update() ruft aber intern .value darauf auf und wirft
AttributeError - jedes Update mit echten Mediendaten schlug dadurch fehl (nur die
generische Idle-Presence ohne activity_type kam durch). Fix: ActivityType-Enum
verwenden (LISTENING/WATCHING statt Default PLAYING), zusammen mit 'instance: False'
nach Vorbild einer frueheren, funktionierenden Electron-Implementierung. Per
Discord-Screenshot verifiziert: RPC erscheint jetzt korrekt.

Zusaetzlich:
- large_image nutzt jetzt die echte Video-/Cover-Thumbnail-URL (Discord akzeptiert
  externe Bild-URLs, nicht nur hochgeladene Asset-Keys) statt des statischen Logos,
  mit Hochskalierung fuer die kleinen YouTube-Music-Player-Bar-Thumbnails.
- media_probe.py: Thumbnail-Erkennung nutzt jetzt getAttribute('src') statt .src -
  bei einem noch nicht geladenen <img> (leeres src-Attribut) loeste .src faelschlich
  auf die aktuelle Seiten-URL auf statt null zu liefern.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:49:41 +02:00
FojadrachiandClaude Sonnet 5 3ddff796ad Entwicklungsmodus nutzt eigenen APPDATA-Ordner (PlaytubeDev statt Playtube)
Verhindert, dass ein lokaler Test-/Dev-Build (python main.py) sich Login-Profil und
Discord-Konfiguration mit einer installierten/gepackten Playtube.exe teilt - genau das
hatte zuletzt zur falschen Discord-Client-ID in der echten Installation gefuehrt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 20:24:19 +02:00
9 changed files with 343 additions and 59 deletions
+18 -2
View File
@@ -33,7 +33,7 @@ jobs:
- name: Playtube.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .zip packen
- name: Als .zip packen (volles Paket)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4
@@ -41,6 +41,14 @@ jobs:
name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-${{ github.ref_name }}-win64-patch.zip
build-linux:
runs-on: ubuntu-latest
steps:
@@ -69,7 +77,7 @@ jobs:
- name: Playtube-Binary bauen
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .tar.gz packen
- name: Als .tar.gz packen (volles Paket)
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
- uses: actions/upload-artifact@v4
@@ -77,6 +85,14 @@ jobs:
name: linux-build
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-patch
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
publish-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
+20 -9
View File
@@ -5,7 +5,9 @@ Erweiterung) mit:
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
Hintergrund weiter wenn du zu Videos wechselst.
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
Google anmelden reicht fuer beide Dienste.
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
@@ -69,17 +71,25 @@ o.ae. umgehen).
## Automatische Updates
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
(Standard 6, in `config.json` einstellbar) die [GitHub Releases](https://github.com/fojadrachi/Playtube/releases)
des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie
installiert werden soll:
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll:
- **Gepackte `Playtube.exe`**: laedt das `.zip`-Asset des Release herunter und ersetzt
nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App
danach selbst neu.
- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket**
herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt
~200 MB) und ersetzt ausschliesslich diese - der riesige PySide6/QtWebEngine-
Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen normalen
Patch-Releases nicht aendert. Nur wenn kein Patch-Paket verfuegbar ist (z.B. beim
allerersten Release oder nach einem Wechsel der PySide6-Version), wird automatisch
auf das volle Release-Paket zurueckgefallen und der komplette Installationsordner
ersetzt. Die App startet sich in beiden Faellen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
requirements.txt` aus und startet den Python-Prozess neu.
Auto-Update laesst sich in `config.json` unter `updates.enabled` deaktivieren.
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren.
### Eine neue Version veroeffentlichen
@@ -133,7 +143,8 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden.
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
Entwicklungsmodus) loeschen und neu anmelden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube"
__version__ = "1.0.1"
__version__ = "2.1.0"
+11 -2
View File
@@ -10,6 +10,13 @@ from typing import Any
APP_NAME = "Playtube"
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
# denselben Browser-Profil-Lock geteilt haben).
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
DEFAULT_CONFIG: dict[str, Any] = {
"app_name": APP_NAME,
"discord": {
@@ -32,9 +39,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
def _app_data_dir() -> Path:
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil."""
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe
stabil. Entwicklungsmodus und gepackte .exe nutzen bewusst unterschiedliche
Ordner (siehe _DATA_DIR_NAME)."""
base = os.environ.get("APPDATA") or str(Path.home())
d = Path(base) / APP_NAME
d = Path(base) / _DATA_DIR_NAME
d.mkdir(parents=True, exist_ok=True)
return d
+32 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import os
import queue
import re
import time
from typing import Any
@@ -16,6 +17,11 @@ from PySide6.QtCore import QThread
from .config import APP_NAME
try:
from pypresence.types import ActivityType
except ImportError: # pypresence fehlt -> Discord-Feature bleibt einfach aus
ActivityType = None
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
# einfach kein Bild an - es gibt keinen Fehler.
@@ -37,6 +43,17 @@ def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
return text
_THUMBNAIL_SIZE_RE = re.compile(r"=w\d+-h\d+(-[a-z0-9-]*)?$", re.IGNORECASE)
def _upsize_thumbnail(url: str | None) -> str | None:
"""YouTube-Music-Player-Bar-Thumbnails kommen sehr klein (z.B. '=w60-h60-l90-rj').
Fuer eine scharfe Darstellung in Discord die Groesse im URL-Suffix hochsetzen."""
if not url:
return url
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
Medien-Informationen."""
@@ -46,10 +63,24 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
default_state = "YouTube Music" if is_music else "YouTube"
state = _truncate(info.get("subtitle"), fallback=default_state)
thumbnail = info.get("thumbnail")
if isinstance(thumbnail, str) and thumbnail.startswith("http"):
# Discord akzeptiert fuer large_image auch direkte externe Bild-URLs (nicht nur
# vorab hochgeladene Asset-Keys) - so zeigt Discord das echte Video-/Cover-Bild
# statt eines statischen Logos.
large_image = _upsize_thumbnail(thumbnail) if is_music else thumbnail
else:
large_image = ASSET_MUSIC if is_music else ASSET_YOUTUBE
payload: dict[str, Any] = {
# LISTENING = Hoert, WATCHING = Schaut - statt des Default-Typs PLAYING
# (Spielt). Muss ein ActivityType-Enum-Member sein, kein rohes int - pypresence
# ruft intern .value darauf auf.
"activity_type": ActivityType.LISTENING if is_music else ActivityType.WATCHING,
"instance": False,
"details": title,
"state": state,
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE,
"large_image": large_image,
"large_text": "YouTube Music" if is_music else "YouTube",
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
"small_text": "Spielt" if playing else "Pausiert",
+37 -4
View File
@@ -52,6 +52,11 @@ class MainWindow(QMainWindow):
self._tabs.addTab(self._settings_tab, "Einstellungen")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
self._settings_tab.installUpdateRequested.connect(
lambda: self._start_update_install(self._pending_download_url)
)
self._pending_download_url = ""
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
@@ -263,13 +268,30 @@ class MainWindow(QMainWindow):
def _check_for_updates(self) -> None:
if self._update_checker is not None and self._update_checker.isRunning():
return
self._settings_tab.set_checking()
self._update_checker = UpdateChecker(self)
self._update_checker.updateAvailable.connect(self._on_update_available)
self._update_checker.upToDate.connect(self._on_update_up_to_date)
self._update_checker.checkFailed.connect(self._on_update_check_failed)
self._update_checker.start()
def _on_update_up_to_date(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(f"Du hast bereits die neueste Version (v{APP_VERSION}).")
def _on_update_check_failed(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(
"Update-Prüfung fehlgeschlagen (keine Internetverbindung oder GitHub nicht erreichbar)."
)
def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
self._settings_tab.set_check_done()
self._pending_download_url = download_url
self._settings_tab.show_update_available(version)
if self._pending_update_version == version:
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt
return # Dialog fuer diese Version wurde schon einmal gezeigt
self._pending_update_version = version
self._show_and_raise()
@@ -290,16 +312,25 @@ class MainWindow(QMainWindow):
self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None:
if not download_url:
return
self._settings_tab.hide_install_button()
self._settings_tab.set_update_status("Lade Update herunter …")
self._settings_tab.set_download_progress(0)
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self)
self._update_installer.progress.connect(
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
)
self._update_installer.progress.connect(self._on_install_progress_text)
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start()
def _on_install_progress_text(self, msg: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg)
def _on_update_finished(self) -> None:
self._settings_tab.set_download_progress(100)
if getattr(sys, "frozen", False):
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
@@ -314,6 +345,8 @@ class MainWindow(QMainWindow):
def _on_update_failed(self, error: str) -> None:
self._tray.setToolTip(APP_NAME)
self._settings_tab.set_progress_hidden()
self._settings_tab.set_update_status(f"Update fehlgeschlagen: {error}")
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
def _restart_dev_process(self) -> None:
+25 -3
View File
@@ -7,15 +7,37 @@ MEDIA_PROBE_JS = r"""
var el = document.querySelector(sel);
return el ? el.textContent.trim() : null;
}
// Wichtig: getAttribute('src') statt .src - bei einem noch nicht (nach-)geladenen
// <img> (leeres/fehlendes src-Attribut, z.B. waehrend Lazy-Loading) loest die .src
// Property faelschlich auf die aktuelle Seiten-URL auf statt null/"" zu liefern.
function imgSrc(sel) {
var el = document.querySelector(sel);
var raw = el ? el.getAttribute('src') : null;
return (raw && /^https?:\/\//i.test(raw)) ? raw : null;
}
// Video-ID aus der URL (?v=... Parameter) - damit laesst sich die Thumbnail-URL
// ueber YouTubes CDN immer zuverlaessig selbst bauen, unabhaengig davon, ob
// gerade ein passendes <img>/<link> im DOM zu finden ist (das <link
// rel="image_src">, auf das sich der Code frueher verlassen hat, fehlt auf
// vielen aktuellen YouTube-Seiten schlicht).
function videoIdFromUrl(url) {
try {
return new URL(url).searchParams.get('v');
} catch (e) { return null; }
}
function ytThumbnailUrl(videoId) {
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
}
var video = document.querySelector('video');
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null;
if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img');
thumbnail = imgM ? imgM.src : null;
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
} else {
var t = document.title.replace(/ - YouTube$/, '');
title = t || null;
@@ -24,7 +46,7 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a');
var imgY = document.querySelector('link[rel="image_src"]');
thumbnail = imgY ? imgY.href : null;
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
}
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
+70 -2
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QSpinBox,
QVBoxLayout,
@@ -30,6 +31,8 @@ class SettingsTab(QWidget):
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
settingsSaved = Signal(dict)
checkUpdatesRequested = Signal()
installUpdateRequested = Signal()
def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent)
@@ -39,9 +42,15 @@ class SettingsTab(QWidget):
outer.setContentsMargins(32, 28, 32, 28)
outer.setSpacing(18)
header = QVBoxLayout()
header.setSpacing(2)
title = QLabel(f"{APP_NAME}-Einstellungen")
title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title)
header.addWidget(title)
version_label = QLabel(f"Version {APP_VERSION}")
version_label.setStyleSheet("color: palette(mid);")
header.addWidget(version_label)
outer.addLayout(header)
discord_cfg = config.get("discord", {})
discord_box = QGroupBox("Discord Rich Presence")
@@ -73,6 +82,29 @@ class SettingsTab(QWidget):
self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6)))
update_form.addRow(self._updates_enabled)
update_form.addRow("Prüfintervall:", self._check_interval)
update_actions = QHBoxLayout()
self._check_updates_btn = QPushButton("Jetzt nach Updates suchen")
self._check_updates_btn.clicked.connect(self.checkUpdatesRequested.emit)
update_actions.addWidget(self._check_updates_btn)
self._install_update_btn = QPushButton("Update installieren")
self._install_update_btn.setVisible(False)
self._install_update_btn.clicked.connect(self.installUpdateRequested.emit)
update_actions.addWidget(self._install_update_btn)
update_actions.addStretch(1)
update_form.addRow(update_actions)
self._update_status_label = QLabel("")
self._update_status_label.setStyleSheet("color: palette(mid);")
self._update_status_label.setWordWrap(True)
update_form.addRow(self._update_status_label)
self._update_progress = QProgressBar()
self._update_progress.setRange(0, 100)
self._update_progress.setTextVisible(True)
self._update_progress.setVisible(False)
update_form.addRow(self._update_progress)
outer.addWidget(update_box)
general_box = QGroupBox("Allgemein")
@@ -92,7 +124,7 @@ class SettingsTab(QWidget):
button_row.addStretch(1)
outer.addLayout(button_row)
info = QLabel(f"{APP_NAME} v{APP_VERSION} · github.com/{GITHUB_REPO}")
info = QLabel(f"github.com/{GITHUB_REPO}")
info.setStyleSheet("color: palette(mid);")
outer.addWidget(info)
@@ -120,3 +152,39 @@ class SettingsTab(QWidget):
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
"mit den neuen Werten neu gestartet).",
)
# ----------------------------------------------------- Update-Status (von MainWindow)
def set_checking(self) -> None:
self._check_updates_btn.setEnabled(False)
self._install_update_btn.setVisible(False)
self._update_progress.setVisible(False)
self._update_status_label.setText("Suche nach Updates …")
def set_check_done(self) -> None:
self._check_updates_btn.setEnabled(True)
def set_update_status(self, text: str) -> None:
self._update_status_label.setText(text)
def show_update_available(self, version: str) -> None:
self._update_status_label.setText(f"Neue Version verfügbar: {version}")
self._install_update_btn.setVisible(True)
def hide_install_button(self) -> None:
self._install_update_btn.setVisible(False)
def set_download_progress(self, percent: int) -> None:
"""percent: 0-100 fuer einen konkreten Fortschritt, -1 fuer unbestimmt
(z.B. waehrend Entpacken/Installieren), negativ->versteckt den Balken nicht
automatisch - dafuer set_progress_hidden() aufrufen."""
if percent < 0:
self._update_progress.setRange(0, 0) # "laufender" Balken ohne Prozentzahl
else:
self._update_progress.setRange(0, 100)
self._update_progress.setValue(percent)
self._update_progress.setVisible(True)
def set_progress_hidden(self) -> None:
self._update_progress.setVisible(False)
self._update_progress.setRange(0, 100)
+129 -35
View File
@@ -5,11 +5,16 @@ Ablauf:
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt das
zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es
und laesst ein kurzes Skript (nach Prozessende) den Installationsordner
ersetzen und die App neu starten - PowerShell+robocopy unter Windows,
ein Shell-Skript unter Linux.
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
Ordner (_internal/) bleibt unangetastet, da er sich zwischen Patch-Releases
normalerweise nicht aendert. Gibt es kein Patch-Paket (z.B. beim allerersten
Release oder wenn CI es nicht gebaut hat), faellt es automatisch auf das
volle Release-Paket zurueck und ersetzt den kompletten Installationsordner.
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu -
PowerShell unter Windows, ein Shell-Skript unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
"""
@@ -17,7 +22,6 @@ from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
import sys
@@ -66,8 +70,9 @@ def fetch_latest_release() -> dict[str, Any] | None:
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket:
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen."""
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
assets = release.get("assets", [])
if sys.platform == "win32":
hints, exts = ("win",), (".zip",)
@@ -76,14 +81,24 @@ def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
else:
return None
for asset in assets:
name = asset.get("name", "").lower()
if name.endswith(exts) and any(h in name for h in hints):
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
for asset in assets:
name = asset.get("name", "").lower()
if not name.endswith(exts):
continue
if require_hint and not any(h in name for h in hints):
continue
if ("patch" in name) != want_patch:
continue
return asset
for asset in assets:
if asset.get("name", "").lower().endswith(exts):
return asset
return None
return None
return (
find(True, True)
or find(True, False)
or find(False, True)
or find(False, False)
)
class UpdateChecker(QThread):
@@ -112,6 +127,9 @@ class UpdateInstaller(QThread):
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
progress = Signal(str)
# Download-Fortschritt in Prozent (0-100); -1 = unbestimmt (z.B. waehrend
# Entpacken/Installieren, wo sich kein Prozentsatz sinnvoll angeben laesst).
progress_percent = Signal(int)
finished_ok = Signal()
failed = Signal(str)
@@ -138,6 +156,7 @@ class UpdateInstaller(QThread):
return
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
archive_name = self._download_url.rsplit("/", 1)[-1]
@@ -145,10 +164,28 @@ class UpdateInstaller(QThread):
extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out:
shutil.copyfileobj(resp, out)
with urllib.request.urlopen(req, timeout=120) as resp:
total = int(resp.headers.get("Content-Length") or 0)
downloaded = 0
last_emitted = -1
with open(archive_path, "wb") as out:
while True:
chunk = resp.read(256 * 1024)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
if total:
percent = int(downloaded * 100 / total)
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
self.progress_percent.emit(-1)
if archive_name.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir)
@@ -156,20 +193,69 @@ class UpdateInstaller(QThread):
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir)
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
is_patch = "patch" in archive_name.lower()
self.progress.emit("Bereite Installation vor …")
if sys.platform == "win32":
self._install_windows(source_dir, install_dir, staging)
if is_patch:
binary_name = _WINDOWS_BINARY_NAME if sys.platform == "win32" else _LINUX_BINARY_NAME
matches = list(extract_dir.rglob(binary_name))
if not matches:
# Unerwartet leeres/falsches Patch-Paket -> nicht kaputt installieren,
# lieber sauber fehlschlagen und den Nutzer auf das volle Paket
# hinweisen (naechster Check faellt automatisch darauf zurueck).
self.failed.emit(
f"Patch-Paket enthielt kein '{binary_name}'. Bitte erneut versuchen."
)
return
if sys.platform == "win32":
self._install_patch_windows(matches[0], install_dir, staging)
else:
self._install_patch_posix(matches[0], install_dir, staging)
else:
self._install_posix(source_dir, install_dir, staging)
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
if sys.platform == "win32":
self._install_full_windows(source_dir, install_dir, staging)
else:
self._install_full_posix(source_dir, install_dir, staging)
self.finished_ok.emit()
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy --
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500
}}
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
self._spawn_windows_script(script, staging)
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh
while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5
done
cp -f "{new_binary}" "{exe_path}"
chmod +x "{exe_path}"
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
self._spawn_posix_script(script, staging)
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f"""
$ErrorActionPreference = "SilentlyContinue"
@@ -183,16 +269,9 @@ Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8")
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
self._spawn_windows_script(script, staging)
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a --
def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh
while kill -0 {os.getpid()} 2>/dev/null; do
@@ -204,6 +283,21 @@ chmod +x "{exe_path}"
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
self._spawn_posix_script(script, staging)
# -- Skript-Ausfuehrung: PowerShell (wartet+robocopy/Copy) unter Windows, Shell
# (wartet+cp) unter Linux; wird detached gestartet, damit es diesen Prozess
# ueberlebt, wenn dieser sich gleich beendet. --
def _spawn_windows_script(self, script: str, staging: Path) -> None:
script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8")
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
def _spawn_posix_script(self, script: str, staging: Path) -> None:
script_path = staging / "apply_update.sh"
script_path.write_text(script, encoding="utf-8")
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)