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:
@@ -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
@@ -3,7 +3,7 @@
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"client_id": "1544058029190938774",
|
||||
"update_interval_seconds": 15,
|
||||
"update_interval_seconds": 5,
|
||||
"show_idle_presence": true
|
||||
},
|
||||
"updates": {
|
||||
|
||||
@@ -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)."
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user