6 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
Fojadrachi 7c202600cb Release v2.2.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 13:09:59 +02:00
FojadrachiandClaude Sonnet 5 7c37868cd1 Echter Discord-Zeit-/Bild-Fix, Cache-Reset nach Update, Windows-Startmenue-Icon
Anhand der neuen Debug-Logs (siehe vorheriger Commit) konnte der Bug endlich anhand
echter Daten diagnostiziert werden: video.currentTime/video.duration sind bei YouTube
Music beim Songwechsel NICHT verlaesslich - wegen nahtloser (gapless) Wiedergabe laeuft
darunter ein durchgehender Buffer, dessen currentTime/duration nicht pro Titel
zurueckgesetzt wird. Log-Beweis: beim Wechsel zu einem neuen Titel wurde die
verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des neuen uebernommen,
und die Dauer wuchs bei jedem Poll weiter statt konstant zu bleiben.

- media_probe.py: liest Fortschritt/Gesamtlaenge jetzt aus der sichtbar angezeigten
  Fortschrittsanzeige (ARIA-Attribute des YT-Music-Sliders in Sekunden, sonst
  Zeit-Text im Player; bei YouTube .ytp-time-current/.ytp-time-duration) statt aus den
  unzuverlaessigen <video>-Eigenschaften - diese Anzeige MUSS pro Titel stimmen, weil
  der Nutzer sie selbst so sieht.
- config.py/main.py: Cache (webprofile/cache) wird automatisch geleert, wenn seit dem
  letzten Start ein Update installiert wurde - Login (webprofile/storage) bleibt
  unangetastet.
- shortcuts.py/main.py: legt beim ersten Start der gepackten .exe automatisch eine
  Windows-Startmenue-Verknuepfung an (Playtube wird als portables ZIP ohne Installer
  ausgeliefert, haette sonst nie einen Startmenue-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 13:09:54 +02:00
10 changed files with 331 additions and 49 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
+22 -1
View File
@@ -55,7 +55,12 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae
powershell -ExecutionPolicy Bypass -File packaging\build.ps1 powershell -ExecutionPolicy Bypass -File packaging\build.ps1
``` ```
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser
`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube
wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es
sonst keinen Startmenue-Eintrag).
Das Build-Skript benennt
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess` `PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
@@ -91,6 +96,22 @@ fragt ein Dialog, ob sie installiert werden soll:
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren. `updates.enabled` deaktivieren.
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon
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 ### Eine neue Version veroeffentlichen
```powershell ```powershell
+28 -2
View File
@@ -10,8 +10,12 @@ from pathlib import Path
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren. # Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import app_id # noqa: E402 from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, load_config # noqa: E402 from playtube.config import APP_NAME, clear_cache_on_update, load_config # 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()
@@ -32,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)
@@ -45,9 +58,22 @@ def main() -> int:
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
config = load_config() config = load_config()
# Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login
# bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst
# 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 = 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()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube"
__version__ = "2.1.5" __version__ = "2.3.0"
+26
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -101,3 +102,28 @@ def profile_dir() -> Path:
d = _app_data_dir() / "webprofile" d = _app_data_dir() / "webprofile"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
def clear_cache_on_update(current_version: str) -> None:
"""Loescht den QtWebEngine-HTTP-Cache (webprofile/cache), wenn seit dem letzten
Start ein Update installiert wurde - der Login (Cookies/LocalStorage liegen in
webprofile/storage, einem komplett getrennten Ordner) bleibt dabei unangetastet.
Alte Cache-Eintraege (z.B. Icon-Sprites, Skripte) koennen nach einem Update nicht
mehr zum neuen Code passen - frueher Ursache fuer fehlende Icons nach einem
beschaedigten Cache, siehe README."""
marker = _app_data_dir() / "installed_version.txt"
previous = None
if marker.exists():
try:
previous = marker.read_text(encoding="utf-8").strip()
except OSError:
previous = None
if previous != current_version:
cache_dir = profile_dir() / "cache"
if cache_dir.exists():
shutil.rmtree(cache_dir, ignore_errors=True)
try:
marker.write_text(current_version, encoding="utf-8")
except OSError:
pass
+2 -1
View File
@@ -259,9 +259,10 @@ class DiscordRPCWorker(QThread):
payload = build_presence_payload(item, self._session_start, start_ts, end_ts) payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload) self._presence.update(**payload)
log_line( 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("title"),
item.get("url"),
item.get("thumbnail"), item.get("thumbnail"),
payload.get("large_image"), payload.get("large_image"),
start_ts, start_ts,
+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)
+64 -4
View File
@@ -28,16 +28,72 @@ MEDIA_PROBE_JS = r"""
function ytThumbnailUrl(videoId) { function ytThumbnailUrl(videoId) {
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null; return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null;
} }
// "1:23" oder "1:02:03" -> Sekunden.
function parseClock(str) {
if (!str) { return null; }
var parts = String(str).trim().split(':').map(function(p) { return parseInt(p, 10); });
if (!parts.length) { return null; }
for (var i = 0; i < parts.length; i++) { if (isNaN(parts[i])) { return null; } }
var seconds = 0;
for (var j = 0; j < parts.length; j++) { seconds = seconds * 60 + parts[j]; }
return seconds;
}
function numAttr(el, name) {
if (!el) { return null; }
var raw = el.getAttribute(name);
var n = raw !== null ? parseFloat(raw) : NaN;
return isFinite(n) ? n : null;
}
// WICHTIG: video.currentTime/video.duration sind bei YouTube Music NICHT
// verlaesslich pro Titel - bei nahtlosem (gapless) Songwechsel laeuft darunter
// teils ein durchgehender Buffer weiter, dessen currentTime/duration beim
// Songwechsel NICHT auf den neuen Titel zurueckgesetzt wird, sondern einfach ueber
// mehrere Songs hinweg weiterzaehlt (empirisch bestaetigt: nach einem Songwechsel
// wurde die verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des NEUEN
// uebernommen, und die "Dauer" wuchs bei jedem Poll weiter statt konstant zu
// bleiben). Deshalb wird die sichtbar angezeigte Fortschrittsanzeige ausgelesen
// (ARIA-Attribute des Sliders in Sekunden, sonst der Zeit-Text im Player) - die
// zeigt garantiert den Fortschritt des GERADE LAUFENDEN Titels, weil der Nutzer sie
// ja selbst so sieht.
function musicTimeInfo() {
var slider = document.querySelector('#progress-bar, tp-yt-paper-slider#progress-bar');
var current = numAttr(slider, 'aria-valuenow');
var total = numAttr(slider, 'aria-valuemax');
if (current !== null && total !== null && total > 0) {
return { current: current, total: total };
}
var el = document.querySelector('.time-info.ytmusic-player-bar, ytmusic-player-bar .time-info');
var parts = el ? (el.textContent || '').split('/') : [];
if (parts.length === 2) {
var c = parseClock(parts[0]), t = parseClock(parts[1]);
if (c !== null && t !== null) { return { current: c, total: t }; }
}
return null;
}
function youtubeTimeInfo() {
var current = parseClock(txt('.ytp-time-current'));
var total = parseClock(txt('.ytp-time-duration'));
if (current !== null && total !== null) { return { current: current, total: total }; }
return null;
}
var video = document.querySelector('video'); var video = document.querySelector('video');
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1; var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var videoId = videoIdFromUrl(location.href); var videoId = videoIdFromUrl(location.href);
var title = null, subtitle = null, thumbnail = null; 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) { if (isMusic) {
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title'); title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline'); 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 { } else {
var t = document.title.replace(/ - YouTube$/, ''); var t = document.title.replace(/ - YouTube$/, '');
title = t || null; title = t || null;
@@ -46,9 +102,13 @@ MEDIA_PROBE_JS = r"""
|| txt('#channel-name a') || txt('#channel-name a')
|| txt('ytd-channel-name#channel-name a'); || txt('ytd-channel-name#channel-name a');
var imgY = document.querySelector('link[rel="image_src"]'); 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();
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
// WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des // zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als // Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
@@ -61,8 +121,8 @@ MEDIA_PROBE_JS = r"""
thumbnail: thumbnail, thumbnail: thumbnail,
url: location.href, url: location.href,
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: video ? video.currentTime : 0, currentTime: currentTime,
duration: (video && isFinite(video.duration)) ? video.duration : 0, duration: duration,
hasVideo: !!video hasVideo: !!video
}); });
})(); })();
+94
View File
@@ -0,0 +1,94 @@
"""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"
Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent -
prueft vorher, ob die Verknuepfung schon existiert)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
def _find_icon(exe_dir: Path) -> Path | None:
matches = list(exe_dir.rglob("icon.ico"))
return matches[0] if matches else None
def ensure_start_menu_shortcut(app_name: str) -> None:
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
try:
appdata = os.environ.get("APPDATA")
if not appdata:
return
start_menu = Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
shortcut_path = start_menu / f"{app_name}.lnk"
if shortcut_path.exists():
return
exe_path = Path(sys.executable).resolve()
icon_path = _find_icon(exe_path.parent) or exe_path
ps_script = (
'$WshShell = New-Object -ComObject WScript.Shell\n'
f'$Shortcut = $WshShell.CreateShortcut("{shortcut_path}")\n'
f'$Shortcut.TargetPath = "{exe_path}"\n'
f'$Shortcut.WorkingDirectory = "{exe_path.parent}"\n'
f'$Shortcut.IconLocation = "{icon_path}"\n'
f'$Shortcut.Description = "{app_name}"\n'
'$Shortcut.Save()\n'
)
subprocess.run(
["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_script],
capture_output=True,
timeout=15,
creationflags=subprocess.CREATE_NO_WINDOW,
)
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
+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: