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]>
This commit is contained in:
2026-09-12 14:03:23 +02:00
co-authored by Claude Sonnet 5
parent 5379be6c11
commit 771c438257
6 changed files with 167 additions and 42 deletions
+8 -2
View File
@@ -42,12 +42,18 @@ jobs:
path: Playtube-${{ github.ref_name }}-win64.zip path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates) - 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 - uses: actions/upload-artifact@v4
with: with:
name: windows-patch name: windows-patch
path: Playtube-${{ github.ref_name }}-win64-patch.zip path: Playtube-${{ github.ref_name }}-win64-patch.play
build-linux: build-linux:
runs-on: ubuntu-latest 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 unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
(`webprofile/storage`) liegen. (`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 ### Eine neue Version veroeffentlichen
```powershell ```powershell
+20 -1
View File
@@ -12,7 +12,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import __version__, app_id # noqa: E402 from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, clear_cache_on_update, load_config # 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.set_app_user_model_id()
app_id.configure_webengine_process_path() app_id.configure_webengine_process_path()
@@ -33,6 +36,15 @@ from PySide6.QtWidgets import QApplication # noqa: E402
from playtube.mainwindow import MainWindow # 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: def main() -> int:
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -51,10 +63,17 @@ def main() -> int:
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird. # komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__) clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME) ensure_start_menu_shortcut(APP_NAME)
ensure_play_file_association(APP_NAME)
window = MainWindow(config) window = MainWindow(config)
window.show() 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() return app.exec()
+18 -5
View File
@@ -311,20 +311,33 @@ class MainWindow(QMainWindow):
if answer == QMessageBox.StandardButton.Yes: if answer == QMessageBox.StandardButton.Yes:
self._start_update_install(download_url) self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None: def _start_update_install(self, download_url: str = "", local_path: str | None = None) -> None:
if not download_url: if not download_url and not local_path:
return return
self._settings_tab.hide_install_button() self._settings_tab.hide_install_button()
self._settings_tab.set_update_status("Lade Update herunter …") if local_path:
self._settings_tab.set_download_progress(0) 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._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.connect(self._on_install_progress_text)
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress) 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.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed) self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start() 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: def _on_install_progress_text(self, msg: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – {msg}") self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg) self._settings_tab.set_update_status(msg)
+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 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" 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: except Exception:
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen. # Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
pass 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
+68 -33
View File
@@ -17,6 +17,14 @@ Ablauf:
PowerShell unter Windows, ein Shell-Skript unter Linux. PowerShell unter Windows, ein Shell-Skript unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r - Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv). 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 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: def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine """Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe "-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", []) assets = release.get("assets", [])
if sys.platform == "win32": if sys.platform == "win32":
hints, exts = ("win",), (".zip",) hints = ("win",)
patch_exts, full_exts = (".play",), (".zip",)
elif sys.platform.startswith("linux"): elif sys.platform.startswith("linux"):
hints, exts = ("linux",), (".tar.gz", ".tgz") hints = ("linux",)
patch_exts = full_exts = (".tar.gz", ".tgz")
else: else:
return None return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | 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: for asset in assets:
name = asset.get("name", "").lower() name = asset.get("name", "").lower()
if not name.endswith(exts): if not name.endswith(exts):
@@ -133,9 +146,18 @@ class UpdateInstaller(QThread):
finished_ok = Signal() finished_ok = Signal()
failed = Signal(str) 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) super().__init__(parent)
self._download_url = download_url 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: def run(self) -> None:
try: try:
@@ -149,39 +171,49 @@ class UpdateInstaller(QThread):
# ---------------------------------------------------------- gepackter Modus # ---------------------------------------------------------- gepackter Modus
def _run_packaged_update(self) -> None: def _run_packaged_update(self) -> None:
if not self._download_url:
self.failed.emit(
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
)
return
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
install_dir = Path(sys.executable).resolve().parent install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_")) staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
extract_dir = staging / "extracted" extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT}) if self._local_archive_path:
with urllib.request.urlopen(req, timeout=120) as resp: archive_path = Path(self._local_archive_path)
total = int(resp.headers.get("Content-Length") or 0) if not archive_path.exists():
downloaded = 0 self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
last_emitted = -1 return
with open(archive_path, "wb") as out: archive_name = archive_path.name
while True: self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
chunk = resp.read(256 * 1024) self.progress_percent.emit(-1)
if not chunk: else:
break if not self._download_url:
out.write(chunk) self.failed.emit(
downloaded += len(chunk) "Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
if total: )
percent = int(downloaded * 100 / total) return
if percent != last_emitted:
self.progress_percent.emit(percent) self.progress.emit("Lade Update herunter …")
last_emitted = percent self.progress_percent.emit(0)
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp:
total = int(resp.headers.get("Content-Length") or 0)
downloaded = 0
last_emitted = -1
with open(archive_path, "wb") as out:
while True:
chunk = resp.read(256 * 1024)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
if total:
percent = int(downloaded * 100 / total)
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
self.progress_percent.emit(100)
self.progress.emit("Entpacke Update …") self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt # Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl. # dafuer einen "laufenden" Balken statt einer Prozentzahl.
@@ -190,10 +222,13 @@ class UpdateInstaller(QThread):
with tarfile.open(archive_path) as tf: with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir) tf.extractall(extract_dir)
else: 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: with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir) 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 …") self.progress.emit("Bereite Installation vor …")
if is_patch: if is_patch: