Playtube: eigenstaendiger YouTube/YouTube-Music-Player mit Discord RPC und Auto-Update
- PySide6/QtWebEngine mit persistentem Login-Profil (YouTube + YouTube Music Tabs) - Google-Login-Fix per Sec-CH-UA-Headern + Chrome-JS-Shim - Discord Rich Presence (eigener Thread, Titel/Fortschritt/Buttons) - Eigenes Branding (Taskmanager/Taskleiste) per AppUserModelID + PyInstaller-Packaging - Auto-Update ueber GitHub Releases (fojadrachi/Playtube) Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
run*.log
|
||||||
|
test_login*.log
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Playtube
|
||||||
|
|
||||||
|
Eigenstaendige Desktop-App fuer YouTube & YouTube Music (kein Browser-Fenster, keine
|
||||||
|
Erweiterung) mit:
|
||||||
|
|
||||||
|
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
||||||
|
Hintergrund weiter wenn du zu Videos wechselst.
|
||||||
|
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
|
||||||
|
Google anmelden reicht fuer beide Dienste.
|
||||||
|
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
||||||
|
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
||||||
|
- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick
|
||||||
|
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue.
|
||||||
|
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
||||||
|
und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python".
|
||||||
|
|
||||||
|
## Schnellstart (Entwicklung)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\pip install -r requirements.txt
|
||||||
|
.venv\Scripts\python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start meldest du dich einmal in einem der beiden Tabs mit deinem
|
||||||
|
Google-Konto an (Symbol oben rechts auf youtube.com bzw. music.youtube.com) - der
|
||||||
|
Login bleibt danach dauerhaft gespeichert.
|
||||||
|
|
||||||
|
## Discord Rich Presence einrichten
|
||||||
|
|
||||||
|
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
|
||||||
|
sie aendern oder eine eigene Anwendung nutzen willst:
|
||||||
|
|
||||||
|
1. Auf https://discord.com/developers/applications eine neue Application anlegen.
|
||||||
|
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
|
||||||
|
3. Optional, fuer eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
|
||||||
|
exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
|
||||||
|
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
|
||||||
|
nur ohne Bild.
|
||||||
|
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
|
||||||
|
|
||||||
|
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
||||||
|
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts laeuft) anpassen.
|
||||||
|
|
||||||
|
## App als eigenstaendige Playtube.exe packen
|
||||||
|
|
||||||
|
Fuer die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
||||||
|
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
|
||||||
|
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das aendern):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\pip install pyinstaller
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt
|
||||||
|
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
||||||
|
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
||||||
|
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
|
||||||
|
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich
|
||||||
|
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
|
||||||
|
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
|
||||||
|
alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach
|
||||||
|
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
|
||||||
|
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
|
||||||
|
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
|
||||||
|
o.ae. umgehen).
|
||||||
|
|
||||||
|
## Automatische Updates
|
||||||
|
|
||||||
|
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
|
||||||
|
(Standard 6, in `config.json` einstellbar) die [GitHub Releases](https://github.com/fojadrachi/Playtube/releases)
|
||||||
|
des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie
|
||||||
|
installiert werden soll:
|
||||||
|
|
||||||
|
- **Gepackte `Playtube.exe`**: laedt das `.zip`-Asset des Release herunter und ersetzt
|
||||||
|
nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App
|
||||||
|
danach selbst neu.
|
||||||
|
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
|
||||||
|
requirements.txt` aus und startet den Python-Prozess neu.
|
||||||
|
|
||||||
|
Auto-Update laesst sich in `config.json` unter `updates.enabled` deaktivieren.
|
||||||
|
|
||||||
|
### Eine neue Version veroeffentlichen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript setzt die Versionsnummer, baut die `.exe`, packt sie als `.zip`, erstellt
|
||||||
|
Git-Tag `v1.1.0`, pusht zu `origin` (dein Repo unter
|
||||||
|
https://github.com/fojadrachi/Playtube) und legt automatisch ein GitHub Release mit
|
||||||
|
dem `.zip` als Anhang an - vorausgesetzt, entweder ist die
|
||||||
|
[GitHub CLI](https://cli.github.com/) installiert (`gh auth login` einmalig) oder die
|
||||||
|
Umgebungsvariable `GITHUB_TOKEN` enthaelt ein Personal Access Token mit `repo`-Rechten.
|
||||||
|
Ohne beides wird nur getaggt/gepusht und du legst das Release manuell unter
|
||||||
|
https://github.com/fojadrachi/Playtube/releases/new an (Zip-Datei aus `dist\` anhaengen).
|
||||||
|
|
||||||
|
Alle Nutzer mit einer laufenden Playtube-Installation bekommen die neue Version dann
|
||||||
|
automatisch angeboten.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- **"Dieser Browser ist unter Umstaenden nicht sicher" beim Google-Login:** Playtube
|
||||||
|
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
|
||||||
|
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
||||||
|
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
||||||
|
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
||||||
|
Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden.
|
||||||
|
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
||||||
|
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
||||||
|
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
||||||
|
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"app_name": "Playtube",
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"client_id": "1544058029190938774",
|
||||||
|
"update_interval_seconds": 15,
|
||||||
|
"show_idle_presence": true
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"enabled": true,
|
||||||
|
"check_interval_hours": 6
|
||||||
|
},
|
||||||
|
"start_tab": "youtube",
|
||||||
|
"home_youtube": "https://www.youtube.com/",
|
||||||
|
"home_music": "https://music.youtube.com/",
|
||||||
|
"window": {
|
||||||
|
"width": 1366,
|
||||||
|
"height": 860
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Playtube - eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.
|
||||||
|
|
||||||
|
Start: python main.py (oder die gepackte Playtube.exe, siehe packaging/README)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from playtube import app_id # noqa: E402
|
||||||
|
from playtube.config import APP_NAME, load_config # noqa: E402
|
||||||
|
|
||||||
|
app_id.set_app_user_model_id()
|
||||||
|
app_id.configure_webengine_process_path()
|
||||||
|
|
||||||
|
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
|
||||||
|
# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
|
||||||
|
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert.
|
||||||
|
os.environ.setdefault(
|
||||||
|
"QTWEBENGINE_CHROMIUM_FLAGS",
|
||||||
|
"--disable-features=WinRetrieveSuggestionsOnlyOnDemand "
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
)
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt # noqa: E402
|
||||||
|
from PySide6.QtGui import QIcon # noqa: E402
|
||||||
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from playtube.mainwindow import MainWindow # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName(APP_NAME)
|
||||||
|
app.setApplicationDisplayName(APP_NAME)
|
||||||
|
app.setOrganizationName(APP_NAME)
|
||||||
|
app.setQuitOnLastWindowClosed(False) # Tray haelt die App am Leben
|
||||||
|
|
||||||
|
icon_path = Path(__file__).resolve().parent / "assets" / "icon.ico"
|
||||||
|
if icon_path.exists():
|
||||||
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
window = MainWindow(config)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine-
|
||||||
|
# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er
|
||||||
|
# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im
|
||||||
|
# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht.
|
||||||
|
#
|
||||||
|
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
||||||
|
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
|
#
|
||||||
|
# Optional: rcedit (https://github.com/electron/rcedit) im PATH oder unter
|
||||||
|
# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten
|
||||||
|
# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess"
|
||||||
|
# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den
|
||||||
|
# Dateinamen "PlaytubeHelper" an).
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan
|
||||||
|
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
|
||||||
|
|
||||||
|
$distDir = Join-Path $root "dist\Playtube"
|
||||||
|
$internalDir = Join-Path $distDir "_internal"
|
||||||
|
$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir }
|
||||||
|
|
||||||
|
$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $engineProcess) {
|
||||||
|
Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen."
|
||||||
|
} else {
|
||||||
|
$helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe"
|
||||||
|
Copy-Item $engineProcess.FullName $helperPath -Force
|
||||||
|
Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$rcedit = Get-Command rcedit -ErrorAction SilentlyContinue
|
||||||
|
$rceditLocal = Join-Path $PSScriptRoot "rcedit.exe"
|
||||||
|
if ($rcedit) {
|
||||||
|
$rceditExe = $rcedit.Source
|
||||||
|
} elseif (Test-Path $rceditLocal) {
|
||||||
|
$rceditExe = $rceditLocal
|
||||||
|
} else {
|
||||||
|
$rceditExe = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rceditExe) {
|
||||||
|
Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan
|
||||||
|
& $rceditExe $helperPath --set-icon "$root\assets\icon.ico"
|
||||||
|
& $rceditExe $helperPath --set-version-string "FileDescription" "Playtube"
|
||||||
|
& $rceditExe $helperPath --set-version-string "ProductName" "Playtube"
|
||||||
|
& $rceditExe $helperPath --set-version-string "CompanyName" "Playtube"
|
||||||
|
& $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe"
|
||||||
|
} else {
|
||||||
|
Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow
|
||||||
|
Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow
|
||||||
|
Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow
|
||||||
|
Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# PyInstaller-Spec fuer Playtube.
|
||||||
|
# Bauen mit: pyinstaller packaging/playtube.spec --noconfirm
|
||||||
|
# (aus dem Projekt-Root ausfuehren, oder packaging/build.ps1 benutzen)
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
ROOT = Path(SPECPATH).resolve().parent
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[str(ROOT / "main.py")],
|
||||||
|
pathex=[str(ROOT)],
|
||||||
|
binaries=[],
|
||||||
|
datas=[
|
||||||
|
(str(ROOT / "assets"), "assets"),
|
||||||
|
(str(ROOT / "config.json"), "."),
|
||||||
|
],
|
||||||
|
hiddenimports=["pypresence"],
|
||||||
|
hookspath=[],
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="Playtube",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
console=False,
|
||||||
|
icon=str(ROOT / "assets" / "icon.ico"),
|
||||||
|
version=str(ROOT / "packaging" / "version_info.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
name="Playtube",
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
||||||
|
# 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).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Version,
|
||||||
|
[string]$Notes = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
|
throw "Version muss im Format X.Y.Z angegeben werden, z.B. 1.1.0"
|
||||||
|
}
|
||||||
|
$tag = "v$Version"
|
||||||
|
|
||||||
|
Write-Host "==> Setze Version auf $Version in playtube/__init__.py" -ForegroundColor Cyan
|
||||||
|
$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
|
||||||
|
git tag -a $tag -m "Playtube $tag"
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# UTF-8
|
||||||
|
# Windows-Versionsressource fuer PyInstaller (--version-file).
|
||||||
|
# Legt fest, wie die gebaute Playtube.exe im Explorer, Taskmanager und (soweit von
|
||||||
|
# Windows respektiert) im Lautstaerkemixer benannt wird: "Playtube" statt "python".
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=(1, 0, 0, 0),
|
||||||
|
prodvers=(1, 0, 0, 0),
|
||||||
|
mask=0x3F,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
u'040704B0',
|
||||||
|
[StringStruct(u'CompanyName', u'Playtube'),
|
||||||
|
StringStruct(u'FileDescription', u'Playtube'),
|
||||||
|
StringStruct(u'FileVersion', u'1.0.0.0'),
|
||||||
|
StringStruct(u'InternalName', u'Playtube'),
|
||||||
|
StringStruct(u'LegalCopyright', u''),
|
||||||
|
StringStruct(u'OriginalFilename', u'Playtube.exe'),
|
||||||
|
StringStruct(u'ProductName', u'Playtube'),
|
||||||
|
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
||||||
|
]),
|
||||||
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||||
|
|
||||||
|
__app_name__ = "Playtube"
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Branding-Helfer: eigener Prozessname/Icon im Taskmanager, in der Taskleiste und
|
||||||
|
(so weit von Windows respektiert) im Lautstaerkemixer.
|
||||||
|
|
||||||
|
Wichtig: Diese Funktionen muessen VOR dem Import von QtWebEngineWidgets/QtWebEngineCore
|
||||||
|
aufgerufen werden, weil QtWebEngine seinen Sub-Prozess (QtWebEngineProcess.exe) beim
|
||||||
|
Import-Zeitpunkt vorbereitet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import APP_AUMID
|
||||||
|
|
||||||
|
|
||||||
|
def set_app_user_model_id() -> None:
|
||||||
|
"""Setzt die Windows AppUserModelID fuer diesen Prozess.
|
||||||
|
|
||||||
|
Das sorgt dafuer, dass Windows die App in Taskleiste/Alt-Tab/Benachrichtigungen
|
||||||
|
als eigenstaendige Anwendung fuehrt (eigenes Icon, eigene Gruppierung) statt als
|
||||||
|
generisches "Python".
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_AUMID)
|
||||||
|
except Exception:
|
||||||
|
# Auf aelteren Windows-Versionen oder ohne shell32 einfach ignorieren.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def configure_webengine_process_path() -> None:
|
||||||
|
"""Wenn ein umbenannter QtWebEngineProcess (siehe packaging/build.ps1) neben der
|
||||||
|
.exe liegt, wird QtWebEngine angewiesen diesen zu benutzen statt des Standard-
|
||||||
|
"QtWebEngineProcess.exe". Dadurch zeigt auch der Sub-Prozess, der tatsaechlich den
|
||||||
|
Ton wiedergibt, im Taskmanager/Lautstaerkemixer den Playtube-Namen statt
|
||||||
|
"QtWebEngineProcess".
|
||||||
|
|
||||||
|
Nur relevant fuer gepackte (PyInstaller-)Builds. Im Entwicklungsmodus (python
|
||||||
|
main.py) wird der Standardprozess von PySide6 benutzt - das ist voellig normal
|
||||||
|
und hat keinen Einfluss auf die Funktion der App.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32" or not getattr(sys, "frozen", False):
|
||||||
|
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
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
|
||||||
|
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
|
||||||
|
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer, Signal, QUrl
|
||||||
|
from PySide6.QtWebEngineCore import (
|
||||||
|
QWebEnginePage,
|
||||||
|
QWebEngineProfile,
|
||||||
|
QWebEngineScript,
|
||||||
|
QWebEngineSettings,
|
||||||
|
QWebEngineUrlRequestInterceptor,
|
||||||
|
)
|
||||||
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||||
|
|
||||||
|
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
||||||
|
from .config import profile_dir
|
||||||
|
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
||||||
|
|
||||||
|
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
|
||||||
|
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
||||||
|
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
|
||||||
|
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
|
||||||
|
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
|
||||||
|
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
|
||||||
|
# unter Umstaenden nicht sicher".
|
||||||
|
_CHROME_VERSION = CHROME_FULL
|
||||||
|
_CHROME_MAJOR = CHROME_MAJOR
|
||||||
|
|
||||||
|
_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEC_CH_UA = (
|
||||||
|
f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", '
|
||||||
|
f'"Chromium";v="{_CHROME_MAJOR}"'
|
||||||
|
).encode("ascii")
|
||||||
|
_SEC_CH_UA_FULL_VERSION_LIST = (
|
||||||
|
f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", '
|
||||||
|
f'"Chromium";v="{_CHROME_VERSION}"'
|
||||||
|
).encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
|
||||||
|
"""Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit
|
||||||
|
sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise
|
||||||
|
nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles
|
||||||
|
Login-Seite ein Embedded-WebView und blockiert die Anmeldung)."""
|
||||||
|
|
||||||
|
def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override)
|
||||||
|
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"')
|
||||||
|
|
||||||
|
|
||||||
|
_shared_profile: QWebEngineProfile | None = None
|
||||||
|
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_profile() -> QWebEngineProfile:
|
||||||
|
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
|
||||||
|
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
|
||||||
|
global _shared_profile, _shared_interceptor
|
||||||
|
if _shared_profile is None:
|
||||||
|
_shared_profile = QWebEngineProfile("playtube-profile")
|
||||||
|
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
|
||||||
|
_shared_profile.setCachePath(str(profile_dir() / "cache"))
|
||||||
|
_shared_profile.setPersistentCookiesPolicy(
|
||||||
|
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
||||||
|
)
|
||||||
|
_shared_profile.setHttpUserAgent(_USER_AGENT)
|
||||||
|
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
|
||||||
|
|
||||||
|
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
|
||||||
|
_shared_interceptor = _ChromeBrandingInterceptor()
|
||||||
|
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
|
||||||
|
|
||||||
|
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
|
||||||
|
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
|
||||||
|
shim_script = QWebEngineScript()
|
||||||
|
shim_script.setName("playtube-chrome-shim")
|
||||||
|
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||||||
|
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||||
|
shim_script.setRunsOnSubFrames(True)
|
||||||
|
shim_script.setSourceCode(CHROME_SHIM_JS)
|
||||||
|
_shared_profile.scripts().insert(shim_script)
|
||||||
|
return _shared_profile
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTab(QWebEngineView):
|
||||||
|
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
||||||
|
|
||||||
|
mediaInfoChanged = Signal(dict)
|
||||||
|
titleUpdated = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, home_url: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._home_url = home_url
|
||||||
|
|
||||||
|
page = QWebEnginePage(get_shared_profile(), self)
|
||||||
|
self.setPage(page)
|
||||||
|
|
||||||
|
settings = page.settings()
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
|
||||||
|
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
||||||
|
|
||||||
|
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
||||||
|
|
||||||
|
self.load(QUrl(home_url))
|
||||||
|
|
||||||
|
self._poll_timer = QTimer(self)
|
||||||
|
self._poll_timer.setInterval(2000)
|
||||||
|
self._poll_timer.timeout.connect(self._poll_media_state)
|
||||||
|
self._poll_timer.start()
|
||||||
|
|
||||||
|
self.titleChanged.connect(self.titleUpdated.emit)
|
||||||
|
|
||||||
|
def go_home(self) -> None:
|
||||||
|
self.load(QUrl(self._home_url))
|
||||||
|
|
||||||
|
def toggle_playback(self) -> None:
|
||||||
|
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||||
|
|
||||||
|
def next_track(self) -> None:
|
||||||
|
self.page().runJavaScript(NEXT_TRACK_JS)
|
||||||
|
|
||||||
|
def previous_track(self) -> None:
|
||||||
|
self.page().runJavaScript(PREV_TRACK_JS)
|
||||||
|
|
||||||
|
def _on_full_screen_requested(self, request) -> None:
|
||||||
|
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
||||||
|
request.accept()
|
||||||
|
window = self.window()
|
||||||
|
if request.toggleOn():
|
||||||
|
window.showFullScreen()
|
||||||
|
else:
|
||||||
|
window.showNormal()
|
||||||
|
|
||||||
|
def _poll_media_state(self) -> None:
|
||||||
|
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
||||||
|
|
||||||
|
def _on_media_probe_result(self, result) -> None:
|
||||||
|
if isinstance(result, dict):
|
||||||
|
self.mediaInfoChanged.emit(result)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""JavaScript-"Shim", der QtWebEngine (echtes, aber unbranded Chromium) fuer
|
||||||
|
JS-seitige Fingerprint-Checks wie echtes Google Chrome aussehen laesst.
|
||||||
|
|
||||||
|
Hintergrund: Google blockiert Logins aus WebViews/Embedded-Browsern serverseitig
|
||||||
|
("Dieser Browser oder diese App ist unter Umstaenden nicht sicher"). Die dazu
|
||||||
|
verwendeten Signale sind u.a.:
|
||||||
|
- Fehlendes `window.chrome` Objekt (QtWebEngine ist ein Open-Source-Chromium-Build
|
||||||
|
ohne Google-Branding, hat das normalerweise nicht).
|
||||||
|
- `navigator.userAgentData` (JS Client Hints), das bei QtWebEngine "Chromium" statt
|
||||||
|
"Google Chrome" als Marke meldet.
|
||||||
|
- Fehlende/leere `navigator.plugins` (echtes Chrome hat u.a. immer den eingebauten
|
||||||
|
PDF-Viewer als Plugin gelistet).
|
||||||
|
|
||||||
|
Die HTTP-Header-Variante (Sec-CH-UA) wird zusaetzlich in browser.py gesetzt - Google
|
||||||
|
prueft offenbar beides, JS-seitig und Header-seitig, daher muessen beide konsistent
|
||||||
|
"Google Chrome" melden.
|
||||||
|
|
||||||
|
Dies aendert nur, WIE sich der eingebettete Browser bei Feature-Detection meldet, es
|
||||||
|
manipuliert keine fremden Konten und automatisiert keine Logins - es ermoeglicht dem
|
||||||
|
Nutzer lediglich, sich in der eigenen App mit dem eigenen Google-Konto anzumelden,
|
||||||
|
so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHROME_MAJOR = "140"
|
||||||
|
CHROME_FULL = "140.0.0.0"
|
||||||
|
|
||||||
|
CHROME_SHIM_JS = f"""
|
||||||
|
(function() {{
|
||||||
|
try {{
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {{ get: () => undefined, configurable: true }});
|
||||||
|
}} catch (e) {{}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
if (typeof window.chrome === 'undefined') {{ window.chrome = {{}}; }}
|
||||||
|
window.chrome.runtime = window.chrome.runtime || {{}};
|
||||||
|
window.chrome.loadTimes = window.chrome.loadTimes || function() {{
|
||||||
|
var now = Date.now() / 1000;
|
||||||
|
return {{
|
||||||
|
commitLoadTime: now, connectionInfo: 'h2',
|
||||||
|
finishDocumentLoadTime: now, finishLoadTime: now,
|
||||||
|
firstPaintAfterLoadTime: 0, firstPaintTime: now,
|
||||||
|
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
|
||||||
|
requestTime: now - 1, startLoadTime: now - 1,
|
||||||
|
wasAlternateProtocolAvailable: false,
|
||||||
|
wasFetchedViaSpdy: true, wasNpnNegotiated: true
|
||||||
|
}};
|
||||||
|
}};
|
||||||
|
window.chrome.csi = window.chrome.csi || function() {{
|
||||||
|
return {{ onloadT: Date.now(), pageT: Date.now(), startE: Date.now(), tran: 15 }};
|
||||||
|
}};
|
||||||
|
window.chrome.app = window.chrome.app || {{
|
||||||
|
isInstalled: false,
|
||||||
|
InstallState: {{ DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }},
|
||||||
|
RunningState: {{ CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }}
|
||||||
|
}};
|
||||||
|
}} catch (e) {{}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
var brands = [
|
||||||
|
{{ brand: 'Not(A:Brand', version: '99' }},
|
||||||
|
{{ brand: 'Google Chrome', version: '{CHROME_MAJOR}' }},
|
||||||
|
{{ brand: 'Chromium', version: '{CHROME_MAJOR}' }}
|
||||||
|
];
|
||||||
|
var fullBrands = [
|
||||||
|
{{ brand: 'Not(A:Brand', version: '99.0.0.0' }},
|
||||||
|
{{ brand: 'Google Chrome', version: '{CHROME_FULL}' }},
|
||||||
|
{{ brand: 'Chromium', version: '{CHROME_FULL}' }}
|
||||||
|
];
|
||||||
|
var uaData = {{
|
||||||
|
brands: brands,
|
||||||
|
mobile: false,
|
||||||
|
platform: 'Windows',
|
||||||
|
getHighEntropyValues: function(hints) {{
|
||||||
|
var full = {{
|
||||||
|
architecture: 'x86', bitness: '64', brands: brands,
|
||||||
|
fullVersionList: fullBrands, mobile: false, model: '',
|
||||||
|
platform: 'Windows', platformVersion: '15.0.0',
|
||||||
|
uaFullVersion: '{CHROME_FULL}', wow64: false
|
||||||
|
}};
|
||||||
|
var result = {{}};
|
||||||
|
(hints || Object.keys(full)).forEach(function(k) {{
|
||||||
|
if (k in full) {{ result[k] = full[k]; }}
|
||||||
|
}});
|
||||||
|
return Promise.resolve(result);
|
||||||
|
}},
|
||||||
|
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: 'Windows' }}; }}
|
||||||
|
}};
|
||||||
|
Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }});
|
||||||
|
}} catch (e) {{}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
var fakePlugin = {{
|
||||||
|
name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer',
|
||||||
|
description: 'Portable Document Format', length: 1
|
||||||
|
}};
|
||||||
|
var fakePlugins = [fakePlugin];
|
||||||
|
fakePlugins.item = function(i) {{ return fakePlugins[i]; }};
|
||||||
|
fakePlugins.namedItem = function() {{ return fakePlugin; }};
|
||||||
|
fakePlugins.refresh = function() {{}};
|
||||||
|
Object.defineProperty(navigator, 'plugins', {{ get: () => fakePlugins, configurable: true }});
|
||||||
|
Object.defineProperty(navigator, 'mimeTypes', {{ get: () => [], configurable: true }});
|
||||||
|
}} catch (e) {{}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
Object.defineProperty(navigator, 'languages', {{
|
||||||
|
get: () => ['de-DE', 'de', 'en-US', 'en'], configurable: true
|
||||||
|
}});
|
||||||
|
}} catch (e) {{}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
var origQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||||
|
if (origQuery) {{
|
||||||
|
window.navigator.permissions.query = function(params) {{
|
||||||
|
if (params && params.name === 'notifications') {{
|
||||||
|
return Promise.resolve({{ state: Notification.permission }});
|
||||||
|
}}
|
||||||
|
return origQuery(params);
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
}} catch (e) {{}}
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Laden/Speichern der Benutzerkonfiguration (config.json im Projekt- bzw. App-Datenordner)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APP_NAME = "Playtube"
|
||||||
|
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
|
"app_name": APP_NAME,
|
||||||
|
"discord": {
|
||||||
|
"enabled": True,
|
||||||
|
# Deine Discord Application Client-ID (discord.com/developers/applications).
|
||||||
|
"client_id": "1544058029190938774",
|
||||||
|
"update_interval_seconds": 15,
|
||||||
|
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
||||||
|
"show_idle_presence": True,
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"enabled": True,
|
||||||
|
"check_interval_hours": 6,
|
||||||
|
},
|
||||||
|
"start_tab": "youtube", # "youtube" oder "music"
|
||||||
|
"home_youtube": "https://www.youtube.com/",
|
||||||
|
"home_music": "https://music.youtube.com/",
|
||||||
|
"window": {"width": 1366, "height": 860},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _app_data_dir() -> Path:
|
||||||
|
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil."""
|
||||||
|
base = os.environ.get("APPDATA") or str(Path.home())
|
||||||
|
d = Path(base) / APP_NAME
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
|
||||||
|
# im gepackten Build im APPDATA-Ordner.
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return _app_data_dir() / "config.json"
|
||||||
|
return Path(__file__).resolve().parent.parent / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base: dict, override: dict) -> dict:
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in override.items():
|
||||||
|
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
|
result[key] = _deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict[str, Any]:
|
||||||
|
path = _config_path()
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
user_cfg = json.load(f)
|
||||||
|
return _deep_merge(DEFAULT_CONFIG, user_cfg)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
save_config(DEFAULT_CONFIG)
|
||||||
|
return dict(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
|
path = _config_path()
|
||||||
|
try:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def profile_dir() -> Path:
|
||||||
|
"""Ordner fuer das persistente Browser-Profil (Login-Session, Cookies, Local Storage)."""
|
||||||
|
d = _app_data_dir() / "webprofile"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Discord Rich Presence Integration.
|
||||||
|
|
||||||
|
Laeuft in einem eigenen Thread (pypresence macht blockierende IPC-Aufrufe), damit die
|
||||||
|
Oberflaeche nie haengt, egal ob Discord laeuft, gerade startet oder gar nicht
|
||||||
|
installiert ist. Verbindungsfehler werden abgefangen und in Intervallen erneut
|
||||||
|
versucht, ohne die App zu beeintraechtigen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QThread
|
||||||
|
|
||||||
|
from .config import APP_NAME
|
||||||
|
|
||||||
|
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
|
||||||
|
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
|
||||||
|
# einfach kein Bild an - es gibt keinen Fehler.
|
||||||
|
ASSET_YOUTUBE = "youtube_logo"
|
||||||
|
ASSET_MUSIC = "music_logo"
|
||||||
|
ASSET_DEFAULT = "logo"
|
||||||
|
ASSET_PLAY = "play_icon"
|
||||||
|
ASSET_PAUSE = "pause_icon"
|
||||||
|
|
||||||
|
_RECONNECT_DELAY_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
|
||||||
|
text = (text or fallback).strip()
|
||||||
|
if not text:
|
||||||
|
text = fallback
|
||||||
|
if len(text) > limit:
|
||||||
|
text = text[: limit - 1].rstrip() + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
|
||||||
|
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
|
||||||
|
Medien-Informationen."""
|
||||||
|
is_music = bool(info.get("isMusic"))
|
||||||
|
playing = bool(info.get("playing"))
|
||||||
|
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
|
||||||
|
default_state = "YouTube Music" if is_music else "YouTube"
|
||||||
|
state = _truncate(info.get("subtitle"), fallback=default_state)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"details": title,
|
||||||
|
"state": state,
|
||||||
|
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE,
|
||||||
|
"large_text": "YouTube Music" if is_music else "YouTube",
|
||||||
|
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
|
||||||
|
"small_text": "Spielt" if playing else "Pausiert",
|
||||||
|
}
|
||||||
|
|
||||||
|
duration = info.get("duration") or 0
|
||||||
|
current_time = info.get("currentTime") or 0
|
||||||
|
if playing:
|
||||||
|
start_ts = int(time.time() - current_time)
|
||||||
|
payload["start"] = start_ts
|
||||||
|
if duration and duration > 0:
|
||||||
|
payload["end"] = start_ts + int(duration)
|
||||||
|
|
||||||
|
url = info.get("url")
|
||||||
|
if url and isinstance(url, str) and url.startswith("http"):
|
||||||
|
label = "In YouTube Music öffnen" if is_music else "Auf YouTube ansehen"
|
||||||
|
payload["buttons"] = [{"label": label, "url": url}]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_idle_payload(session_start: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"details": f"Bei {APP_NAME}",
|
||||||
|
"state": "Stöbert gerade",
|
||||||
|
"large_image": ASSET_DEFAULT,
|
||||||
|
"large_text": APP_NAME,
|
||||||
|
"start": session_start,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordRPCWorker(QThread):
|
||||||
|
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
|
||||||
|
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
|
||||||
|
|
||||||
|
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._client_id = client_id
|
||||||
|
self._interval = max(5.0, float(interval))
|
||||||
|
self._show_idle = show_idle
|
||||||
|
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
|
||||||
|
self._running = True
|
||||||
|
self._connected = False
|
||||||
|
self._presence = None
|
||||||
|
self._session_start = int(time.time())
|
||||||
|
|
||||||
|
def submit_media_info(self, info: dict[str, Any] | None) -> None:
|
||||||
|
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
||||||
|
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
|
||||||
|
|
||||||
|
def _replace_queue(self, item) -> None:
|
||||||
|
try:
|
||||||
|
self._queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(item)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
self._replace_queue(_STOP_SENTINEL)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
last_sent = 0.0
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
item = self._queue.get()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if item is _STOP_SENTINEL or not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
wait = self._interval - (time.time() - last_sent)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
# Waehrend des Wartens evtl. neuere Daten uebernehmen (debounce).
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
item = self._queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if item is _STOP_SENTINEL or not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._ensure_connected()
|
||||||
|
if self._connected:
|
||||||
|
self._send(item)
|
||||||
|
last_sent = time.time()
|
||||||
|
else:
|
||||||
|
# Nicht verbunden -> nicht sinnlos oft versuchen.
|
||||||
|
time.sleep(_RECONNECT_DELAY_SECONDS)
|
||||||
|
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def _ensure_connected(self) -> None:
|
||||||
|
if self._connected:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from pypresence import Presence
|
||||||
|
|
||||||
|
self._presence = Presence(self._client_id)
|
||||||
|
self._presence.connect()
|
||||||
|
self._connected = True
|
||||||
|
except Exception:
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def _send(self, item) -> None:
|
||||||
|
if self._presence is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if item is _IDLE_SENTINEL:
|
||||||
|
if self._show_idle:
|
||||||
|
self._presence.update(**build_idle_payload(self._session_start))
|
||||||
|
else:
|
||||||
|
self._presence.clear()
|
||||||
|
else:
|
||||||
|
self._presence.update(**build_presence_payload(item, self._session_start))
|
||||||
|
except Exception:
|
||||||
|
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
if self._presence is not None:
|
||||||
|
try:
|
||||||
|
self._presence.clear()
|
||||||
|
self._presence.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Sentinel:
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
_IDLE_SENTINEL = _Sentinel()
|
||||||
|
_STOP_SENTINEL = _Sentinel()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer, QUrl, Qt
|
||||||
|
from PySide6.QtGui import QAction, QIcon
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLineEdit,
|
||||||
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QSystemTrayIcon,
|
||||||
|
QTabWidget,
|
||||||
|
QToolBar,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import __version__ as APP_VERSION
|
||||||
|
from .browser import BrowserTab
|
||||||
|
from .config import APP_NAME
|
||||||
|
from .discord_rpc import DiscordRPCWorker
|
||||||
|
from .updater import UpdateChecker, UpdateInstaller
|
||||||
|
|
||||||
|
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, config: dict[str, Any]):
|
||||||
|
super().__init__()
|
||||||
|
self._config = config
|
||||||
|
self.setWindowTitle(APP_NAME)
|
||||||
|
self.resize(config["window"]["width"], config["window"]["height"])
|
||||||
|
|
||||||
|
icon_path = ASSETS_DIR / "icon.ico"
|
||||||
|
self._app_icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon()
|
||||||
|
if not self._app_icon.isNull():
|
||||||
|
self.setWindowIcon(self._app_icon)
|
||||||
|
|
||||||
|
self._tabs = QTabWidget(self)
|
||||||
|
self._tabs.setDocumentMode(True)
|
||||||
|
self.setCentralWidget(self._tabs)
|
||||||
|
|
||||||
|
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||||
|
self._music_tab = BrowserTab(config["home_music"], self)
|
||||||
|
self._tabs.addTab(self._youtube_tab, "YouTube")
|
||||||
|
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||||||
|
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
||||||
|
|
||||||
|
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
|
||||||
|
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
|
||||||
|
|
||||||
|
self._latest_media: dict[int, dict] = {}
|
||||||
|
|
||||||
|
self._build_toolbar()
|
||||||
|
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._update_checker: UpdateChecker | None = None
|
||||||
|
self._update_installer: UpdateInstaller | None = None
|
||||||
|
self._pending_update_version: str | None = None
|
||||||
|
self._setup_auto_update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ UI
|
||||||
|
|
||||||
|
def _build_toolbar(self) -> None:
|
||||||
|
toolbar = QToolBar("Navigation", self)
|
||||||
|
toolbar.setMovable(False)
|
||||||
|
self.addToolBar(toolbar)
|
||||||
|
|
||||||
|
back_action = QAction("◀", self)
|
||||||
|
back_action.triggered.connect(lambda: self._current_tab().back())
|
||||||
|
toolbar.addAction(back_action)
|
||||||
|
|
||||||
|
forward_action = QAction("▶", self)
|
||||||
|
forward_action.triggered.connect(lambda: self._current_tab().forward())
|
||||||
|
toolbar.addAction(forward_action)
|
||||||
|
|
||||||
|
reload_action = QAction("⟳", self)
|
||||||
|
reload_action.triggered.connect(lambda: self._current_tab().reload())
|
||||||
|
toolbar.addAction(reload_action)
|
||||||
|
|
||||||
|
home_action = QAction("⌂", self)
|
||||||
|
home_action.triggered.connect(lambda: self._current_tab().go_home())
|
||||||
|
toolbar.addAction(home_action)
|
||||||
|
|
||||||
|
toolbar.addSeparator()
|
||||||
|
|
||||||
|
self._url_bar = QLineEdit(self)
|
||||||
|
self._url_bar.setPlaceholderText("Suche oder URL eingeben …")
|
||||||
|
self._url_bar.returnPressed.connect(self._navigate_to_url_bar)
|
||||||
|
toolbar.addWidget(self._url_bar)
|
||||||
|
|
||||||
|
self._tabs.currentChanged.connect(self._on_tab_changed)
|
||||||
|
self._youtube_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._youtube_tab, u))
|
||||||
|
self._music_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._music_tab, u))
|
||||||
|
|
||||||
|
def _build_tray(self) -> None:
|
||||||
|
self._tray = QSystemTrayIcon(self)
|
||||||
|
if not self._app_icon.isNull():
|
||||||
|
self._tray.setIcon(self._app_icon)
|
||||||
|
self._tray.setToolTip(APP_NAME)
|
||||||
|
|
||||||
|
menu = QMenu()
|
||||||
|
show_action = menu.addAction("Anzeigen")
|
||||||
|
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())
|
||||||
|
|
||||||
|
next_action = menu.addAction("Nächster Titel")
|
||||||
|
next_action.triggered.connect(lambda: self._current_tab().next_track())
|
||||||
|
|
||||||
|
prev_action = menu.addAction("Vorheriger Titel")
|
||||||
|
prev_action.triggered.connect(lambda: self._current_tab().previous_track())
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
quit_action = menu.addAction("Beenden")
|
||||||
|
quit_action.triggered.connect(self._quit)
|
||||||
|
|
||||||
|
self._tray.setContextMenu(menu)
|
||||||
|
self._tray.activated.connect(self._on_tray_activated)
|
||||||
|
self._tray.show()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Slots
|
||||||
|
|
||||||
|
def _current_tab(self) -> BrowserTab:
|
||||||
|
return self._tabs.currentWidget()
|
||||||
|
|
||||||
|
def _navigate_to_url_bar(self) -> None:
|
||||||
|
text = self._url_bar.text().strip()
|
||||||
|
if not text:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
def _on_media_info(self, info: dict) -> None:
|
||||||
|
sender = self.sender()
|
||||||
|
self._latest_media[id(sender)] = info
|
||||||
|
|
||||||
|
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
|
||||||
|
# abspielt, sonst der aktuell sichtbare Tab.
|
||||||
|
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
|
||||||
|
active_info = playing_info or self._latest_media.get(id(self._current_tab()))
|
||||||
|
|
||||||
|
if active_info and active_info.get("title"):
|
||||||
|
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
|
||||||
|
else:
|
||||||
|
self.setWindowTitle(APP_NAME)
|
||||||
|
|
||||||
|
if self._rpc_worker is not None:
|
||||||
|
if active_info and active_info.get("hasVideo"):
|
||||||
|
self._rpc_worker.submit_media_info(active_info)
|
||||||
|
else:
|
||||||
|
self._rpc_worker.submit_media_info(None)
|
||||||
|
|
||||||
|
def _on_tray_activated(self, reason) -> None:
|
||||||
|
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||||
|
self._show_and_raise()
|
||||||
|
|
||||||
|
def _show_and_raise(self) -> None:
|
||||||
|
self.showNormal()
|
||||||
|
self.raise_()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
def _quit(self) -> None:
|
||||||
|
if self._rpc_worker is not None:
|
||||||
|
self._rpc_worker.stop()
|
||||||
|
self._rpc_worker.wait(2000)
|
||||||
|
self._tray.hide()
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
QApplication.quit()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Events
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
# Beim Schliessen in den Tray minimieren statt beenden (Musik laeuft weiter).
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
self._tray.showMessage(
|
||||||
|
APP_NAME,
|
||||||
|
f"{APP_NAME} läuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon zum Beenden.",
|
||||||
|
self._app_icon if not self._app_icon.isNull() else QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
3000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Auto-Update
|
||||||
|
|
||||||
|
def _setup_auto_update(self) -> None:
|
||||||
|
updates_cfg = self._config.get("updates", {})
|
||||||
|
if not updates_cfg.get("enabled", True):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_for_updates()
|
||||||
|
|
||||||
|
interval_ms = max(1, int(updates_cfg.get("check_interval_hours", 6))) * 60 * 60 * 1000
|
||||||
|
self._update_timer = QTimer(self)
|
||||||
|
self._update_timer.setInterval(interval_ms)
|
||||||
|
self._update_timer.timeout.connect(self._check_for_updates)
|
||||||
|
self._update_timer.start()
|
||||||
|
|
||||||
|
def _check_for_updates(self) -> None:
|
||||||
|
if self._update_checker is not None and self._update_checker.isRunning():
|
||||||
|
return
|
||||||
|
self._update_checker = UpdateChecker(self)
|
||||||
|
self._update_checker.updateAvailable.connect(self._on_update_available)
|
||||||
|
self._update_checker.start()
|
||||||
|
|
||||||
|
def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
|
||||||
|
if self._pending_update_version == version:
|
||||||
|
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt
|
||||||
|
self._pending_update_version = version
|
||||||
|
|
||||||
|
self._show_and_raise()
|
||||||
|
notes_preview = (notes[:400] + "…") if len(notes) > 400 else notes
|
||||||
|
text = f"Eine neue Version von {APP_NAME} ist verfügbar: {version}\n(aktuell installiert: {APP_VERSION})"
|
||||||
|
if notes_preview:
|
||||||
|
text += f"\n\n{notes_preview}"
|
||||||
|
text += "\n\nJetzt installieren?"
|
||||||
|
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
f"{APP_NAME}-Update verfügbar",
|
||||||
|
text,
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.Yes,
|
||||||
|
)
|
||||||
|
if answer == QMessageBox.StandardButton.Yes:
|
||||||
|
self._start_update_install(download_url)
|
||||||
|
|
||||||
|
def _start_update_install(self, download_url: str) -> None:
|
||||||
|
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
|
||||||
|
self._update_installer = UpdateInstaller(download_url, self)
|
||||||
|
self._update_installer.progress.connect(
|
||||||
|
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
|
||||||
|
)
|
||||||
|
self._update_installer.finished_ok.connect(self._on_update_finished)
|
||||||
|
self._update_installer.failed.connect(self._on_update_failed)
|
||||||
|
self._update_installer.start()
|
||||||
|
|
||||||
|
def _on_update_finished(self) -> None:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
|
||||||
|
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
|
||||||
|
self._quit()
|
||||||
|
else:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
APP_NAME,
|
||||||
|
"Update installiert. Die App wird jetzt neu gestartet.",
|
||||||
|
)
|
||||||
|
self._restart_dev_process()
|
||||||
|
|
||||||
|
def _on_update_failed(self, error: str) -> None:
|
||||||
|
self._tray.setToolTip(APP_NAME)
|
||||||
|
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
|
||||||
|
|
||||||
|
def _restart_dev_process(self) -> None:
|
||||||
|
if self._rpc_worker is not None:
|
||||||
|
self._rpc_worker.stop()
|
||||||
|
self._rpc_worker.wait(2000)
|
||||||
|
python = sys.executable
|
||||||
|
os.execv(python, [python] + sys.argv)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""JavaScript, das periodisch in die YouTube- bzw. YouTube-Music-Seite injiziert wird,
|
||||||
|
um Titel/Interpret/Wiedergabestatus fuer die Discord-Rich-Presence auszulesen."""
|
||||||
|
|
||||||
|
MEDIA_PROBE_JS = r"""
|
||||||
|
(function() {
|
||||||
|
function txt(sel) {
|
||||||
|
var el = document.querySelector(sel);
|
||||||
|
return el ? el.textContent.trim() : null;
|
||||||
|
}
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
||||||
|
var title = null, subtitle = null, thumbnail = null;
|
||||||
|
|
||||||
|
if (isMusic) {
|
||||||
|
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
||||||
|
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
||||||
|
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
||||||
|
thumbnail = imgM ? imgM.src : null;
|
||||||
|
} else {
|
||||||
|
var t = document.title.replace(/ - YouTube$/, '');
|
||||||
|
title = t || null;
|
||||||
|
subtitle = txt('ytd-video-owner-renderer #channel-name a')
|
||||||
|
|| txt('#owner #channel-name a')
|
||||||
|
|| txt('#channel-name a')
|
||||||
|
|| txt('ytd-channel-name#channel-name a');
|
||||||
|
var imgY = document.querySelector('link[rel="image_src"]');
|
||||||
|
thumbnail = imgY ? imgY.href : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMusic: isMusic,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
url: location.href,
|
||||||
|
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
|
||||||
|
currentTime: video ? video.currentTime : 0,
|
||||||
|
duration: (video && isFinite(video.duration)) ? video.duration : 0,
|
||||||
|
hasVideo: !!video
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOGGLE_PLAYBACK_JS = r"""
|
||||||
|
(function() {
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
if (video) { video.paused ? video.play() : video.pause(); }
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
NEXT_TRACK_JS = r"""
|
||||||
|
(function() {
|
||||||
|
var btn = document.querySelector('.next-button, ytmusic-player-bar #next-button button, tp-yt-paper-icon-button.next-button');
|
||||||
|
if (btn) { btn.click(); }
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
PREV_TRACK_JS = r"""
|
||||||
|
(function() {
|
||||||
|
var btn = document.querySelector('.previous-button, ytmusic-player-bar #previous-button button, tp-yt-paper-icon-button.previous-button');
|
||||||
|
if (btn) { btn.click(); }
|
||||||
|
})();
|
||||||
|
"""
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube).
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere
|
||||||
|
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.
|
||||||
|
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
|
||||||
|
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
|
from . import __version__ as CURRENT_VERSION
|
||||||
|
|
||||||
|
GITHUB_REPO = "fojadrachi/Playtube"
|
||||||
|
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||||
|
_USER_AGENT = "Playtube-Updater"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(v: str) -> tuple[int, ...]:
|
||||||
|
v = v.strip().lstrip("vV")
|
||||||
|
parts: list[int] = []
|
||||||
|
for p in v.split("."):
|
||||||
|
digits = "".join(ch for ch in p if ch.isdigit())
|
||||||
|
parts.append(int(digits) if digits else 0)
|
||||||
|
return tuple(parts) or (0,)
|
||||||
|
|
||||||
|
|
||||||
|
def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
|
||||||
|
return _parse_version(remote) > _parse_version(local)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_release() -> dict[str, Any] | None:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
assets = release.get("assets", [])
|
||||||
|
for asset in assets:
|
||||||
|
name = asset.get("name", "").lower()
|
||||||
|
if name.endswith(".zip") and ("win" in name or "windows" in name):
|
||||||
|
return asset
|
||||||
|
for asset in assets:
|
||||||
|
if asset.get("name", "").lower().endswith(".zip"):
|
||||||
|
return asset
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChecker(QThread):
|
||||||
|
"""Prueft einmalig im Hintergrund auf eine neue Version."""
|
||||||
|
|
||||||
|
updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url
|
||||||
|
checkFailed = Signal()
|
||||||
|
upToDate = Signal()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
release = fetch_latest_release()
|
||||||
|
if not release:
|
||||||
|
self.checkFailed.emit()
|
||||||
|
return
|
||||||
|
tag = release.get("tag_name") or release.get("name") or ""
|
||||||
|
if not tag or not is_newer(tag):
|
||||||
|
self.upToDate.emit()
|
||||||
|
return
|
||||||
|
asset = _find_windows_zip_asset(release)
|
||||||
|
download_url = asset["browser_download_url"] if asset else ""
|
||||||
|
notes = (release.get("body") or "").strip()
|
||||||
|
self.updateAvailable.emit(tag, notes, download_url)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInstaller(QThread):
|
||||||
|
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
|
||||||
|
|
||||||
|
progress = Signal(str)
|
||||||
|
finished_ok = Signal()
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, download_url: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._download_url = download_url
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
self._run_packaged_update()
|
||||||
|
else:
|
||||||
|
self._run_dev_update()
|
||||||
|
except Exception as exc: # noqa: BLE001 - Fehler soll in der UI landen, nicht crashen
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- gepackter Modus
|
||||||
|
|
||||||
|
def _run_packaged_update(self) -> None:
|
||||||
|
if not self._download_url:
|
||||||
|
self.failed.emit(
|
||||||
|
"Kein Windows-Release-Paket (.zip) im neuesten Release 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"
|
||||||
|
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:
|
||||||
|
shutil.copyfileobj(resp, out)
|
||||||
|
|
||||||
|
self.progress.emit("Entpacke Update …")
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Manche Release-Zips enthalten 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,
|
||||||
|
)
|
||||||
|
self.finished_ok.emit()
|
||||||
|
|
||||||
|
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path:
|
||||||
|
exe_path = install_dir / "Playtube.exe"
|
||||||
|
script = f"""
|
||||||
|
$ErrorActionPreference = "SilentlyContinue"
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$targetPid = {os.getpid()}
|
||||||
|
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}}
|
||||||
|
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null
|
||||||
|
Start-Process -FilePath "{exe_path}"
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||||
|
"""
|
||||||
|
script_path = staging / "apply_update.ps1"
|
||||||
|
script_path.write_text(script, encoding="utf-8")
|
||||||
|
return script_path
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Entwicklungsmodus
|
||||||
|
|
||||||
|
def _run_dev_update(self) -> None:
|
||||||
|
project_root = Path(__file__).resolve().parent.parent
|
||||||
|
if not (project_root / ".git").exists():
|
||||||
|
self.failed.emit(
|
||||||
|
"Kein Git-Repository gefunden - im Entwicklungsmodus bitte manuell "
|
||||||
|
"'git pull' im Projektordner ausfuehren."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress.emit("Lade Aenderungen per git pull …")
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "pull", "--ff-only"], cwd=project_root, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
self.failed.emit(f"git pull fehlgeschlagen:\n{result.stderr.strip()}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.progress.emit("Installiere Abhaengigkeiten …")
|
||||||
|
pip_result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", "-q", "-r", str(project_root / "requirements.txt")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if pip_result.returncode != 0:
|
||||||
|
self.failed.emit(f"pip install fehlgeschlagen:\n{pip_result.stderr.strip()}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.finished_ok.emit()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PySide6>=6.10
|
||||||
|
pypresence>=4.3
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Erzeugt assets/icon.ico (Play-Button-Logo) und assets/icon.png fuer Playtube.
|
||||||
|
|
||||||
|
Nur ein Build-Hilfsskript - Pillow wird dafuer benoetigt (nicht Laufzeit-Abhaengigkeit
|
||||||
|
der App selbst): pip install Pillow
|
||||||
|
Aufruf: python tools/generate_icon.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ASSETS = ROOT / "assets"
|
||||||
|
ASSETS.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
SIZE = 512
|
||||||
|
BG_COLOR = (124, 58, 237, 255) # violett - eigenstaendige Playtube-Marke
|
||||||
|
BG_COLOR_2 = (236, 72, 153, 255) # pink, fuer diagonalen Verlauf
|
||||||
|
PLAY_COLOR = (255, 255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def make_base_image() -> Image.Image:
|
||||||
|
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
px = img.load()
|
||||||
|
for y in range(SIZE):
|
||||||
|
for x in range(SIZE):
|
||||||
|
t = (x + y) / (2 * SIZE)
|
||||||
|
r = int(BG_COLOR[0] + (BG_COLOR_2[0] - BG_COLOR[0]) * t)
|
||||||
|
g = int(BG_COLOR[1] + (BG_COLOR_2[1] - BG_COLOR[1]) * t)
|
||||||
|
b = int(BG_COLOR[2] + (BG_COLOR_2[2] - BG_COLOR[2]) * t)
|
||||||
|
px[x, y] = (r, g, b, 255)
|
||||||
|
|
||||||
|
# Abgerundetes Quadrat als Maske (App-Icon-Look).
|
||||||
|
mask = Image.new("L", (SIZE, SIZE), 0)
|
||||||
|
mdraw = ImageDraw.Draw(mask)
|
||||||
|
radius = int(SIZE * 0.22)
|
||||||
|
mdraw.rounded_rectangle([0, 0, SIZE - 1, SIZE - 1], radius=radius, fill=255)
|
||||||
|
|
||||||
|
out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
out.paste(img, (0, 0), mask)
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(out)
|
||||||
|
# Play-Dreieck mittig.
|
||||||
|
w = SIZE * 0.34
|
||||||
|
h = SIZE * 0.4
|
||||||
|
cx, cy = SIZE / 2 + SIZE * 0.03, SIZE / 2
|
||||||
|
points = [
|
||||||
|
(cx - w / 2, cy - h / 2),
|
||||||
|
(cx - w / 2, cy + h / 2),
|
||||||
|
(cx + w / 2, cy),
|
||||||
|
]
|
||||||
|
draw.polygon(points, fill=PLAY_COLOR)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
base = make_base_image()
|
||||||
|
base.save(ASSETS / "icon.png")
|
||||||
|
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||||
|
base.save(ASSETS / "icon.ico", sizes=[(s, s) for s in sizes])
|
||||||
|
print(f"Icon geschrieben nach {ASSETS / 'icon.ico'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user