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:
@@ -0,0 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "1.0.0"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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) {{}}
|
||||
}})();
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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(); }
|
||||
})();
|
||||
"""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user