4 Commits
Author SHA1 Message Date
Fojadrachi 31797cdabb Release v2.3.0
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 14:03:28 +02:00
FojadrachiandClaude Sonnet 5 771c438257 Eigene .play-Dateiendung fuer Windows-Patchdateien + manuelle Installation per Doppelklick
Windows-Patch-Pakete heissen jetzt Playtube-vX.Y.Z-win64-patch.play statt .zip
(technisch weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim
Entpacken auf die Magic Bytes, nicht auf die Endung). Playtube registriert .play
beim ersten Start als Windows-Dateizuordnung (HKCU, keine Admin-Rechte noetig),
sodass eine manuell heruntergeladene Patchdatei per Doppelklick installiert
werden kann, ohne dass Playtube selbst etwas herunterladen muss:

- shortcuts.py: ensure_play_file_association() registriert ProgID +
  DefaultIcon + shell/open/command und stoesst SHChangeNotify an, damit die
  Zuordnung sofort greift.
- updater.py: UpdateInstaller akzeptiert jetzt optional local_archive_path
  statt einer download_url und ueberspringt dann den Download komplett.
- main.py: erkennt eine .play-Datei als Kommandozeilenargument (Doppelklick-
  Start) und uebergibt sie an MainWindow.install_local_patch().
- release.yml: Windows-Patch wird als .zip gepackt und anschliessend zu .play
  umbenannt; _find_platform_asset() sucht fuer Windows-Patches jetzt nach .play
  statt .zip.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 14:03:23 +02:00
Fojadrachi 5379be6c11 Release v2.2.1
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 13:44:47 +02:00
FojadrachiandClaude Sonnet 5 c6fe895334 Fix: Discord-Thumbnail bleibt ueber mehrere Songs stehen (YT Music)
Log-Beweis: dieselbe Thumbnail-URL blieb ueber mehrere komplett verschiedene
Songs (unterschiedliche Titel/Interpreten) unveraendert stehen, waehrend Titel
und Zeit bereits korrekt aktualisiert wurden. Das bisher bevorzugte <img> in
der YT-Music-Playerleiste wird von YouTube per Crossfade/Shadow-DOM animiert
und aktualisiert sein src-Attribut dabei nicht zuverlaessig pro Titel.

Thumbnail wird jetzt primaer aus der Video-ID in der URL abgeleitet (wechselt
garantiert synchron mit dem Titel, dieselbe Quelle die schon zuverlaessig als
Fallback diente) statt aus dem unzuverlaessigen Player-Leisten-<img>, das nur
noch als letzter Fallback dient. Debug-Log zeigt jetzt zusaetzlich die URL pro
Send fuer weitere Verifikation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 13:44:41 +02:00
9 changed files with 180 additions and 46 deletions
+8 -2
View File
@@ -42,12 +42,18 @@ jobs:
path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
# Technisch bleibt es ein ganz normales ZIP-Archiv.
run: |
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
Rename-Item -Path Playtube-${{ github.ref_name }}-win64-patch.zip -NewName Playtube-${{ github.ref_name }}-win64-patch.play
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-${{ github.ref_name }}-win64-patch.zip
path: Playtube-${{ github.ref_name }}-win64-patch.play
build-linux:
runs-on: ubuntu-latest
+10
View File
@@ -102,6 +102,16 @@ zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt d
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
(`webprofile/storage`) liegen.
### Patch-Dateien manuell installieren (`.play`)
Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technisch
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) laesst sich also
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
muss.
### Eine neue Version veroeffentlichen
```powershell
+20 -1
View File
@@ -12,7 +12,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, clear_cache_on_update, load_config # noqa: E402
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
from playtube.shortcuts import ( # noqa: E402
ensure_play_file_association,
ensure_start_menu_shortcut,
)
app_id.set_app_user_model_id()
app_id.configure_webengine_process_path()
@@ -33,6 +36,15 @@ from PySide6.QtWidgets import QApplication # noqa: E402
from playtube.mainwindow import MainWindow # noqa: E402
def _pending_local_patch() -> str | None:
"""Falls Playtube per Doppelklick auf eine ".play"-Patchdatei gestartet wurde
(siehe shortcuts.ensure_play_file_association), liefert den Pfad dazu."""
for arg in sys.argv[1:]:
if arg.lower().endswith(".play") and Path(arg).is_file():
return str(Path(arg).resolve())
return None
def main() -> int:
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
app = QApplication(sys.argv)
@@ -51,10 +63,17 @@ def main() -> int:
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME)
ensure_play_file_association(APP_NAME)
window = MainWindow(config)
window.show()
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
local_patch = _pending_local_patch()
if local_patch:
window.install_local_patch(local_patch)
return app.exec()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube"
__version__ = "2.2.0"
__version__ = "2.3.0"
+2 -1
View File
@@ -259,9 +259,10 @@ class DiscordRPCWorker(QThread):
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload)
log_line(
"[send] title=%r thumbnail=%r large_image=%r start=%s end=%s"
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
% (
item.get("title"),
item.get("url"),
item.get("thumbnail"),
payload.get("large_image"),
start_ts,
+16 -3
View File
@@ -311,20 +311,33 @@ class MainWindow(QMainWindow):
if answer == QMessageBox.StandardButton.Yes:
self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None:
if not download_url:
def _start_update_install(self, download_url: str = "", local_path: str | None = None) -> None:
if not download_url and not local_path:
return
self._settings_tab.hide_install_button()
if local_path:
self._settings_tab.set_update_status("Installiere lokale Patch-Datei …")
self._settings_tab.set_download_progress(-1)
else:
self._settings_tab.set_update_status("Lade Update herunter …")
self._settings_tab.set_download_progress(0)
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self)
self._update_installer = UpdateInstaller(
download_url, local_archive_path=local_path, parent=self
)
self._update_installer.progress.connect(self._on_install_progress_text)
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start()
def install_local_patch(self, path: str) -> None:
"""Wird von main.py aufgerufen, wenn Playtube per Doppelklick auf eine
heruntergeladene .play-Patchdatei gestartet wurde (siehe
shortcuts.ensure_play_file_association)."""
self._tabs.setCurrentWidget(self._settings_tab)
self._start_update_install(local_path=path)
def _on_install_progress_text(self, msg: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg)
+10 -2
View File
@@ -82,10 +82,18 @@ MEDIA_PROBE_JS = r"""
var videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null;
// WICHTIG: die Video-ID aus der URL ist die zuverlaessigste Thumbnail-Quelle - sie
// wechselt garantiert synchron mit dem Titel. Das <img> in der YT-Music-Playerleiste
// (fruehere Praeferenz) wird von YouTube per Crossfade/Shadow-DOM animiert und
// aktualisiert sein src-Attribut dabei nachweislich NICHT zuverlaessig pro Titel -
// empirisch bestaetigt: dieselbe Thumbnail-URL blieb ueber mehrere komplett
// unterschiedliche Songs hinweg stehen, obwohl Titel/Zeit schon laengst gewechselt
// hatten. Das DOM-<img> dient nur noch als Fallback, falls keine Video-ID in der
// URL steckt.
if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
thumbnail = ytThumbnailUrl(videoId) || imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
} else {
var t = document.title.replace(/ - YouTube$/, '');
title = t || null;
@@ -94,7 +102,7 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a');
var imgY = document.querySelector('link[rel="image_src"]');
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
}
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
+43 -1
View File
@@ -1,4 +1,5 @@
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung.
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung sowie die Dateizuordnung
fuer die eigene ".play"-Patchdateiendung.
Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es
also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten"
@@ -50,3 +51,44 @@ def ensure_start_menu_shortcut(app_name: str) -> None:
except Exception:
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
pass
def ensure_play_file_association(app_name: str) -> None:
"""Registriert ".play" (unsere eigene Endung fuer Patch-Pakete, siehe updater.py)
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann
danach per Doppelklick installiert werden (Playtube startet dann mit dem Dateipfad
als Kommandozeilenargument, siehe main.py). Nur unter HKEY_CURRENT_USER, damit keine
Admin-Rechte noetig sind. Wird bei jedem Start erneut geschrieben (billig, idempotent
und heilt sich selbst, falls die .exe z.B. nach einem Update an einem neuen Pfad
liegt)."""
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
try:
import winreg
exe_path = str(Path(sys.executable).resolve())
prog_id = f"{app_name}.PatchFile"
icon_path = _find_icon(Path(exe_path).parent) or Path(exe_path)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\.play") as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, prog_id)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}") as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f"{app_name}-Patchdatei")
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\DefaultIcon"
) as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(icon_path))
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\shell\open\command"
) as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f'"{exe_path}" "%1"')
# Explorer informieren, damit die neue Zuordnung sofort (ohne Neustart) greift.
import ctypes
SHCNE_ASSOCCHANGED = 0x08000000
SHCNF_IDLIST = 0x0000
ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None)
except Exception:
# Keine Dateizuordnung ist kein Grund, den App-Start scheitern zu lassen.
pass
+44 -9
View File
@@ -17,6 +17,14 @@ Ablauf:
PowerShell unter Windows, ein Shell-Skript unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
Windows-Patch-Pakete tragen die eigene Dateiendung ".play" statt ".zip" (technisch
weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die
Magic Bytes, nicht auf die Endung). shortcuts.py registriert ".play" beim ersten Start
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann so
auch per Doppelklick installiert werden, ohne dass Playtube selbst etwas herunterladen
muss (siehe main.py: wird eine .play-Datei als Kommandozeilenargument uebergeben,
installiert MainWindow.install_local_patch() sie direkt ueber UpdateInstaller).
"""
from __future__ import annotations
@@ -72,16 +80,21 @@ def fetch_latest_release() -> dict[str, Any] | None:
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
Moduldoku. Windows: volles Paket -> *.zip, Patch -> *.play (unsere eigene
Dateiendung, technisch ein ganz normales .zip - siehe Moduldoku), beide mit "win" im
Namen. Linux -> *.tar.gz mit "linux"."""
assets = release.get("assets", [])
if sys.platform == "win32":
hints, exts = ("win",), (".zip",)
hints = ("win",)
patch_exts, full_exts = (".play",), (".zip",)
elif sys.platform.startswith("linux"):
hints, exts = ("linux",), (".tar.gz", ".tgz")
hints = ("linux",)
patch_exts = full_exts = (".tar.gz", ".tgz")
else:
return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
exts = patch_exts if want_patch else full_exts
for asset in assets:
name = asset.get("name", "").lower()
if not name.endswith(exts):
@@ -133,9 +146,18 @@ class UpdateInstaller(QThread):
finished_ok = Signal()
failed = Signal(str)
def __init__(self, download_url: str, parent=None):
def __init__(
self,
download_url: str = "",
local_archive_path: str | None = None,
parent=None,
):
super().__init__(parent)
self._download_url = download_url
# Gesetzt, wenn der Nutzer eine bereits heruntergeladene .play-Patchdatei per
# Doppelklick geoeffnet hat (siehe shortcuts.py-Dateiverknuepfung) - dann wird
# nichts heruntergeladen, sondern direkt diese lokale Datei installiert.
self._local_archive_path = local_archive_path
def run(self) -> None:
try:
@@ -149,6 +171,19 @@ class UpdateInstaller(QThread):
# ---------------------------------------------------------- gepackter Modus
def _run_packaged_update(self) -> None:
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
extract_dir = staging / "extracted"
if self._local_archive_path:
archive_path = Path(self._local_archive_path)
if not archive_path.exists():
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
return
archive_name = archive_path.name
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
self.progress_percent.emit(-1)
else:
if not self._download_url:
self.failed.emit(
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
@@ -157,11 +192,8 @@ class UpdateInstaller(QThread):
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
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:
@@ -180,8 +212,8 @@ class UpdateInstaller(QThread):
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
@@ -190,10 +222,13 @@ class UpdateInstaller(QThread):
with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir)
else:
# .zip UND .play (unsere eigene Dateiendung fuer per Doppelklick startbare
# Patchdateien - technisch ein ganz normales .zip, siehe Moduldoku) werden
# beide als ZIP entpackt.
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir)
is_patch = "patch" in archive_name.lower()
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
self.progress.emit("Bereite Installation vor …")
if is_patch: