16 Commits
Author SHA1 Message Date
Fojadrachi 61b38fe6b8 Merge pull request #1 from fojadrachi/Preview
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
Preview
2026-09-19 20:13:33 +02:00
Fojadrachi cf6b6733e4 Release v2.4.0 2026-09-19 20:11:59 +02:00
Fojadrachi 5f93be4325 Update-Fix: Installer beendet sich nicht mehr selbst, Neustart in die neue Version
Ursache: Der Installer beendete laufende Playtube-Prozesse per "taskkill /F /T". Der
Updater startet ihn aus Playtube.exe heraus, er ist also ein Kindprozess - solange
Playtube noch beendet wurde, schoss "/T" den Installer selbst mit ab, bevor er etwas
installiert hatte (kein Kopieren, kein Neustart, nur der Download blieb im Temp-Ordner).

- playtube.iss: taskkill ohne /T; Setup wartet per /WAITPID=<PID> (max. 20 s) darauf, dass
  sich die alte App selbst beendet; uebrig gebliebene QtWebEngine-Prozesse aus dem
  Installationsordner werden gezielt nach Pfad beendet
- updater: uebergibt /WAITPID und /LOG (Setup-Protokoll nach %TEMP%); robocopy im
  ZIP-Fallback mit /R:3 /W:2, damit gesperrte Dateien das Update nicht endlos haengen
- mainwindow: hartes Beenden nach 8 s, falls Qt/QtWebEngine beim Beenden haengt
  (sonst wartet der Update-Helfer ewig und die alte Version bleibt laufen)
2026-09-19 20:11:32 +02:00
Fojadrachi e655cbceea Fix typos in README.md 2026-09-19 19:52:11 +02:00
Fojadrachi 1acd08934e Merge remote-tracking branch 'origin/main' 2026-09-19 19:47:22 +02:00
Fojadrachi a4ba6c296e Release v2.3.2
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-19 19:46:40 +02:00
Fojadrachi c1465a4e49 Einstellungen: Versionsnummer, Update-Status und GitHub-Link in heller Schrift
Die drei Texte nutzten palette(mid) (dunkles Grau) und waren im dunklen Design kaum
lesbar. Jetzt palette(text) (weiss im dunklen, dunkel im hellen Design). Der
Update-Fortschrittsbalken bekommt eine helle Beschriftung und einen gut sichtbaren
blauen Balken.
2026-09-19 19:46:28 +02:00
Fojadrachi 874d6454ec Fix spelling and grammar in README.md 2026-09-19 19:37:51 +02:00
Fojadrachi 8840ba130c Release v2.3.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-19 19:25:22 +02:00
Fojadrachi 04bc96d33e Setup-Installer, Einzelinstanz-Schutz und Update-System mit einer einzigen Installation
- Inno-Setup-Installer (packaging/playtube.iss): installiert pro Benutzer nach
  %LOCALAPPDATA%\Programs\Playtube, feste AppId -> jede neue Version ueberschreibt die
  vorhandene Installation an Ort und Stelle, raeumt den alten _internal-Ordner auf und
  beendet laufende Playtube-Prozesse vor dem Kopieren
- Updater: installierte Kopie nimmt das kleine Patch (Versionseintrag in "Apps &
  Features" wird nachgezogen), portable Kopie wechselt per stillem Setup in die
  regulaere Installation; Patch nur bei passender PySide6-Version (steht im Dateinamen),
  sonst Setup; Aufraeumen des heruntergeladenen Installers
- Einzelinstanz-Schutz (playtube/single_instance.py): zwei Prozesse auf demselben
  Browser-Profil liessen auf YouTube alle Icons verschwinden; ein zweiter Start holt jetzt
  das laufende Fenster nach vorn (auch .play-Patchdateien werden weitergereicht)
- build.ps1 baut den Installer mit, Release-Pipeline haengt ihn an jedes Release
- README: Installation, Update-Ablauf, Troubleshooting fuer fehlende Icons
2026-09-19 19:25:05 +02:00
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
14 changed files with 995 additions and 135 deletions
+35 -3
View File
@@ -33,7 +33,27 @@ jobs:
- name: Playtube.exe bauen - name: Playtube.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .zip packen (volles Paket) - name: Setup-Installer bauen (Inno Setup)
# Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was
# Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige
# Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in
# packaging/playtube.iss) - keine parallelen Versionsordner mehr.
shell: pwsh
run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
choco install innosetup -y --no-progress
}
& $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
- uses: actions/upload-artifact@v4
with:
name: windows-setup
path: dist/installer/Playtube-Setup-v*.exe
- name: Als .zip packen (volles Paket, portabel)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -42,12 +62,24 @@ 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.
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
# installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und
# der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible).
shell: pwsh
run: |
$pyside = python -c "import PySide6; print(PySide6.__version__)"
$name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch"
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
Rename-Item -Path "$name.zip" -NewName "$name.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-*-win64-pyside*-patch.play
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+118 -48
View File
@@ -1,20 +1,39 @@
# Playtube # Playtube
Eigenstaendige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
Erweiterung) mit: Erweiterung) mit:
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im - **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
Hintergrund weiter wenn du zu Videos wechselst. Hintergrund weiter wenn du zu Videos wechselst.
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im - **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale Google anmelden reicht für beide Dienste.
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
Google anmelden reicht fuer beide Dienste.
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und - **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft. einen Link-Button in deinem Discord-Profil, sobald etwas läuft.
- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick - **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue. aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab - **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python". und (nach dem Packaging, siehe unten) im Lautstärkemixer statt als "python".
## Installation (Windows)
Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
Admin-Rechte nötig) und legt Startmenü-Eintrag (optional Desktop-Verknüpfung) an,
- ersetzt bei jeder späteren Ausführung die vorhandene Installation **an Ort und
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
mehrere nebeneinander,
- beendet dafür ein noch laufendes Playtube selbst und räumt den alten Programmordner
auf, damit keine Reste alter Versionen übrig bleiben,
- lässt Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
über "Apps & Features" wird gefragt, ob sie mitgelöscht werden sollen.
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
Setup-Datei wird dafür nicht erneut von Hand gebraucht. Das ZIP-Paket
(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
Playtube bietet einer portablen Kopie beim nächsten Update automatisch an, in die
reguläre Installation zu wechseln (danach kann der alte Ordner gelöscht werden).
## Schnellstart (Entwicklung) ## Schnellstart (Entwicklung)
@@ -31,67 +50,102 @@ Login bleibt danach dauerhaft gespeichert.
## Discord Rich Presence einrichten ## Discord Rich Presence einrichten
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
sie aendern oder eine eigene Anwendung nutzen willst: sie ändern oder eine eigene Anwendung nutzen willst:
1. Auf https://discord.com/developers/applications eine neue Application anlegen. 1. Auf https://discord.com/developers/applications eine neue Application anlegen.
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen. 2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
3. Optional, fuer eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den 3. Optional, für eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau exakten Schlüsseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem, diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
nur ohne Bild. nur ohne Bild.
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird. 4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts laeuft) anpassen. Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
## App als eigenstaendige Playtube.exe packen ## App als eigenständige Playtube.exe packen
Fuer die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" - (ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das aendern): nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ändern):
```powershell ```powershell
.venv\Scripts\pip install pyinstaller .venv\Scripts\pip install pyinstaller
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`. Ist
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu [Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` überspringbar).
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
Verknüpfung im Windows-Startmenü an - eine per Setup installierte Kopie hat den
Eintrag bereits vom Installer.
Das Build-Skript benennt
zusätzlich 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. Für eine vollständige Umbenennung inkl. Icon/Versionsinfo dieses
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich Hilfsprozesses (relevant für den Lautstärkemixer) zusätzlich
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe` [rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach alles genauso, nur zeigt der Lautstärkemixer für den Ton-Unterprozess je nach
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes, kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit Windows-versionsabhängiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
o.ae. umgehen). o.ä. umgehen).
## Automatische Updates ## Automatische Updates
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden Playtube prüft beim Start und danach alle `updates.check_interval_hours` Stunden
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die (Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit Einstellungen-Tab gibt es zusätzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version, Status-Anzeige und Fortschrittsbalken für den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll: fragt ein Dialog, ob sie installiert werden soll:
- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket** - **Windows, per Setup installiert** (siehe "Installation"): lädt bevorzugt das kleine
herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt **Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB)
~200 MB) und ersetzt ausschliesslich diese - der riesige PySide6/QtWebEngine- und überschreibt damit die Datei im Installationsordner - der riesige PySide6/
Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen normalen QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
Patch-Releases nicht aendert. Nur wenn kein Patch-Paket verfuegbar ist (z.B. beim normalen Patch-Releases nicht ändert. Die PySide6-Version steht im Dateinamen des
allerersten Release oder nach einem Wechsel der PySide6-Version), wird automatisch Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab,
auf das volle Release-Paket zurueckgefallen und der komplette Installationsordner ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den
ersetzt. Die App startet sich in beiden Faellen danach selbst neu. **Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach
einem Patch ebenfalls nachgezogen.
- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): lädt den
**Setup-Installer** und führt ihn still aus. Playtube liegt danach in der regulären
Installation - Startmenü-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
Kopie ist nicht mehr nötig (und kann gelöscht werden). So entstehen nicht länger
versionierte Ordner nebeneinander. Gibt es (bei älteren Releases) keinen Installer,
wird wie früher das volle ZIP über den vorhandenen Ordner kopiert.
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
Die App startet sich in allen Fällen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): führt `git pull` + `pip install -r
requirements.txt` aus und startet den Python-Prozess neu. requirements.txt` aus und startet den Python-Prozess neu.
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter Auto-Update lässt sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren. `updates.enabled` deaktivieren.
### Eine neue Version veroeffentlichen Nach einem erkannten Update wird beim nächsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Einträge können sonst nicht mehr
zum neuen Code passen (frühere Ursache für fehlende Icons). Der Login bleibt davon
unberührt, 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)) lässt sich also
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
muss.
### Eine neue Version veröffentlichen
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0 powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
@@ -99,22 +153,27 @@ powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
`origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag `origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag
loest automatisch die GitHub-Actions-Pipeline löst automatisch die GitHub-Actions-Pipeline
([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl ([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl
eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub
Release veroeffentlicht - Fortschritt unter Release veröffentlicht - Fortschritt unter
https://github.com/fojadrachi/Playtube/actions. https://github.com/fojadrachi/Playtube/actions.
Für Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, für
Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die
neue Version danach automatisch angeboten. neue Version danach automatisch angeboten.
## Native Linux-Version ## Native Linux-Version
Playtube laeuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und Playtube läuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und
wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter
https://github.com/fojadrachi/Playtube/releases mitgebaut. https://github.com/fojadrachi/Playtube/releases mitgebaut.
**Fertiges Release installieren** (richtet Startmenue-Eintrag + Icon ein): **Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
```sh ```sh
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
@@ -122,7 +181,7 @@ cd Playtube
sh install-linux.sh sh install-linux.sh
``` ```
Danach ist Playtube ueber das Anwendungsmenue oder den Befehl `playtube` startbar. Danach ist Playtube über das Anwendungsmenü oder den Befehl `playtube` startbar.
**Aus dem Quellcode starten:** **Aus dem Quellcode starten:**
@@ -132,20 +191,31 @@ python3 -m venv .venv
.venv/bin/python main.py .venv/bin/python main.py
``` ```
QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu): QtWebEngine benötigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu):
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in `sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in
[.github/workflows/release.yml](.github/workflows/release.yml)). [.github/workflows/release.yml](.github/workflows/release.yml)).
## Hinweise ## Hinweise
- **"Dieser Browser ist unter Umstaenden nicht sicher" beim Google-Login:** Playtube - **"Dieser Browser ist unter Umständen nicht sicher" beim Google-Login:** Playtube
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den starten (die Header greifen erst ab dem nächsten Prozessstart), notfalls einmal den
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
Entwicklungsmodus) loeschen und neu anmelden. Entwicklungsmodus) löschen und neu anmelden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von - **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menü ...):** Fast
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
Fensters Playtube nur in den Tray minimiert: eine ältere Version läuft unsichtbar
weiter, während eine neu heruntergeladene zusätzlich gestartet wird. Abhilfe: alle
Playtube-Instanzen über das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
beenden und nur EINE neu starten. Ab der Version mit
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
ältere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
beendet werden.
- 4K/Premium-Videoqualität kann eingeschränkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitäten funktionieren normal).
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py` - Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen. erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
+46 -2
View File
@@ -10,8 +10,14 @@ 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, app_data_dir, clear_cache_on_update, load_config # noqa: E402
from playtube.single_instance import SingleInstanceGuard # noqa: E402
from playtube.shortcuts import ( # noqa: E402
ensure_play_file_association,
ensure_start_menu_shortcut,
)
from playtube.updater import cleanup_old_staging # noqa: E402
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 +38,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)
@@ -44,10 +59,39 @@ def main() -> int:
if icon_path.exists(): if icon_path.exists():
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
# Nur EINE Playtube-Instanz pro Datenordner: laufen zwei gleichzeitig auf demselben
# Browser-Profil, zeigt die zweite auf YouTube keine Icons mehr (siehe
# playtube/single_instance.py). Muss vor jedem Profil-Zugriff (Cache-Reset, Browser)
# passieren. Die Instanz eines aelteren Playtube ohne diesen Schutz wird dadurch
# allerdings nicht erkannt - die muss einmalig ueber das Tray-Icon beendet werden.
local_patch = _pending_local_patch()
instance_guard = SingleInstanceGuard(f"{APP_NAME}.SingleInstance.{app_data_dir().name}", app)
if not instance_guard.acquire(local_patch):
return 0
cleanup_old_staging() # Reste eines frueheren Updates (heruntergeladenes Setup) entfernen
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()
# Ein zweiter Start (Verknuepfung, .play-Doppelklick) landet hier in der laufenden
# Instanz: Fenster aus dem Tray holen bzw. den Patch installieren.
instance_guard.showRequested.connect(window.show_and_raise)
instance_guard.patchRequested.connect(window.install_local_patch)
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
if local_patch:
window.install_local_patch(local_patch)
return app.exec() return app.exec()
+36
View File
@@ -12,6 +12,11 @@
# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den # stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den
# Dateinamen "PlaytubeHelper" an). # Dateinamen "PlaytubeHelper" an).
param(
# Ueberspringt den Bau des Setup-Installers (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
[switch]$SkipInstaller
)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
Set-Location $root Set-Location $root
@@ -59,3 +64,34 @@ if (-not $engineProcess) {
Write-Host "" Write-Host ""
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
if (-not $SkipInstaller) {
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
if (-not $versionLine) { throw "Konnte __version__ in playtube\__init__.py nicht lesen." }
$version = $versionLine.Matches[0].Groups[1].Value
$isccCandidates = @(
(Get-Command iscc -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }),
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
)
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $iscc) {
Write-Host ""
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
} else {
Write-Host ""
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green
}
}
+168
View File
@@ -0,0 +1,168 @@
; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe).
;
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1).
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe
;
; Wichtig fuer "es soll nie mehrere Versionen geben":
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen).
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar).
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben.
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten.
;
; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
; Inno Setup nicht ins Spiel kommt.
#ifndef AppVersion
#define AppVersion "0.0.0"
#endif
#define AppName "Playtube"
#define AppExeName "Playtube.exe"
#define AppHelperName "PlaytubeHelper.exe"
[Setup]
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit
; _INNO_APP_ID in playtube/updater.py uebereinstimmen.
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher=Playtube
AppPublisherURL=https://github.com/fojadrachi/Playtube
AppSupportURL=https://github.com/fojadrachi/Playtube/issues
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
VersionInfoVersion={#AppVersion}
DefaultDirName={localappdata}\Programs\{#AppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist\installer
OutputBaseFilename=Playtube-Setup-v{#AppVersion}
SetupIconFile=..\assets\icon.ico
UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu
; schliessen, die Inno per CloseApplications senden wuerde.
CloseApplications=no
RestartApplications=no
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[InstallDelete]
; Vor dem Kopieren die PyInstaller-Laufzeit der alten Version entfernen, damit nach einem
; Update keine Dateien einer aelteren Version uebrig bleiben.
Type: filesandordirs; Name: "{app}\_internal"
[Files]
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die
; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die
; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt.
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py).
Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}"
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1"""
[Run]
; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende.
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch
; wieder starten.
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
[Code]
function ShouldRelaunch(): Boolean;
begin
Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
end;
function PowerShellPath(): String;
begin
Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
end;
procedure WaitForOldInstance();
var
OldPid, ResultCode: Integer;
begin
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat,
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
hier nichts. }
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
if OldPid > 0 then
Exec(PowerShellPath(),
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "Wait-Process -Id ' + IntToStr(OldPid) + ' -Timeout 20 -ErrorAction SilentlyContinue"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
procedure StopLeftoverProcesses();
var
ResultCode: Integer;
begin
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
if DirExists(ExpandConstant('{app}')) then
Exec(PowerShellPath(),
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($d, [StringComparison]::OrdinalIgnoreCase) } | ' +
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ Dateisperren freigeben lassen, bevor kopiert wird. }
Sleep(800);
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
WaitForOldInstance();
StopLeftoverProcesses();
Result := '';
end;
function InitializeUninstall(): Boolean;
begin
StopLeftoverProcesses();
Result := True;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
begin
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True);
end;
end;
+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.4.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,
+37 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os import os
import sys import sys
import threading
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -28,6 +29,15 @@ from .updater import UpdateChecker, UpdateInstaller
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
def _force_exit_after(seconds: float) -> None:
"""Beendet den Prozess nach `seconds` hart (os._exit), falls das normale Beenden bis
dahin noch nicht durch ist. Der Timer laeuft als Daemon-Thread und stoert das
normale, schnelle Beenden nicht."""
timer = threading.Timer(seconds, os._exit, args=(0,))
timer.daemon = True
timer.start()
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
super().__init__() super().__init__()
@@ -221,6 +231,11 @@ class MainWindow(QMainWindow):
self.raise_() self.raise_()
self.activateWindow() self.activateWindow()
def show_and_raise(self) -> None:
"""Oeffentlicher Einstieg fuer den Einzelinstanz-Schutz (main.py): holt das Fenster
aus dem Tray, wenn Playtube ein zweites Mal gestartet wird."""
self._show_and_raise()
def _quit(self) -> None: def _quit(self) -> None:
if self._rpc_worker is not None: if self._rpc_worker is not None:
self._rpc_worker.stop() self._rpc_worker.stop()
@@ -311,20 +326,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)
@@ -341,6 +369,10 @@ class MainWindow(QMainWindow):
"Installation abgeschlossen. Playtube wird neu gestartet …" "Installation abgeschlossen. Playtube wird neu gestartet …"
) )
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …") self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
# Haengt sich Qt/QtWebEngine beim Beenden auf, wuerde der Update-Helfer (bzw.
# Setup, siehe /WAITPID) ewig auf das Ende dieses Prozesses warten - und die
# alte Version bliebe einfach weiterlaufen. Nach 8 s hart beenden.
_force_exit_after(8.0)
self._quit() self._quit()
else: else:
QMessageBox.information( QMessageBox.information(
+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
}); });
})(); })();
+26 -3
View File
@@ -25,6 +25,28 @@ from . import __version__ as APP_VERSION
from .config import APP_NAME, save_config from .config import APP_NAME, save_config
from .updater import GITHUB_REPO from .updater import GITHUB_REPO
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
# palette(mid) war ein dunkles Grau, das auf dem dunklen Hintergrund kaum lesbar war.
_LIGHT_TEXT_STYLE = "color: palette(text);"
# Update-Fortschrittsbalken: helle Beschriftung und gut sichtbarer Balken. Sobald ein
# Stylesheet gesetzt ist, zeichnet Qt den Balken nicht mehr mit dem nativen Stil - deshalb
# hier Rahmen, Hintergrund und Balken (chunk) vollstaendig angegeben.
_PROGRESS_BAR_STYLE = """
QProgressBar {
color: palette(text);
background-color: palette(base);
border: 1px solid palette(mid);
border-radius: 4px;
text-align: center;
}
QProgressBar::chunk {
background-color: #2f81f7;
border-radius: 3px;
}
"""
class SettingsTab(QWidget): class SettingsTab(QWidget):
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an """Speichert Aenderungen sofort in config.json und meldet sie per Signal an
@@ -48,7 +70,7 @@ class SettingsTab(QWidget):
title.setStyleSheet("font-size: 20px; font-weight: 600;") title.setStyleSheet("font-size: 20px; font-weight: 600;")
header.addWidget(title) header.addWidget(title)
version_label = QLabel(f"Version {APP_VERSION}") version_label = QLabel(f"Version {APP_VERSION}")
version_label.setStyleSheet("color: palette(mid);") version_label.setStyleSheet(_LIGHT_TEXT_STYLE)
header.addWidget(version_label) header.addWidget(version_label)
outer.addLayout(header) outer.addLayout(header)
@@ -97,13 +119,14 @@ class SettingsTab(QWidget):
update_form.addRow(update_actions) update_form.addRow(update_actions)
self._update_status_label = QLabel("") self._update_status_label = QLabel("")
self._update_status_label.setStyleSheet("color: palette(mid);") self._update_status_label.setStyleSheet(_LIGHT_TEXT_STYLE)
self._update_status_label.setWordWrap(True) self._update_status_label.setWordWrap(True)
update_form.addRow(self._update_status_label) update_form.addRow(self._update_status_label)
self._update_progress = QProgressBar() self._update_progress = QProgressBar()
self._update_progress.setRange(0, 100) self._update_progress.setRange(0, 100)
self._update_progress.setTextVisible(True) self._update_progress.setTextVisible(True)
self._update_progress.setStyleSheet(_PROGRESS_BAR_STYLE)
self._update_progress.setVisible(False) self._update_progress.setVisible(False)
update_form.addRow(self._update_progress) update_form.addRow(self._update_progress)
@@ -127,7 +150,7 @@ class SettingsTab(QWidget):
outer.addLayout(button_row) outer.addLayout(button_row)
info = QLabel(f"github.com/{GITHUB_REPO}") info = QLabel(f"github.com/{GITHUB_REPO}")
info.setStyleSheet("color: palette(mid);") info.setStyleSheet(_LIGHT_TEXT_STYLE)
outer.addWidget(info) outer.addWidget(info)
outer.addStretch(1) outer.addStretch(1)
+102
View File
@@ -0,0 +1,102 @@
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung sowie die Dateizuordnung
fuer die eigene ".play"-Patchdateiendung.
Playtube-Setup (packaging/playtube.iss) legt Startmenue-Eintrag und Dateizuordnung
selbst an. Fuer eine portable, aus einem ZIP entpackte Kopie gaebe es sonst nie einen
Eintrag im Windows-Startmenue - der wird beim Start der gepackten .exe einmalig
nachgeholt (idempotent - prueft vorher, ob die Verknuepfung schon existiert). Eine durch
Setup installierte Kopie fasst die Verknuepfung nicht an, damit sie nicht versehentlich
wieder auf eine andere (portable) Kopie umgebogen wird."""
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
# Import erst hier: shortcuts.py wird vor dem restlichen Qt-Setup importiert (siehe
# main.py), updater.py zieht PySide6 nach.
from .updater import is_installed_via_setup
if is_installed_via_setup():
return # Verknuepfung gehoert dem Installer (siehe Moduldoku)
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
+86
View File
@@ -0,0 +1,86 @@
"""Einzelinstanz-Schutz: verhindert, dass zwei Playtube-Prozesse gleichzeitig auf
demselben Browser-Profil (webprofile/) laufen.
Hintergrund: Laufen zwei Instanzen auf demselben QtWebEngine-Profil (Cache, GPUCache,
Service Worker, Cookies), zeigt die ZWEITE Instanz auf YouTube/YouTube Music sichtbar
keine Icons mehr (Play/Pause, Suche, Menue ...). Das passiert leicht, weil das Schliessen
des Fensters Playtube nur in den Tray minimiert - eine alte Version laeuft dann
unsichtbar weiter, waehrend eine neu heruntergeladene Version zusaetzlich gestartet wird.
Ansatz: ein lokaler Server (Named Pipe unter Windows, Unix-Socket unter Linux) pro
Datenordner. Der erste Prozess lauscht darauf; jeder weitere verbindet sich damit,
bittet die laufende Instanz, ihr Fenster zu zeigen, und beendet sich selbst.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
_CONNECT_TIMEOUT_MS = 500
_WRITE_TIMEOUT_MS = 500
_MSG_SHOW = "show"
_MSG_PATCH = "patch"
_SEPARATOR = "\t"
class SingleInstanceGuard(QObject):
"""Der erste Prozess mit einem bestimmten `key` "besitzt" die Instanz; jeder weitere
Aufruf von acquire() liefert False und reicht seine Anfrage an den Besitzer weiter."""
showRequested = Signal()
patchRequested = Signal(str)
def __init__(self, key: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._key = key
self._server: QLocalServer | None = None
def acquire(self, local_patch: str | None = None) -> bool:
"""True, wenn dieser Prozess die (einzige) Instanz sein darf. False, wenn bereits
eine laeuft - dann wurde ihr "Fenster zeigen" (bzw. der Patch-Pfad) gemeldet."""
if self._notify_running_instance(local_patch):
return False
# Kein Besitzer erreichbar: ggf. verwaisten Socket (Linux) aufraeumen und selbst
# lauschen.
QLocalServer.removeServer(self._key)
server = QLocalServer(self)
if not server.listen(self._key):
# Knappes Rennen zweier gleichzeitiger Starts: der andere war einen Tick
# frueher - nochmal versuchen, ihn zu erreichen, sonst trotzdem starten
# (lieber ohne Schutz laufen als gar nicht).
return not self._notify_running_instance(local_patch)
server.newConnection.connect(self._on_new_connection)
self._server = server
return True
def _notify_running_instance(self, local_patch: str | None) -> bool:
socket = QLocalSocket(self)
socket.connectToServer(self._key)
if not socket.waitForConnected(_CONNECT_TIMEOUT_MS):
socket.abort()
return False
message = f"{_MSG_PATCH}{_SEPARATOR}{local_patch}" if local_patch else _MSG_SHOW
socket.write(message.encode("utf-8"))
socket.waitForBytesWritten(_WRITE_TIMEOUT_MS)
socket.disconnectFromServer()
return True
def _on_new_connection(self) -> None:
if self._server is None:
return
while self._server.hasPendingConnections():
socket = self._server.nextPendingConnection()
socket.waitForReadyRead(_CONNECT_TIMEOUT_MS)
message = bytes(socket.readAll().data()).decode("utf-8", errors="replace")
socket.disconnectFromServer()
socket.deleteLater()
self._dispatch(message)
def _dispatch(self, message: str) -> None:
command, _, argument = message.partition(_SEPARATOR)
if command == _MSG_PATCH and argument:
self.patchRequested.emit(argument)
self.showRequested.emit()
+248 -68
View File
@@ -5,23 +5,39 @@ Ablauf:
Version als die aktuell laufende (playtube.__version__). Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung. 2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update: 3. UpdateInstaller installiert das Update:
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt - Gepackte App unter Windows: Es gibt EINE Installation (Playtube-Setup, siehe
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche packaging/playtube.iss, Ordner %LOCALAPPDATA%/Programs/Playtube), die jedes
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und Update an Ort und Stelle ueberschreibt - keine parallelen Versionsordner.
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit- * Installierte Kopie: laedt bevorzugt das kleine "Patch"-Paket (nur die
Ordner (_internal/) bleibt unangetastet, da er sich zwischen Patch-Releases Playtube.exe, ca. 2-3 MB) und ersetzt lediglich diese Datei; der grosse
normalerweise nicht aendert. Gibt es kein Patch-Paket (z.B. beim allerersten PySide6/QtWebEngine-Ordner (_internal/) bleibt unangetastet. Passt die
Release oder wenn CI es nicht gebaut hat), faellt es automatisch auf das PySide6-Version des Patches nicht zur installierten Laufzeit (siehe
volle Release-Paket zurueck und ersetzt den kompletten Installationsordner. _patch_is_compatible), wird stattdessen der Setup-Installer benutzt.
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu - * Portable Kopie (aus einem ZIP entpackt, z.B. in Downloads): laedt den
PowerShell unter Windows, ein Shell-Skript unter Linux. Setup-Installer und fuehrt ihn still aus. Danach liegt Playtube in der
regulaeren Installation und Startmenue/Desktop zeigen dorthin - die alte
portable Kopie wird nicht mehr gebraucht.
* Ohne Setup-Asset (aeltere Releases): Fallback auf das volle ZIP wie frueher.
- Gepackte App unter Linux: Patch-Paket (nur das Binary) bzw. volles .tar.gz.
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu -
PowerShell unter Windows (Patch/ZIP), Inno Setup selbst (Setup), Shell 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
import json import json
import os import os
import re
import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
@@ -44,6 +60,17 @@ _USER_AGENT = "Playtube-Updater"
_LINUX_BINARY_NAME = "Playtube" _LINUX_BINARY_NAME = "Playtube"
_WINDOWS_BINARY_NAME = "Playtube.exe" _WINDOWS_BINARY_NAME = "Playtube.exe"
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
_INNO_APP_ID = "{87FBA502-2B84-4C42-A66B-2F03F490912D}"
_STAGING_PREFIX = "playtube_update_"
# Release-Asset-Arten (siehe _asset_kind): "setup" = Inno-Installer (nur Windows),
# "patch" = kleines Paket mit nur der .exe/dem Binary, "full" = komplettes ZIP/.tar.gz.
_KIND_SETUP, _KIND_PATCH, _KIND_FULL = "setup", "patch", "full"
def _parse_version(v: str) -> tuple[int, ...]: def _parse_version(v: str) -> tuple[int, ...]:
v = v.strip().lstrip("vV") v = v.strip().lstrip("vV")
@@ -58,6 +85,20 @@ def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
return _parse_version(remote) > _parse_version(local) return _parse_version(remote) > _parse_version(local)
def _version_from_asset_name(name: str) -> str | None:
"""Zieht "X.Y.Z" aus einem Release-Asset-Namen wie "Playtube-v2.4.0-win64-patch.play"
(die PySide6-Version im Patch-Namen, "pyside6.11.2", wird dabei uebersprungen)."""
match = re.search(r"v(\d+\.\d+\.\d+)", name, flags=re.IGNORECASE)
return match.group(1) if match else None
def cleanup_old_staging() -> None:
"""Loescht Reste frueherer Update-Laeufe im Temp-Ordner (u.a. den heruntergeladenen
Setup-Installer - er laesst sich nicht selbst loeschen, solange er laeuft)."""
for old in Path(tempfile.gettempdir()).glob(f"{_STAGING_PREFIX}*"):
shutil.rmtree(old, ignore_errors=True)
def fetch_latest_release() -> dict[str, Any] | None: def fetch_latest_release() -> dict[str, Any] | None:
req = urllib.request.Request( req = urllib.request.Request(
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"} _API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
@@ -69,36 +110,91 @@ def fetch_latest_release() -> dict[str, Any] | None:
return None return None
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None: def is_installed_via_setup() -> bool:
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine """True, wenn diese Playtube.exe aus einer Installation durch Playtube-Setup stammt
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe (Inno Setup legt im Installationsordner immer unins000.exe ab) - im Gegensatz zu
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux".""" einer portablen, aus einem ZIP entpackten Kopie."""
assets = release.get("assets", []) if sys.platform != "win32" or not getattr(sys, "frozen", False):
return False
return (Path(sys.executable).resolve().parent / "unins000.exe").exists()
def _asset_kind(name: str) -> str | None:
"""Ordnet ein Release-Asset (nach Dateiname) einer Art zu - oder None, wenn es fuer
die laufende Plattform nicht in Frage kommt. Windows: Setup -> *Setup*.exe, Patch ->
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
name = name.lower()
if sys.platform == "win32": if sys.platform == "win32":
hints, exts = ("win",), (".zip",) if name.endswith(".exe") and "setup" in name:
return _KIND_SETUP
if name.endswith(".play"):
return _KIND_PATCH
if name.endswith(".zip") and "patch" not in name:
return _KIND_FULL
return None
if sys.platform.startswith("linux") and name.endswith((".tar.gz", ".tgz")):
return _KIND_PATCH if "patch" in name else _KIND_FULL
return None
def _patch_is_compatible(name: str) -> bool:
"""Patch-Pakete tragen die PySide6-Version, gegen die sie gebaut wurden, im Namen
(z.B. "Playtube-v2.4.0-win64-pyside6.11.2-patch.play"). Weicht sie von der Laufzeit
der installierten App ab, waere die neue Playtube.exe mit dem vorhandenen _internal/
inkompatibel - dann muss stattdessen der Setup-Installer die Laufzeit mit erneuern.
Ohne Angabe im Namen (aeltere Releases) wird wie frueher Kompatibilitaet angenommen."""
match = re.search(r"pyside(\d+\.\d+\.\d+)", name.lower())
if not match:
return True
try:
import PySide6
return match.group(1) == PySide6.__version__
except Exception: # noqa: BLE001 - im Zweifel wie frueher patchen
return True
def _preferred_kinds() -> tuple[str, ...]:
"""Reihenfolge, in der Release-Asset-Arten ausprobiert werden."""
if sys.platform == "win32":
if is_installed_via_setup():
# Installierte Kopie: kleines Patch zuerst (schnell, ueberschreibt nur die
# .exe im Installationsordner).
return (_KIND_PATCH, _KIND_SETUP, _KIND_FULL)
# Portable Kopie: Setup zuerst - danach liegt Playtube in der regulaeren
# Installation, statt dass weiter versionierte Ordner nebeneinander entstehen.
return (_KIND_SETUP, _KIND_PATCH, _KIND_FULL)
return (_KIND_PATCH, _KIND_FULL)
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket (Reihenfolge der Arten
siehe _preferred_kinds). Innerhalb einer Art wird ein Asset mit Plattformhinweis
("win"/"linux") im Namen bevorzugt."""
if sys.platform == "win32":
hint = "win"
elif sys.platform.startswith("linux"): elif sys.platform.startswith("linux"):
hints, exts = ("linux",), (".tar.gz", ".tgz") hint = "linux"
else: else:
return None return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None: candidates: list[tuple[str, bool, dict[str, Any]]] = []
for asset in assets: for asset in release.get("assets", []):
name = asset.get("name", "").lower() name = asset.get("name", "")
if not name.endswith(exts): kind = _asset_kind(name)
continue if kind is None:
if require_hint and not any(h in name for h in hints): continue
continue if kind == _KIND_PATCH and not _patch_is_compatible(name):
if ("patch" in name) != want_patch: continue
continue candidates.append((kind, hint in name.lower(), asset))
return asset
return None
return ( for kind in _preferred_kinds():
find(True, True) matching = [(has_hint, asset) for k, has_hint, asset in candidates if k == kind]
or find(True, False) if matching:
or find(False, True) matching.sort(key=lambda item: not item[0]) # stabil: Treffer mit Hinweis zuerst
or find(False, False) return matching[0][1]
) return None
class UpdateChecker(QThread): class UpdateChecker(QThread):
@@ -133,9 +229,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 +254,60 @@ 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_")) cleanup_old_staging()
archive_name = self._download_url.rsplit("/", 1)[-1] staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
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)
if sys.platform == "win32" and archive_name.lower().endswith(".exe"):
# Setup-Installer: nichts zu entpacken - er installiert selbst ueber die
# vorhandene Installation (bzw. richtet bei einer portablen Kopie die
# regulaere Installation ein) und startet Playtube danach neu.
self.progress.emit("Starte Installer …")
self.progress_percent.emit(-1)
self._install_setup_windows(archive_path)
self.finished_ok.emit()
return
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 +316,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:
@@ -208,7 +337,9 @@ class UpdateInstaller(QThread):
) )
return return
if sys.platform == "win32": if sys.platform == "win32":
self._install_patch_windows(matches[0], install_dir, staging) self._install_patch_windows(
matches[0], install_dir, staging, _version_from_asset_name(archive_name)
)
else: else:
self._install_patch_posix(matches[0], install_dir, staging) self._install_patch_posix(matches[0], install_dir, staging)
else: else:
@@ -256,8 +387,23 @@ $form.Refresh()
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) -- # -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None: def _install_patch_windows(
self, new_exe: Path, install_dir: Path, staging: Path, version: str | None = None
) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME exe_path = install_dir / _WINDOWS_BINARY_NAME
# Nach einem Patch zeigt "Apps & Features" sonst weiter die Version des zuletzt
# ausgefuehrten Setups an. Nur relevant fuer Installationen durch Playtube-Setup
# (der Eintrag existiert bei portablen Kopien nicht - dann ist Test-Path $false).
registry_update = ""
if version and is_installed_via_setup():
registry_update = f"""
if ($copied) {{
$uninstallKey = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{_INNO_APP_ID}_is1"
if (Test-Path -LiteralPath $uninstallKey) {{
Set-ItemProperty -LiteralPath $uninstallKey -Name DisplayVersion -Value "{version}"
}}
}}
"""
script = self._WINDOWS_PROGRESS_FORM_PS + f""" script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
$targetPid = {os.getpid()} $targetPid = {os.getpid()}
@@ -269,6 +415,8 @@ $label.Text = "Kopiere aktualisierte Datei …"
$form.Refresh() $form.Refresh()
[System.Windows.Forms.Application]::DoEvents() [System.Windows.Forms.Application]::DoEvents()
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
$copied = $?
{registry_update}
$label.Text = "Fertig – Playtube wird neu gestartet …" $label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh() $form.Refresh()
[System.Windows.Forms.Application]::DoEvents() [System.Windows.Forms.Application]::DoEvents()
@@ -309,6 +457,36 @@ rm -rf "{staging}"
""" """
self._spawn_posix_script(script, staging) self._spawn_posix_script(script, staging)
# -- Setup-Installer (Windows): eine einzige, ueberschriebene Installation --
def _install_setup_windows(self, setup_path: Path) -> None:
"""Startet Playtube-Setup still (nur Fortschrittsfenster, keine Rueckfragen).
Der Installer beendet laufende Playtube-Prozesse selbst, ueberschreibt die
vorhandene Installation im selben Ordner (gleiche AppId, siehe
packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die
laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss
hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das
Beenden dieses Prozesses ueberlebt.
/WAITPID=<eigene PID>: Setup wartet, bis sich diese App wirklich beendet hat,
bevor es Dateien tauscht. /LOG (ohne Pfad): Setup schreibt ein Protokoll nach
%TEMP% ("Setup Log ....txt") - so laesst sich ein fehlgeschlagenes Update
nachtraeglich nachvollziehen, obwohl die Installation selbst still laeuft."""
subprocess.Popen(
[
str(setup_path),
"/SILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/NOCANCEL",
"/RELAUNCH=1",
f"/WAITPID={os.getpid()}",
"/LOG",
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True,
)
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen -- # -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
@@ -326,7 +504,9 @@ $form.Refresh()
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait # Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft - # abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen. # sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP") # /R:3 /W:2 begrenzt die Wiederholungen bei gesperrten Dateien (Standard: praktisch endlos,
# das Update haengt dann sichtbar "fuer immer" im Fortschrittsfenster).
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru $roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
while (-not $roboProc.HasExited) {{ while (-not $roboProc.HasExited) {{
Start-Sleep -Milliseconds 200 Start-Sleep -Milliseconds 200