From 771c4382571d1e3576b1c4c079858d0640669a1c Mon Sep 17 00:00:00 2001 From: fojadrachi Date: Sat, 12 Sep 2026 14:03:23 +0200 Subject: [PATCH] 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 --- .github/workflows/release.yml | 10 +++- README.md | 10 ++++ main.py | 21 ++++++- playtube/mainwindow.py | 23 ++++++-- playtube/shortcuts.py | 44 ++++++++++++++- playtube/updater.py | 101 +++++++++++++++++++++++----------- 6 files changed, 167 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86a906a..8fe294b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index f1a8528..3e1040c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.py b/main.py index e883b09..b15989f 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py index e884495..9ec992c 100644 --- a/playtube/mainwindow.py +++ b/playtube/mainwindow.py @@ -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() - self._settings_tab.set_update_status("Lade Update herunter …") - self._settings_tab.set_download_progress(0) + 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) diff --git a/playtube/shortcuts.py b/playtube/shortcuts.py index e1aa8ac..a67d42d 100644 --- a/playtube/shortcuts.py +++ b/playtube/shortcuts.py @@ -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 diff --git a/playtube/updater.py b/playtube/updater.py index 7204677..a88374b 100644 --- a/playtube/updater.py +++ b/playtube/updater.py @@ -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,39 +171,49 @@ class UpdateInstaller(QThread): # ---------------------------------------------------------- gepackter Modus 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 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: - 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 + 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." + ) + return + + self.progress.emit("Lade Update herunter …") + 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 …") # 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: