Playtube: eigenstaendiger YouTube/YouTube-Music-Player mit Discord RPC und Auto-Update

- PySide6/QtWebEngine mit persistentem Login-Profil (YouTube + YouTube Music Tabs)
- Google-Login-Fix per Sec-CH-UA-Headern + Chrome-JS-Shim
- Discord Rich Presence (eigener Thread, Titel/Fortschritt/Buttons)
- Eigenes Branding (Taskmanager/Taskleiste) per AppUserModelID + PyInstaller-Packaging
- Auto-Update ueber GitHub Releases (fojadrachi/Playtube)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-11 19:13:13 +02:00
co-authored by Claude Sonnet 5
commit 578cda499a
21 changed files with 1640 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube"
__version__ = "1.0.0"
+57
View File
@@ -0,0 +1,57 @@
"""Branding-Helfer: eigener Prozessname/Icon im Taskmanager, in der Taskleiste und
(so weit von Windows respektiert) im Lautstaerkemixer.
Wichtig: Diese Funktionen muessen VOR dem Import von QtWebEngineWidgets/QtWebEngineCore
aufgerufen werden, weil QtWebEngine seinen Sub-Prozess (QtWebEngineProcess.exe) beim
Import-Zeitpunkt vorbereitet.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from .config import APP_AUMID
def set_app_user_model_id() -> None:
"""Setzt die Windows AppUserModelID fuer diesen Prozess.
Das sorgt dafuer, dass Windows die App in Taskleiste/Alt-Tab/Benachrichtigungen
als eigenstaendige Anwendung fuehrt (eigenes Icon, eigene Gruppierung) statt als
generisches "Python".
"""
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_AUMID)
except Exception:
# Auf aelteren Windows-Versionen oder ohne shell32 einfach ignorieren.
pass
def configure_webengine_process_path() -> None:
"""Wenn ein umbenannter QtWebEngineProcess (siehe packaging/build.ps1) neben der
.exe liegt, wird QtWebEngine angewiesen diesen zu benutzen statt des Standard-
"QtWebEngineProcess.exe". Dadurch zeigt auch der Sub-Prozess, der tatsaechlich den
Ton wiedergibt, im Taskmanager/Lautstaerkemixer den Playtube-Namen statt
"QtWebEngineProcess".
Nur relevant fuer gepackte (PyInstaller-)Builds. Im Entwicklungsmodus (python
main.py) wird der Standardprozess von PySide6 benutzt - das ist voellig normal
und hat keinen Einfluss auf die Funktion der App.
"""
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
exe_dir = Path(sys.executable).resolve().parent
candidates = [
exe_dir / "PlaytubeHelper.exe",
exe_dir / "_internal" / "PlaytubeHelper.exe",
]
for candidate in candidates:
if candidate.exists():
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
return
+151
View File
@@ -0,0 +1,151 @@
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
der aktuellen Wiedergabe fuer Discord Rich Presence."""
from __future__ import annotations
from PySide6.QtCore import QTimer, Signal, QUrl
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineScript,
QWebEngineSettings,
QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
from .config import profile_dir
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
# unter Umstaenden nicht sicher".
_CHROME_VERSION = CHROME_FULL
_CHROME_MAJOR = CHROME_MAJOR
_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
)
_SEC_CH_UA = (
f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", '
f'"Chromium";v="{_CHROME_MAJOR}"'
).encode("ascii")
_SEC_CH_UA_FULL_VERSION_LIST = (
f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", '
f'"Chromium";v="{_CHROME_VERSION}"'
).encode("ascii")
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
"""Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit
sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise
nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles
Login-Seite ein Embedded-WebView und blockiert die Anmeldung)."""
def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override)
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA)
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST)
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0")
info.setHttpHeader(b"sec-ch-ua-platform", b'"Windows"')
info.setHttpHeader(b"sec-ch-ua-platform-version", b'"15.0.0"')
_shared_profile: QWebEngineProfile | None = None
_shared_interceptor: _ChromeBrandingInterceptor | None = None
def get_shared_profile() -> QWebEngineProfile:
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
global _shared_profile, _shared_interceptor
if _shared_profile is None:
_shared_profile = QWebEngineProfile("playtube-profile")
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
_shared_profile.setCachePath(str(profile_dir() / "cache"))
_shared_profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
)
_shared_profile.setHttpUserAgent(_USER_AGENT)
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
_shared_interceptor = _ChromeBrandingInterceptor()
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
shim_script = QWebEngineScript()
shim_script.setName("playtube-chrome-shim")
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
shim_script.setRunsOnSubFrames(True)
shim_script.setSourceCode(CHROME_SHIM_JS)
_shared_profile.scripts().insert(shim_script)
return _shared_profile
class BrowserTab(QWebEngineView):
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
mediaInfoChanged = Signal(dict)
titleUpdated = Signal(str)
def __init__(self, home_url: str, parent=None):
super().__init__(parent)
self._home_url = home_url
page = QWebEnginePage(get_shared_profile(), self)
self.setPage(page)
settings = page.settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
page.fullScreenRequested.connect(self._on_full_screen_requested)
self.load(QUrl(home_url))
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(2000)
self._poll_timer.timeout.connect(self._poll_media_state)
self._poll_timer.start()
self.titleChanged.connect(self.titleUpdated.emit)
def go_home(self) -> None:
self.load(QUrl(self._home_url))
def toggle_playback(self) -> None:
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
def next_track(self) -> None:
self.page().runJavaScript(NEXT_TRACK_JS)
def previous_track(self) -> None:
self.page().runJavaScript(PREV_TRACK_JS)
def _on_full_screen_requested(self, request) -> None:
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
request.accept()
window = self.window()
if request.toggleOn():
window.showFullScreen()
else:
window.showNormal()
def _poll_media_state(self) -> None:
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
def _on_media_probe_result(self, result) -> None:
if isinstance(result, dict):
self.mediaInfoChanged.emit(result)
+122
View File
@@ -0,0 +1,122 @@
"""JavaScript-"Shim", der QtWebEngine (echtes, aber unbranded Chromium) fuer
JS-seitige Fingerprint-Checks wie echtes Google Chrome aussehen laesst.
Hintergrund: Google blockiert Logins aus WebViews/Embedded-Browsern serverseitig
("Dieser Browser oder diese App ist unter Umstaenden nicht sicher"). Die dazu
verwendeten Signale sind u.a.:
- Fehlendes `window.chrome` Objekt (QtWebEngine ist ein Open-Source-Chromium-Build
ohne Google-Branding, hat das normalerweise nicht).
- `navigator.userAgentData` (JS Client Hints), das bei QtWebEngine "Chromium" statt
"Google Chrome" als Marke meldet.
- Fehlende/leere `navigator.plugins` (echtes Chrome hat u.a. immer den eingebauten
PDF-Viewer als Plugin gelistet).
Die HTTP-Header-Variante (Sec-CH-UA) wird zusaetzlich in browser.py gesetzt - Google
prueft offenbar beides, JS-seitig und Header-seitig, daher muessen beide konsistent
"Google Chrome" melden.
Dies aendert nur, WIE sich der eingebettete Browser bei Feature-Detection meldet, es
manipuliert keine fremden Konten und automatisiert keine Logins - es ermoeglicht dem
Nutzer lediglich, sich in der eigenen App mit dem eigenen Google-Konto anzumelden,
so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist.
"""
CHROME_MAJOR = "140"
CHROME_FULL = "140.0.0.0"
CHROME_SHIM_JS = f"""
(function() {{
try {{
Object.defineProperty(navigator, 'webdriver', {{ get: () => undefined, configurable: true }});
}} catch (e) {{}}
try {{
if (typeof window.chrome === 'undefined') {{ window.chrome = {{}}; }}
window.chrome.runtime = window.chrome.runtime || {{}};
window.chrome.loadTimes = window.chrome.loadTimes || function() {{
var now = Date.now() / 1000;
return {{
commitLoadTime: now, connectionInfo: 'h2',
finishDocumentLoadTime: now, finishLoadTime: now,
firstPaintAfterLoadTime: 0, firstPaintTime: now,
navigationType: 'Other', npnNegotiatedProtocol: 'h2',
requestTime: now - 1, startLoadTime: now - 1,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true, wasNpnNegotiated: true
}};
}};
window.chrome.csi = window.chrome.csi || function() {{
return {{ onloadT: Date.now(), pageT: Date.now(), startE: Date.now(), tran: 15 }};
}};
window.chrome.app = window.chrome.app || {{
isInstalled: false,
InstallState: {{ DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }},
RunningState: {{ CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }}
}};
}} catch (e) {{}}
try {{
var brands = [
{{ brand: 'Not(A:Brand', version: '99' }},
{{ brand: 'Google Chrome', version: '{CHROME_MAJOR}' }},
{{ brand: 'Chromium', version: '{CHROME_MAJOR}' }}
];
var fullBrands = [
{{ brand: 'Not(A:Brand', version: '99.0.0.0' }},
{{ brand: 'Google Chrome', version: '{CHROME_FULL}' }},
{{ brand: 'Chromium', version: '{CHROME_FULL}' }}
];
var uaData = {{
brands: brands,
mobile: false,
platform: 'Windows',
getHighEntropyValues: function(hints) {{
var full = {{
architecture: 'x86', bitness: '64', brands: brands,
fullVersionList: fullBrands, mobile: false, model: '',
platform: 'Windows', platformVersion: '15.0.0',
uaFullVersion: '{CHROME_FULL}', wow64: false
}};
var result = {{}};
(hints || Object.keys(full)).forEach(function(k) {{
if (k in full) {{ result[k] = full[k]; }}
}});
return Promise.resolve(result);
}},
toJSON: function() {{ return {{ brands: brands, mobile: false, platform: 'Windows' }}; }}
}};
Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }});
}} catch (e) {{}}
try {{
var fakePlugin = {{
name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer',
description: 'Portable Document Format', length: 1
}};
var fakePlugins = [fakePlugin];
fakePlugins.item = function(i) {{ return fakePlugins[i]; }};
fakePlugins.namedItem = function() {{ return fakePlugin; }};
fakePlugins.refresh = function() {{}};
Object.defineProperty(navigator, 'plugins', {{ get: () => fakePlugins, configurable: true }});
Object.defineProperty(navigator, 'mimeTypes', {{ get: () => [], configurable: true }});
}} catch (e) {{}}
try {{
Object.defineProperty(navigator, 'languages', {{
get: () => ['de-DE', 'de', 'en-US', 'en'], configurable: true
}});
}} catch (e) {{}}
try {{
var origQuery = window.navigator.permissions && window.navigator.permissions.query;
if (origQuery) {{
window.navigator.permissions.query = function(params) {{
if (params && params.name === 'notifications') {{
return Promise.resolve({{ state: Notification.permission }});
}}
return origQuery(params);
}};
}}
}} catch (e) {{}}
}})();
"""
+86
View File
@@ -0,0 +1,86 @@
"""Laden/Speichern der Benutzerkonfiguration (config.json im Projekt- bzw. App-Datenordner)."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
APP_NAME = "Playtube"
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
DEFAULT_CONFIG: dict[str, Any] = {
"app_name": APP_NAME,
"discord": {
"enabled": True,
# Deine Discord Application Client-ID (discord.com/developers/applications).
"client_id": "1544058029190938774",
"update_interval_seconds": 15,
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
"show_idle_presence": True,
},
"updates": {
"enabled": True,
"check_interval_hours": 6,
},
"start_tab": "youtube", # "youtube" oder "music"
"home_youtube": "https://www.youtube.com/",
"home_music": "https://music.youtube.com/",
"window": {"width": 1366, "height": 860},
}
def _app_data_dir() -> Path:
"""Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil."""
base = os.environ.get("APPDATA") or str(Path.home())
d = Path(base) / APP_NAME
d.mkdir(parents=True, exist_ok=True)
return d
def _config_path() -> Path:
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
# im gepackten Build im APPDATA-Ordner.
if getattr(sys, "frozen", False):
return _app_data_dir() / "config.json"
return Path(__file__).resolve().parent.parent / "config.json"
def _deep_merge(base: dict, override: dict) -> dict:
result = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config() -> dict[str, Any]:
path = _config_path()
if path.exists():
try:
with open(path, "r", encoding="utf-8") as f:
user_cfg = json.load(f)
return _deep_merge(DEFAULT_CONFIG, user_cfg)
except (json.JSONDecodeError, OSError):
pass
save_config(DEFAULT_CONFIG)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict[str, Any]) -> None:
path = _config_path()
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
except OSError:
pass
def profile_dir() -> Path:
"""Ordner fuer das persistente Browser-Profil (Login-Session, Cookies, Local Storage)."""
d = _app_data_dir() / "webprofile"
d.mkdir(parents=True, exist_ok=True)
return d
+191
View File
@@ -0,0 +1,191 @@
"""Discord Rich Presence Integration.
Laeuft in einem eigenen Thread (pypresence macht blockierende IPC-Aufrufe), damit die
Oberflaeche nie haengt, egal ob Discord laeuft, gerade startet oder gar nicht
installiert ist. Verbindungsfehler werden abgefangen und in Intervallen erneut
versucht, ohne die App zu beeintraechtigen.
"""
from __future__ import annotations
import queue
import time
from typing import Any
from PySide6.QtCore import QThread
from .config import APP_NAME
# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich
# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord
# einfach kein Bild an - es gibt keinen Fehler.
ASSET_YOUTUBE = "youtube_logo"
ASSET_MUSIC = "music_logo"
ASSET_DEFAULT = "logo"
ASSET_PLAY = "play_icon"
ASSET_PAUSE = "pause_icon"
_RECONNECT_DELAY_SECONDS = 10
def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str:
text = (text or fallback).strip()
if not text:
text = fallback
if len(text) > limit:
text = text[: limit - 1].rstrip() + "…"
return text
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]:
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
Medien-Informationen."""
is_music = bool(info.get("isMusic"))
playing = bool(info.get("playing"))
title = _truncate(info.get("title"), fallback="Unbekannter Titel")
default_state = "YouTube Music" if is_music else "YouTube"
state = _truncate(info.get("subtitle"), fallback=default_state)
payload: dict[str, Any] = {
"details": title,
"state": state,
"large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE,
"large_text": "YouTube Music" if is_music else "YouTube",
"small_image": ASSET_PLAY if playing else ASSET_PAUSE,
"small_text": "Spielt" if playing else "Pausiert",
}
duration = info.get("duration") or 0
current_time = info.get("currentTime") or 0
if playing:
start_ts = int(time.time() - current_time)
payload["start"] = start_ts
if duration and duration > 0:
payload["end"] = start_ts + int(duration)
url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"):
label = "In YouTube Music öffnen" if is_music else "Auf YouTube ansehen"
payload["buttons"] = [{"label": label, "url": url}]
return payload
def build_idle_payload(session_start: int) -> dict[str, Any]:
return {
"details": f"Bei {APP_NAME}",
"state": "Stöbert gerade",
"large_image": ASSET_DEFAULT,
"large_text": APP_NAME,
"start": session_start,
}
class DiscordRPCWorker(QThread):
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
super().__init__(parent)
self._client_id = client_id
self._interval = max(5.0, float(interval))
self._show_idle = show_idle
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
self._running = True
self._connected = False
self._presence = None
self._session_start = int(time.time())
def submit_media_info(self, info: dict[str, Any] | None) -> None:
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
def _replace_queue(self, item) -> None:
try:
self._queue.get_nowait()
except queue.Empty:
pass
try:
self._queue.put_nowait(item)
except queue.Full:
pass
def stop(self) -> None:
self._running = False
self._replace_queue(_STOP_SENTINEL)
def run(self) -> None:
last_sent = 0.0
while self._running:
try:
item = self._queue.get()
except Exception:
break
if item is _STOP_SENTINEL or not self._running:
break
wait = self._interval - (time.time() - last_sent)
if wait > 0:
time.sleep(wait)
# Waehrend des Wartens evtl. neuere Daten uebernehmen (debounce).
try:
while True:
item = self._queue.get_nowait()
except queue.Empty:
pass
if item is _STOP_SENTINEL or not self._running:
break
self._ensure_connected()
if self._connected:
self._send(item)
last_sent = time.time()
else:
# Nicht verbunden -> nicht sinnlos oft versuchen.
time.sleep(_RECONNECT_DELAY_SECONDS)
self._cleanup()
def _ensure_connected(self) -> None:
if self._connected:
return
try:
from pypresence import Presence
self._presence = Presence(self._client_id)
self._presence.connect()
self._connected = True
except Exception:
self._connected = False
def _send(self, item) -> None:
if self._presence is None:
return
try:
if item is _IDLE_SENTINEL:
if self._show_idle:
self._presence.update(**build_idle_payload(self._session_start))
else:
self._presence.clear()
else:
self._presence.update(**build_presence_payload(item, self._session_start))
except Exception:
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False
def _cleanup(self) -> None:
if self._presence is not None:
try:
self._presence.clear()
self._presence.close()
except Exception:
pass
class _Sentinel:
__slots__ = ()
_IDLE_SENTINEL = _Sentinel()
_STOP_SENTINEL = _Sentinel()
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
from PySide6.QtCore import QTimer, QUrl, Qt
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import (
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QSystemTrayIcon,
QTabWidget,
QToolBar,
QWidget,
)
from . import __version__ as APP_VERSION
from .browser import BrowserTab
from .config import APP_NAME
from .discord_rpc import DiscordRPCWorker
from .updater import UpdateChecker, UpdateInstaller
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
class MainWindow(QMainWindow):
def __init__(self, config: dict[str, Any]):
super().__init__()
self._config = config
self.setWindowTitle(APP_NAME)
self.resize(config["window"]["width"], config["window"]["height"])
icon_path = ASSETS_DIR / "icon.ico"
self._app_icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon()
if not self._app_icon.isNull():
self.setWindowIcon(self._app_icon)
self._tabs = QTabWidget(self)
self._tabs.setDocumentMode(True)
self.setCentralWidget(self._tabs)
self._youtube_tab = BrowserTab(config["home_youtube"], self)
self._music_tab = BrowserTab(config["home_music"], self)
self._tabs.addTab(self._youtube_tab, "YouTube")
self._tabs.addTab(self._music_tab, "YouTube Music")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info)
self._latest_media: dict[int, dict] = {}
self._build_toolbar()
self._build_tray()
self._rpc_worker: DiscordRPCWorker | None = None
discord_cfg = config.get("discord", {})
if discord_cfg.get("enabled") and discord_cfg.get("client_id"):
self._rpc_worker = DiscordRPCWorker(
client_id=str(discord_cfg["client_id"]),
interval=discord_cfg.get("update_interval_seconds", 15),
show_idle=discord_cfg.get("show_idle_presence", True),
)
self._rpc_worker.start()
self._update_checker: UpdateChecker | None = None
self._update_installer: UpdateInstaller | None = None
self._pending_update_version: str | None = None
self._setup_auto_update()
# ------------------------------------------------------------------ UI
def _build_toolbar(self) -> None:
toolbar = QToolBar("Navigation", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)
back_action = QAction("◀", self)
back_action.triggered.connect(lambda: self._current_tab().back())
toolbar.addAction(back_action)
forward_action = QAction("▶", self)
forward_action.triggered.connect(lambda: self._current_tab().forward())
toolbar.addAction(forward_action)
reload_action = QAction("⟳", self)
reload_action.triggered.connect(lambda: self._current_tab().reload())
toolbar.addAction(reload_action)
home_action = QAction("⌂", self)
home_action.triggered.connect(lambda: self._current_tab().go_home())
toolbar.addAction(home_action)
toolbar.addSeparator()
self._url_bar = QLineEdit(self)
self._url_bar.setPlaceholderText("Suche oder URL eingeben …")
self._url_bar.returnPressed.connect(self._navigate_to_url_bar)
toolbar.addWidget(self._url_bar)
self._tabs.currentChanged.connect(self._on_tab_changed)
self._youtube_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._youtube_tab, u))
self._music_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._music_tab, u))
def _build_tray(self) -> None:
self._tray = QSystemTrayIcon(self)
if not self._app_icon.isNull():
self._tray.setIcon(self._app_icon)
self._tray.setToolTip(APP_NAME)
menu = QMenu()
show_action = menu.addAction("Anzeigen")
show_action.triggered.connect(self._show_and_raise)
play_pause_action = menu.addAction("Wiedergabe umschalten")
play_pause_action.triggered.connect(lambda: self._current_tab().toggle_playback())
next_action = menu.addAction("Nächster Titel")
next_action.triggered.connect(lambda: self._current_tab().next_track())
prev_action = menu.addAction("Vorheriger Titel")
prev_action.triggered.connect(lambda: self._current_tab().previous_track())
menu.addSeparator()
quit_action = menu.addAction("Beenden")
quit_action.triggered.connect(self._quit)
self._tray.setContextMenu(menu)
self._tray.activated.connect(self._on_tray_activated)
self._tray.show()
# --------------------------------------------------------------- Slots
def _current_tab(self) -> BrowserTab:
return self._tabs.currentWidget()
def _navigate_to_url_bar(self) -> None:
text = self._url_bar.text().strip()
if not text:
return
if " " in text or ("." not in text and "://" not in text):
url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode())
else:
url = QUrl.fromUserInput(text)
self._current_tab().setUrl(url)
def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None:
if self._current_tab() is tab:
self._url_bar.setText(url.toString())
def _on_tab_changed(self, index: int) -> None:
self._url_bar.setText(self._current_tab().url().toString())
def _on_media_info(self, info: dict) -> None:
sender = self.sender()
self._latest_media[id(sender)] = info
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
# abspielt, sonst der aktuell sichtbare Tab.
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
active_info = playing_info or self._latest_media.get(id(self._current_tab()))
if active_info and active_info.get("title"):
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
else:
self.setWindowTitle(APP_NAME)
if self._rpc_worker is not None:
if active_info and active_info.get("hasVideo"):
self._rpc_worker.submit_media_info(active_info)
else:
self._rpc_worker.submit_media_info(None)
def _on_tray_activated(self, reason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self._show_and_raise()
def _show_and_raise(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
def _quit(self) -> None:
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
self._tray.hide()
from PySide6.QtWidgets import QApplication
QApplication.quit()
# -------------------------------------------------------------- Events
def closeEvent(self, event) -> None:
# Beim Schliessen in den Tray minimieren statt beenden (Musik laeuft weiter).
event.ignore()
self.hide()
self._tray.showMessage(
APP_NAME,
f"{APP_NAME} läuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon zum Beenden.",
self._app_icon if not self._app_icon.isNull() else QSystemTrayIcon.MessageIcon.Information,
3000,
)
# -------------------------------------------------------------- Auto-Update
def _setup_auto_update(self) -> None:
updates_cfg = self._config.get("updates", {})
if not updates_cfg.get("enabled", True):
return
self._check_for_updates()
interval_ms = max(1, int(updates_cfg.get("check_interval_hours", 6))) * 60 * 60 * 1000
self._update_timer = QTimer(self)
self._update_timer.setInterval(interval_ms)
self._update_timer.timeout.connect(self._check_for_updates)
self._update_timer.start()
def _check_for_updates(self) -> None:
if self._update_checker is not None and self._update_checker.isRunning():
return
self._update_checker = UpdateChecker(self)
self._update_checker.updateAvailable.connect(self._on_update_available)
self._update_checker.start()
def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
if self._pending_update_version == version:
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt
self._pending_update_version = version
self._show_and_raise()
notes_preview = (notes[:400] + "…") if len(notes) > 400 else notes
text = f"Eine neue Version von {APP_NAME} ist verfügbar: {version}\n(aktuell installiert: {APP_VERSION})"
if notes_preview:
text += f"\n\n{notes_preview}"
text += "\n\nJetzt installieren?"
answer = QMessageBox.question(
self,
f"{APP_NAME}-Update verfügbar",
text,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if answer == QMessageBox.StandardButton.Yes:
self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self)
self._update_installer.progress.connect(
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
)
self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start()
def _on_update_finished(self) -> None:
if getattr(sys, "frozen", False):
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
self._quit()
else:
QMessageBox.information(
self,
APP_NAME,
"Update installiert. Die App wird jetzt neu gestartet.",
)
self._restart_dev_process()
def _on_update_failed(self, error: str) -> None:
self._tray.setToolTip(APP_NAME)
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
def _restart_dev_process(self) -> None:
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
python = sys.executable
os.execv(python, [python] + sys.argv)
+63
View File
@@ -0,0 +1,63 @@
"""JavaScript, das periodisch in die YouTube- bzw. YouTube-Music-Seite injiziert wird,
um Titel/Interpret/Wiedergabestatus fuer die Discord-Rich-Presence auszulesen."""
MEDIA_PROBE_JS = r"""
(function() {
function txt(sel) {
var el = document.querySelector(sel);
return el ? el.textContent.trim() : null;
}
var video = document.querySelector('video');
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var title = null, subtitle = null, thumbnail = null;
if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img');
thumbnail = imgM ? imgM.src : null;
} else {
var t = document.title.replace(/ - YouTube$/, '');
title = t || null;
subtitle = txt('ytd-video-owner-renderer #channel-name a')
|| txt('#owner #channel-name a')
|| txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a');
var imgY = document.querySelector('link[rel="image_src"]');
thumbnail = imgY ? imgY.href : null;
}
return {
isMusic: isMusic,
title: title,
subtitle: subtitle,
thumbnail: thumbnail,
url: location.href,
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: video ? video.currentTime : 0,
duration: (video && isFinite(video.duration)) ? video.duration : 0,
hasVideo: !!video
};
})();
"""
TOGGLE_PLAYBACK_JS = r"""
(function() {
var video = document.querySelector('video');
if (video) { video.paused ? video.play() : video.pause(); }
})();
"""
NEXT_TRACK_JS = r"""
(function() {
var btn = document.querySelector('.next-button, ytmusic-player-bar #next-button button, tp-yt-paper-icon-button.next-button');
if (btn) { btn.click(); }
})();
"""
PREV_TRACK_JS = r"""
(function() {
var btn = document.querySelector('.previous-button, ytmusic-player-bar #previous-button button, tp-yt-paper-icon-button.previous-button');
if (btn) { btn.click(); }
})();
"""
+197
View File
@@ -0,0 +1,197 @@
"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube).
Ablauf:
1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
- Gepackte Playtube.exe: laedt das Windows-Release-Zip herunter, entpackt es
und laesst ein kurzes PowerShell-Skript (nach Prozessende) den Installations-
ordner per robocopy /MIR ersetzen und die App neu starten.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Any
from PySide6.QtCore import QThread, Signal
from . import __version__ as CURRENT_VERSION
GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
_USER_AGENT = "Playtube-Updater"
def _parse_version(v: str) -> tuple[int, ...]:
v = v.strip().lstrip("vV")
parts: list[int] = []
for p in v.split("."):
digits = "".join(ch for ch in p if ch.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts) or (0,)
def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
return _parse_version(remote) > _parse_version(local)
def fetch_latest_release() -> dict[str, Any] | None:
req = urllib.request.Request(
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
return None
def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None:
assets = release.get("assets", [])
for asset in assets:
name = asset.get("name", "").lower()
if name.endswith(".zip") and ("win" in name or "windows" in name):
return asset
for asset in assets:
if asset.get("name", "").lower().endswith(".zip"):
return asset
return None
class UpdateChecker(QThread):
"""Prueft einmalig im Hintergrund auf eine neue Version."""
updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url
checkFailed = Signal()
upToDate = Signal()
def run(self) -> None:
release = fetch_latest_release()
if not release:
self.checkFailed.emit()
return
tag = release.get("tag_name") or release.get("name") or ""
if not tag or not is_newer(tag):
self.upToDate.emit()
return
asset = _find_windows_zip_asset(release)
download_url = asset["browser_download_url"] if asset else ""
notes = (release.get("body") or "").strip()
self.updateAvailable.emit(tag, notes, download_url)
class UpdateInstaller(QThread):
"""Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
progress = Signal(str)
finished_ok = Signal()
failed = Signal(str)
def __init__(self, download_url: str, parent=None):
super().__init__(parent)
self._download_url = download_url
def run(self) -> None:
try:
if getattr(sys, "frozen", False):
self._run_packaged_update()
else:
self._run_dev_update()
except Exception as exc: # noqa: BLE001 - Fehler soll in der UI landen, nicht crashen
self.failed.emit(str(exc))
# ---------------------------------------------------------- gepackter Modus
def _run_packaged_update(self) -> None:
if not self._download_url:
self.failed.emit(
"Kein Windows-Release-Paket (.zip) im neuesten Release gefunden."
)
return
self.progress.emit("Lade Update herunter …")
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
zip_path = staging / "update.zip"
extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, open(zip_path, "wb") as out:
shutil.copyfileobj(resp, out)
self.progress.emit("Entpacke Update …")
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(extract_dir)
# Manche Release-Zips enthalten einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
self.progress.emit("Bereite Installation vor …")
script_path = self._write_apply_script(source_dir, install_dir, staging)
subprocess.Popen(
["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)],
creationflags=subprocess.CREATE_NO_WINDOW,
)
self.finished_ok.emit()
def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path:
exe_path = install_dir / "Playtube.exe"
script = f"""
$ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500
}}
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null
Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8")
return script_path
# ------------------------------------------------------------- Entwicklungsmodus
def _run_dev_update(self) -> None:
project_root = Path(__file__).resolve().parent.parent
if not (project_root / ".git").exists():
self.failed.emit(
"Kein Git-Repository gefunden - im Entwicklungsmodus bitte manuell "
"'git pull' im Projektordner ausfuehren."
)
return
self.progress.emit("Lade Aenderungen per git pull …")
result = subprocess.run(
["git", "pull", "--ff-only"], cwd=project_root, capture_output=True, text=True
)
if result.returncode != 0:
self.failed.emit(f"git pull fehlgeschlagen:\n{result.stderr.strip()}")
return
self.progress.emit("Installiere Abhaengigkeiten …")
pip_result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "-r", str(project_root / "requirements.txt")],
capture_output=True,
text=True,
)
if pip_result.returncode != 0:
self.failed.emit(f"pip install fehlgeschlagen:\n{pip_result.stderr.strip()}")
return
self.finished_ok.emit()