From 0e6b580018a39c975e8abf1be1456e5d453995c2 Mon Sep 17 00:00:00 2001 From: fojadrachi Date: Fri, 11 Sep 2026 19:34:45 +0200 Subject: [PATCH] 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 --- .github/workflows/release.yml | 93 ++++++++++++++++++++++++++ config.json | 4 +- packaging/install-linux.sh | 54 +++++++++++++++ packaging/playtube.spec | 14 ++-- packaging/release.ps1 | 57 +++------------- playtube/app_id.py | 15 ++--- playtube/browser.py | 21 +++++- playtube/chrome_shim.py | 18 ++++- playtube/mainwindow.py | 77 +++++++++++++++------ playtube/settings_tab.py | 122 ++++++++++++++++++++++++++++++++++ playtube/updater.py | 93 ++++++++++++++++++++------ 11 files changed, 459 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/install-linux.sh create mode 100644 playtube/settings_tab.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf09900 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/config.json b/config.json index 140852f..130a39e 100644 --- a/config.json +++ b/config.json @@ -3,7 +3,7 @@ "discord": { "enabled": true, "client_id": "1544058029190938774", - "update_interval_seconds": 15, + "update_interval_seconds": 5, "show_idle_presence": true }, "updates": { @@ -17,4 +17,4 @@ "width": 1366, "height": 860 } -} +} \ No newline at end of file diff --git a/packaging/install-linux.sh b/packaging/install-linux.sh new file mode 100644 index 0000000..6399ee1 --- /dev/null +++ b/packaging/install-linux.sh @@ -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" </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)." diff --git a/packaging/playtube.spec b/packaging/playtube.spec index 3ad77dd..032685c 100644 --- a/packaging/playtube.spec +++ b/packaging/playtube.spec @@ -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, diff --git a/packaging/release.ps1 b/packaging/release.ps1 index a7654a4..4cb936c 100644 --- a/packaging/release.ps1 +++ b/packaging/release.ps1 @@ -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 diff --git a/playtube/app_id.py b/playtube/app_id.py index 36196ca..b1bbd75 100644 --- a/playtube/app_id.py +++ b/playtube/app_id.py @@ -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) diff --git a/playtube/browser.py b/playtube/browser.py index faa24e5..37e2281 100644 --- a/playtube/browser.py +++ b/playtube/browser.py @@ -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 diff --git a/playtube/chrome_shim.py b/playtube/chrome_shim.py index 6b6c7f6..6923958 100644 --- a/playtube/chrome_shim.py +++ b/playtube/chrome_shim.py @@ -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) {{}} diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index da4f07f..2c2aec2 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -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 diff --git a/playtube/settings_tab.py b/playtube/settings_tab.py new file mode 100644 index 0000000..7f57f2a --- /dev/null +++ b/playtube/settings_tab.py @@ -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).", + ) diff --git a/playtube/updater.py b/playtube/updater.py index 8eeb28c..0a3a87e 100644 --- a/playtube/updater.py +++ b/playtube/updater.py @@ -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