20 Commits
Author SHA1 Message Date
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
Fojadrachi d63b6b36d4 Release v2.1.5
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 12:34:00 +02:00
FojadrachiandClaude Sonnet 5 06e88ced19 Sichtbarer Fortschritt + garantierter Neustart waehrend der Update-Installation
Bisher lief der Datei-Austausch (Skript wartet auf Prozessende, kopiert/robocopy,
startet App neu) nach dem Schliessen des Hauptfensters komplett unsichtbar im
Hintergrund - bei einem vollen Paket-Update konnte das mehrere Sekunden dauern,
in denen es aussah, als waere die App abgestuerzt oder haenge.

- Windows: PowerShell-Skript zeigt jetzt ein kleines WinForms-Fenster mit
  Marquee-Fortschrittsbalken und Statustext (Warten auf Prozessende / Kopieren /
  Fertig - Neustart), robocopy laeuft dafuer per Start-Process+Polling statt
  -Wait, damit die Fensternachrichtenschleife per DoEvents() nicht einfriert.
- Linux: optional dasselbe ueber zenity --progress (best effort, falls
  installiert - ohne zenity laeuft es wie bisher unsichtbar durch).
- App zeigt vor dem Neustart noch kurz eine Statusmeldung im Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 12:33:56 +02:00
Fojadrachi fbf5f3b93f Release v2.1.4
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 12:05:34 +02:00
FojadrachiandClaude Sonnet 5 9bd4b360cd Debug-Log fuer Discord-RPC-Sends (gepackte .exe laeuft ohne Konsole)
console=False im PyInstaller-Build bedeutet: print()/PLAYTUBE_DEBUG-Ausgaben
sind fuer den Nutzer komplett unsichtbar - alle bisherigen Fixes basierten
daher auf Symptombeschreibungen statt echten Daten. Schreibt jetzt bei jedem
RPC-Send (Titel, Thumbnail, large_image, start/end, Track-Wechsel-Erkennung)
eine Zeile in %APPDATA%/Playtube/logs/discord_rpc.log (auf 500 Zeilen
gekappt), damit sich das Bild-Problem anhand echter Daten diagnostizieren
laesst statt weiter zu raten.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 12:05:29 +02:00
Fojadrachi e67e8d5d90 Release v2.1.3
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 00:08:37 +02:00
FojadrachiandClaude Sonnet 5 2ba4e1bb6f Fix: Discord-Fortschrittszeit stackt weiterhin bei YT-Music-Songwechsel (gapless playback)
video.currentTime wird bei YouTube Music beim Songwechsel wegen nahtloser
(gapless) Wiedergabe aus einem durchgehenden Buffer nicht zuverlaessig auf 0
zurueckgesetzt. Da start/end bisher bei JEDEM RPC-Update frisch aus
currentTime berechnet wurden, blieb die in Discord angezeigte Zeit ueber
Songwechsel hinweg stehen bzw. stackte sich, obwohl Titel/Bild laengst
gewechselt hatten (v2.1.2 hat nur das Rate-Limit-Problem fuer das Bild
behoben, nicht dieses).

Der Zeit-Anker wird jetzt im DiscordRPCWorker anhand von Titel-/Untertitel-
Aenderung gesetzt (_track_timestamps) und bleibt danach stabil ueber die
Systemzeit, statt bei jedem Send erneut aus currentTime abgeleitet zu werden.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 00:08:32 +02:00
Fojadrachi 39ebaa84c5 Release v2.1.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-11 23:57:48 +02:00
FojadrachiandClaude Sonnet 5 1b587edaaa Fix: Discord-RPC-Updates unterhalb 15s werden von Discord verworfen (eingefrorenes Bild/Zeit)
Discord ignoriert Rich-Presence-Updates stillschweigend, wenn sie haeufiger als
etwa alle 15 Sekunden gesendet werden. Das update_interval_seconds war auf 5s
konfigurierbar/konfiguriert, wodurch ein Teil der Updates (inkl. neuem
Thumbnail und neuem Start-Zeitstempel bei Songwechsel) verworfen wurde -
sichtbar als eingefrorenes altes Bild und 'stackende' Zeit bei Autoplay.
Hartes Minimum von 15s jetzt sowohl im Worker als auch im Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:57:43 +02:00
Fojadrachi 46035ccd5d Release v2.1.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-11 23:47:16 +02:00
FojadrachiandClaude Sonnet 5 b79d42d56d Fix: Discord-Fortschrittszeit stackt bei Autoplay-Songwechseln
Ursache: start/end wurden nur gesendet, wenn 'playing' gerade true war. Bei jedem
automatischen Songwechsel laedt das <video>-Element kurz neu, wodurch 'playing' fuer
1-2 Polls faelschlich false wird - in diesem Fenster fehlten start/end im Update
komplett. Discord ersetzt 'timestamps' bei einem unvollstaendigen SET_ACTIVITY-Update
offenbar nicht sauber, sondern behaelt die zuletzt bekannten (viel zu alten) Werte
bei, wodurch die angezeigte Zeit ueber mehrere Songs hinweg immer weiter waechst statt
pro Lied neu zu starten. Fix: start/end werden jetzt IMMER mitgeschickt, unabhaengig
vom playing-Status (ein pausierter Titel zeigt dann einfach einen eingefrorenen statt
gar keinen Fortschrittsbalken).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:47:02 +02:00
Fojadrachi 45e9b6054d Release v2.1.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-11 21:11:44 +02:00
FojadrachiandClaude Sonnet 5 2ce2e716c5 Update-UI im Einstellungen-Tab + schnelle Patch-Updates statt Komplettinstallation
- Einstellungen-Tab: 'Jetzt nach Updates suchen'-Button, Status-Text (aktuell/verfuegbar/
  fehlgeschlagen), Fortschrittsbalken fuer den Download (Prozent bei bekannter Groesse,
  unbestimmt waehrend Entpacken/Installieren), 'Update installieren'-Button erscheint bei Fund.
- updater.py/UpdateInstaller: laedt Download jetzt in Chunks (statt copyfileobj) und meldet
  echten Fortschritt in Prozent ueber ein neues progress_percent-Signal.
- Patch-Updates: CI baut zusaetzlich zum vollen Release-Paket ein kleines '-patch'-Paket,
  das nur die ausfuehrbare Datei (Playtube.exe/Playtube, ~2-3 MB) enthaelt - der riesige
  PySide6/QtWebEngine-Laufzeitordner _internal/ (~200 MB) bleibt unangetastet, da er sich
  zwischen normalen Code-Patches nicht aendert. updater.py bevorzugt das Patch-Paket und
  faellt nur auf das volle Paket zurueck, wenn kein Patch-Asset gefunden wird.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 21:11:31 +02:00
16 changed files with 1286 additions and 98 deletions
+50 -2
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 - 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
@@ -41,6 +61,26 @@ jobs:
name: windows-build name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
# .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
with:
name: windows-patch
path: Playtube-*-win64-pyside*-patch.play
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -69,7 +109,7 @@ jobs:
- name: Playtube-Binary bauen - name: Playtube-Binary bauen
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .tar.gz packen - name: Als .tar.gz packen (volles Paket)
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -77,6 +117,14 @@ jobs:
name: linux-build name: linux-build
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-patch
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
publish-release: publish-release:
needs: [build-windows, build-linux] needs: [build-windows, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
+88 -8
View File
@@ -16,6 +16,27 @@ Erweiterung) mit:
- **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 Lautstaerkemixer 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 noetig) und legt Startmenue-Eintrag (optional Desktop-Verknuepfung) an,
- ersetzt bei jeder spaeteren Ausfuehrung die vorhandene Installation **an Ort und
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
mehrere nebeneinander,
- beendet dafuer ein noch laufendes Playtube selbst und raeumt den alten Programmordner
auf, damit keine Reste alter Versionen uebrig bleiben,
- laesst Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
ueber "Apps & Features" wird gefragt, ob sie mitgeloescht werden sollen.
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
Setup-Datei wird dafuer 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 naechsten Update automatisch an, in die
regulaere Installation zu wechseln (danach kann der alte Ordner geloescht werden).
## Schnellstart (Entwicklung) ## Schnellstart (Entwicklung)
```powershell ```powershell
@@ -55,7 +76,16 @@ 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`. Ist
[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` ueberspringbar).
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
Verknuepfung im Windows-Startmenue an - eine per Setup installierte Kopie hat den
Eintrag bereits vom Installer.
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
@@ -71,17 +101,51 @@ o.ae. umgehen).
## Automatische Updates ## Automatische Updates
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
(Standard 6, in `config.json` einstellbar) die [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) (Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
installiert werden soll: Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll:
- **Gepackte `Playtube.exe`**: laedt das `.zip`-Asset des Release herunter und ersetzt - **Windows, per Setup installiert** (siehe "Installation"): laedt bevorzugt das kleine
nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App **Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB)
danach selbst neu. und ueberschreibt damit die Datei im Installationsordner - der riesige PySide6/
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
normalen Patch-Releases nicht aendert. Die PySide6-Version steht im Dateinamen des
Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab,
ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den
**Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
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): laedt den
**Setup-Installer** und fuehrt ihn still aus. Playtube liegt danach in der regulaeren
Installation - Startmenue-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
Kopie ist nicht mehr noetig (und kann geloescht werden). So entstehen nicht laenger
versionierte Ordner nebeneinander. Gibt es (bei aelteren Releases) keinen Installer,
wird wie frueher das volle ZIP ueber den vorhandenen Ordner kopiert.
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
Die App startet sich in allen Faellen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r - **Entwicklungsmodus** (`python main.py`): fuehrt `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 in `config.json` unter `updates.enabled` deaktivieren. Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`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
@@ -97,6 +161,11 @@ eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem G
Release veroeffentlicht - Fortschritt unter Release veroeffentlicht - Fortschritt unter
https://github.com/fojadrachi/Playtube/actions. https://github.com/fojadrachi/Playtube/actions.
Fuer Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, fuer
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.
@@ -137,6 +206,17 @@ QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubunt
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
Entwicklungsmodus) loeschen und neu anmelden. Entwicklungsmodus) loeschen und neu anmelden.
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menue ...):** Fast
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 aeltere Version laeuft unsichtbar
weiter, waehrend eine neu heruntergeladene zusaetzlich gestartet wird. Abhilfe: alle
Playtube-Instanzen ueber 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
aeltere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
beendet werden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von - 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten 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`
+1 -1
View File
@@ -3,7 +3,7 @@
"discord": { "discord": {
"enabled": true, "enabled": true,
"client_id": "1548023494976086127", "client_id": "1548023494976086127",
"update_interval_seconds": 5, "update_interval_seconds": 15,
"show_idle_presence": true "show_idle_presence": true
}, },
"updates": { "updates": {
+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
}
}
+134
View File
@@ -0,0 +1,134 @@
; 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;
procedure StopRunningPlaytube();
var
ResultCode: Integer;
begin
{ /T beendet auch die Kindprozesse (QtWebEngine); der Hilfsprozess wird zusaetzlich
einzeln beendet, falls er als Waise ueberlebt hat. }
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /T /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ Dateisperren freigeben lassen, bevor kopiert wird. }
Sleep(800);
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
StopRunningPlaytube();
Result := '';
end;
function InitializeUninstall(): Boolean;
begin
StopRunningPlaytube();
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.0.1" __version__ = "2.3.1"
+34
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
@@ -48,6 +49,14 @@ def _app_data_dir() -> Path:
return d return d
def app_data_dir() -> Path:
"""Oeffentlicher Zugriff auf den App-Datenordner, z.B. fuer Debug-Logs - die
gepackte .exe laeuft ohne Konsolenfenster (console=False), print()-Debugging ist
dort also unsichtbar; ein Log-File ist die einzige Moeglichkeit, dort etwas
nachtraeglich einzusehen."""
return _app_data_dir()
def _config_path() -> Path: def _config_path() -> Path:
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar), # Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
# im gepackten Build im APPDATA-Ordner. # im gepackten Build im APPDATA-Ordner.
@@ -93,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
+44
View File
@@ -0,0 +1,44 @@
"""Sehr einfaches, immer aktives Datei-Log fuer schwer reproduzierbare Bugs.
Die gepackte .exe laeuft ohne Konsolenfenster (console=False in packaging/playtube.spec),
also sind print()-Debugausgaben dort unsichtbar - selbst mit PLAYTUBE_DEBUG=1 sieht der
Nutzer nichts. Dieses Modul schreibt stattdessen in eine kleine, automatisch gekappte
Log-Datei im App-Datenordner, die sich jederzeit nachtraeglich auslesen laesst.
"""
from __future__ import annotations
import time
import traceback
from pathlib import Path
from .config import app_data_dir
_MAX_LINES = 500
def _log_path() -> Path:
d = app_data_dir() / "logs"
d.mkdir(parents=True, exist_ok=True)
return d / "discord_rpc.log"
def log_line(msg: str) -> None:
"""Haengt eine Zeile mit Zeitstempel an - haelt die Datei klein, indem bei
Ueberlaenge nur die letzten _MAX_LINES Zeilen behalten werden."""
try:
path = _log_path()
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] {msg}\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
if len(lines) > _MAX_LINES:
path.write_text("\n".join(lines[-_MAX_LINES:]) + "\n", encoding="utf-8")
except Exception:
# Logging darf niemals die App zum Absturz bringen.
pass
def log_exception(context: str, exc: BaseException) -> None:
log_line(f"{context}: {exc!r}\n{''.join(traceback.format_exception(exc))}")
+73 -12
View File
@@ -16,6 +16,7 @@ from typing import Any
from PySide6.QtCore import QThread from PySide6.QtCore import QThread
from .config import APP_NAME from .config import APP_NAME
from .debug_log import log_line
try: try:
from pypresence.types import ActivityType from pypresence.types import ActivityType
@@ -54,9 +55,13 @@ def _upsize_thumbnail(url: str | None) -> str | None:
return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url) return _THUMBNAIL_SIZE_RE.sub("=w544-h544-l90-rj", url)
def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]: def build_presence_payload(
info: dict[str, Any], session_start: int, start_ts: int, end_ts: int | None
) -> dict[str, Any]:
"""Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten """Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten
Medien-Informationen.""" Medien-Informationen. start_ts/end_ts werden vom DiscordRPCWorker mitgegeben (siehe
dort _track_timestamps) statt hier direkt aus info["currentTime"] berechnet zu
werden."""
is_music = bool(info.get("isMusic")) is_music = bool(info.get("isMusic"))
playing = bool(info.get("playing")) playing = bool(info.get("playing"))
title = _truncate(info.get("title"), fallback="Unbekannter Titel") title = _truncate(info.get("title"), fallback="Unbekannter Titel")
@@ -84,15 +89,10 @@ def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str
"large_text": "YouTube Music" if is_music else "YouTube", "large_text": "YouTube Music" if is_music else "YouTube",
"small_image": ASSET_PLAY if playing else ASSET_PAUSE, "small_image": ASSET_PLAY if playing else ASSET_PAUSE,
"small_text": "Spielt" if playing else "Pausiert", "small_text": "Spielt" if playing else "Pausiert",
"start": start_ts,
} }
if end_ts:
duration = info.get("duration") or 0 payload["end"] = end_ts
current_time = info.get("currentTime") or 0
if playing:
start_ts = int(time.time() - current_time)
payload["start"] = start_ts
if duration and duration > 0:
payload["end"] = start_ts + int(duration)
url = info.get("url") url = info.get("url")
if url and isinstance(url, str) and url.startswith("http"): if url and isinstance(url, str) and url.startswith("http"):
@@ -119,13 +119,25 @@ class DiscordRPCWorker(QThread):
def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None): def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None):
super().__init__(parent) super().__init__(parent)
self._client_id = client_id self._client_id = client_id
self._interval = max(5.0, float(interval)) # Discord ignoriert/verwirft Rich-Presence-Updates stillschweigend, wenn sie
# haeufiger als ca. alle 15 Sekunden gesendet werden (offizielle Grenze fuer
# SET_ACTIVITY). Wird diese Grenze unterschritten, landet zwar technisch jedes
# Update im Code, aber Discord uebernimmt nur einen Teil davon - nach aussen
# sieht das wie "eingefrorene" Bilder/Zeiten aus (Bild wechselt nicht, Fortschritt
# "stackt" beim Songwechsel), weil zufaellig immer wieder ein veraltetes Update
# durchkommt statt des aktuellen. Deshalb hartes Minimum von 15s, unabhaengig
# davon, was in der Konfiguration steht.
self._interval = max(15.0, float(interval))
self._show_idle = show_idle self._show_idle = show_idle
self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1) self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1)
self._running = True self._running = True
self._connected = False self._connected = False
self._presence = None self._presence = None
self._session_start = int(time.time()) self._session_start = int(time.time())
# Anker fuer die Fortschrittsanzeige: wird NICHT mehr bei jedem Send aus
# video.currentTime neu berechnet (siehe _track_timestamps).
self._track_key: tuple | None = None
self._track_start_ts: int | None = None
def submit_media_info(self, info: dict[str, Any] | None) -> None: def submit_media_info(self, info: dict[str, Any] | None) -> None:
"""Neuester bekannter Zustand (None = nichts spielt / idle).""" """Neuester bekannter Zustand (None = nichts spielt / idle)."""
@@ -188,6 +200,7 @@ class DiscordRPCWorker(QThread):
self._presence = Presence(self._client_id) self._presence = Presence(self._client_id)
self._presence.connect() self._presence.connect()
self._connected = True self._connected = True
log_line("[connect] verbunden")
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print("[discord-rpc] verbunden", flush=True) print("[discord-rpc] verbunden", flush=True)
except Exception as exc: except Exception as exc:
@@ -195,11 +208,46 @@ class DiscordRPCWorker(QThread):
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True) print(f"[discord-rpc] Verbindung fehlgeschlagen: {exc!r}", flush=True)
def _track_timestamps(self, info: dict[str, Any]) -> tuple[int, int | None]:
"""Liefert (start_ts, end_ts) fuer die Fortschrittsanzeige. Der Anker wird nur
NEU gesetzt, wenn sich Titel/Untertitel aendern (= neuer Track) - nicht bei
jedem Send aus video.currentTime neu berechnet. Grund: YouTube Music spielt
beim Songwechsel oft nahtlos (gapless) aus einem durchgehenden Buffer weiter -
video.currentTime springt dabei nicht zuverlaessig auf 0 zurueck, sondern kann
einfach vom vorherigen Titel weiterzaehlen. Wuerde man start_ts jedes Mal aus
currentTime neu ableiten, "stackt" die in Discord angezeigte Zeit ueber mehrere
Songs hinweg, obwohl Titel/Bild laengst gewechselt haben."""
title = info.get("title") or ""
subtitle = info.get("subtitle") or ""
is_music = bool(info.get("isMusic"))
key = (is_music, title, subtitle)
duration = info.get("duration") or 0
current_time = info.get("currentTime") or 0
now = time.time()
if key != self._track_key:
old_key = self._track_key
self._track_key = key
# current_time nur als grobe Anfangs-Schaetzung verwenden (z.B. Programm
# startet waehrend ein Titel schon laeuft) - plausibilisiert, damit ein
# verlaesslicher Wert genau EINMAL beim Trackwechsel einfriert und danach
# rein ueber die Systemzeit weiterlaeuft statt ueber currentTime.
offset = current_time if (duration <= 0 or 0 <= current_time <= duration) else 0
self._track_start_ts = int(now - offset)
log_line(f"[track-change] alt={old_key!r} neu={key!r} offset={offset:.1f}s")
start_ts = self._track_start_ts if self._track_start_ts is not None else int(now)
end_ts = start_ts + int(duration) if duration and duration > 0 else None
return start_ts, end_ts
def _send(self, item) -> None: def _send(self, item) -> None:
if self._presence is None: if self._presence is None:
return return
try: try:
if item is _IDLE_SENTINEL: if item is _IDLE_SENTINEL:
# Naechster echter Track soll wieder einen frischen Zeit-Anker bekommen.
self._track_key = None
self._track_start_ts = None
if self._show_idle: if self._show_idle:
payload = build_idle_payload(self._session_start) payload = build_idle_payload(self._session_start)
self._presence.update(**payload) self._presence.update(**payload)
@@ -207,13 +255,26 @@ class DiscordRPCWorker(QThread):
payload = None payload = None
self._presence.clear() self._presence.clear()
else: else:
payload = build_presence_payload(item, self._session_start) start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
self._presence.update(**payload) self._presence.update(**payload)
log_line(
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
% (
item.get("title"),
item.get("url"),
item.get("thumbnail"),
payload.get("large_image"),
start_ts,
end_ts,
)
)
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] gesendet: {payload!r}", flush=True) print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
except Exception as exc: except Exception as exc:
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden. # Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
self._connected = False self._connected = False
log_line(f"[send-error] {exc!r}")
if os.environ.get("PLAYTUBE_DEBUG"): if os.environ.get("PLAYTUBE_DEBUG"):
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True) print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
+64 -6
View File
@@ -52,6 +52,11 @@ class MainWindow(QMainWindow):
self._tabs.addTab(self._settings_tab, "Einstellungen") self._tabs.addTab(self._settings_tab, "Einstellungen")
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1) self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
self._settings_tab.settingsSaved.connect(self._on_settings_saved) self._settings_tab.settingsSaved.connect(self._on_settings_saved)
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
self._settings_tab.installUpdateRequested.connect(
lambda: self._start_update_install(self._pending_download_url)
)
self._pending_download_url = ""
self._youtube_tab.mediaInfoChanged.connect(self._on_media_info) self._youtube_tab.mediaInfoChanged.connect(self._on_media_info)
self._music_tab.mediaInfoChanged.connect(self._on_media_info) self._music_tab.mediaInfoChanged.connect(self._on_media_info)
@@ -216,6 +221,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()
@@ -263,13 +273,30 @@ class MainWindow(QMainWindow):
def _check_for_updates(self) -> None: def _check_for_updates(self) -> None:
if self._update_checker is not None and self._update_checker.isRunning(): if self._update_checker is not None and self._update_checker.isRunning():
return return
self._settings_tab.set_checking()
self._update_checker = UpdateChecker(self) self._update_checker = UpdateChecker(self)
self._update_checker.updateAvailable.connect(self._on_update_available) self._update_checker.updateAvailable.connect(self._on_update_available)
self._update_checker.upToDate.connect(self._on_update_up_to_date)
self._update_checker.checkFailed.connect(self._on_update_check_failed)
self._update_checker.start() self._update_checker.start()
def _on_update_up_to_date(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(f"Du hast bereits die neueste Version (v{APP_VERSION}).")
def _on_update_check_failed(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(
"Update-Prüfung fehlgeschlagen (keine Internetverbindung oder GitHub nicht erreichbar)."
)
def _on_update_available(self, version: str, notes: str, download_url: str) -> None: def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
self._settings_tab.set_check_done()
self._pending_download_url = download_url
self._settings_tab.show_update_available(version)
if self._pending_update_version == version: if self._pending_update_version == version:
return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt return # Dialog fuer diese Version wurde schon einmal gezeigt
self._pending_update_version = version self._pending_update_version = version
self._show_and_raise() self._show_and_raise()
@@ -289,20 +316,49 @@ 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 and not local_path:
return
self._settings_tab.hide_install_button()
if local_path:
self._settings_tab.set_update_status("Installiere lokale Patch-Datei …")
self._settings_tab.set_download_progress(-1)
else:
self._settings_tab.set_update_status("Lade Update herunter …")
self._settings_tab.set_download_progress(0)
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …") self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self) self._update_installer = UpdateInstaller(
self._update_installer.progress.connect( download_url, local_archive_path=local_path, parent=self
lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}")
) )
self._update_installer.progress.connect(self._on_install_progress_text)
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
self._update_installer.finished_ok.connect(self._on_update_finished) self._update_installer.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:
self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg)
def _on_update_finished(self) -> None: def _on_update_finished(self) -> None:
self._settings_tab.set_download_progress(100)
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess # Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
# beendet wird, tauscht dann die Dateien aus und startet die App neu. # beendet wird, tauscht dann die Dateien aus und startet die App neu -
# es zeigt dabei selbst ein kleines Fortschrittsfenster an (siehe
# updater.py), damit der Nutzer zwischen "App schliesst sich" und
# "neue App startet" nicht denkt, etwas sei abgestuerzt.
self._settings_tab.set_update_status(
"Installation abgeschlossen. Playtube wird neu gestartet …"
)
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
self._quit() self._quit()
else: else:
QMessageBox.information( QMessageBox.information(
@@ -314,6 +370,8 @@ class MainWindow(QMainWindow):
def _on_update_failed(self, error: str) -> None: def _on_update_failed(self, error: str) -> None:
self._tray.setToolTip(APP_NAME) self._tray.setToolTip(APP_NAME)
self._settings_tab.set_progress_hidden()
self._settings_tab.set_update_status(f"Update fehlgeschlagen: {error}")
QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error) QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error)
def _restart_dev_process(self) -> None: def _restart_dev_process(self) -> None:
+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
}); });
})(); })();
+66 -2
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QProgressBar,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QVBoxLayout, QVBoxLayout,
@@ -30,6 +31,8 @@ class SettingsTab(QWidget):
MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden.""" MainWindow, damit Discord-RPC/Auto-Update ohne Neustart neu konfiguriert werden."""
settingsSaved = Signal(dict) settingsSaved = Signal(dict)
checkUpdatesRequested = Signal()
installUpdateRequested = Signal()
def __init__(self, config: dict[str, Any], parent=None): def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent) super().__init__(parent)
@@ -57,9 +60,11 @@ class SettingsTab(QWidget):
self._client_id = QLineEdit(str(discord_cfg.get("client_id", ""))) self._client_id = QLineEdit(str(discord_cfg.get("client_id", "")))
self._client_id.setPlaceholderText("Discord Application Client-ID") self._client_id.setPlaceholderText("Discord Application Client-ID")
self._discord_interval = QSpinBox() self._discord_interval = QSpinBox()
self._discord_interval.setRange(5, 120) # Minimum 15s: Discord ignoriert Rich-Presence-Updates, die haeufiger kommen,
# stillschweigend (fuehrt zu "eingefrorenem" Bild/Fortschrittsbalken).
self._discord_interval.setRange(15, 120)
self._discord_interval.setSuffix(" s") self._discord_interval.setSuffix(" s")
self._discord_interval.setValue(int(discord_cfg.get("update_interval_seconds", 15))) self._discord_interval.setValue(max(15, int(discord_cfg.get("update_interval_seconds", 15))))
self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft") self._show_idle = QCheckBox("Status anzeigen, wenn gerade nichts läuft")
self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True))) self._show_idle.setChecked(bool(discord_cfg.get("show_idle_presence", True)))
discord_form.addRow(self._discord_enabled) discord_form.addRow(self._discord_enabled)
@@ -79,6 +84,29 @@ class SettingsTab(QWidget):
self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6))) self._check_interval.setValue(int(updates_cfg.get("check_interval_hours", 6)))
update_form.addRow(self._updates_enabled) update_form.addRow(self._updates_enabled)
update_form.addRow("Prüfintervall:", self._check_interval) update_form.addRow("Prüfintervall:", self._check_interval)
update_actions = QHBoxLayout()
self._check_updates_btn = QPushButton("Jetzt nach Updates suchen")
self._check_updates_btn.clicked.connect(self.checkUpdatesRequested.emit)
update_actions.addWidget(self._check_updates_btn)
self._install_update_btn = QPushButton("Update installieren")
self._install_update_btn.setVisible(False)
self._install_update_btn.clicked.connect(self.installUpdateRequested.emit)
update_actions.addWidget(self._install_update_btn)
update_actions.addStretch(1)
update_form.addRow(update_actions)
self._update_status_label = QLabel("")
self._update_status_label.setStyleSheet("color: palette(mid);")
self._update_status_label.setWordWrap(True)
update_form.addRow(self._update_status_label)
self._update_progress = QProgressBar()
self._update_progress.setRange(0, 100)
self._update_progress.setTextVisible(True)
self._update_progress.setVisible(False)
update_form.addRow(self._update_progress)
outer.addWidget(update_box) outer.addWidget(update_box)
general_box = QGroupBox("Allgemein") general_box = QGroupBox("Allgemein")
@@ -126,3 +154,39 @@ class SettingsTab(QWidget):
"Einstellungen gespeichert und übernommen (Discord-Verbindung wurde " "Einstellungen gespeichert und übernommen (Discord-Verbindung wurde "
"mit den neuen Werten neu gestartet).", "mit den neuen Werten neu gestartet).",
) )
# ----------------------------------------------------- Update-Status (von MainWindow)
def set_checking(self) -> None:
self._check_updates_btn.setEnabled(False)
self._install_update_btn.setVisible(False)
self._update_progress.setVisible(False)
self._update_status_label.setText("Suche nach Updates …")
def set_check_done(self) -> None:
self._check_updates_btn.setEnabled(True)
def set_update_status(self, text: str) -> None:
self._update_status_label.setText(text)
def show_update_available(self, version: str) -> None:
self._update_status_label.setText(f"Neue Version verfügbar: {version}")
self._install_update_btn.setVisible(True)
def hide_install_button(self) -> None:
self._install_update_btn.setVisible(False)
def set_download_progress(self, percent: int) -> None:
"""percent: 0-100 fuer einen konkreten Fortschritt, -1 fuer unbestimmt
(z.B. waehrend Entpacken/Installieren), negativ->versteckt den Balken nicht
automatisch - dafuer set_progress_hidden() aufrufen."""
if percent < 0:
self._update_progress.setRange(0, 0) # "laufender" Balken ohne Prozentzahl
else:
self._update_progress.setRange(0, 100)
self._update_progress.setValue(percent)
self._update_progress.setVisible(True)
def set_progress_hidden(self) -> None:
self._update_progress.setVisible(False)
self._update_progress.setRange(0, 100)
+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()
+397 -60
View File
@@ -5,18 +5,38 @@ 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 das - Gepackte App unter Windows: Es gibt EINE Installation (Playtube-Setup, siehe
zum laufenden Betriebssystem passende Release-Paket herunter, entpackt es packaging/playtube.iss, Ordner %LOCALAPPDATA%/Programs/Playtube), die jedes
und laesst ein kurzes Skript (nach Prozessende) den Installationsordner Update an Ort und Stelle ueberschreibt - keine parallelen Versionsordner.
ersetzen und die App neu starten - PowerShell+robocopy unter Windows, * Installierte Kopie: laedt bevorzugt das kleine "Patch"-Paket (nur die
ein Shell-Skript unter Linux. Playtube.exe, ca. 2-3 MB) und ersetzt lediglich diese Datei; der grosse
PySide6/QtWebEngine-Ordner (_internal/) bleibt unangetastet. Passt die
PySide6-Version des Patches nicht zur installierten Laufzeit (siehe
_patch_is_compatible), wird stattdessen der Setup-Installer benutzt.
* Portable Kopie (aus einem ZIP entpackt, z.B. in Downloads): laedt den
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 shutil
import stat import stat
import subprocess import subprocess
@@ -40,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")
@@ -54,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"}
@@ -65,24 +110,90 @@ 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: """True, wenn diese Playtube.exe aus einer Installation durch Playtube-Setup stammt
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen.""" (Inno Setup legt im Installationsordner immer unins000.exe ab) - im Gegensatz zu
assets = release.get("assets", []) einer portablen, aus einem ZIP entpackten Kopie."""
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
for asset in assets: candidates: list[tuple[str, bool, dict[str, Any]]] = []
name = asset.get("name", "").lower() for asset in release.get("assets", []):
if name.endswith(exts) and any(h in name for h in hints): name = asset.get("name", "")
return asset kind = _asset_kind(name)
for asset in assets: if kind is None:
if asset.get("name", "").lower().endswith(exts): continue
return asset if kind == _KIND_PATCH and not _patch_is_compatible(name):
continue
candidates.append((kind, hint in name.lower(), asset))
for kind in _preferred_kinds():
matching = [(has_hint, asset) for k, has_hint, asset in candidates if k == kind]
if matching:
matching.sort(key=lambda item: not item[0]) # stabil: Treffer mit Hinweis zuerst
return matching[0][1]
return None return None
@@ -112,12 +223,24 @@ class UpdateInstaller(QThread):
"""Laedt ein Release herunter und installiert es (siehe Moduldoku).""" """Laedt ein Release herunter und installiert es (siehe Moduldoku)."""
progress = Signal(str) progress = Signal(str)
# Download-Fortschritt in Prozent (0-100); -1 = unbestimmt (z.B. waehrend
# Entpacken/Installieren, wo sich kein Prozentsatz sinnvoll angeben laesst).
progress_percent = Signal(int)
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:
@@ -131,58 +254,285 @@ 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 …")
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, open(archive_path, "wb") as out: archive_path = Path(self._local_archive_path)
shutil.copyfileobj(resp, out) if not archive_path.exists():
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
return
archive_name = archive_path.name
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
self.progress_percent.emit(-1)
else:
if not self._download_url:
self.failed.emit(
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
)
return
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp:
total = int(resp.headers.get("Content-Length") or 0)
downloaded = 0
last_emitted = -1
with open(archive_path, "wb") as out:
while True:
chunk = resp.read(256 * 1024)
if not chunk:
break
out.write(chunk)
downloaded += len(chunk)
if total:
percent = int(downloaded * 100 / total)
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
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.emit("Entpacke Update …") self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
self.progress_percent.emit(-1)
if archive_name.endswith((".tar.gz", ".tgz")): if archive_name.endswith((".tar.gz", ".tgz")):
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)
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/"). is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
self.progress.emit("Bereite Installation vor …") self.progress.emit("Bereite Installation vor …")
if sys.platform == "win32":
self._install_windows(source_dir, install_dir, staging) if is_patch:
binary_name = _WINDOWS_BINARY_NAME if sys.platform == "win32" else _LINUX_BINARY_NAME
matches = list(extract_dir.rglob(binary_name))
if not matches:
# Unerwartet leeres/falsches Patch-Paket -> nicht kaputt installieren,
# lieber sauber fehlschlagen und den Nutzer auf das volle Paket
# hinweisen (naechster Check faellt automatisch darauf zurueck).
self.failed.emit(
f"Patch-Paket enthielt kein '{binary_name}'. Bitte erneut versuchen."
)
return
if sys.platform == "win32":
self._install_patch_windows(
matches[0], install_dir, staging, _version_from_asset_name(archive_name)
)
else:
self._install_patch_posix(matches[0], install_dir, staging)
else: else:
self._install_posix(source_dir, install_dir, staging) # Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
entries = list(extract_dir.iterdir())
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
if sys.platform == "win32":
self._install_full_windows(source_dir, install_dir, staging)
else:
self._install_full_posix(source_dir, install_dir, staging)
self.finished_ok.emit() self.finished_ok.emit()
# -- Windows: PowerShell-Skript wartet auf Prozessende, kopiert per robocopy -- # Kleines, immer sichtbares Fortschrittsfenster fuer den Windows-Installationsschritt.
# Der Hauptprozess ist zu diesem Zeitpunkt schon beendet (Datei-Sperren!), daher
# laeuft das komplett im separaten PowerShell-Skript - ohne dieses Fenster wuerde
# der Nutzer nach dem Schliessen der App fuer die Dauer der Installation (bei
# einem vollen Paket ggf. mehrere Sekunden) gar nichts sehen, was wie ein Absturz
# oder Haenger wirkt.
_WINDOWS_PROGRESS_FORM_PS = """
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$form = New-Object System.Windows.Forms.Form
$form.Text = "Playtube-Update"
$form.Size = New-Object System.Drawing.Size(380,130)
$form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog"
$form.ControlBox = $false
$form.TopMost = $true
$label = New-Object System.Windows.Forms.Label
$label.Text = "Playtube wird aktualisiert – bitte warten …"
$label.AutoSize = $false
$label.Size = New-Object System.Drawing.Size(340,20)
$label.Location = New-Object System.Drawing.Point(20,15)
$form.Controls.Add($label)
$bar = New-Object System.Windows.Forms.ProgressBar
$bar.Style = "Marquee"
$bar.MarqueeAnimationSpeed = 30
$bar.Size = New-Object System.Drawing.Size(340,20)
$bar.Location = New-Object System.Drawing.Point(20,50)
$form.Controls.Add($bar)
$form.Show()
$form.Refresh()
"""
def _install_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None: # -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
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
script = f""" # 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"""
$ErrorActionPreference = "SilentlyContinue" $ErrorActionPreference = "SilentlyContinue"
Start-Sleep -Seconds 1
$targetPid = {os.getpid()} $targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{ while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 300
[System.Windows.Forms.Application]::DoEvents()
}} }}
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null $label.Text = "Kopiere aktualisierte Datei …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
$copied = $?
{registry_update}
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Start-Process -FilePath "{exe_path}" Start-Process -FilePath "{exe_path}"
Start-Sleep -Seconds 2 Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
""" """
self._spawn_windows_script(script, staging)
# Falls zenity installiert ist (auf den meisten Desktop-Distros vorhanden), waehrend
# Wartezeit/Installation ein pulsierendes Fortschrittsfenster zeigen - rein optisch,
# das Update funktioniert auch ohne (dann passiert der Neustart einfach unsichtbar).
_POSIX_PROGRESS_HEADER = """#!/bin/sh
ZPID=""
if command -v zenity >/dev/null 2>&1; then
tail -f /dev/null | zenity --progress --title="Playtube-Update" \
--text="Playtube wird aktualisiert - bitte warten ..." --pulsate --no-cancel \
>/dev/null 2>&1 &
ZPID=$!
fi
"""
_POSIX_PROGRESS_FOOTER = """
[ -n "$ZPID" ] && kill "$ZPID" 2>/dev/null
"""
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = self._POSIX_PROGRESS_HEADER + f"""
while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5
done
cp -f "{new_binary}" "{exe_path}"
chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{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."""
subprocess.Popen(
[
str(setup_path),
"/SILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/NOCANCEL",
"/RELAUNCH=1",
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True,
)
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue"
$targetPid = {os.getpid()}
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
Start-Sleep -Milliseconds 300
[System.Windows.Forms.Application]::DoEvents()
}}
$label.Text = "Kopiere Programmdateien … (kann etwas dauern)"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
while (-not $roboProc.HasExited) {{
Start-Sleep -Milliseconds 200
[System.Windows.Forms.Application]::DoEvents()
}}
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Start-Process -FilePath "{exe_path}"
Start-Sleep -Milliseconds 800
$form.Close()
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
"""
self._spawn_windows_script(script, staging)
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = self._POSIX_PROGRESS_HEADER + f"""
while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5
done
rm -rf "{install_dir}"/*
cp -a "{source_dir}"/. "{install_dir}"/
chmod +x "{exe_path}"
""" + self._POSIX_PROGRESS_FOOTER + f"""
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
self._spawn_posix_script(script, staging)
# -- Skript-Ausfuehrung: PowerShell (wartet+robocopy/Copy) unter Windows, Shell
# (wartet+cp) unter Linux; wird detached gestartet, damit es diesen Prozess
# ueberlebt, wenn dieser sich gleich beendet. --
def _spawn_windows_script(self, script: str, staging: Path) -> None:
script_path = staging / "apply_update.ps1" script_path = staging / "apply_update.ps1"
script_path.write_text(script, encoding="utf-8") script_path.write_text(script, encoding="utf-8")
subprocess.Popen( subprocess.Popen(
@@ -190,20 +540,7 @@ Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
creationflags=subprocess.CREATE_NO_WINDOW, creationflags=subprocess.CREATE_NO_WINDOW,
) )
# -- Linux: Shell-Skript wartet auf Prozessende, kopiert per cp -a -- def _spawn_posix_script(self, script: str, staging: Path) -> None:
def _install_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
exe_path = install_dir / _LINUX_BINARY_NAME
script = f"""#!/bin/sh
while kill -0 {os.getpid()} 2>/dev/null; do
sleep 0.5
done
rm -rf "{install_dir}"/*
cp -a "{source_dir}"/. "{install_dir}"/
chmod +x "{exe_path}"
nohup "{exe_path}" >/dev/null 2>&1 &
rm -rf "{staging}"
"""
script_path = staging / "apply_update.sh" script_path = staging / "apply_update.sh"
script_path.write_text(script, encoding="utf-8") script_path.write_text(script, encoding="utf-8")
script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) script_path.chmod(script_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)