- Settings-Tab in der App (Discord/Update/Start-Tab live editierbar, kein config.json-Handbearbeiten noetig) - updater.py: plattformabhaengige Asset-Auswahl + Installation (Windows .zip/robocopy, Linux .tar.gz/Shell-Skript) - browser.py/chrome_shim.py: Sec-CH-UA + navigator.userAgentData jetzt je nach sys.platform (Windows/Linux/macOS) - app_id.py: robustere Suche nach umbenanntem QtWebEngine-Hilfsprozess (rglob statt fixer Pfade) - packaging/playtube.spec: icon/version nur unter Windows setzen (Linux-Build faehig) - packaging/install-linux.sh: Installationsskript fuer native Linux-Version (Desktop-Eintrag, Icon, PATH) - .github/workflows/release.yml: baut bei Tag-Push automatisch Windows-.exe UND Linux-Binary und veroeffentlicht beide als GitHub Release Co-Authored-By: Claude Sonnet 5 <[email protected]>
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
|
|
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
|
|
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from PySide6.QtCore import QTimer, Signal, QUrl
|
|
from PySide6.QtWebEngineCore import (
|
|
QWebEnginePage,
|
|
QWebEngineProfile,
|
|
QWebEngineScript,
|
|
QWebEngineSettings,
|
|
QWebEngineUrlRequestInterceptor,
|
|
)
|
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
|
|
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
|
from .config import profile_dir
|
|
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
|
|
|
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
|
|
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
|
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
|
|
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
|
|
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
|
|
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
|
|
# unter Umstaenden nicht sicher".
|
|
_CHROME_VERSION = CHROME_FULL
|
|
_CHROME_MAJOR = CHROME_MAJOR
|
|
|
|
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")
|
|
|
|
|
|
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)
|
|
|
|
|
|
_shared_profile: QWebEngineProfile | None = None
|
|
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
|
|
|
|
|
def get_shared_profile() -> QWebEngineProfile:
|
|
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
|
|
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
|
|
global _shared_profile, _shared_interceptor
|
|
if _shared_profile is None:
|
|
_shared_profile = QWebEngineProfile("playtube-profile")
|
|
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
|
|
_shared_profile.setCachePath(str(profile_dir() / "cache"))
|
|
_shared_profile.setPersistentCookiesPolicy(
|
|
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
|
)
|
|
_shared_profile.setHttpUserAgent(_USER_AGENT)
|
|
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
|
|
|
|
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
|
|
_shared_interceptor = _ChromeBrandingInterceptor()
|
|
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
|
|
|
|
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
|
|
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
|
|
shim_script = QWebEngineScript()
|
|
shim_script.setName("playtube-chrome-shim")
|
|
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
|
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
|
shim_script.setRunsOnSubFrames(True)
|
|
shim_script.setSourceCode(CHROME_SHIM_JS)
|
|
_shared_profile.scripts().insert(shim_script)
|
|
return _shared_profile
|
|
|
|
|
|
class BrowserTab(QWebEngineView):
|
|
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
|
|
|
mediaInfoChanged = Signal(dict)
|
|
titleUpdated = Signal(str)
|
|
|
|
def __init__(self, home_url: str, parent=None):
|
|
super().__init__(parent)
|
|
self._home_url = home_url
|
|
|
|
page = QWebEnginePage(get_shared_profile(), self)
|
|
self.setPage(page)
|
|
|
|
settings = page.settings()
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
|
|
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
|
|
|
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
|
|
|
self.load(QUrl(home_url))
|
|
|
|
self._poll_timer = QTimer(self)
|
|
self._poll_timer.setInterval(2000)
|
|
self._poll_timer.timeout.connect(self._poll_media_state)
|
|
self._poll_timer.start()
|
|
|
|
self.titleChanged.connect(self.titleUpdated.emit)
|
|
|
|
def go_home(self) -> None:
|
|
self.load(QUrl(self._home_url))
|
|
|
|
def toggle_playback(self) -> None:
|
|
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
|
|
|
def next_track(self) -> None:
|
|
self.page().runJavaScript(NEXT_TRACK_JS)
|
|
|
|
def previous_track(self) -> None:
|
|
self.page().runJavaScript(PREV_TRACK_JS)
|
|
|
|
def _on_full_screen_requested(self, request) -> None:
|
|
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
|
request.accept()
|
|
window = self.window()
|
|
if request.toggleOn():
|
|
window.showFullScreen()
|
|
else:
|
|
window.showNormal()
|
|
|
|
def _poll_media_state(self) -> None:
|
|
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
|
|
|
def _on_media_probe_result(self, result) -> None:
|
|
if isinstance(result, dict):
|
|
self.mediaInfoChanged.emit(result)
|