4 Commits
Author SHA1 Message Date
Fojadrachi d14272991c fix: Release mit gh statt softprops veroeffentlichen, Notizen aus Datei
Release (Edge) / publish-release (push) Blocked by required conditions
Release (Edge) / build-windows (push) Waiting to run
2026-09-26 20:40:09 +02:00
Fojadrachi bb15ff3316 fix: Mark of the Web von den .NET-DLLs entfernen (ZIP aus dem Internet startete nicht) 2026-09-26 20:31:56 +02:00
Fojadrachi f1bde49cf7 fix: Setup und ZIP als getrennte Artefakte hochladen (Setup fehlte im Release) 2026-09-26 20:27:08 +02:00
Fojadrachi c3e69bd5da feat: Playtube Edge 1.0.0 - WebView2 statt QtWebEngine (eigene Musik/AAC abspielbar)
Eigene Anwendung neben der Qt-Version: PlaytubeEdge.exe, eigene Installer-ID,
eigener Datenordner, Updates nur ueber Releases mit Tag *-edge.
2026-09-26 20:23:24 +02:00
23 changed files with 662 additions and 421 deletions
+35 -86
View File
@@ -1,14 +1,19 @@
name: Release name: Release (Edge)
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem # Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch # und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird.
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue #
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an. # WICHTIG:
# - Nur Tags mit dem Suffix "-edge" loesen diesen Workflow aus - die normale (Qt-)Playtube
# baut aus dem Branch "main" mit Tags "vX.Y.Z" und bleibt davon unberuehrt.
# - Das Release wird als VORABVERSION veroeffentlicht und nie als "latest" markiert. Die Qt-
# Version fragt "/releases/latest" ab und sieht dieses Release dadurch nie; der Updater der
# Edge-Version sucht seinerseits nur nach Tags "...-edge" (siehe playtube/updater.py).
on: on:
push: push:
tags: tags:
- "v*.*.*" - "v*-edge"
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@@ -30,14 +35,10 @@ jobs:
pip install -r requirements.txt pip install -r requirements.txt
pip install pyinstaller pip install pyinstaller
- name: Playtube.exe bauen - name: PlaytubeEdge.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Setup-Installer bauen (Inno Setup) - name: Setup-Installer bauen (Inno Setup)
# Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was
# Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige
# Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in
# packaging/playtube.iss) - keine parallelen Versionsordner mehr.
shell: pwsh shell: pwsh
run: | run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value $version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
@@ -48,94 +49,42 @@ jobs:
& $iscc "/DAppVersion=$version" packaging\playtube.iss & $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
# Zwei getrennte Artefakte: bei mehreren Pfaden in EINEM Artefakt behielte das Setup seinen
# Unterordner (dist/installer/) und wuerde beim Veroeffentlichen nicht gefunden.
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: windows-setup name: windows-edge-setup
path: dist/installer/Playtube-Setup-v*.exe path: dist/installer/PlaytubeEdge-Setup-v*.exe
- name: Als .zip packen (volles Paket, portabel) - name: Als .zip packen (portabel)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: windows-build name: windows-edge-zip
path: Playtube-${{ github.ref_name }}-win64.zip path: PlaytubeEdge-*-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates) publish-release:
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als needs: [build-windows]
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
# Technisch bleibt es ein ganz normales ZIP-Archiv.
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
# installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und
# der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible).
shell: pwsh
run: |
$pyside = python -c "import PySide6; print(PySide6.__version__)"
$name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch"
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
Rename-Item -Path "$name.zip" -NewName "$name.play"
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-*-win64-pyside*-patch.play
build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \
libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \
libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \
libxfixes3 libxi6 libxext6 fonts-liberation
- name: Abhaengigkeiten installieren
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Playtube-Binary bauen
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .tar.gz packen (volles Paket)
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-build
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-patch
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
publish-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts
merge-multiple: true merge-multiple: true
- name: GitHub Release veroeffentlichen - name: GitHub Release veroeffentlichen (Vorabversion, nie "latest")
uses: softprops/action-gh-release@v2 # gh statt softprops/action-gh-release: letzteres brach beim erneuten Veroeffentlichen
with: # desselben Tags mit "Too many retries" ab. Ein vorhandenes Release zum selben Tag wird
files: artifacts/* # zuerst entfernt (der Tag bleibt), dann sauber neu angelegt.
generate_release_notes: true env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release delete "${{ github.ref_name }}" --repo "${{ github.repository }}" --yes || true
gh release create "${{ github.ref_name }}" artifacts/* \
--repo "${{ github.repository }}" \
--title "Playtube Edge ${{ github.ref_name }}" \
--notes-file packaging/release_notes.md \
--prerelease --latest=false --verify-tag
+4
View File
@@ -4,4 +4,8 @@ __pycache__/
build/ build/
dist/ dist/
run*.log run*.log
*_run.log
build_edge.log
.claude/
.pytest_cache/
test_login*.log test_login*.log
+20 -1
View File
@@ -1,4 +1,23 @@
# Playtube # Playtube Edge
> **Das ist die „Edge“-Variante von Playtube (Branch `edge`).**
> **Diese Version brauchst du, wenn du selbst hochgeladene Musik in YouTube Music hören
> willst.** Die normale Playtube (Branch `main`) zeigt bei eigenen Uploads „Dieses
> Videoformat wird nicht unterstützt“, weil ihre Browser-Engine (QtWebEngine) kein AAC und
> kein H.264 abspielen kann. Playtube Edge rendert stattdessen mit der **Microsoft-Edge-Engine
> (WebView2)** – damit laufen auch Uploads.
>
> - Download: [Releases](https://github.com/fojadrachi/Playtube/releases) → Eintrag „Playtube
> Edge vX.Y.Z-edge“ → `PlaytubeEdge-Setup-vX.Y.Z.exe`. Voraussetzung: Windows 10/11 mit
> WebView2-Runtime (Windows 11: vorinstalliert).
> - Läuft **neben** der normalen Playtube (eigener Ordner `%APPDATA%\PlaytubeEdge`, eigenes
> Login – beim ersten Start einmal bei Google anmelden).
> - Updates kommen nur über die `-edge`-Releases (die normale Playtube sieht sie nie).
> - Entwicklung: `pip install -r requirements.txt`, dann `python main.py`. Die
> WebView2-DLLs liegen in `vendor/webview2` (Microsoft, NuGet `Microsoft.Web.WebView2`).
>
> Der Rest dieser README beschreibt die gemeinsame Basis mit der normalen Playtube; wo dort
> „QtWebEngine“ steht, ist in dieser Variante WebView2 gemeint.
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
Erweiterung) mit: Erweiterung) mit:
+2 -11
View File
@@ -14,10 +14,7 @@ from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402 from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402
from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402 from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
from playtube.single_instance import SingleInstanceGuard # noqa: E402 from playtube.single_instance import SingleInstanceGuard # noqa: E402
from playtube.shortcuts import ( # noqa: E402 from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
ensure_play_file_association,
ensure_start_menu_shortcut,
)
from playtube.updater import cleanup_old_staging # noqa: E402 from playtube.updater import cleanup_old_staging # noqa: E402
app_id.set_app_user_model_id() app_id.set_app_user_model_id()
@@ -26,12 +23,6 @@ app_id.configure_webengine_process_path()
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in # 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 # playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert. # 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.QtCore import Qt # noqa: E402
from PySide6.QtGui import QIcon # noqa: E402 from PySide6.QtGui import QIcon # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402
@@ -78,7 +69,7 @@ def main() -> int:
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird. # komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__) clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME) ensure_start_menu_shortcut(APP_NAME)
ensure_play_file_association(APP_NAME) # Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates).
window = MainWindow(config) window = MainWindow(config)
window.show() window.show()
+7 -56
View File
@@ -1,19 +1,11 @@
# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine- # Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und
# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er # danach den Setup-Installer (Inno Setup 6).
# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im
# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht.
# #
# Aufruf (im Projekt-Root, mit aktivierter venv): # Aufruf (im Projekt-Root, mit aktivierter venv):
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1 # 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).
param( param(
# Ueberspringt den Bau des Setup-Installers (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6). # Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
[switch]$SkipInstaller [switch]$SkipInstaller
) )
@@ -21,53 +13,13 @@ $ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
Set-Location $root Set-Location $root
Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan Write-Host "==> Baue PlaytubeEdge.exe mit PyInstaller ..." -ForegroundColor Cyan
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." } if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.exe" -ForegroundColor Green
$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
# --- Setup-Installer (Inno Setup) ------------------------------------------------------ # --- Setup-Installer (Inno Setup) ------------------------------------------------------
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und # Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen. # vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
if (-not $SkipInstaller) { if (-not $SkipInstaller) {
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1 $versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
@@ -86,12 +38,11 @@ if (-not $SkipInstaller) {
Write-Host "" Write-Host ""
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
} else { } else {
Write-Host "" Write-Host ""
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss") & $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
} }
} }
+34 -51
View File
@@ -1,18 +1,19 @@
; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe). ; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-Setup-vX.Y.Z.exe).
; ;
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php): ; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss ; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1). ; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe ; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
; ;
; Wichtig fuer "es soll nie mehrere Versionen geben": ; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene ; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen). ; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte ; parallel installiert sein; keine ueberschreibt die andere.
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar). ; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner ; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben. ; uebereinstimmen.
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten. ; - Keine ".play"-Dateizuordnung und kein Beenden von Playtube.exe / PlaytubeHelper.exe - das
; gehoert alles der Qt-Version.
; ;
; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von ; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
; Inno Setup nicht ins Spiel kommt. ; Inno Setup nicht ins Spiel kommt.
@@ -21,14 +22,13 @@
#define AppVersion "0.0.0" #define AppVersion "0.0.0"
#endif #endif
#define AppName "Playtube" #define AppName "Playtube Edge"
#define AppExeName "Playtube.exe" #define AppDirName "PlaytubeEdge"
#define AppHelperName "PlaytubeHelper.exe" #define AppExeName "PlaytubeEdge.exe"
[Setup] [Setup]
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit ; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
; _INNO_APP_ID in playtube/updater.py uebereinstimmen. AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
AppName={#AppName} AppName={#AppName}
AppVersion={#AppVersion} AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion} AppVerName={#AppName} {#AppVersion}
@@ -37,22 +37,21 @@ AppPublisherURL=https://github.com/fojadrachi/Playtube
AppSupportURL=https://github.com/fojadrachi/Playtube/issues AppSupportURL=https://github.com/fojadrachi/Playtube/issues
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
VersionInfoVersion={#AppVersion} VersionInfoVersion={#AppVersion}
DefaultDirName={localappdata}\Programs\{#AppName} DefaultDirName={localappdata}\Programs\{#AppDirName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
PrivilegesRequired=lowest PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist\installer OutputDir=..\dist\installer
OutputBaseFilename=Playtube-Setup-v{#AppVersion} OutputBaseFilename=PlaytubeEdge-Setup-v{#AppVersion}
SetupIconFile=..\assets\icon.ico SetupIconFile=..\assets\icon.ico
UninstallDisplayIcon={app}\{#AppExeName} UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName} UninstallDisplayName={#AppName}
Compression=lzma2/max Compression=lzma2/max
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des ; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu ; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
; schliessen, die Inno per CloseApplications senden wuerde.
CloseApplications=no CloseApplications=no
RestartApplications=no RestartApplications=no
@@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Type: filesandordirs; Name: "{app}\_internal" Type: filesandordirs; Name: "{app}\_internal"
[Files] [Files]
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons] [Icons]
; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die
; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die
; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt.
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py).
Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}"
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1"""
[Run] [Run]
; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende. ; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch ; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
; wieder starten.
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
[Code] [Code]
@@ -107,10 +95,8 @@ procedure WaitForOldInstance();
var var
OldPid, ResultCode: Integer; OldPid, ResultCode: Integer;
begin begin
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren { Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat, (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). }
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
hier nichts. }
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0); OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
if OldPid > 0 then if OldPid > 0 then
Exec(PowerShellPath(), Exec(PowerShellPath(),
@@ -122,16 +108,13 @@ procedure StopLeftoverProcesses();
var var
ResultCode: Integer; ResultCode: Integer;
begin begin
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus { WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem { WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen,
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden wuerden Dateien sperren - gezielt nach Pfad beenden. }
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
if DirExists(ExpandConstant('{app}')) then if DirExists(ExpandConstant('{app}')) then
Exec(PowerShellPath(), Exec(PowerShellPath(),
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' + '-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
@@ -161,8 +144,8 @@ begin
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
begin begin
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 + if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')', '(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True); DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
end; end;
end; end;
+15 -6
View File
@@ -4,22 +4,31 @@
import sys import sys
from pathlib import Path from pathlib import Path
from PyInstaller.utils.hooks import collect_all
block_cipher = None block_cipher = None
ROOT = Path(SPECPATH).resolve().parent ROOT = Path(SPECPATH).resolve().parent
# Edge-Variante: pythonnet + clr_loader (bringen Python.Runtime.dll/ClrLoader.dll mit) und die
# Microsoft-WebView2-DLLs aus vendor/webview2 (siehe playtube/webview2_runtime.py).
pythonnet_datas, pythonnet_binaries, pythonnet_hidden = collect_all("pythonnet")
clr_datas, clr_binaries, clr_hidden = collect_all("clr_loader")
a = Analysis( a = Analysis(
[str(ROOT / "main.py")], [str(ROOT / "main.py")],
pathex=[str(ROOT)], pathex=[str(ROOT)],
binaries=[], binaries=pythonnet_binaries + clr_binaries,
datas=[ datas=[
(str(ROOT / "assets"), "assets"), (str(ROOT / "assets"), "assets"),
(str(ROOT / "config.json"), "."), (str(ROOT / "config.json"), "."),
], (str(ROOT / "vendor" / "webview2"), "vendor/webview2"),
hiddenimports=["pypresence"], ] + pythonnet_datas + clr_datas,
hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden,
hookspath=[], hookspath=[],
runtime_hooks=[], runtime_hooks=[],
excludes=[], # Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein.
excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"],
noarchive=False, noarchive=False,
) )
@@ -29,7 +38,7 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen. # Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
exe_kwargs = dict( exe_kwargs = dict(
exclude_binaries=True, exclude_binaries=True,
name="Playtube", name="PlaytubeEdge",
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
@@ -49,5 +58,5 @@ coll = COLLECT(
a.datas, a.datas,
strip=False, strip=False,
upx=False, upx=False,
name="Playtube", name="PlaytubeEdge",
) )
+19
View File
@@ -0,0 +1,19 @@
## Playtube Edge - die Version für eigene (hochgeladene) Musik
**Diese Version brauchst du, wenn du selbst hochgeladene Songs in YouTube Music hören willst.**
Die normale Playtube-Version kann sie nicht abspielen („Dieses Videoformat wird nicht
unterstützt“), weil ihre eingebaute Browser-Engine (QtWebEngine) kein AAC und kein H.264 kann.
Playtube Edge benutzt stattdessen die Microsoft-Edge-Engine (WebView2) – damit laufen auch
hochgeladene Titel.
### Installieren
- Download: **PlaytubeEdge-Setup-vX.Y.Z.exe** (Setup, empfohlen) oder die ZIP-Datei (portabel).
- Voraussetzung: Windows 10/11 mit „WebView2 Runtime“ (auf Windows 11 schon vorhanden, sonst
kostenlos bei Microsoft).
- Playtube Edge läuft **neben** der normalen Playtube (eigener Ordner, eigene Einstellungen).
Beim ersten Start musst du dich in YouTube/YouTube Music einmal neu anmelden. Lass am besten
nur eine der beiden Versionen gleichzeitig spielen.
- Updates: Playtube Edge aktualisiert sich nur über diese „Edge“-Releases – nie über die
normale Playtube (und umgekehrt).
Wer keine eigenen Uploads abspielt, braucht diese Version nicht – die normale Playtube reicht dann.
+4 -4
View File
@@ -19,12 +19,12 @@ VSVersionInfo(
StringTable( StringTable(
u'040704B0', u'040704B0',
[StringStruct(u'CompanyName', u'Playtube'), [StringStruct(u'CompanyName', u'Playtube'),
StringStruct(u'FileDescription', u'Playtube'), StringStruct(u'FileDescription', u'Playtube Edge'),
StringStruct(u'FileVersion', u'1.0.0.0'), StringStruct(u'FileVersion', u'1.0.0.0'),
StringStruct(u'InternalName', u'Playtube'), StringStruct(u'InternalName', u'PlaytubeEdge'),
StringStruct(u'LegalCopyright', u''), StringStruct(u'LegalCopyright', u''),
StringStruct(u'OriginalFilename', u'Playtube.exe'), StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'),
StringStruct(u'ProductName', u'Playtube'), StringStruct(u'ProductName', u'Playtube Edge'),
StringStruct(u'ProductVersion', u'1.0.0.0')]) StringStruct(u'ProductVersion', u'1.0.0.0')])
]), ]),
VarFileInfo([VarStruct(u'Translation', [1031, 1200])]) VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
+2 -2
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube Edge"
__version__ = "4.5.1" __version__ = "1.0.0"
+7 -21
View File
@@ -30,15 +30,7 @@ from __future__ import annotations
import json import json
from PySide6.QtCore import QUrl
from PySide6.QtMultimedia import QMediaDevices from PySide6.QtMultimedia import QMediaDevices
from PySide6.QtWebEngineCore import QWebEnginePermission, QWebEngineProfile
# Name des eingeschleusten Skripts (pro Seite), um es beim Aendern ersetzen zu koennen.
SCRIPT_NAME = "playtube-audio-output"
# Herkunft (Origin), fuer die die Berechtigung erteilt wird - nur die beiden Tabs.
YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
SYSTEM_DEFAULT_LABEL = "Systemstandard" SYSTEM_DEFAULT_LABEL = "Systemstandard"
@@ -54,19 +46,13 @@ def list_output_devices() -> list[str]:
return names return names
def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None: def sync_audio_permissions(tabs, wanted: bool) -> None:
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer """Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"), Freigabe selbst (WebView2-Profil, bleibt dort gespeichert)."""
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt.""" for tab in tabs:
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture tab.set_device_names_allowed(wanted)
for origin in YOUTUBE_ORIGINS:
permission = profile.queryPermission(QUrl(origin), permission_type)
if wanted:
permission.grant()
else:
permission.reset()
# JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt. # JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt.
+283 -171
View File
@@ -1,25 +1,23 @@
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login- """Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
der aktuellen Wiedergabe fuer Discord Rich Presence.""" Presence.
Edge-Variante von Playtube: statt QtWebEngine (kein AAC/H.264 - hochgeladene Titel liessen sich
nicht abspielen) rendert hier WebView2. Die Schnittstelle von BrowserTab ist dieselbe wie in der
Qt-Variante, damit MainWindow, Fernsteuerung und Discord unveraendert bleiben.
"""
from __future__ import annotations from __future__ import annotations
import ctypes
import json import json
import os import os
import sys from typing import Any, Callable
from PySide6.QtCore import QTimer, Signal, QUrl from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtWebEngineCore import ( from PySide6.QtWidgets import QWidget
QWebEnginePage,
QWebEngineProfile,
QWebEngineScript,
QWebEngineSettings,
QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME from . import webview2_runtime
from .audio_routing import build_router_script from .audio_routing import build_router_script
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
from .config import profile_dir from .config import profile_dir
from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
@@ -27,214 +25,328 @@ from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PL
# Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen # Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht). # Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
_REFRESH_AFTER_COMMAND_MS = 300 _REFRESH_AFTER_COMMAND_MS = 300
_TASK_POLL_MS = 25
_MEDIA_POLL_MS = 2000
_YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
# Ohne Nutzergeste starten koennen (Playlist-Start per Stream Dock, Autoplay).
_BROWSER_ARGUMENTS = "--autoplay-policy=no-user-gesture-required"
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten _user32 = ctypes.windll.user32 if os.name == "nt" else None
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy- if _user32 is not None:
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS, # 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome") _user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges _user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist _user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
# unter Umstaenden nicht sicher". _user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
_CHROME_VERSION = CHROME_FULL _user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
_CHROME_MAJOR = CHROME_MAJOR _user32.MoveWindow.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_bool)
_user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int)
if sys.platform == "win32": _GWL_STYLE = -16
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64" _WS_POPUP = 0x80000000
_SEC_CH_UA_PLATFORM = b'"Windows"' _WS_CAPTION = 0x00C00000
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"' _WS_CHILD = 0x40000000
elif sys.platform.startswith("linux"): _WS_VISIBLE = 0x10000000
_UA_PLATFORM_TOKEN = "X11; Linux x86_64" _WS_CLIPSIBLINGS = 0x04000000
_SEC_CH_UA_PLATFORM = b'"Linux"' _WS_CLIPCHILDREN = 0x02000000
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"' _SW_SHOW = 5
else:
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
_SEC_CH_UA_PLATFORM = b'"macOS"'
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
_USER_AGENT = (
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) 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): def _decode_script_result(raw: Any) -> Any:
"""Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit """ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst
sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert."""
nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles if raw is None:
Login-Seite ein Embedded-WebView und blockiert die Anmeldung).""" return None
try:
def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override) value = json.loads(str(raw))
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA) except (TypeError, ValueError):
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST) return None
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0") if isinstance(value, str):
info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM) try:
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION) return json.loads(value)
except ValueError:
return value
return value
_shared_profile: QWebEngineProfile | None = None def _in_document(source: str) -> str:
_shared_interceptor: _ChromeBrandingInterceptor | None = None """Umhuellt ein Skript, das document.documentElement braucht, damit es auch beim frueh
(vor dem DOM) ausgefuehrten WebView2-Dokumentskript funktioniert."""
return (
"(function(){function go(){" + source + "}"
"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',go);}"
"else{go();}})();"
)
def get_shared_profile() -> QWebEngineProfile: class BrowserTab(QWidget):
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges """Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
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) mediaInfoChanged = Signal(dict)
titleUpdated = Signal(str) titleUpdated = Signal(str)
loadFinished = Signal(bool)
urlChanged = Signal(QUrl)
def __init__(self, home_url: str, parent=None): def __init__(self, home_url: str, parent=None):
super().__init__(parent) super().__init__(parent)
self._home_url = home_url self._home_url = home_url
self._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output) self._current_url = QUrl(home_url)
self._audio_output_set = False
self._allow_device_names = False
self._router_script_id: str | None = None
self._core = None
self._pending: list[tuple[Any, Callable[[Any], None] | None]] = []
self._queued_urls: list[str] = [home_url]
self._queued_router: str | None = None
page = QWebEnginePage(get_shared_profile(), self) # Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
self.setPage(page) self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setMinimumSize(200, 150)
settings = page.settings() runtime = webview2_runtime.load()
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True) self._runtime = runtime
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False) properties = runtime.CreationProperties()
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True) properties.UserDataFolder = str(profile_dir() / "webview2")
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True) properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True) properties.Language = "de-DE"
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True) self._view = runtime.WebView2()
self._view.CreationProperties = properties
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
page.fullScreenRequested.connect(self._on_full_screen_requested) self._task_timer = QTimer(self)
self._task_timer.setInterval(_TASK_POLL_MS)
self.load(QUrl(home_url)) self._task_timer.timeout.connect(self._drain_tasks)
self._task_timer.start()
self._poll_timer = QTimer(self) self._poll_timer = QTimer(self)
self._poll_timer.setInterval(2000) self._poll_timer.setInterval(_MEDIA_POLL_MS)
self._poll_timer.timeout.connect(self._poll_media_state) self._poll_timer.timeout.connect(self._poll_media_state)
self._poll_timer.start() self._poll_timer.start()
self.titleChanged.connect(self.titleUpdated.emit) # Steuerelement erst nach dem Aufbau des Fensters erzeugen.
QTimer.singleShot(0, self._create_control)
# ------------------------------------------------------------ Aufbau
def _create_control(self) -> None:
self._view.CreateControl()
handle = self._handle()
# Aus dem frei stehenden WinForms-Fenster ein sichtbares Kindfenster des Qt-Widgets machen.
style = _user32.GetWindowLongPtrW(handle, _GWL_STYLE)
style = (style & ~_WS_POPUP & ~_WS_CAPTION) | _WS_CHILD | _WS_VISIBLE | _WS_CLIPSIBLINGS | _WS_CLIPCHILDREN
_user32.SetWindowLongPtrW(handle, _GWL_STYLE, style)
_user32.SetParent(handle, int(self.winId()))
_user32.ShowWindow(handle, _SW_SHOW)
self._fit_to_widget()
self._view.EnsureCoreWebView2Async(None)
def _handle(self) -> int:
return int(self._view.Handle.ToInt64())
def _fit_to_widget(self) -> None:
if _user32 is None or self._view is None or not self._view.IsHandleCreated:
return
ratio = self.devicePixelRatioF()
_user32.MoveWindow(self._handle(), 0, 0, int(self.width() * ratio), int(self.height() * ratio), True)
def resizeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
super().resizeEvent(event)
self._fit_to_widget()
def _on_core_initialized(self, _sender, args) -> None:
try:
self._setup_core(args)
except Exception: # pythonnet verschluckt Fehler in Ereignis-Handlern sonst still
import traceback
traceback.print_exc()
def _setup_core(self, args) -> None:
if not args.IsSuccess:
print(f"[webview2] Initialisierung fehlgeschlagen: {args.InitializationException}", flush=True)
self.loadFinished.emit(False)
return
core = self._view.CoreWebView2
self._core = core
settings = core.Settings
settings.IsStatusBarEnabled = False
settings.AreDefaultScriptDialogsEnabled = True
core.NavigationCompleted += lambda _s, a: self.loadFinished.emit(bool(a.IsSuccess))
core.SourceChanged += lambda _s, _a: self._on_source_changed()
core.DocumentTitleChanged += lambda _s, _a: self.titleUpdated.emit(str(core.DocumentTitle or ""))
core.NewWindowRequested += self._on_new_window
core.ContainsFullScreenElementChanged += lambda _s, _a: self._on_fullscreen_changed()
self._apply_device_name_permission()
if self._queued_router is not None:
self._install_router(self._queued_router)
self._queued_router = None
for url in self._queued_urls:
core.Navigate(url)
self._queued_urls.clear()
def _on_source_changed(self) -> None:
self._current_url = QUrl(str(self._core.Source))
self.urlChanged.emit(self._current_url)
def _on_new_window(self, _sender, args) -> None:
# Links mit target=_blank im selben Tab oeffnen (kein zusaetzliches Fenster).
args.Handled = True
self.setUrl(QUrl(str(args.Uri)))
def _on_fullscreen_changed(self) -> None:
window = self.window()
if self._core.ContainsFullScreenElement:
window.showFullScreen()
else:
window.showNormal()
self._fit_to_widget()
# ------------------------------------------------ asynchrone .NET-Aufgaben
def _await(self, task: Any, callback: Callable[[Any], None] | None = None) -> None:
"""Wartet ohne Blockieren auf eine .NET-Task und ruft danach callback(Ergebnis) auf."""
self._pending.append((task, callback))
def _drain_tasks(self) -> None:
if not self._pending:
return
still_pending = []
for task, callback in self._pending:
if not task.IsCompleted:
still_pending.append((task, callback))
continue
if callback is None:
continue
try:
callback(None if task.IsFaulted or task.IsCanceled else task.Result)
except Exception: # Fehler im Rueckruf duerfen die Warteschlange nicht blockieren
pass
self._pending = still_pending
def _run_js(self, script: str, callback: Callable[[Any], None] | None = None) -> None:
if self._core is None:
return
task = self._core.ExecuteScriptAsync(script)
self._await(task, (lambda raw: callback(_decode_script_result(raw))) if callback else None)
# ------------------------------------------------------------ Navigation
def url(self) -> QUrl:
return self._current_url
def setUrl(self, url: QUrl) -> None: # noqa: N802 (Qt-kompatibler Name)
target = url.toString()
self._current_url = url
if self._core is None:
self._queued_urls = [target]
else:
self._core.Navigate(target)
def go_home(self) -> None: def go_home(self) -> None:
self.load(QUrl(self._home_url)) self.setUrl(QUrl(self._home_url))
def back(self) -> None:
if self._core is not None and self._core.CanGoBack:
self._core.GoBack()
def forward(self) -> None:
if self._core is not None and self._core.CanGoForward:
self._core.GoForward()
def reload(self) -> None:
if self._core is not None:
self._core.Reload()
# ------------------------------------------------------ Audio-Ausgabe
def set_audio_output(self, device_name: str) -> None: def set_audio_output(self, device_name: str) -> None:
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer = """Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
bleibt bei Seitenwechseln/Neuladen erhalten (Skript pro Seite).""" bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
page = self.page()
scripts = page.scripts()
for old in scripts.find(AUDIO_SCRIPT_NAME):
scripts.remove(old)
source = build_router_script(device_name) source = build_router_script(device_name)
if device_name: if self._core is None:
script = QWebEngineScript() self._queued_router = source if device_name else None
script.setName(AUDIO_SCRIPT_NAME) else:
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) self._install_router(source if device_name else None)
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) # Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard.
script.setRunsOnSubFrames(False) if device_name or self._audio_output_set:
script.setSourceCode(source) self._run_js(source)
scripts.insert(script)
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard,
# falls vorher ein eigenes Geraet gewaehlt war. Ohne jemals gewaehltes Geraet wird die
# Seite gar nicht angefasst.
if device_name or self._audio_output_set:
page.runJavaScript(source)
self._audio_output_set = bool(device_name) self._audio_output_set = bool(device_name)
def _install_router(self, source: str | None) -> None:
old_id, self._router_script_id = self._router_script_id, None
if old_id:
self._core.RemoveScriptToExecuteOnDocumentCreated(old_id)
if source:
task = self._core.AddScriptToExecuteOnDocumentCreatedAsync(_in_document(source))
self._await(task, self._remember_router_id)
def _remember_router_id(self, script_id: Any) -> None:
self._router_script_id = str(script_id) if script_id else None
def set_device_names_allowed(self, allowed: bool) -> None:
"""Erlaubt der Seite, die Namen der Ausgabegeraete zu sehen (siehe audio_routing.py)."""
self._allow_device_names = allowed
self._apply_device_name_permission()
def _apply_device_name_permission(self) -> None:
if self._core is None:
return
state = self._runtime.PermissionState.Allow if self._allow_device_names else self._runtime.PermissionState.Default
try:
for origin in _YOUTUBE_ORIGINS:
self._core.Profile.SetPermissionStateAsync(self._runtime.PermissionKind.Microphone, origin, state)
except Exception as error: # aeltere Runtime ohne diese API: Seite laedt trotzdem
print(f"[webview2] Geraetenamen-Freigabe nicht setzbar: {error}", flush=True)
# ----------------------------------------------------------- Wiedergabe
def toggle_playback(self) -> None: def toggle_playback(self) -> None:
self.page().runJavaScript(TOGGLE_PLAYBACK_JS) self._run_js(TOGGLE_PLAYBACK_JS)
def next_track(self) -> None: def next_track(self) -> None:
self.page().runJavaScript(NEXT_TRACK_JS) self._run_js(NEXT_TRACK_JS)
def previous_track(self) -> None: def previous_track(self) -> None:
self.page().runJavaScript(PREV_TRACK_JS) self._run_js(PREV_TRACK_JS)
def run_media_command(self, command: str, value: float = 0) -> None: def run_media_command(self, command: str, value: float = 0) -> None:
"""Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den """Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den
Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand
sofort statt erst beim naechsten 2-Sekunden-Poll sieht.""" sofort statt erst beim naechsten 2-Sekunden-Poll sieht."""
self.page().runJavaScript(build_control_js(command, value)) self._run_js(build_control_js(command, value))
QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state) QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state)
def list_playlists(self, callback) -> None: def list_playlists(self, callback) -> None:
"""Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf.""" """Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf."""
if self._core is None:
def on_result(result) -> None: callback(None)
try: return
callback(json.loads(result) if isinstance(result, str) and result else None) self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None))
except json.JSONDecodeError:
callback(None)
self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result)
def play_playlist(self, playlist_id: str) -> None: def play_playlist(self, playlist_id: str) -> None:
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet """Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
die Wiedergabe (PlaybackRequiresUserGesture ist aus).""" die Wiedergabe."""
self.load(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id))) self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
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: def _poll_media_state(self) -> None:
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result) self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result)
def _on_media_probe_result(self, result) -> None:
# runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig
# als dict (siehe media_probe.py) - das Probe-Skript gibt daher einen
# JSON-String zurueck, der hier geparst wird.
info = None
if isinstance(result, str) and result:
try:
info = json.loads(result)
except json.JSONDecodeError:
info = None
elif isinstance(result, dict):
info = result
def _on_media_probe_result(self, info: Any) -> None:
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[media-probe:{self._home_url}] roh={result!r} geparst={info!r}", flush=True) print(f"[media-probe:{self._home_url}] geparst={info!r}", flush=True)
if isinstance(info, dict): if isinstance(info, dict):
self.mediaInfoChanged.emit(info) self.mediaInfoChanged.emit(info)
# ----------------------------------------------------------- Aufraeumen
def closeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
self.dispose()
super().closeEvent(event)
def dispose(self) -> None:
self._task_timer.stop()
self._poll_timer.stop()
if self._view is not None:
self._view.Dispose()
self._view = None
+7 -3
View File
@@ -8,15 +8,18 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
APP_NAME = "Playtube" APP_NAME = "Playtube Edge"
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner # Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer # ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass # gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw. # ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
# denselben Browser-Profil-Lock geteilt haben). # denselben Browser-Profil-Lock geteilt haben).
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev" #
# Edge-Variante (WebView2): eigene Ordnernamen ("PlaytubeEdge"/"PlaytubeEdgeDev"), damit sie sich
# nie mit der Qt-Variante ueberschneidet (Profil, Einzelinstanz-Schutz, Fernsteuerungs-Pipe).
_DATA_DIR_NAME = "PlaytubeEdge" if getattr(sys, "frozen", False) else "PlaytubeEdgeDev"
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"app_name": APP_NAME, "app_name": APP_NAME,
@@ -28,6 +31,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren. # Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
"show_idle_presence": True, "show_idle_presence": True,
}, },
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
"updates": { "updates": {
"enabled": True, "enabled": True,
"check_interval_hours": 6, "check_interval_hours": 6,
+3 -3
View File
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
from . import __version__ as APP_VERSION from . import __version__ as APP_VERSION
from .audio_routing import sync_audio_permissions from .audio_routing import sync_audio_permissions
from .browser import BrowserTab, get_shared_profile from .browser import BrowserTab
from .config import APP_NAME from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker from .discord_rpc import DiscordRPCWorker
from .remote_control import build_state as build_remote_state from .remote_control import build_state as build_remote_state
@@ -57,9 +57,9 @@ class MainWindow(QMainWindow):
self._tabs.setDocumentMode(True) self._tabs.setDocumentMode(True)
self.setCentralWidget(self._tabs) self.setCentralWidget(self._tabs)
self._sync_audio_permission()
self._youtube_tab = BrowserTab(config["home_youtube"], self) self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], self) self._music_tab = BrowserTab(config["home_music"], self)
self._sync_audio_permission()
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte # Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach # Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
# erteilte schon (siehe audio_routing.py). # erteilte schon (siehe audio_routing.py).
@@ -254,7 +254,7 @@ class MainWindow(QMainWindow):
def _sync_audio_permission(self) -> None: def _sync_audio_permission(self) -> None:
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) - """Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
nur solange mindestens ein Tab ein eigenes Geraet nutzt.""" nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
sync_audio_permissions(get_shared_profile(), any(self._audio_outputs())) sync_audio_permissions((self._youtube_tab, self._music_tab), any(self._audio_outputs()))
def _on_tab_load_finished(self, _ok: bool) -> None: def _on_tab_load_finished(self, _ok: bool) -> None:
if any(self._audio_outputs()): if any(self._audio_outputs()):
+22 -6
View File
@@ -54,16 +54,19 @@ from PySide6.QtCore import QThread, Signal
from . import __version__ as CURRENT_VERSION from . import __version__ as CURRENT_VERSION
GITHUB_REPO = "fojadrachi/Playtube" GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" # Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
_USER_AGENT = "Playtube-Updater" # Qt-Version sie nie als "neueste Version" sieht) - deshalb die Liste statt /releases/latest.
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=30"
_EDGE_TAG_SUFFIX = "-edge"
_USER_AGENT = "PlaytubeEdge-Updater"
_LINUX_BINARY_NAME = "Playtube" _LINUX_BINARY_NAME = "PlaytubeEdge"
_WINDOWS_BINARY_NAME = "Playtube.exe" _WINDOWS_BINARY_NAME = "PlaytubeEdge.exe"
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno # Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update # Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows). # wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
_INNO_APP_ID = "{87FBA502-2B84-4C42-A66B-2F03F490912D}" _INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
_STAGING_PREFIX = "playtube_update_" _STAGING_PREFIX = "playtube_update_"
@@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None:
) )
try: try:
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8")) releases = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError): except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
return None return None
if not isinstance(releases, list):
return None
# Neueste Edge-Version = hoechste Versionsnummer unter den "-edge"-Releases (ohne Entwuerfe).
edge_releases = [
release for release in releases
if isinstance(release, dict) and not release.get("draft")
and str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX)
]
return max(edge_releases, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None)
def is_installed_via_setup() -> bool: def is_installed_via_setup() -> bool:
@@ -125,6 +137,10 @@ def _asset_kind(name: str) -> str | None:
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip. *.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket.""" Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
name = name.lower() name = name.lower()
# Zweite Absicherung neben dem "-edge"-Tag: nur Dateien der Edge-Variante kommen in Frage,
# nie ein Paket der Qt-Version ("Playtube-...").
if "playtubeedge" not in name:
return None
if sys.platform == "win32": if sys.platform == "win32":
if name.endswith(".exe") and "setup" in name: if name.endswith(".exe") and "setup" in name:
return _KIND_SETUP return _KIND_SETUP
+85
View File
@@ -0,0 +1,85 @@
"""Laedt Microsoft Edge WebView2 (per pythonnet / .NET Framework) fuer den Browser-Tab.
Die DLLs (Microsoft.Web.WebView2.Core/WinForms, WebView2Loader) liegen im Projektordner
vendor/webview2 (offizielles NuGet-Paket "Microsoft.Web.WebView2"). Die eigentliche
Browser-Engine ist die auf Windows 10/11 vorinstallierte "WebView2 Runtime" (Edge) - sie
bringt AAC/H.264 mit, die in QtWebEngine fehlen.
"""
from __future__ import annotations
import os
import sys
from functools import lru_cache
from pathlib import Path
from types import SimpleNamespace
class WebView2Unavailable(RuntimeError):
"""WebView2 konnte nicht geladen werden (DLLs oder Runtime fehlen)."""
def _app_base_dir() -> Path:
return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
def _vendor_dir() -> Path:
return _app_base_dir() / "vendor" / "webview2"
def _unblock_downloaded_files(folders: tuple[Path, ...]) -> None:
"""Entfernt die "Mark of the Web" (Zone.Identifier) von den DLLs, die .NET laden soll.
Wer die ZIP aus dem Internet laedt und entpackt, bekommt alle Dateien mit dieser
Markierung; .NET verweigert dann das Laden von Python.Runtime.dll ("Failed to resolve
Python.Runtime.Loader.Initialize"). Die Dateien liegen im eigenen App-Ordner - das
Entfernen der Markierung betrifft nur sie."""
if os.name != "nt":
return
for folder in folders:
for path in folder.rglob("*"):
if path.suffix.lower() in (".dll", ".exe"):
try:
os.remove(f"{path}:Zone.Identifier")
except OSError:
pass # keine Markierung vorhanden (Normalfall) oder Dateisystem ohne Datenstroeme
@lru_cache(maxsize=1)
def load() -> SimpleNamespace:
"""Laedt .NET + WebView2 einmalig und gibt die benoetigten Klassen zurueck."""
vendor = _vendor_dir()
core_dll = vendor / "Microsoft.Web.WebView2.Core.dll"
winforms_dll = vendor / "Microsoft.Web.WebView2.WinForms.dll"
if not core_dll.exists() or not winforms_dll.exists():
raise WebView2Unavailable(f"WebView2-DLLs fehlen in {vendor}")
base = _app_base_dir()
_unblock_downloaded_files((vendor, base / "pythonnet", base / "clr_loader"))
# WebView2Loader.dll (nativ) wird ueber den Suchpfad bzw. runtimes/win-x64/native gefunden.
os.environ["PATH"] = f"{vendor}{os.pathsep}{os.environ.get('PATH', '')}"
try:
from pythonnet import load as load_runtime
load_runtime("netfx")
import clr
clr.AddReference("System.Windows.Forms")
clr.AddReference("System.Drawing")
clr.AddReference(str(core_dll))
clr.AddReference(str(winforms_dll))
from Microsoft.Web.WebView2.Core import (
CoreWebView2PermissionKind,
CoreWebView2PermissionState,
)
from Microsoft.Web.WebView2.WinForms import CoreWebView2CreationProperties, WebView2
except Exception as error: # pythonnet wirft je nach Fehler unterschiedliche Typen
raise WebView2Unavailable(f"WebView2 konnte nicht geladen werden: {error}") from error
return SimpleNamespace(
WebView2=WebView2,
CreationProperties=CoreWebView2CreationProperties,
PermissionKind=CoreWebView2PermissionKind,
PermissionState=CoreWebView2PermissionState,
)
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.10 PySide6>=6.10
pypresence>=4.3 pypresence>=4.3
pythonnet>=3.1
+27
View File
@@ -0,0 +1,27 @@
Copyright (C) Microsoft Corporation. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* The name of Microsoft Corporation, or the names of its contributors
may not be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Binary file not shown.
Binary file not shown.
+85
View File
@@ -0,0 +1,85 @@
NOTICES AND INFORMATION
Do Not Translate or Localize
This software incorporates material from third parties. Microsoft makes certain
open source code available at https://3rdpartysource.microsoft.com, or you may
send a check or money order for US $5.00, including the product name, the open
source component name, and version number, to:
Source Code Compliance Team
Microsoft Corporation
One Microsoft Way
Redmond, WA 98052
USA
Notwithstanding any other terms, you may reverse engineer this software to the
extent required to debug changes to any libraries licensed under the GNU Lesser
General Public License.
----------------------------------------------------------------
Antlr3.Runtime 3.5.2-rc1 - BSD 3-Clause
[The "BSD license"]
Copyright (c) 2011 The ANTLR Project
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---------------------------------------------------------
---------------------------------------------------------
StringTemplate4 4.0.9-rc1 - BSD 3-Clause
[The "BSD license"]
Copyright (c) 2011 The ANTLR Project
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---------------------------------------------------------
Binary file not shown.
Binary file not shown.