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
+2 -2
View File
@@ -3,7 +3,7 @@
"discord": { "discord": {
"enabled": true, "enabled": true,
"client_id": "1544058029190938774", "client_id": "1544058029190938774",
"update_interval_seconds": 15, "update_interval_seconds": 5,
"show_idle_presence": true "show_idle_presence": true
}, },
"updates": { "updates": {
@@ -17,4 +17,4 @@
"width": 1366, "width": 1366,
"height": 860 "height": 860
} }
} }
+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) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE( # icon/version-Ressourcen sind Windows-spezifisch (.ico + Versionsressource) - unter
pyz, # Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
a.scripts, exe_kwargs = dict(
[],
exclude_binaries=True, exclude_binaries=True,
name="Playtube", name="Playtube",
debug=False, debug=False,
@@ -36,9 +35,12 @@ exe = EXE(
strip=False, strip=False,
upx=False, upx=False,
console=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( coll = COLLECT(
exe, exe,
+11 -46
View File
@@ -1,21 +1,17 @@
# Baut eine neue Playtube-Version, taggt und veroeffentlicht sie als GitHub Release # Veroeffentlicht eine neue Playtube-Version: setzt die Versionsnummer, committet,
# unter https://github.com/fojadrachi/Playtube - Playtube.exe erkennt neue Releases # taggt und pusht. Der eigentliche Build fuer Windows UND Linux sowie die
# danach automatisch (siehe playtube/updater.py) und bietet dem Nutzer die Installation # Veroeffentlichung als GitHub Release passiert danach automatisch per GitHub Actions
# per Update-Dialog an. # (.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 # powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
# #
# Voraussetzungen: # Voraussetzung: git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube
# - git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube # und du bist dort push-berechtigt.
# - 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).
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Version, [string]$Version
[string]$Notes = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -31,19 +27,6 @@ Write-Host "==> Setze Version auf $Version in playtube/__init__.py" -ForegroundC
$initFile = Join-Path $root "playtube\__init__.py" $initFile = Join-Path $root "playtube\__init__.py"
(Get-Content $initFile) -replace '__version__ = ".*"', "__version__ = `"$Version`"" | Set-Content $initFile -Encoding utf8 (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 Write-Host "==> Commit + Tag $tag ..." -ForegroundColor Cyan
git add -A git add -A
git commit -m "Release $tag" --allow-empty 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 HEAD
git push origin $tag 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 ""
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 return
exe_dir = Path(sys.executable).resolve().parent exe_dir = Path(sys.executable).resolve().parent
candidates = [ # PyInstaller legt PySide6 je nach Version unterschiedlich tief in _internal ab
exe_dir / "PlaytubeHelper.exe", # (z.B. _internal\PySide6\), daher rekursiv suchen statt feste Pfade anzunehmen.
exe_dir / "_internal" / "PlaytubeHelper.exe", try:
] candidate = next(exe_dir.rglob("PlaytubeHelper.exe"))
for candidate in candidates: except StopIteration:
if candidate.exists(): return
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate) os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
return
+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.""" der aktuellen Wiedergabe fuer Discord Rich Presence."""
from __future__ import annotations from __future__ import annotations
import sys
from PySide6.QtCore import QTimer, Signal, QUrl from PySide6.QtCore import QTimer, Signal, QUrl
from PySide6.QtWebEngineCore import ( from PySide6.QtWebEngineCore import (
QWebEnginePage, 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_VERSION = CHROME_FULL
_CHROME_MAJOR = CHROME_MAJOR _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 = ( _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" 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", _SEC_CH_UA)
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST) 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-mobile", b"?0")
info.setHttpHeader(b"sec-ch-ua-platform", b'"Windows"') info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM)
info.setHttpHeader(b"sec-ch-ua-platform-version", b'"15.0.0"') info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
_shared_profile: QWebEngineProfile | None = None _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. so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist.
""" """
import sys
CHROME_MAJOR = "140" CHROME_MAJOR = "140"
CHROME_FULL = "140.0.0.0" 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""" CHROME_SHIM_JS = f"""
(function() {{ (function() {{
try {{ try {{
@@ -69,12 +81,12 @@ CHROME_SHIM_JS = f"""
var uaData = {{ var uaData = {{
brands: brands, brands: brands,
mobile: false, mobile: false,
platform: 'Windows', platform: '{_JS_PLATFORM}',
getHighEntropyValues: function(hints) {{ getHighEntropyValues: function(hints) {{
var full = {{ var full = {{
architecture: 'x86', bitness: '64', brands: brands, architecture: 'x86', bitness: '64', brands: brands,
fullVersionList: fullBrands, mobile: false, model: '', fullVersionList: fullBrands, mobile: false, model: '',
platform: 'Windows', platformVersion: '15.0.0', platform: '{_JS_PLATFORM}', platformVersion: '{_JS_PLATFORM_VERSION}',
uaFullVersion: '{CHROME_FULL}', wow64: false uaFullVersion: '{CHROME_FULL}', wow64: false
}}; }};
var result = {{}}; var result = {{}};
@@ -83,7 +95,7 @@ CHROME_SHIM_JS = f"""
}}); }});
return Promise.resolve(result); 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 }}); Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }});
}} catch (e) {{}} }} catch (e) {{}}
+58 -19
View File
@@ -22,6 +22,7 @@ from . import __version__ as APP_VERSION
from .browser import BrowserTab from .browser import BrowserTab
from .config import APP_NAME from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker from .discord_rpc import DiscordRPCWorker
from .settings_tab import SettingsTab
from .updater import UpdateChecker, UpdateInstaller from .updater import UpdateChecker, UpdateInstaller
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
@@ -45,9 +46,12 @@ class MainWindow(QMainWindow):
self._youtube_tab = BrowserTab(config["home_youtube"], self) self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], 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._youtube_tab, "YouTube")
self._tabs.addTab(self._music_tab, "YouTube Music") 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._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._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)
@@ -58,14 +62,7 @@ class MainWindow(QMainWindow):
self._build_tray() self._build_tray()
self._rpc_worker: DiscordRPCWorker | None = None self._rpc_worker: DiscordRPCWorker | None = None
discord_cfg = config.get("discord", {}) self._start_discord_worker()
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._update_checker: UpdateChecker | None = None self._update_checker: UpdateChecker | None = None
self._update_installer: UpdateInstaller | None = None self._update_installer: UpdateInstaller | None = None
@@ -80,19 +77,19 @@ class MainWindow(QMainWindow):
self.addToolBar(toolbar) self.addToolBar(toolbar)
back_action = QAction("◀", self) 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) toolbar.addAction(back_action)
forward_action = QAction("▶", self) 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) toolbar.addAction(forward_action)
reload_action = QAction("⟳", self) 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) toolbar.addAction(reload_action)
home_action = QAction("⌂", self) 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.addAction(home_action)
toolbar.addSeparator() toolbar.addSeparator()
@@ -117,13 +114,13 @@ class MainWindow(QMainWindow):
show_action.triggered.connect(self._show_and_raise) show_action.triggered.connect(self._show_and_raise)
play_pause_action = menu.addAction("Wiedergabe umschalten") 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 = 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 = 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() menu.addSeparator()
quit_action = menu.addAction("Beenden") quit_action = menu.addAction("Beenden")
@@ -135,25 +132,39 @@ class MainWindow(QMainWindow):
# --------------------------------------------------------------- Slots # --------------------------------------------------------------- Slots
def _current_tab(self) -> BrowserTab: def _current_tab(self) -> QWidget:
return self._tabs.currentWidget() 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: def _navigate_to_url_bar(self) -> None:
text = self._url_bar.text().strip() text = self._url_bar.text().strip()
if not text: tab = self._current_browser_tab()
if not text or tab is None:
return return
if " " in text or ("." not in text and "://" not in text): if " " in text or ("." not in text and "://" not in text):
url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode()) url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode())
else: else:
url = QUrl.fromUserInput(text) url = QUrl.fromUserInput(text)
self._current_tab().setUrl(url) tab.setUrl(url)
def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None: def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None:
if self._current_tab() is tab: if self._current_tab() is tab:
self._url_bar.setText(url.toString()) self._url_bar.setText(url.toString())
def _on_tab_changed(self, index: int) -> None: 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: def _on_media_info(self, info: dict) -> None:
sender = self.sender() sender = self.sender()
@@ -175,6 +186,27 @@ class MainWindow(QMainWindow):
else: else:
self._rpc_worker.submit_media_info(None) 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: def _on_tray_activated(self, reason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger: if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._show_and_raise() self._show_and_raise()
@@ -209,6 +241,13 @@ class MainWindow(QMainWindow):
# -------------------------------------------------------------- Auto-Update # -------------------------------------------------------------- Auto-Update
def _setup_auto_update(self) -> None: 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", {}) updates_cfg = self._config.get("updates", {})
if not updates_cfg.get("enabled", True): if not updates_cfg.get("enabled", True):
return 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__). 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 Playtube.exe: laedt das Windows-Release-Zip herunter, entpackt es - Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt das
und laesst ein kurzes PowerShell-Skript (nach Prozessende) den Installations- zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es
ordner per robocopy /MIR ersetzen und die App neu starten. 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 - 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).
""" """
@@ -16,8 +18,10 @@ from __future__ import annotations
import json import json
import os import os
import shutil import shutil
import stat
import subprocess import subprocess
import sys import sys
import tarfile
import tempfile import tempfile
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -33,6 +37,9 @@ GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" _API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
_USER_AGENT = "Playtube-Updater" _USER_AGENT = "Playtube-Updater"
_LINUX_BINARY_NAME = "Playtube"
_WINDOWS_BINARY_NAME = "Playtube.exe"
def _parse_version(v: str) -> tuple[int, ...]: def _parse_version(v: str) -> tuple[int, ...]:
v = v.strip().lstrip("vV") v = v.strip().lstrip("vV")
@@ -58,14 +65,23 @@ def fetch_latest_release() -> dict[str, Any] | None:
return 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", []) 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: for asset in assets:
name = asset.get("name", "").lower() 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 return asset
for asset in assets: for asset in assets:
if asset.get("name", "").lower().endswith(".zip"): if asset.get("name", "").lower().endswith(exts):
return asset return asset
return None return None
@@ -73,7 +89,7 @@ def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
class UpdateChecker(QThread): class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version.""" """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() checkFailed = Signal()
upToDate = Signal() upToDate = Signal()
@@ -86,7 +102,7 @@ class UpdateChecker(QThread):
if not tag or not is_newer(tag): if not tag or not is_newer(tag):
self.upToDate.emit() self.upToDate.emit()
return return
asset = _find_windows_zip_asset(release) asset = _find_platform_asset(release)
download_url = asset["browser_download_url"] if asset else "" download_url = asset["browser_download_url"] if asset else ""
notes = (release.get("body") or "").strip() notes = (release.get("body") or "").strip()
self.updateAvailable.emit(tag, notes, download_url) self.updateAvailable.emit(tag, notes, download_url)
@@ -117,38 +133,44 @@ class UpdateInstaller(QThread):
def _run_packaged_update(self) -> None: def _run_packaged_update(self) -> None:
if not self._download_url: if not self._download_url:
self.failed.emit( self.failed.emit(
"Kein Windows-Release-Paket (.zip) im neuesten Release gefunden." "Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
) )
return return
self.progress.emit("Lade Update herunter …") self.progress.emit("Lade Update herunter …")
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_"))
zip_path = staging / "update.zip" archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
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(zip_path, "wb") as out: with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out:
shutil.copyfileobj(resp, out) shutil.copyfileobj(resp, out)
self.progress.emit("Entpacke Update …") self.progress.emit("Entpacke Update …")
with zipfile.ZipFile(zip_path) as zf: if archive_name.endswith((".tar.gz", ".tgz")):
zf.extractall(extract_dir) 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()) 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 …") self.progress.emit("Bereite Installation vor …")
script_path = self._write_apply_script(source_dir, install_dir, staging) if sys.platform == "win32":
subprocess.Popen( self._install_windows(source_dir, install_dir, staging)
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], else:
creationflags=subprocess.CREATE_NO_WINDOW, self._install_posix(source_dir, install_dir, staging)
)
self.finished_ok.emit() self.finished_ok.emit()
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path: # -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy --
exe_path = install_dir / "Playtube.exe"
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = f""" script = f"""
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
@@ -163,7 +185,34 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
""" """
script_path = staging / "apply_update.ps1" script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8") script_path.write_text(script, encoding="utf-8")
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 # ------------------------------------------------------------- Entwicklungsmodus