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
+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)