Einstellungen-Tab, plattformuebergreifendes Update/Branding, CI-Release-Pipeline

- Settings-Tab in der App (Discord/Update/Start-Tab live editierbar, kein config.json-Handbearbeiten noetig)
- updater.py: plattformabhaengige Asset-Auswahl + Installation (Windows .zip/robocopy, Linux .tar.gz/Shell-Skript)
- browser.py/chrome_shim.py: Sec-CH-UA + navigator.userAgentData jetzt je nach sys.platform (Windows/Linux/macOS)
- app_id.py: robustere Suche nach umbenanntem QtWebEngine-Hilfsprozess (rglob statt fixer Pfade)
- packaging/playtube.spec: icon/version nur unter Windows setzen (Linux-Build faehig)
- packaging/install-linux.sh: Installationsskript fuer native Linux-Version (Desktop-Eintrag, Icon, PATH)
- .github/workflows/release.yml: baut bei Tag-Push automatisch Windows-.exe UND Linux-Binary und veroeffentlicht beide als GitHub Release

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-11 19:34:45 +02:00
co-authored by Claude Sonnet 5
parent 578cda499a
commit 0e6b580018
11 changed files with 459 additions and 109 deletions
+93
View File
@@ -0,0 +1,93 @@
name: Release
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch: {}
permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Abhaengigkeiten installieren
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Playtube.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .zip packen
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4
with:
name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \
libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \
libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \
libxfixes3 libxi6 libxext6 fonts-liberation
- name: Abhaengigkeiten installieren
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Playtube-Binary bauen
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .tar.gz packen
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-build
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
publish-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: GitHub Release veroeffentlichen
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
+1 -1
View File
@@ -3,7 +3,7 @@
"discord": {
"enabled": true,
"client_id": "1544058029190938774",
"update_interval_seconds": 15,
"update_interval_seconds": 5,
"show_idle_presence": true
},
"updates": {
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Installiert eine entpackte Playtube-Linux-Version (aus dem Playtube-vX.Y.Z-linux-x86_64.tar.gz
# Release-Paket) als "richtige" Desktop-App: eigener Ordner unter ~/.local/share/Playtube,
# Startmenue-Eintrag mit Icon, Kommandozeilen-Befehl "playtube".
#
# Aufruf: im entpackten Ordner (der die Datei "Playtube" enthaelt) ausfuehren:
# sh install-linux.sh
set -e
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="$HOME/.local/share/Playtube"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
if [ ! -f "$SRC_DIR/Playtube" ]; then
echo "Fehler: $SRC_DIR/Playtube nicht gefunden - bitte im entpackten Release-Ordner ausfuehren." >&2
exit 1
fi
echo "==> Installiere nach $INSTALL_DIR ..."
mkdir -p "$INSTALL_DIR"
cp -a "$SRC_DIR"/. "$INSTALL_DIR"/
chmod +x "$INSTALL_DIR/Playtube"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/Playtube" "$BIN_DIR/playtube"
mkdir -p "$ICON_DIR"
if [ -f "$INSTALL_DIR/assets/icon.png" ]; then
cp -f "$INSTALL_DIR/assets/icon.png" "$ICON_DIR/playtube.png"
fi
mkdir -p "$DESKTOP_DIR"
cat > "$DESKTOP_DIR/playtube.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Playtube
Comment=YouTube & YouTube Music mit Discord Rich Presence
Exec=$INSTALL_DIR/Playtube
Icon=playtube
Terminal=false
Categories=AudioVideo;Audio;Video;Network;
StartupWMClass=Playtube
EOF
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
echo ""
echo "Fertig! Playtube ist jetzt im Anwendungsmenue verfuegbar,"
echo "oder direkt per Terminal-Befehl 'playtube' startbar"
echo "(ggf. $BIN_DIR zum PATH hinzufuegen, falls das nicht klappt)."
+8 -6
View File
@@ -25,10 +25,9 @@ a = Analysis(
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
# icon/version-Ressourcen sind Windows-spezifisch (.ico + Versionsressource) - unter
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
exe_kwargs = dict(
exclude_binaries=True,
name="Playtube",
debug=False,
@@ -36,9 +35,12 @@ exe = EXE(
strip=False,
upx=False,
console=False,
icon=str(ROOT / "assets" / "icon.ico"),
version=str(ROOT / "packaging" / "version_info.txt"),
)
if sys.platform == "win32":
exe_kwargs["icon"] = str(ROOT / "assets" / "icon.ico")
exe_kwargs["version"] = str(ROOT / "packaging" / "version_info.txt")
exe = EXE(pyz, a.scripts, [], **exe_kwargs)
coll = COLLECT(
exe,
+11 -46
View File
@@ -1,21 +1,17 @@
# Baut eine neue Playtube-Version, taggt und veroeffentlicht sie als GitHub Release
# unter https://github.com/fojadrachi/Playtube - Playtube.exe erkennt neue Releases
# danach automatisch (siehe playtube/updater.py) und bietet dem Nutzer die Installation
# per Update-Dialog an.
# Veroeffentlicht eine neue Playtube-Version: setzt die Versionsnummer, committet,
# taggt und pusht. Der eigentliche Build fuer Windows UND Linux sowie die
# Veroeffentlichung als GitHub Release passiert danach automatisch per GitHub Actions
# (.github/workflows/release.yml), ausgeloest durch den gepushten Tag.
#
# Aufruf (im Projekt-Root, mit aktivierter venv):
# Aufruf (im Projekt-Root):
# powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
#
# Voraussetzungen:
# - git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube
# - Entweder die GitHub-CLI "gh" ist installiert und eingeloggt (gh auth login),
# oder die Umgebungsvariable GITHUB_TOKEN enthaelt ein Personal Access Token mit
# "repo"-Rechten (dann wird die GitHub REST API direkt per curl angesprochen).
# Voraussetzung: git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube
# und du bist dort push-berechtigt.
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$Notes = ""
[string]$Version
)
$ErrorActionPreference = "Stop"
@@ -31,19 +27,6 @@ Write-Host "==> Setze Version auf $Version in playtube/__init__.py" -ForegroundC
$initFile = Join-Path $root "playtube\__init__.py"
(Get-Content $initFile) -replace '__version__ = ".*"', "__version__ = `"$Version`"" | Set-Content $initFile -Encoding utf8
Write-Host "==> Baue Playtube.exe ..." -ForegroundColor Cyan
& (Join-Path $PSScriptRoot "build.ps1")
$distDir = Join-Path $root "dist\Playtube"
if (-not (Test-Path $distDir)) { throw "Build fehlgeschlagen: $distDir nicht gefunden." }
$zipName = "Playtube-$tag-win64.zip"
$zipPath = Join-Path $root "dist\$zipName"
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
Write-Host "==> Packe $zipName ..." -ForegroundColor Cyan
Compress-Archive -Path $distDir -DestinationPath $zipPath
Write-Host "==> Commit + Tag $tag ..." -ForegroundColor Cyan
git add -A
git commit -m "Release $tag" --allow-empty
@@ -53,25 +36,7 @@ Write-Host "==> Push zu origin ..." -ForegroundColor Cyan
git push origin HEAD
git push origin $tag
$gh = Get-Command gh -ErrorAction SilentlyContinue
if ($gh) {
Write-Host "==> Erstelle GitHub Release mit gh CLI ..." -ForegroundColor Cyan
if ([string]::IsNullOrWhiteSpace($Notes)) { $Notes = "Playtube $tag" }
gh release create $tag $zipPath --title "Playtube $Version" --notes $Notes
} elseif ($env:GITHUB_TOKEN) {
Write-Host "==> Erstelle GitHub Release ueber die REST API ..." -ForegroundColor Cyan
$repo = "fojadrachi/Playtube"
$body = @{ tag_name = $tag; name = "Playtube $Version"; body = $(if ($Notes) { $Notes } else { "Playtube $tag" }) } | ConvertTo-Json
$headers = @{ Authorization = "Bearer $($env:GITHUB_TOKEN)"; Accept = "application/vnd.github+json" }
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases" -Method Post -Headers $headers -Body $body -ContentType "application/json"
$uploadUrl = $release.upload_url -replace '\{\?name,label\}', "?name=$zipName"
Invoke-RestMethod -Uri $uploadUrl -Method Post -Headers ($headers + @{ "Content-Type" = "application/zip" }) -InFile $zipPath | Out-Null
Write-Host "Release veroeffentlicht: $($release.html_url)" -ForegroundColor Green
} else {
Write-Warning "Weder 'gh' CLI noch GITHUB_TOKEN gefunden - Tag wurde gepusht, aber kein GitHub Release erstellt."
Write-Warning "Entweder 'winget install GitHub.cli' + 'gh auth login' ausfuehren und dieses Skript erneut starten,"
Write-Warning "oder manuell unter https://github.com/fojadrachi/Playtube/releases/new ein Release fuer Tag '$tag' anlegen und '$zipPath' als Asset anhaengen."
}
Write-Host ""
Write-Host "Fertig." -ForegroundColor Green
Write-Host "Fertig. GitHub Actions baut jetzt Windows- und Linux-Pakete und" -ForegroundColor Green
Write-Host "veroeffentlicht sie als Release $tag - Fortschritt unter:" -ForegroundColor Green
Write-Host "https://github.com/fojadrachi/Playtube/actions" -ForegroundColor Green
+7 -8
View File
@@ -47,11 +47,10 @@ def configure_webengine_process_path() -> None:
return
exe_dir = Path(sys.executable).resolve().parent
candidates = [
exe_dir / "PlaytubeHelper.exe",
exe_dir / "_internal" / "PlaytubeHelper.exe",
]
for candidate in candidates:
if candidate.exists():
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
return
# PyInstaller legt PySide6 je nach Version unterschiedlich tief in _internal ab
# (z.B. _internal\PySide6\), daher rekursiv suchen statt feste Pfade anzunehmen.
try:
candidate = next(exe_dir.rglob("PlaytubeHelper.exe"))
except StopIteration:
return
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
+18 -3
View File
@@ -3,6 +3,8 @@ Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Ausles
der aktuellen Wiedergabe fuer Discord Rich Presence."""
from __future__ import annotations
import sys
from PySide6.QtCore import QTimer, Signal, QUrl
from PySide6.QtWebEngineCore import (
QWebEnginePage,
@@ -27,8 +29,21 @@ from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PL
_CHROME_VERSION = CHROME_FULL
_CHROME_MAJOR = CHROME_MAJOR
if sys.platform == "win32":
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
_SEC_CH_UA_PLATFORM = b'"Windows"'
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
elif sys.platform.startswith("linux"):
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
_SEC_CH_UA_PLATFORM = b'"Linux"'
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
else:
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
_SEC_CH_UA_PLATFORM = b'"macOS"'
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
)
@@ -52,8 +67,8 @@ class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA)
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST)
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0")
info.setHttpHeader(b"sec-ch-ua-platform", b'"Windows"')
info.setHttpHeader(b"sec-ch-ua-platform-version", b'"15.0.0"')
info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM)
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
_shared_profile: QWebEngineProfile | None = None
+15 -3
View File
@@ -21,9 +21,21 @@ Nutzer lediglich, sich in der eigenen App mit dem eigenen Google-Konto anzumelde
so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist.
"""
import sys
CHROME_MAJOR = "140"
CHROME_FULL = "140.0.0.0"
if sys.platform == "win32":
_JS_PLATFORM = "Windows"
_JS_PLATFORM_VERSION = "15.0.0"
elif sys.platform.startswith("linux"):
_JS_PLATFORM = "Linux"
_JS_PLATFORM_VERSION = "6.0.0"
else:
_JS_PLATFORM = "macOS"
_JS_PLATFORM_VERSION = "14.0.0"
CHROME_SHIM_JS = f"""
(function() {{
try {{
@@ -69,12 +81,12 @@ CHROME_SHIM_JS = f"""
var uaData = {{
brands: brands,
mobile: false,
platform: 'Windows',
platform: '{_JS_PLATFORM}',
getHighEntropyValues: function(hints) {{
var full = {{
architecture: 'x86', bitness: '64', brands: brands,
fullVersionList: fullBrands, mobile: false, model: '',
platform: 'Windows', platformVersion: '15.0.0',
platform: '{_JS_PLATFORM}', platformVersion: '{_JS_PLATFORM_VERSION}',
uaFullVersion: '{CHROME_FULL}', wow64: false
}};
var result = {{}};
@@ -83,7 +95,7 @@ CHROME_SHIM_JS = f"""
}});
return Promise.resolve(result);
}},
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: 'Windows' }}; }}
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: '{_JS_PLATFORM}' }}; }}
}};
Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }});
}} catch (e) {{}}
+58 -19
View File
@@ -22,6 +22,7 @@ from . import __version__ as APP_VERSION
from .browser import BrowserTab
from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker
from .settings_tab import SettingsTab
from .updater import UpdateChecker, UpdateInstaller
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
@@ -45,9 +46,12 @@ class MainWindow(QMainWindow):
self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], self)
self._settings_tab = SettingsTab(config, self)
self._tabs.addTab(self._youtube_tab, "YouTube")
self._tabs.addTab(self._music_tab, "YouTube Music")
self._tabs.addTab(self._settings_tab, "Einstellungen")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
@@ -58,14 +62,7 @@ class MainWindow(QMainWindow):
self._build_tray()
self._rpc_worker: DiscordRPCWorker | None = None
discord_cfg = config.get("discord", {})
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
self._rpc_worker = DiscordRPCWorker(
client_id=str(discord_cfg["client_id"]),
interval=discord_cfg.get("update_interval_seconds", 15),
show_idle=discord_cfg.get("show_idle_presence", True),
)
self._rpc_worker.start()
self._start_discord_worker()
self._update_checker: UpdateChecker | None = None
self._update_installer: UpdateInstaller | None = None
@@ -80,19 +77,19 @@ class MainWindow(QMainWindow):
self.addToolBar(toolbar)
back_action = QAction("◀", self)
back_action.triggered.connect(lambda: self._current_tab().back())
back_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.back()))
toolbar.addAction(back_action)
forward_action = QAction("▶", self)
forward_action.triggered.connect(lambda: self._current_tab().forward())
forward_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.forward()))
toolbar.addAction(forward_action)
reload_action = QAction("⟳", self)
reload_action.triggered.connect(lambda: self._current_tab().reload())
reload_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.reload()))
toolbar.addAction(reload_action)
home_action = QAction("⌂", self)
home_action.triggered.connect(lambda: self._current_tab().go_home())
home_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.go_home()))
toolbar.addAction(home_action)
toolbar.addSeparator()
@@ -117,13 +114,13 @@ class MainWindow(QMainWindow):
show_action.triggered.connect(self._show_and_raise)
play_pause_action = menu.addAction("Wiedergabe umschalten")
play_pause_action.triggered.connect(lambda: self._current_tab().toggle_playback())
play_pause_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.toggle_playback()))
next_action = menu.addAction("Nächster Titel")
next_action.triggered.connect(lambda: self._current_tab().next_track())
next_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.next_track()))
prev_action = menu.addAction("Vorheriger Titel")
prev_action.triggered.connect(lambda: self._current_tab().previous_track())
prev_action.triggered.connect(lambda: self._with_browser_tab(lambda t: t.previous_track()))
menu.addSeparator()
quit_action = menu.addAction("Beenden")
@@ -135,25 +132,39 @@ class MainWindow(QMainWindow):
# --------------------------------------------------------------- Slots
def _current_tab(self) -> BrowserTab:
def _current_tab(self) -> QWidget:
return self._tabs.currentWidget()
def _current_browser_tab(self) -> BrowserTab | None:
tab = self._tabs.currentWidget()
return tab if isinstance(tab, BrowserTab) else None
def _with_browser_tab(self, fn) -> None:
"""Fuehrt fn(tab) nur aus, wenn der aktive Tab ein Browser-Tab ist (nicht
die Einstellungen)."""
tab = self._current_browser_tab()
if tab is not None:
fn(tab)
def _navigate_to_url_bar(self) -> None:
text = self._url_bar.text().strip()
if not text:
tab = self._current_browser_tab()
if not text or tab is None:
return
if " " in text or ("." not in text and "://" not in text):
url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode())
else:
url = QUrl.fromUserInput(text)
self._current_tab().setUrl(url)
tab.setUrl(url)
def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None:
if self._current_tab() is tab:
self._url_bar.setText(url.toString())
def _on_tab_changed(self, index: int) -> None:
self._url_bar.setText(self._current_tab().url().toString())
tab = self._current_browser_tab()
self._url_bar.setEnabled(tab is not None)
self._url_bar.setText(tab.url().toString() if tab is not None else "")
def _on_media_info(self, info: dict) -> None:
sender = self.sender()
@@ -175,6 +186,27 @@ class MainWindow(QMainWindow):
else:
self._rpc_worker.submit_media_info(None)
def _start_discord_worker(self) -> None:
discord_cfg = self._config.get("discord", {})
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
self._rpc_worker = DiscordRPCWorker(
client_id=str(discord_cfg["client_id"]),
interval=discord_cfg.get("update_interval_seconds", 15),
show_idle=discord_cfg.get("show_idle_presence", True),
)
self._rpc_worker.start()
def _on_settings_saved(self, new_config: dict) -> None:
self._config = new_config
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
self._rpc_worker = None
self._start_discord_worker()
self._setup_auto_update()
def _on_tray_activated(self, reason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._show_and_raise()
@@ -209,6 +241,13 @@ class MainWindow(QMainWindow):
# -------------------------------------------------------------- Auto-Update
def _setup_auto_update(self) -> None:
"""(Re-)Konfiguriert die Auto-Update-Pruefung - sicher mehrfach aufrufbar,
z.B. nach Aenderungen im Einstellungen-Tab."""
if getattr(self, "_update_timer", None) is not None:
self._update_timer.stop()
self._update_timer.deleteLater()
self._update_timer = None
updates_cfg = self._config.get("updates", {})
if not updates_cfg.get("enabled", True):
return
+122
View File
@@ -0,0 +1,122 @@
"""Einstellungen-Tab: Discord-RPC, Auto-Update und Start-Tab direkt in der App
bearbeitbar, ohne config.json von Hand anfassen zu muessen."""
from __future__ import annotations
from typing import Any
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from . import __version__ as APP_VERSION
from .config import APP_NAME, save_config
from .updater import GITHUB_REPO
class SettingsTab(QWidget):
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
settingsSaved = Signal(dict)
def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent)
self._config = config
outer = QVBoxLayout(self)
outer.setContentsMargins(32, 28, 32, 28)
outer.setSpacing(18)
title = QLabel(f"{APP_NAME}-Einstellungen")
title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title)
discord_cfg = config.get("discord", {})
discord_box = QGroupBox("Discord Rich Presence")
discord_form = QFormLayout(discord_box)
self._discord_enabled = QCheckBox("Aktiviert")
self._discord_enabled.setChecked(bool(discord_cfg.get("enabled", True)))
self._client_id = QLineEdit(str(discord_cfg.get("client_id", "")))
self._client_id.setPlaceholderText("Discord Application Client-ID")
self._discord_interval = QSpinBox()
self._discord_interval.setRange(5, 120)
self._discord_interval.setSuffix(" s")
self._discord_interval.setValue(int(discord_cfg.get("update_interval_seconds", 15)))
self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft")
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
discord_form.addRow(self._discord_enabled)
discord_form.addRow("Client-ID:", self._client_id)
discord_form.addRow("Update-Intervall:", self._discord_interval)
discord_form.addRow(self._show_idle)
outer.addWidget(discord_box)
updates_cfg = config.get("updates", {})
update_box = QGroupBox("Automatische Updates")
update_form = QFormLayout(update_box)
self._updates_enabled = QCheckBox("Aktiviert")
self._updates_enabled.setChecked(bool(updates_cfg.get("enabled", True)))
self._check_interval = QSpinBox()
self._check_interval.setRange(1, 168)
self._check_interval.setSuffix(" h")
self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6)))
update_form.addRow(self._updates_enabled)
update_form.addRow("Prüfintervall:", self._check_interval)
outer.addWidget(update_box)
general_box = QGroupBox("Allgemein")
general_form = QFormLayout(general_box)
self._start_tab = QComboBox()
self._start_tab.addItem("YouTube", "youtube")
self._start_tab.addItem("YouTube Music", "music")
idx = self._start_tab.findData(config.get("start_tab", "youtube"))
self._start_tab.setCurrentIndex(max(0, idx))
general_form.addRow("Beim Start öffnen:", self._start_tab)
outer.addWidget(general_box)
button_row = QHBoxLayout()
save_btn = QPushButton("Speichern")
save_btn.clicked.connect(self._on_save)
button_row.addWidget(save_btn)
button_row.addStretch(1)
outer.addLayout(button_row)
info = QLabel(f"{APP_NAME} v{APP_VERSION} · github.com/{GITHUB_REPO}")
info.setStyleSheet("color: palette(mid);")
outer.addWidget(info)
outer.addStretch(1)
def _on_save(self) -> None:
self._config.setdefault("discord", {})
self._config.setdefault("updates", {})
self._config["discord"]["enabled"] = self._discord_enabled.isChecked()
self._config["discord"]["client_id"] = self._client_id.text().strip()
self._config["discord"]["update_interval_seconds"] = self._discord_interval.value()
self._config["discord"]["show_idle_presence"] = self._show_idle.isChecked()
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
self._config["start_tab"] = self._start_tab.currentData()
save_config(self._config)
self.settingsSaved.emit(self._config)
QMessageBox.information(
self,
"Gespeichert",
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
"mit den neuen Werten neu gestartet).",
)
+71 -22
View File
@@ -5,9 +5,11 @@ Ablauf:
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
- Gepackte Playtube.exe: laedt das Windows-Release-Zip herunter, entpackt es
und laesst ein kurzes PowerShell-Skript (nach Prozessende) den Installations-
ordner per robocopy /MIR ersetzen und die App neu starten.
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt das
zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es
und laesst ein kurzes Skript (nach Prozessende) den Installationsordner
ersetzen und die App neu starten - PowerShell+robocopy unter Windows,
ein Shell-Skript unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
"""
@@ -16,8 +18,10 @@ from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
@@ -33,6 +37,9 @@ GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
_USER_AGENT = "Playtube-Updater"
_LINUX_BINARY_NAME = "Playtube"
_WINDOWS_BINARY_NAME = "Playtube.exe"
def _parse_version(v: str) -> tuple[int, ...]:
v = v.strip().lstrip("vV")
@@ -58,14 +65,23 @@ def fetch_latest_release() -> dict[str, Any] | None:
return None
def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket:
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen."""
assets = release.get("assets", [])
if sys.platform == "win32":
hints, exts = ("win",), (".zip",)
elif sys.platform.startswith("linux"):
hints, exts = ("linux",), (".tar.gz", ".tgz")
else:
return None
for asset in assets:
name = asset.get("name", "").lower()
if name.endswith(".zip") and ("win" in name or "windows" in name):
if name.endswith(exts) and any(h in name for h in hints):
return asset
for asset in assets:
if asset.get("name", "").lower().endswith(".zip"):
if asset.get("name", "").lower().endswith(exts):
return asset
return None
@@ -73,7 +89,7 @@ def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version."""
updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url
updateAvailable = Signal(str, str, str) # version, release_notes, download_url
checkFailed = Signal()
upToDate = Signal()
@@ -86,7 +102,7 @@ class UpdateChecker(QThread):
if not tag or not is_newer(tag):
self.upToDate.emit()
return
asset = _find_windows_zip_asset(release)
asset = _find_platform_asset(release)
download_url = asset["browser_download_url"] if asset else ""
notes = (release.get("body") or "").strip()
self.updateAvailable.emit(tag, notes, download_url)
@@ -117,38 +133,44 @@ class UpdateInstaller(QThread):
def _run_packaged_update(self) -> None:
if not self._download_url:
self.failed.emit(
"Kein Windows-Release-Paket (.zip) im neuesten Release gefunden."
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
)
return
self.progress.emit("Lade Update herunter …")
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
zip_path = staging / "update.zip"
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, open(zip_path, "wb") as out:
with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out:
shutil.copyfileobj(resp, out)
self.progress.emit("Entpacke Update …")
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(extract_dir)
if archive_name.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir)
else:
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir)
# Manche Release-Zips enthalten einen einzelnen Unterordner (z.B. "Playtube/").
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
self.progress.emit("Bereite Installation vor …")
script_path = self._write_apply_script(source_dir, install_dir, staging)
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
if sys.platform == "win32":
self._install_windows(source_dir, install_dir, staging)
else:
self._install_posix(source_dir, install_dir, staging)
self.finished_ok.emit()
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path:
exe_path = install_dir / "Playtube.exe"
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy --
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
@@ -163,7 +185,34 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8")
return script_path
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a --
def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh
while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5
done
rm -rf "{install_dir}"/*
cp -a "{source_dir}"/. "{install_dir}"/
chmod +x "{exe_path}"
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
script_path = staging / "apply_update.sh"
script_path.write_text(script, encoding="utf-8")
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
subprocess.Popen(
["/bin/sh", str(script_path)],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# ------------------------------------------------------------- Entwicklungsmodus