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.
This commit is contained in:
2026-09-26 20:23:24 +02:00
parent 6a682fc49c
commit c3e69bd5da
22 changed files with 628 additions and 422 deletions
+283 -171
View File
@@ -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