commit 578cda499a8a81e5d9d9dd7b3329b0501f7a0208 Author: fojadrachi Date: Fri Sep 11 19:13:13 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..734c424 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +build/ +dist/ +run*.log +test_login*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4efb78f --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..b9245f2 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..9b8d2a9 Binary files /dev/null and b/assets/icon.png differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..140852f --- /dev/null +++ b/config.json @@ -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 + } +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..754891c --- /dev/null +++ b/main.py @@ -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()) diff --git a/packaging/build.ps1 b/packaging/build.ps1 new file mode 100644 index 0000000..71d7d83 --- /dev/null +++ b/packaging/build.ps1 @@ -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 diff --git a/packaging/playtube.spec b/packaging/playtube.spec new file mode 100644 index 0000000..3ad77dd --- /dev/null +++ b/packaging/playtube.spec @@ -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", +) diff --git a/packaging/release.ps1 b/packaging/release.ps1 new file mode 100644 index 0000000..a7654a4 --- /dev/null +++ b/packaging/release.ps1 @@ -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 diff --git a/packaging/version_info.txt b/packaging/version_info.txt new file mode 100644 index 0000000..eddd93a --- /dev/null +++ b/packaging/version_info.txt @@ -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])]) + ] +) diff --git a/playtube/__init__.py b/playtube/__init__.py new file mode 100644 index 0000000..76f1451 --- /dev/null +++ b/playtube/__init__.py @@ -0,0 +1,4 @@ +"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" + +__app_name__ = "Playtube" +__version__ = "1.0.0" diff --git a/playtube/app_id.py b/playtube/app_id.py new file mode 100644 index 0000000..36196ca --- /dev/null +++ b/playtube/app_id.py @@ -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 diff --git a/playtube/browser.py b/playtube/browser.py new file mode 100644 index 0000000..faa24e5 --- /dev/null +++ b/playtube/browser.py @@ -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) diff --git a/playtube/chrome_shim.py b/playtube/chrome_shim.py new file mode 100644 index 0000000..6b6c7f6 --- /dev/null +++ b/playtube/chrome_shim.py @@ -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) {{}} +}})(); +""" diff --git a/playtube/config.py b/playtube/config.py new file mode 100644 index 0000000..c40a459 --- /dev/null +++ b/playtube/config.py @@ -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 diff --git a/playtube/discord_rpc.py b/playtube/discord_rpc.py new file mode 100644 index 0000000..9b5a624 --- /dev/null +++ b/playtube/discord_rpc.py @@ -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() diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py new file mode 100644 index 0000000..da4f07f --- /dev/null +++ b/playtube/mainwindow.py @@ -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) diff --git a/playtube/media_probe.py b/playtube/media_probe.py new file mode 100644 index 0000000..bce2d99 --- /dev/null +++ b/playtube/media_probe.py @@ -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(); } +})(); +""" diff --git a/playtube/updater.py b/playtube/updater.py new file mode 100644 index 0000000..8eeb28c --- /dev/null +++ b/playtube/updater.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..faa020b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.10 +pypresence>=4.3 diff --git a/tools/generate_icon.py b/tools/generate_icon.py new file mode 100644 index 0000000..a2066f9 --- /dev/null +++ b/tools/generate_icon.py @@ -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()