Compare commits
4
Commits
v4.5.1
...
d14272991c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14272991c | ||
|
|
bb15ff3316 | ||
|
|
f1bde49cf7 | ||
|
|
c3e69bd5da |
@@ -1,14 +1,19 @@
|
||||
name: Release
|
||||
name: Release (Edge)
|
||||
|
||||
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem
|
||||
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch
|
||||
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue
|
||||
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
|
||||
# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
|
||||
# und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird.
|
||||
#
|
||||
# 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:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
- "v*-edge"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -30,14 +35,10 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Playtube.exe bauen
|
||||
- name: PlaytubeEdge.exe bauen
|
||||
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
||||
|
||||
- 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
|
||||
run: |
|
||||
$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
|
||||
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
|
||||
with:
|
||||
name: windows-setup
|
||||
path: dist/installer/Playtube-Setup-v*.exe
|
||||
name: windows-edge-setup
|
||||
path: dist/installer/PlaytubeEdge-Setup-v*.exe
|
||||
|
||||
- name: Als .zip packen (volles Paket, portabel)
|
||||
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
|
||||
- name: Als .zip packen (portabel)
|
||||
run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-build
|
||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
||||
name: windows-edge-zip
|
||||
path: PlaytubeEdge-*-win64.zip
|
||||
|
||||
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
|
||||
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
|
||||
# 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:
|
||||
publish-release:
|
||||
needs: [build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \
|
||||
libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \
|
||||
libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
|
||||
libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \
|
||||
libxfixes3 libxi6 libxext6 fonts-liberation
|
||||
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Playtube-Binary bauen
|
||||
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
|
||||
|
||||
- name: Als .tar.gz packen (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
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: GitHub Release veroeffentlichen
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
- name: GitHub Release veroeffentlichen (Vorabversion, nie "latest")
|
||||
# gh statt softprops/action-gh-release: letzteres brach beim erneuten Veroeffentlichen
|
||||
# desselben Tags mit "Too many retries" ab. Ein vorhandenes Release zum selben Tag wird
|
||||
# zuerst entfernt (der Tag bleibt), dann sauber neu angelegt.
|
||||
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,4 +4,8 @@ __pycache__/
|
||||
build/
|
||||
dist/
|
||||
run*.log
|
||||
*_run.log
|
||||
build_edge.log
|
||||
.claude/
|
||||
.pytest_cache/
|
||||
test_login*.log
|
||||
|
||||
@@ -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
|
||||
Erweiterung) mit:
|
||||
|
||||
@@ -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.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
|
||||
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
||||
from playtube.shortcuts import ( # noqa: E402
|
||||
ensure_play_file_association,
|
||||
ensure_start_menu_shortcut,
|
||||
)
|
||||
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
|
||||
from playtube.updater import cleanup_old_staging # noqa: E402
|
||||
|
||||
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
|
||||
# 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
|
||||
@@ -78,7 +69,7 @@ def main() -> int:
|
||||
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
|
||||
clear_cache_on_update(__version__)
|
||||
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.show()
|
||||
|
||||
+7
-56
@@ -1,19 +1,11 @@
|
||||
# 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.
|
||||
# Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und
|
||||
# danach den Setup-Installer (Inno Setup 6).
|
||||
#
|
||||
# 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).
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@@ -21,53 +13,13 @@ $ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
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
|
||||
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
|
||||
Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.exe" -ForegroundColor Green
|
||||
|
||||
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
|
||||
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und
|
||||
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
|
||||
# Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
|
||||
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
|
||||
if (-not $SkipInstaller) {
|
||||
$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 "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 "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
|
||||
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
|
||||
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
@@ -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):
|
||||
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss
|
||||
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1).
|
||||
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe
|
||||
; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
|
||||
; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
|
||||
; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
|
||||
;
|
||||
; Wichtig fuer "es soll nie mehrere Versionen geben":
|
||||
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene
|
||||
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen).
|
||||
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte
|
||||
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar).
|
||||
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner
|
||||
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben.
|
||||
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten.
|
||||
; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
|
||||
; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
|
||||
; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
|
||||
; parallel installiert sein; keine ueberschreibt die andere.
|
||||
; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
|
||||
; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
|
||||
; uebereinstimmen.
|
||||
; - 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
|
||||
; Inno Setup nicht ins Spiel kommt.
|
||||
@@ -21,14 +22,13 @@
|
||||
#define AppVersion "0.0.0"
|
||||
#endif
|
||||
|
||||
#define AppName "Playtube"
|
||||
#define AppExeName "Playtube.exe"
|
||||
#define AppHelperName "PlaytubeHelper.exe"
|
||||
#define AppName "Playtube Edge"
|
||||
#define AppDirName "PlaytubeEdge"
|
||||
#define AppExeName "PlaytubeEdge.exe"
|
||||
|
||||
[Setup]
|
||||
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit
|
||||
; _INNO_APP_ID in playtube/updater.py uebereinstimmen.
|
||||
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
|
||||
; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
|
||||
AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
@@ -37,22 +37,21 @@ AppPublisherURL=https://github.com/fojadrachi/Playtube
|
||||
AppSupportURL=https://github.com/fojadrachi/Playtube/issues
|
||||
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
|
||||
VersionInfoVersion={#AppVersion}
|
||||
DefaultDirName={localappdata}\Programs\{#AppName}
|
||||
DefaultDirName={localappdata}\Programs\{#AppDirName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir=..\dist\installer
|
||||
OutputBaseFilename=Playtube-Setup-v{#AppVersion}
|
||||
OutputBaseFilename=PlaytubeEdge-Setup-v{#AppVersion}
|
||||
SetupIconFile=..\assets\icon.ico
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
UninstallDisplayName={#AppName}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des
|
||||
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu
|
||||
; schliessen, die Inno per CloseApplications senden wuerde.
|
||||
; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des
|
||||
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
|
||||
CloseApplications=no
|
||||
RestartApplications=no
|
||||
|
||||
@@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
|
||||
Type: filesandordirs; Name: "{app}\_internal"
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[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: "{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]
|
||||
; 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
|
||||
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch
|
||||
; wieder starten.
|
||||
; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
|
||||
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
|
||||
|
||||
[Code]
|
||||
@@ -107,10 +95,8 @@ procedure WaitForOldInstance();
|
||||
var
|
||||
OldPid, ResultCode: Integer;
|
||||
begin
|
||||
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren
|
||||
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat,
|
||||
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
|
||||
hier nichts. }
|
||||
{ Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID
|
||||
(/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). }
|
||||
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
|
||||
if OldPid > 0 then
|
||||
Exec(PowerShellPath(),
|
||||
@@ -122,16 +108,13 @@ procedure StopLeftoverProcesses();
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus
|
||||
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet
|
||||
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat
|
||||
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
|
||||
{ WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
|
||||
also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
|
||||
PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
|
||||
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
|
||||
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden
|
||||
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
|
||||
{ WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen,
|
||||
wuerden Dateien sperren - gezielt nach Pfad beenden. }
|
||||
if DirExists(ExpandConstant('{app}')) then
|
||||
Exec(PowerShellPath(),
|
||||
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
|
||||
@@ -161,8 +144,8 @@ begin
|
||||
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
|
||||
begin
|
||||
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
|
||||
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True);
|
||||
DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
|
||||
end;
|
||||
end;
|
||||
|
||||
+15
-6
@@ -4,22 +4,31 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
block_cipher = None
|
||||
|
||||
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(
|
||||
[str(ROOT / "main.py")],
|
||||
pathex=[str(ROOT)],
|
||||
binaries=[],
|
||||
binaries=pythonnet_binaries + clr_binaries,
|
||||
datas=[
|
||||
(str(ROOT / "assets"), "assets"),
|
||||
(str(ROOT / "config.json"), "."),
|
||||
],
|
||||
hiddenimports=["pypresence"],
|
||||
(str(ROOT / "vendor" / "webview2"), "vendor/webview2"),
|
||||
] + pythonnet_datas + clr_datas,
|
||||
hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden,
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
# Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein.
|
||||
excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"],
|
||||
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.
|
||||
exe_kwargs = dict(
|
||||
exclude_binaries=True,
|
||||
name="Playtube",
|
||||
name="PlaytubeEdge",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
@@ -49,5 +58,5 @@ coll = COLLECT(
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name="Playtube",
|
||||
name="PlaytubeEdge",
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
@@ -19,12 +19,12 @@ VSVersionInfo(
|
||||
StringTable(
|
||||
u'040704B0',
|
||||
[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'InternalName', u'Playtube'),
|
||||
StringStruct(u'InternalName', u'PlaytubeEdge'),
|
||||
StringStruct(u'LegalCopyright', u''),
|
||||
StringStruct(u'OriginalFilename', u'Playtube.exe'),
|
||||
StringStruct(u'ProductName', u'Playtube'),
|
||||
StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'),
|
||||
StringStruct(u'ProductName', u'Playtube Edge'),
|
||||
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
||||
]),
|
||||
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "4.5.1"
|
||||
__app_name__ = "Playtube Edge"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -30,15 +30,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
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"
|
||||
|
||||
@@ -54,19 +46,13 @@ def list_output_devices() -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
def sync_audio_permissions(profile: QWebEngineProfile, wanted: bool) -> None:
|
||||
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer
|
||||
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe
|
||||
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der
|
||||
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"),
|
||||
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt."""
|
||||
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture
|
||||
for origin in YOUTUBE_ORIGINS:
|
||||
permission = profile.queryPermission(QUrl(origin), permission_type)
|
||||
if wanted:
|
||||
permission.grant()
|
||||
else:
|
||||
permission.reset()
|
||||
def sync_audio_permissions(tabs, wanted: bool) -> None:
|
||||
"""Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die
|
||||
Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der
|
||||
Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die
|
||||
Freigabe selbst (WebView2-Profil, bleibt dort gespeichert)."""
|
||||
for tab in tabs:
|
||||
tab.set_device_names_allowed(wanted)
|
||||
|
||||
|
||||
# JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt.
|
||||
|
||||
+283
-171
@@ -1,25 +1,23 @@
|
||||
"""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."""
|
||||
"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
|
||||
persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
|
||||
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
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage,
|
||||
QWebEngineProfile,
|
||||
QWebEngineScript,
|
||||
QWebEngineSettings,
|
||||
QWebEngineUrlRequestInterceptor,
|
||||
)
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
|
||||
from . import webview2_runtime
|
||||
from .audio_routing import build_router_script
|
||||
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
||||
from .config import profile_dir
|
||||
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
|
||||
@@ -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
|
||||
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
|
||||
_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
|
||||
# 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
|
||||
_user32 = ctypes.windll.user32 if os.name == "nt" else None
|
||||
if _user32 is not None:
|
||||
# 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
|
||||
_user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||
_user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
||||
_user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||
_user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
|
||||
_user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
||||
_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":
|
||||
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Windows"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
|
||||
elif sys.platform.startswith("linux"):
|
||||
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
|
||||
_SEC_CH_UA_PLATFORM = b'"Linux"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
|
||||
else:
|
||||
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
|
||||
_SEC_CH_UA_PLATFORM = b'"macOS"'
|
||||
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
|
||||
|
||||
_USER_AGENT = (
|
||||
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")
|
||||
_GWL_STYLE = -16
|
||||
_WS_POPUP = 0x80000000
|
||||
_WS_CAPTION = 0x00C00000
|
||||
_WS_CHILD = 0x40000000
|
||||
_WS_VISIBLE = 0x10000000
|
||||
_WS_CLIPSIBLINGS = 0x04000000
|
||||
_WS_CLIPCHILDREN = 0x02000000
|
||||
_SW_SHOW = 5
|
||||
|
||||
|
||||
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", _SEC_CH_UA_PLATFORM)
|
||||
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
|
||||
def _decode_script_result(raw: Any) -> Any:
|
||||
"""ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst
|
||||
einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(str(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
_shared_profile: QWebEngineProfile | None = None
|
||||
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
||||
def _in_document(source: str) -> str:
|
||||
"""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:
|
||||
"""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."""
|
||||
class BrowserTab(QWidget):
|
||||
"""Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
||||
|
||||
mediaInfoChanged = Signal(dict)
|
||||
titleUpdated = Signal(str)
|
||||
loadFinished = Signal(bool)
|
||||
urlChanged = Signal(QUrl)
|
||||
|
||||
def __init__(self, home_url: str, parent=None):
|
||||
super().__init__(parent)
|
||||
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)
|
||||
self.setPage(page)
|
||||
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||
self.setMinimumSize(200, 150)
|
||||
|
||||
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)
|
||||
runtime = webview2_runtime.load()
|
||||
self._runtime = runtime
|
||||
properties = runtime.CreationProperties()
|
||||
properties.UserDataFolder = str(profile_dir() / "webview2")
|
||||
properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
|
||||
properties.Language = "de-DE"
|
||||
self._view = runtime.WebView2()
|
||||
self._view.CreationProperties = properties
|
||||
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
|
||||
|
||||
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
||||
|
||||
self.load(QUrl(home_url))
|
||||
self._task_timer = QTimer(self)
|
||||
self._task_timer.setInterval(_TASK_POLL_MS)
|
||||
self._task_timer.timeout.connect(self._drain_tasks)
|
||||
self._task_timer.start()
|
||||
|
||||
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.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:
|
||||
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:
|
||||
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
|
||||
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
|
||||
bleibt bei Seitenwechseln/Neuladen erhalten (Skript pro Seite)."""
|
||||
page = self.page()
|
||||
scripts = page.scripts()
|
||||
for old in scripts.find(AUDIO_SCRIPT_NAME):
|
||||
scripts.remove(old)
|
||||
|
||||
bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
|
||||
source = build_router_script(device_name)
|
||||
if device_name:
|
||||
script = QWebEngineScript()
|
||||
script.setName(AUDIO_SCRIPT_NAME)
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
script.setRunsOnSubFrames(False)
|
||||
script.setSourceCode(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)
|
||||
if self._core is None:
|
||||
self._queued_router = source if device_name else None
|
||||
else:
|
||||
self._install_router(source if device_name else None)
|
||||
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard.
|
||||
if device_name or self._audio_output_set:
|
||||
self._run_js(source)
|
||||
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:
|
||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||
self._run_js(TOGGLE_PLAYBACK_JS)
|
||||
|
||||
def next_track(self) -> None:
|
||||
self.page().runJavaScript(NEXT_TRACK_JS)
|
||||
self._run_js(NEXT_TRACK_JS)
|
||||
|
||||
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:
|
||||
"""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
|
||||
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)
|
||||
|
||||
def list_playlists(self, callback) -> None:
|
||||
"""Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf."""
|
||||
|
||||
def on_result(result) -> None:
|
||||
try:
|
||||
callback(json.loads(result) if isinstance(result, str) and result else None)
|
||||
except json.JSONDecodeError:
|
||||
callback(None)
|
||||
|
||||
self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result)
|
||||
if self._core is None:
|
||||
callback(None)
|
||||
return
|
||||
self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None))
|
||||
|
||||
def play_playlist(self, playlist_id: str) -> None:
|
||||
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
||||
die Wiedergabe (PlaybackRequiresUserGesture ist aus)."""
|
||||
self.load(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()
|
||||
die Wiedergabe."""
|
||||
self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
||||
|
||||
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:
|
||||
# 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
|
||||
self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result)
|
||||
|
||||
def _on_media_probe_result(self, info: Any) -> None:
|
||||
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):
|
||||
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
@@ -8,15 +8,18 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "Playtube"
|
||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
||||
APP_NAME = "Playtube Edge"
|
||||
APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
|
||||
|
||||
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||
# 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] = {
|
||||
"app_name": APP_NAME,
|
||||
@@ -28,6 +31,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
||||
"show_idle_presence": True,
|
||||
},
|
||||
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
|
||||
"updates": {
|
||||
"enabled": True,
|
||||
"check_interval_hours": 6,
|
||||
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .audio_routing import sync_audio_permissions
|
||||
from .browser import BrowserTab, get_shared_profile
|
||||
from .browser import BrowserTab
|
||||
from .config import APP_NAME
|
||||
from .discord_rpc import DiscordRPCWorker
|
||||
from .remote_control import build_state as build_remote_state
|
||||
@@ -57,9 +57,9 @@ class MainWindow(QMainWindow):
|
||||
self._tabs.setDocumentMode(True)
|
||||
self.setCentralWidget(self._tabs)
|
||||
|
||||
self._sync_audio_permission()
|
||||
self._youtube_tab = BrowserTab(config["home_youtube"], 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
|
||||
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
||||
# erteilte schon (siehe audio_routing.py).
|
||||
@@ -254,7 +254,7 @@ class MainWindow(QMainWindow):
|
||||
def _sync_audio_permission(self) -> None:
|
||||
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
||||
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:
|
||||
if any(self._audio_outputs()):
|
||||
|
||||
+22
-6
@@ -54,16 +54,19 @@ 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"
|
||||
# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
|
||||
# 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"
|
||||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
||||
_LINUX_BINARY_NAME = "PlaytubeEdge"
|
||||
_WINDOWS_BINARY_NAME = "PlaytubeEdge.exe"
|
||||
|
||||
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
|
||||
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
|
||||
# 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_"
|
||||
|
||||
@@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None:
|
||||
)
|
||||
try:
|
||||
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):
|
||||
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:
|
||||
@@ -125,6 +137,10 @@ def _asset_kind(name: str) -> str | None:
|
||||
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
|
||||
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
|
||||
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 name.endswith(".exe") and "setup" in name:
|
||||
return _KIND_SETUP
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
PySide6>=6.10
|
||||
pypresence>=4.3
|
||||
pythonnet>=3.1
|
||||
|
||||
Vendored
+27
@@ -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.
|
||||
BIN
Binary file not shown.
Binary file not shown.
Vendored
+85
@@ -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.
|
||||
|
||||
---------------------------------------------------------
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user