Compare commits
37
Commits
v2.0.1
...
d14272991c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14272991c | ||
|
|
bb15ff3316 | ||
|
|
f1bde49cf7 | ||
|
|
c3e69bd5da | ||
|
|
6a682fc49c | ||
|
|
33043f1319 | ||
|
|
c30efec612 | ||
|
|
3f3f86f597 | ||
|
|
d28b5d05da | ||
|
|
61b38fe6b8 | ||
|
|
cf6b6733e4 | ||
|
|
5f93be4325 | ||
|
|
e655cbceea | ||
|
|
1acd08934e | ||
|
|
a4ba6c296e | ||
|
|
c1465a4e49 | ||
|
|
874d6454ec | ||
|
|
8840ba130c | ||
|
|
04bc96d33e | ||
|
|
31797cdabb | ||
|
|
771c438257 | ||
|
|
5379be6c11 | ||
|
|
c6fe895334 | ||
|
|
7c202600cb | ||
|
|
7c37868cd1 | ||
|
|
d63b6b36d4 | ||
|
|
06e88ced19 | ||
|
|
fbf5f3b93f | ||
|
|
9bd4b360cd | ||
|
|
e67e8d5d90 | ||
|
|
2ba4e1bb6f | ||
|
|
39ebaa84c5 | ||
|
|
1b587edaaa | ||
|
|
46035ccd5d | ||
|
|
b79d42d56d | ||
|
|
45e9b6054d | ||
|
|
2ce2e716c5 |
@@ -1,14 +1,19 @@
|
|||||||
name: Release
|
name: Release (Edge)
|
||||||
|
|
||||||
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem
|
# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
|
||||||
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch
|
# und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird.
|
||||||
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue
|
#
|
||||||
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
|
# WICHTIG:
|
||||||
|
# - Nur Tags mit dem Suffix "-edge" loesen diesen Workflow aus - die normale (Qt-)Playtube
|
||||||
|
# baut aus dem Branch "main" mit Tags "vX.Y.Z" und bleibt davon unberuehrt.
|
||||||
|
# - Das Release wird als VORABVERSION veroeffentlicht und nie als "latest" markiert. Die Qt-
|
||||||
|
# Version fragt "/releases/latest" ab und sieht dieses Release dadurch nie; der Updater der
|
||||||
|
# Edge-Version sucht seinerseits nur nach Tags "...-edge" (siehe playtube/updater.py).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*-edge"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -30,64 +35,56 @@ jobs:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
|
|
||||||
- name: Playtube.exe bauen
|
- name: PlaytubeEdge.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)
|
||||||
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
|
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." }
|
||||||
|
|
||||||
|
# Zwei getrennte Artefakte: bei mehreren Pfaden in EINEM Artefakt behielte das Setup seinen
|
||||||
|
# Unterordner (dist/installer/) und wuerde beim Veroeffentlichen nicht gefunden.
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-edge-setup
|
||||||
|
path: dist/installer/PlaytubeEdge-Setup-v*.exe
|
||||||
|
|
||||||
|
- name: Als .zip packen (portabel)
|
||||||
|
run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-build
|
name: windows-edge-zip
|
||||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
path: PlaytubeEdge-*-win64.zip
|
||||||
|
|
||||||
build-linux:
|
publish-release:
|
||||||
|
needs: [build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Qt-WebEngine-Laufzeitabhaengigkeiten installieren
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libxkbcommon0 libegl1 libopengl0 libnss3 libnspr4 \
|
|
||||||
libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxcursor1 \
|
|
||||||
libgbm1 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libcups2 \
|
|
||||||
libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 \
|
|
||||||
libxfixes3 libxi6 libxext6 fonts-liberation
|
|
||||||
|
|
||||||
- name: Abhaengigkeiten installieren
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install pyinstaller
|
|
||||||
|
|
||||||
- name: Playtube-Binary bauen
|
|
||||||
run: pyinstaller packaging/playtube.spec --noconfirm --distpath dist --workpath build
|
|
||||||
|
|
||||||
- name: Als .tar.gz packen
|
|
||||||
run: tar -C dist -czf Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz Playtube
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-build
|
|
||||||
path: Playtube-${{ github.ref_name }}-linux-x86_64.tar.gz
|
|
||||||
|
|
||||||
publish-release:
|
|
||||||
needs: [build-windows, build-linux]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: GitHub Release veroeffentlichen
|
- name: GitHub Release veroeffentlichen (Vorabversion, nie "latest")
|
||||||
uses: softprops/action-gh-release@v2
|
# gh statt softprops/action-gh-release: letzteres brach beim erneuten Veroeffentlichen
|
||||||
with:
|
# desselben Tags mit "Too many retries" ab. Ein vorhandenes Release zum selben Tag wird
|
||||||
files: artifacts/*
|
# zuerst entfernt (der Tag bleibt), dann sauber neu angelegt.
|
||||||
generate_release_notes: true
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release delete "${{ github.ref_name }}" --repo "${{ github.repository }}" --yes || true
|
||||||
|
gh release create "${{ github.ref_name }}" artifacts/* \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Playtube Edge ${{ github.ref_name }}" \
|
||||||
|
--notes-file packaging/release_notes.md \
|
||||||
|
--prerelease --latest=false --verify-tag
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
run*.log
|
run*.log
|
||||||
|
*_run.log
|
||||||
|
build_edge.log
|
||||||
|
.claude/
|
||||||
|
.pytest_cache/
|
||||||
test_login*.log
|
test_login*.log
|
||||||
|
|||||||
@@ -1,20 +1,65 @@
|
|||||||
# Playtube
|
# Playtube Edge
|
||||||
|
|
||||||
Eigenstaendige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
> **Das ist die „Edge“-Variante von Playtube (Branch `edge`).**
|
||||||
|
> **Diese Version brauchst du, wenn du selbst hochgeladene Musik in YouTube Music hören
|
||||||
|
> willst.** Die normale Playtube (Branch `main`) zeigt bei eigenen Uploads „Dieses
|
||||||
|
> Videoformat wird nicht unterstützt“, weil ihre Browser-Engine (QtWebEngine) kein AAC und
|
||||||
|
> kein H.264 abspielen kann. Playtube Edge rendert stattdessen mit der **Microsoft-Edge-Engine
|
||||||
|
> (WebView2)** – damit laufen auch Uploads.
|
||||||
|
>
|
||||||
|
> - Download: [Releases](https://github.com/fojadrachi/Playtube/releases) → Eintrag „Playtube
|
||||||
|
> Edge vX.Y.Z-edge“ → `PlaytubeEdge-Setup-vX.Y.Z.exe`. Voraussetzung: Windows 10/11 mit
|
||||||
|
> WebView2-Runtime (Windows 11: vorinstalliert).
|
||||||
|
> - Läuft **neben** der normalen Playtube (eigener Ordner `%APPDATA%\PlaytubeEdge`, eigenes
|
||||||
|
> Login – beim ersten Start einmal bei Google anmelden).
|
||||||
|
> - Updates kommen nur über die `-edge`-Releases (die normale Playtube sieht sie nie).
|
||||||
|
> - Entwicklung: `pip install -r requirements.txt`, dann `python main.py`. Die
|
||||||
|
> WebView2-DLLs liegen in `vendor/webview2` (Microsoft, NuGet `Microsoft.Web.WebView2`).
|
||||||
|
>
|
||||||
|
> Der Rest dieser README beschreibt die gemeinsame Basis mit der normalen Playtube; wo dort
|
||||||
|
> „QtWebEngine“ steht, ist in dieser Variante WebView2 gemeint.
|
||||||
|
|
||||||
|
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
||||||
Erweiterung) mit:
|
Erweiterung) mit:
|
||||||
|
|
||||||
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
||||||
Hintergrund weiter wenn du zu Videos wechselst.
|
Hintergrund weiter wenn du zu Videos wechselst.
|
||||||
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
|
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
|
||||||
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
|
Google anmelden reicht für beide Dienste.
|
||||||
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
|
|
||||||
Google anmelden reicht fuer beide Dienste.
|
|
||||||
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
||||||
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
einen Link-Button in deinem Discord-Profil, sobald etwas läuft. Ist der
|
||||||
- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick
|
**Einstellungen-Tab** offen, steht dort stattdessen „In den Einstellungen“ und welches
|
||||||
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue.
|
Feld du gerade bearbeitest (z.B. „Discord Rich Presence › Client-ID“) - nur der
|
||||||
|
Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts
|
||||||
|
läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens
|
||||||
|
alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher.
|
||||||
|
- **Audioausgabe pro Tab** – YouTube und YouTube Musik lassen sich in den Einstellungen
|
||||||
|
auf verschiedene Ausgabegeräte legen (z.B. getrennte Sonar-Kanäle), siehe unten.
|
||||||
|
- **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
|
||||||
|
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
|
||||||
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
||||||
und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python".
|
und (nach dem Packaging, siehe unten) im Lautstärkemixer statt als "python".
|
||||||
|
|
||||||
|
## Installation (Windows)
|
||||||
|
|
||||||
|
Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
|
||||||
|
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
|
||||||
|
|
||||||
|
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
|
||||||
|
Admin-Rechte nötig) und legt Startmenü-Eintrag (optional Desktop-Verknüpfung) an,
|
||||||
|
- ersetzt bei jeder späteren Ausführung die vorhandene Installation **an Ort und
|
||||||
|
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
|
||||||
|
mehrere nebeneinander,
|
||||||
|
- beendet dafür ein noch laufendes Playtube selbst und räumt den alten Programmordner
|
||||||
|
auf, damit keine Reste alter Versionen übrig bleiben,
|
||||||
|
- lässt Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
|
||||||
|
über "Apps & Features" wird gefragt, ob sie mitgelöscht werden sollen.
|
||||||
|
|
||||||
|
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
|
||||||
|
Setup-Datei wird dafür nicht erneut von Hand gebraucht. Das ZIP-Paket
|
||||||
|
(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
|
||||||
|
Playtube bietet einer portablen Kopie beim nächsten Update automatisch an, in die
|
||||||
|
reguläre Installation zu wechseln (danach kann der alte Ordner gelöscht werden).
|
||||||
|
|
||||||
## Schnellstart (Entwicklung)
|
## Schnellstart (Entwicklung)
|
||||||
|
|
||||||
@@ -31,59 +76,147 @@ Login bleibt danach dauerhaft gespeichert.
|
|||||||
## Discord Rich Presence einrichten
|
## Discord Rich Presence einrichten
|
||||||
|
|
||||||
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
|
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
|
||||||
sie aendern oder eine eigene Anwendung nutzen willst:
|
sie ändern oder eine eigene Anwendung nutzen willst:
|
||||||
|
|
||||||
1. Auf https://discord.com/developers/applications eine neue Application anlegen.
|
1. Auf https://discord.com/developers/applications eine neue Application anlegen.
|
||||||
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
|
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
|
||||||
3. Optional, fuer eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
|
3. Optional, für eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
|
||||||
exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
|
exakten Schlüsseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
|
||||||
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
|
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
|
||||||
nur ohne Bild.
|
nur ohne Bild.
|
||||||
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
|
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
|
||||||
|
|
||||||
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
||||||
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts laeuft) anpassen.
|
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
|
||||||
|
|
||||||
## App als eigenstaendige Playtube.exe packen
|
## Audioausgabe pro Tab (YouTube / YouTube Musik)
|
||||||
|
|
||||||
Fuer die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
Im **Einstellungen-Tab** legst du unter „Audioausgabe“ getrennt fest, über welches
|
||||||
|
Ausgabegerät der Ton von **YouTube** und von **YouTube Musik** läuft (Standard:
|
||||||
|
Systemstandard). So kannst du z.B. YouTube auf „Sonar - Media“ und YouTube Musik auf
|
||||||
|
„Sonar - Aux“ legen und beide getrennt regeln. Nach „Speichern“ gilt die Auswahl sofort,
|
||||||
|
auch für bereits geöffnete Seiten (kein Neuladen nötig).
|
||||||
|
|
||||||
|
Wichtig zu wissen:
|
||||||
|
|
||||||
|
- **Windows zeigt weiterhin einen Eintrag „Playtube“.** Chromium (QtWebEngine) spielt den
|
||||||
|
Ton beider Tabs über *einen* gemeinsamen Audio-Prozess ab, deshalb kann Windows (auch
|
||||||
|
„App-Lautstärke und Geräteeinstellungen“) die Tabs nicht einzeln benennen oder routen.
|
||||||
|
Getrennt wird stattdessen über das **Gerät** - in Sonar & Co. also über den gewählten
|
||||||
|
Kanal/das gewählte Gerät.
|
||||||
|
- **Freigabe der Gerätenamen:** Chromium blendet Gerätenamen aus, solange die Seite keine
|
||||||
|
Mikrofon-Berechtigung hat. Damit die Seite das gewählte Gerät findet, erteilt Playtube
|
||||||
|
YouTube/YouTube Musik diese Freigabe - nur, solange mindestens ein Tab ein eigenes Gerät
|
||||||
|
nutzt, nur für diese beiden Seiten und nur für die laufende Sitzung (sie wird nicht
|
||||||
|
gespeichert und bei jedem Start neu erteilt). Es wird nichts aufgenommen.
|
||||||
|
- Ist das gewählte Gerät gerade nicht angeschlossen, bleibt die Auswahl (als „nicht
|
||||||
|
verfügbar“) gespeichert und der Ton läuft solange über den Systemstandard.
|
||||||
|
- Technik: [playtube/audio_routing.py](playtube/audio_routing.py) legt per
|
||||||
|
`HTMLMediaElement.setSinkId` die Medienelemente der Seite auf das Gerät (Suche über den
|
||||||
|
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
|
||||||
|
`audio.music_output` (leer = Systemstandard).
|
||||||
|
|
||||||
|
## Fernsteuerung (Stream Dock)
|
||||||
|
|
||||||
|
Playtube lässt sich lokal fernsteuern, z.B. über das Stream-Dock-Plugin
|
||||||
|
`com.fojadrachi.playtube.sdPlugin` (Ajazz/Mirabox AKP153E & Co.). Dafür lauscht Playtube
|
||||||
|
auf einer Named Pipe (`\\.\pipe\Playtube.Remote.Playtube`, im Entwicklungsmodus
|
||||||
|
`...PlaytubeDev`). Es gibt keinen Netzwerk-Port, verbinden darf nur der angemeldete
|
||||||
|
Benutzer.
|
||||||
|
|
||||||
|
- Protokoll: eine JSON-Nachricht pro Zeile, z.B. `{"id":1,"cmd":"play_pause","target":"auto"}`,
|
||||||
|
Antwort `{"id":1,"ok":true}`. Details in [playtube/remote_control.py](playtube/remote_control.py).
|
||||||
|
- Befehle: `status`, `show`, `switch_tab`, `play_pause`, `play`, `pause`, `next`,
|
||||||
|
`previous`, `seek`, `volume_change`, `set_volume`, `mute_toggle`, `like`, `dislike`,
|
||||||
|
`shuffle`, `repeat`, `list_playlists`, `play_playlist`. Es gibt eine feste Whitelist;
|
||||||
|
beliebiges JavaScript oder URLs lassen sich nicht senden.
|
||||||
|
- `target: "auto"` steuert den Tab, der gerade abspielt, sonst den sichtbaren.
|
||||||
|
- Abschalten: in `config.json` `"remote_control": {"enabled": false}`.
|
||||||
|
- Tests: `.venv\Scripts\python -m unittest discover -s tests -v`
|
||||||
|
|
||||||
|
## App als eigenständige Playtube.exe packen
|
||||||
|
|
||||||
|
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
||||||
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
|
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
|
||||||
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das aendern):
|
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ändern):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.venv\Scripts\pip install pyinstaller
|
.venv\Scripts\pip install pyinstaller
|
||||||
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt
|
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Ist
|
||||||
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
|
||||||
|
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
|
||||||
|
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
|
||||||
|
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` überspringbar).
|
||||||
|
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
|
||||||
|
Verknüpfung im Windows-Startmenü an - eine per Setup installierte Kopie hat den
|
||||||
|
Eintrag bereits vom Installer.
|
||||||
|
|
||||||
|
Das Build-Skript benennt
|
||||||
|
zusätzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
||||||
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
||||||
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
|
auftaucht. Für eine vollständige Umbenennung inkl. Icon/Versionsinfo dieses
|
||||||
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich
|
Hilfsprozesses (relevant für den Lautstärkemixer) zusätzlich
|
||||||
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
|
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
|
||||||
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
|
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
|
||||||
alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach
|
alles genauso, nur zeigt der Lautstärkemixer für den Ton-Unterprozess je nach
|
||||||
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
|
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
|
||||||
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
|
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
|
||||||
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
|
Windows-versionsabhängiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
|
||||||
o.ae. umgehen).
|
o.ä. umgehen).
|
||||||
|
|
||||||
## Automatische Updates
|
## Automatische Updates
|
||||||
|
|
||||||
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
|
Playtube prüft beim Start und danach alle `updates.check_interval_hours` Stunden
|
||||||
(Standard 6, 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 zusätzlich einen "Jetzt nach Updates suchen"-Button mit
|
||||||
|
Status-Anzeige und Fortschrittsbalken für 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"): lädt 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 überschreibt damit die Datei im Installationsordner - der riesige PySide6/
|
||||||
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
|
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
|
||||||
|
normalen Patch-Releases nicht ändert. 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): lädt den
|
||||||
|
**Setup-Installer** und führt ihn still aus. Playtube liegt danach in der regulären
|
||||||
|
Installation - Startmenü-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
|
||||||
|
Kopie ist nicht mehr nötig (und kann gelöscht werden). So entstehen nicht länger
|
||||||
|
versionierte Ordner nebeneinander. Gibt es (bei älteren Releases) keinen Installer,
|
||||||
|
wird wie früher das volle ZIP über den vorhandenen Ordner kopiert.
|
||||||
|
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
|
||||||
|
Die App startet sich in allen Fällen danach selbst neu.
|
||||||
|
- **Entwicklungsmodus** (`python main.py`): führt `git pull` + `pip install -r
|
||||||
requirements.txt` aus und startet den Python-Prozess neu.
|
requirements.txt` aus und startet den Python-Prozess neu.
|
||||||
|
|
||||||
Auto-Update laesst sich in `config.json` unter `updates.enabled` deaktivieren.
|
Auto-Update lässt sich im Einstellungen-Tab oder in `config.json` unter
|
||||||
|
`updates.enabled` deaktivieren.
|
||||||
|
|
||||||
### Eine neue Version veroeffentlichen
|
Nach einem erkannten Update wird beim nächsten Start automatisch der QtWebEngine-
|
||||||
|
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Einträge können sonst nicht mehr
|
||||||
|
zum neuen Code passen (frühere Ursache für fehlende Icons). Der Login bleibt davon
|
||||||
|
unberührt, da Cookies/LocalStorage in einem komplett getrennten Ordner
|
||||||
|
(`webprofile/storage`) liegen.
|
||||||
|
|
||||||
|
### Patch-Dateien manuell installieren (`.play`)
|
||||||
|
|
||||||
|
Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technisch
|
||||||
|
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
|
||||||
|
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
|
||||||
|
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
|
||||||
|
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) lässt sich also
|
||||||
|
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
|
||||||
|
muss.
|
||||||
|
|
||||||
|
### Eine neue Version veröffentlichen
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
||||||
@@ -91,22 +224,27 @@ powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
|||||||
|
|
||||||
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
|
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
|
||||||
`origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag
|
`origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag
|
||||||
loest automatisch die GitHub-Actions-Pipeline
|
löst automatisch die GitHub-Actions-Pipeline
|
||||||
([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl
|
([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl
|
||||||
eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub
|
eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub
|
||||||
Release veroeffentlicht - Fortschritt unter
|
Release veröffentlicht - Fortschritt unter
|
||||||
https://github.com/fojadrachi/Playtube/actions.
|
https://github.com/fojadrachi/Playtube/actions.
|
||||||
|
|
||||||
|
Für Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, für
|
||||||
|
Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
|
||||||
|
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
|
||||||
|
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
|
||||||
|
|
||||||
Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die
|
Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die
|
||||||
neue Version danach automatisch angeboten.
|
neue Version danach automatisch angeboten.
|
||||||
|
|
||||||
## Native Linux-Version
|
## Native Linux-Version
|
||||||
|
|
||||||
Playtube laeuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und
|
Playtube läuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und
|
||||||
wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter
|
wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter
|
||||||
https://github.com/fojadrachi/Playtube/releases mitgebaut.
|
https://github.com/fojadrachi/Playtube/releases mitgebaut.
|
||||||
|
|
||||||
**Fertiges Release installieren** (richtet Startmenue-Eintrag + Icon ein):
|
**Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
|
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
|
||||||
@@ -114,7 +252,7 @@ cd Playtube
|
|||||||
sh install-linux.sh
|
sh install-linux.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach ist Playtube ueber das Anwendungsmenue oder den Befehl `playtube` startbar.
|
Danach ist Playtube über das Anwendungsmenü oder den Befehl `playtube` startbar.
|
||||||
|
|
||||||
**Aus dem Quellcode starten:**
|
**Aus dem Quellcode starten:**
|
||||||
|
|
||||||
@@ -124,20 +262,31 @@ python3 -m venv .venv
|
|||||||
.venv/bin/python main.py
|
.venv/bin/python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu):
|
QtWebEngine benötigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu):
|
||||||
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in
|
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in
|
||||||
[.github/workflows/release.yml](.github/workflows/release.yml)).
|
[.github/workflows/release.yml](.github/workflows/release.yml)).
|
||||||
|
|
||||||
## Hinweise
|
## Hinweise
|
||||||
|
|
||||||
- **"Dieser Browser ist unter Umstaenden nicht sicher" beim Google-Login:** Playtube
|
- **"Dieser Browser ist unter Umständen nicht sicher" beim Google-Login:** Playtube
|
||||||
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
|
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
|
||||||
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
||||||
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
||||||
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
starten (die Header greifen erst ab dem nächsten Prozessstart), notfalls einmal den
|
||||||
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
||||||
Entwicklungsmodus) loeschen und neu anmelden.
|
Entwicklungsmodus) löschen und neu anmelden.
|
||||||
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menü ...):** Fast
|
||||||
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
|
||||||
|
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
|
||||||
|
Fensters Playtube nur in den Tray minimiert: eine ältere Version läuft unsichtbar
|
||||||
|
weiter, während eine neu heruntergeladene zusätzlich gestartet wird. Abhilfe: alle
|
||||||
|
Playtube-Instanzen über das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
|
||||||
|
beenden und nur EINE neu starten. Ab der Version mit
|
||||||
|
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
|
||||||
|
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
|
||||||
|
ältere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
|
||||||
|
beendet werden.
|
||||||
|
- 4K/Premium-Videoqualität kann eingeschränkt sein, da die Open-Source-Variante von
|
||||||
|
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitäten funktionieren normal).
|
||||||
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
||||||
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
|
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
|
||||||
|
|||||||
+1
-1
@@ -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": {
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
|
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from playtube import app_id # noqa: E402
|
from playtube import __version__, app_id # noqa: E402
|
||||||
from playtube.config import APP_NAME, load_config # noqa: E402
|
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402
|
||||||
|
from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
|
||||||
|
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
||||||
|
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
|
||||||
|
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()
|
||||||
@@ -19,12 +23,6 @@ app_id.configure_webengine_process_path()
|
|||||||
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
|
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
|
||||||
# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
|
# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login
|
||||||
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert.
|
# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert.
|
||||||
os.environ.setdefault(
|
|
||||||
"QTWEBENGINE_CHROMIUM_FLAGS",
|
|
||||||
"--disable-features=WinRetrieveSuggestionsOnlyOnDemand "
|
|
||||||
"--disable-blink-features=AutomationControlled",
|
|
||||||
)
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt # noqa: E402
|
from PySide6.QtCore import Qt # noqa: E402
|
||||||
from PySide6.QtGui import QIcon # noqa: E402
|
from PySide6.QtGui import QIcon # noqa: E402
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
@@ -32,6 +30,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 +51,45 @@ 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)
|
||||||
|
# Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates).
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
# Lokale Fernsteuerung (Stream-Dock-Plugin), eigener Pipe-Name pro Datenordner - so
|
||||||
|
# steuert das Plugin die installierte Playtube.exe, nicht versehentlich einen Dev-Start.
|
||||||
|
if config.get("remote_control", {}).get("enabled", True):
|
||||||
|
remote_server = RemoteControlServer(f"{PIPE_PREFIX}{app_data_dir().name}", window, app)
|
||||||
|
remote_server.start()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-47
@@ -1,61 +1,48 @@
|
|||||||
# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine-
|
# Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und
|
||||||
# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er
|
# danach den Setup-Installer (Inno Setup 6).
|
||||||
# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im
|
|
||||||
# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht.
|
|
||||||
#
|
#
|
||||||
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
# Aufruf (im Projekt-Root, mit aktivierter venv):
|
||||||
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
# powershell -ExecutionPolicy Bypass -File packaging\build.ps1
|
||||||
#
|
|
||||||
# Optional: rcedit (https://github.com/electron/rcedit) im PATH oder unter
|
param(
|
||||||
# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten
|
# Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
|
||||||
# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess"
|
[switch]$SkipInstaller
|
||||||
# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den
|
)
|
||||||
# Dateinamen "PlaytubeHelper" an).
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
Set-Location $root
|
Set-Location $root
|
||||||
|
|
||||||
Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan
|
Write-Host "==> Baue PlaytubeEdge.exe mit PyInstaller ..." -ForegroundColor Cyan
|
||||||
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
|
||||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." }
|
||||||
|
Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.exe" -ForegroundColor Green
|
||||||
|
|
||||||
$distDir = Join-Path $root "dist\Playtube"
|
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
|
||||||
$internalDir = Join-Path $distDir "_internal"
|
# Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
|
||||||
$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir }
|
# 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
|
||||||
|
|
||||||
$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
$isccCandidates = @(
|
||||||
if (-not $engineProcess) {
|
(Get-Command iscc -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }),
|
||||||
Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen."
|
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
|
||||||
} else {
|
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
|
||||||
$helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe"
|
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
|
||||||
Copy-Item $engineProcess.FullName $helperPath -Force
|
)
|
||||||
Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan
|
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
|
||||||
|
|
||||||
$rcedit = Get-Command rcedit -ErrorAction SilentlyContinue
|
|
||||||
$rceditLocal = Join-Path $PSScriptRoot "rcedit.exe"
|
|
||||||
if ($rcedit) {
|
|
||||||
$rceditExe = $rcedit.Source
|
|
||||||
} elseif (Test-Path $rceditLocal) {
|
|
||||||
$rceditExe = $rceditLocal
|
|
||||||
} else {
|
|
||||||
$rceditExe = $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rceditExe) {
|
|
||||||
Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan
|
|
||||||
& $rceditExe $helperPath --set-icon "$root\assets\icon.ico"
|
|
||||||
& $rceditExe $helperPath --set-version-string "FileDescription" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "ProductName" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "CompanyName" "Playtube"
|
|
||||||
& $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe"
|
|
||||||
} else {
|
|
||||||
Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow
|
|
||||||
Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow
|
|
||||||
Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow
|
|
||||||
Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (-not $iscc) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
|
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
|
||||||
|
} 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\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-Setup-vX.Y.Z.exe).
|
||||||
|
;
|
||||||
|
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
|
||||||
|
; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
|
||||||
|
; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
|
||||||
|
; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
|
||||||
|
;
|
||||||
|
; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
|
||||||
|
; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
|
||||||
|
; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
|
||||||
|
; parallel installiert sein; keine ueberschreibt die andere.
|
||||||
|
; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
|
||||||
|
; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
|
||||||
|
; uebereinstimmen.
|
||||||
|
; - Keine ".play"-Dateizuordnung und kein Beenden von Playtube.exe / PlaytubeHelper.exe - das
|
||||||
|
; gehoert alles der Qt-Version.
|
||||||
|
;
|
||||||
|
; 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 Edge"
|
||||||
|
#define AppDirName "PlaytubeEdge"
|
||||||
|
#define AppExeName "PlaytubeEdge.exe"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
|
||||||
|
AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
|
||||||
|
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\{#AppDirName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
OutputDir=..\dist\installer
|
||||||
|
OutputBaseFilename=PlaytubeEdge-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 Edge versteckt sich beim Schliessen des
|
||||||
|
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
|
||||||
|
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\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||||
|
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
|
||||||
|
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
|
||||||
|
; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
|
||||||
|
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
function ShouldRelaunch(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
|
||||||
|
end;
|
||||||
|
|
||||||
|
function PowerShellPath(): String;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure WaitForOldInstance();
|
||||||
|
var
|
||||||
|
OldPid, ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
{ Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID
|
||||||
|
(/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). }
|
||||||
|
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
|
||||||
|
if OldPid > 0 then
|
||||||
|
Exec(PowerShellPath(),
|
||||||
|
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "Wait-Process -Id ' + IntToStr(OldPid) + ' -Timeout 20 -ErrorAction SilentlyContinue"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure StopLeftoverProcesses();
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
{ WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
|
||||||
|
also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
|
||||||
|
PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
|
||||||
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
|
||||||
|
{ WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen,
|
||||||
|
wuerden Dateien sperren - gezielt nach Pfad beenden. }
|
||||||
|
if DirExists(ExpandConstant('{app}')) then
|
||||||
|
Exec(PowerShellPath(),
|
||||||
|
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
|
||||||
|
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($d, [StringComparison]::OrdinalIgnoreCase) } | ' +
|
||||||
|
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
|
||||||
|
{ Dateisperren freigeben lassen, bevor kopiert wird. }
|
||||||
|
Sleep(800);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
|
begin
|
||||||
|
WaitForOldInstance();
|
||||||
|
StopLeftoverProcesses();
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
|
function InitializeUninstall(): Boolean;
|
||||||
|
begin
|
||||||
|
StopLeftoverProcesses();
|
||||||
|
Result := True;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
begin
|
||||||
|
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
|
||||||
|
begin
|
||||||
|
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
|
||||||
|
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
|
||||||
|
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
|
||||||
|
DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
+15
-6
@@ -4,22 +4,31 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
ROOT = Path(SPECPATH).resolve().parent
|
ROOT = Path(SPECPATH).resolve().parent
|
||||||
|
|
||||||
|
# Edge-Variante: pythonnet + clr_loader (bringen Python.Runtime.dll/ClrLoader.dll mit) und die
|
||||||
|
# Microsoft-WebView2-DLLs aus vendor/webview2 (siehe playtube/webview2_runtime.py).
|
||||||
|
pythonnet_datas, pythonnet_binaries, pythonnet_hidden = collect_all("pythonnet")
|
||||||
|
clr_datas, clr_binaries, clr_hidden = collect_all("clr_loader")
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
[str(ROOT / "main.py")],
|
[str(ROOT / "main.py")],
|
||||||
pathex=[str(ROOT)],
|
pathex=[str(ROOT)],
|
||||||
binaries=[],
|
binaries=pythonnet_binaries + clr_binaries,
|
||||||
datas=[
|
datas=[
|
||||||
(str(ROOT / "assets"), "assets"),
|
(str(ROOT / "assets"), "assets"),
|
||||||
(str(ROOT / "config.json"), "."),
|
(str(ROOT / "config.json"), "."),
|
||||||
],
|
(str(ROOT / "vendor" / "webview2"), "vendor/webview2"),
|
||||||
hiddenimports=["pypresence"],
|
] + pythonnet_datas + clr_datas,
|
||||||
|
hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
# Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein.
|
||||||
|
excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +38,7 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|||||||
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
|
# Linux/macOS gibt es diese Konzepte fuer ELF-Binaries nicht, deshalb nur dort setzen.
|
||||||
exe_kwargs = dict(
|
exe_kwargs = dict(
|
||||||
exclude_binaries=True,
|
exclude_binaries=True,
|
||||||
name="Playtube",
|
name="PlaytubeEdge",
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
@@ -49,5 +58,5 @@ coll = COLLECT(
|
|||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
name="Playtube",
|
name="PlaytubeEdge",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## Playtube Edge - die Version für eigene (hochgeladene) Musik
|
||||||
|
|
||||||
|
**Diese Version brauchst du, wenn du selbst hochgeladene Songs in YouTube Music hören willst.**
|
||||||
|
Die normale Playtube-Version kann sie nicht abspielen („Dieses Videoformat wird nicht
|
||||||
|
unterstützt“), weil ihre eingebaute Browser-Engine (QtWebEngine) kein AAC und kein H.264 kann.
|
||||||
|
Playtube Edge benutzt stattdessen die Microsoft-Edge-Engine (WebView2) – damit laufen auch
|
||||||
|
hochgeladene Titel.
|
||||||
|
|
||||||
|
### Installieren
|
||||||
|
- Download: **PlaytubeEdge-Setup-vX.Y.Z.exe** (Setup, empfohlen) oder die ZIP-Datei (portabel).
|
||||||
|
- Voraussetzung: Windows 10/11 mit „WebView2 Runtime“ (auf Windows 11 schon vorhanden, sonst
|
||||||
|
kostenlos bei Microsoft).
|
||||||
|
- Playtube Edge läuft **neben** der normalen Playtube (eigener Ordner, eigene Einstellungen).
|
||||||
|
Beim ersten Start musst du dich in YouTube/YouTube Music einmal neu anmelden. Lass am besten
|
||||||
|
nur eine der beiden Versionen gleichzeitig spielen.
|
||||||
|
- Updates: Playtube Edge aktualisiert sich nur über diese „Edge“-Releases – nie über die
|
||||||
|
normale Playtube (und umgekehrt).
|
||||||
|
|
||||||
|
Wer keine eigenen Uploads abspielt, braucht diese Version nicht – die normale Playtube reicht dann.
|
||||||
@@ -19,12 +19,12 @@ VSVersionInfo(
|
|||||||
StringTable(
|
StringTable(
|
||||||
u'040704B0',
|
u'040704B0',
|
||||||
[StringStruct(u'CompanyName', u'Playtube'),
|
[StringStruct(u'CompanyName', u'Playtube'),
|
||||||
StringStruct(u'FileDescription', u'Playtube'),
|
StringStruct(u'FileDescription', u'Playtube Edge'),
|
||||||
StringStruct(u'FileVersion', u'1.0.0.0'),
|
StringStruct(u'FileVersion', u'1.0.0.0'),
|
||||||
StringStruct(u'InternalName', u'Playtube'),
|
StringStruct(u'InternalName', u'PlaytubeEdge'),
|
||||||
StringStruct(u'LegalCopyright', u''),
|
StringStruct(u'LegalCopyright', u''),
|
||||||
StringStruct(u'OriginalFilename', u'Playtube.exe'),
|
StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'),
|
||||||
StringStruct(u'ProductName', u'Playtube'),
|
StringStruct(u'ProductName', u'Playtube Edge'),
|
||||||
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
StringStruct(u'ProductVersion', u'1.0.0.0')])
|
||||||
]),
|
]),
|
||||||
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
||||||
|
|||||||
@@ -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 Edge"
|
||||||
__version__ = "2.0.1"
|
__version__ = "1.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Audio-Ausgabe pro Tab: YouTube und YouTube Musik koennen ueber verschiedene
|
||||||
|
Ausgabegeraete laufen (z.B. verschiedene Sonar-Kanaele), einstellbar im Einstellungen-Tab.
|
||||||
|
|
||||||
|
Warum ueber die Seite und nicht ueber Windows: QtWebEngine (Chromium) spielt den Ton beider
|
||||||
|
Tabs ueber EINEN gemeinsamen Audio-Prozess ab - Windows kann die Tabs deshalb nicht einzeln
|
||||||
|
routen (auch nicht "App-Lautstaerke und Geraeteeinstellungen"). Chromium selbst kann aber pro
|
||||||
|
Medienelement ein Ausgabegeraet waehlen (HTMLMediaElement.setSinkId). Dafuer wird in jeden
|
||||||
|
Tab ein kleines Skript eingeschleust (build_router_script), das die <video>/<audio>-Elemente
|
||||||
|
der Seite auf das gewaehlte Geraet legt.
|
||||||
|
|
||||||
|
Geraete werden im Skript ueber ihren NAMEN gefunden (Chromium vergibt pro Website eigene
|
||||||
|
Geraete-IDs, die man von aussen nicht kennt). Chromium blendet Geraetenamen aber aus, solange
|
||||||
|
die Seite keine Mikrofon-Berechtigung hat - deshalb erteilt Playtube diese Berechtigung fuer
|
||||||
|
YouTube/YouTube Musik, allerdings nur, solange mindestens ein Tab ein eigenes Geraet nutzt
|
||||||
|
(sync_audio_permissions). Zum Aufnehmen wird nichts geoeffnet; die Berechtigung dient nur dazu,
|
||||||
|
dass die Seite die Geraetenamen sehen darf.
|
||||||
|
|
||||||
|
Verhalten der Berechtigung (per Test in QtWebEngine 6.11 ermittelt): Sie gilt nur fuer die
|
||||||
|
laufende Sitzung - Qt schreibt Mikrofon-Freigaben nicht auf die Festplatte. Sie muss also bei
|
||||||
|
JEDEM Programmstart neu erteilt werden (MainWindow tut das), und wer die Funktion ausschaltet,
|
||||||
|
hat nach dem naechsten Start keine Freigabe mehr. Innerhalb einer Sitzung bleiben bereits
|
||||||
|
sichtbar gewordene Geraetenamen bis zum Neustart sichtbar, auch nach dem Entziehen.
|
||||||
|
Wichtig: Auf der YouTube-Startseite (www.youtube.com/) greift eine Freigabe, die NUR VOR dem
|
||||||
|
Seitenaufbau erteilt wurde, nicht - eine danach erteilte schon (auf music.youtube.com und
|
||||||
|
anderen Seiten reicht die Freigabe vorher). Deshalb erteilt MainWindow sie nach jedem
|
||||||
|
Seitenaufbau erneut; der Router (build_router_script) wiederholt die Geraetesuche alle 2,5 s
|
||||||
|
und findet die Namen dann von selbst - ohne Neuladen der Seite.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from PySide6.QtMultimedia import QMediaDevices
|
||||||
|
|
||||||
|
SYSTEM_DEFAULT_LABEL = "Systemstandard"
|
||||||
|
|
||||||
|
|
||||||
|
def list_output_devices() -> list[str]:
|
||||||
|
"""Namen aller aktuell vorhandenen Ausgabegeraete (Reihenfolge des Systems, ohne
|
||||||
|
Doppelte). Diese Namen speichert die Konfiguration und sucht das Skript in der Seite."""
|
||||||
|
names: list[str] = []
|
||||||
|
for device in QMediaDevices.audioOutputs():
|
||||||
|
name = device.description()
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def sync_audio_permissions(tabs, wanted: bool) -> None:
|
||||||
|
"""Erlaubt (wanted=True) bzw. entzieht (wanted=False) den Browser-Tabs die
|
||||||
|
Audiogeraete-Berechtigung fuer YouTube/YouTube Musik - damit die Seite die Namen der
|
||||||
|
Ausgabegeraete sehen kann (siehe Moduldoku). In der Edge-Variante setzt jeder Tab die
|
||||||
|
Freigabe selbst (WebView2-Profil, bleibt dort gespeichert)."""
|
||||||
|
for tab in tabs:
|
||||||
|
tab.set_device_names_allowed(wanted)
|
||||||
|
|
||||||
|
|
||||||
|
# JavaScript-Vorlage; __TARGET__ wird durch den (JSON-kodierten) Geraetenamen ersetzt.
|
||||||
|
# Leerer Name = Systemstandard. Mehrfaches Ausfuehren ist sicher: ist der Router schon
|
||||||
|
# installiert, wird nur das Ziel gewechselt.
|
||||||
|
_ROUTER_TEMPLATE = r"""
|
||||||
|
(function () {
|
||||||
|
var TARGET = __TARGET__;
|
||||||
|
if (window.__playtubeAudio) { window.__playtubeAudio.setTarget(TARGET); return; }
|
||||||
|
|
||||||
|
var SPECIAL = { "default": 1, "communications": 1 }; // virtuelle Eintraege, keine echten Geraete
|
||||||
|
var HW_SUFFIX = /^ \([0-9a-f]{4}:[0-9a-f]{4}\)$/i; // Chromium haengt bei manchen Geraeten die Hardware-ID an
|
||||||
|
var state = { target: TARGET, deviceId: "", resolved: false, tries: 0, timer: null, applied: 0, lastError: "", labels: 0 };
|
||||||
|
|
||||||
|
function matches(label, wanted) {
|
||||||
|
if (label === wanted) return true;
|
||||||
|
return label.indexOf(wanted) === 0 && HW_SUFFIX.test(label.slice(wanted.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDevice() {
|
||||||
|
if (!state.target) { state.deviceId = ""; state.resolved = true; return; }
|
||||||
|
try {
|
||||||
|
var list = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
var found = null, labelled = 0;
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var d = list[i];
|
||||||
|
if (d.kind !== "audiooutput" || !d.deviceId || SPECIAL[d.deviceId]) continue;
|
||||||
|
if (d.label) labelled++;
|
||||||
|
if (!found && d.label && matches(d.label, state.target)) found = d;
|
||||||
|
}
|
||||||
|
state.labels = labelled;
|
||||||
|
if (found) { state.deviceId = found.deviceId; state.resolved = true; }
|
||||||
|
else if (labelled > 0) { state.deviceId = ""; state.resolved = true; } // Namen sichtbar, Geraet aber weg -> Standard
|
||||||
|
else { state.resolved = false; } // Namen noch ausgeblendet (Berechtigung fehlt noch)
|
||||||
|
} catch (e) { state.lastError = String(e && e.name || e); state.resolved = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTo(el) {
|
||||||
|
if (!el || typeof el.setSinkId !== "function") return;
|
||||||
|
if (el.sinkId === state.deviceId) return;
|
||||||
|
try { await el.setSinkId(state.deviceId); state.applied++; }
|
||||||
|
catch (e) { state.lastError = String(e && e.name || e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAll() {
|
||||||
|
var els = document.querySelectorAll("video, audio");
|
||||||
|
for (var i = 0; i < els.length; i++) applyTo(els[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await resolveDevice();
|
||||||
|
applyAll();
|
||||||
|
if (!state.resolved && state.tries++ < 40) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = setTimeout(refresh, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue/abspielende Medienelemente sofort auf das Ziel legen (YouTube erzeugt sie dynamisch).
|
||||||
|
var onMedia = function (e) { applyTo(e.target); };
|
||||||
|
document.addEventListener("play", onMedia, true);
|
||||||
|
document.addEventListener("loadstart", onMedia, true);
|
||||||
|
var pending = null;
|
||||||
|
new MutationObserver(function () {
|
||||||
|
if (pending) return;
|
||||||
|
pending = setTimeout(function () { pending = null; applyAll(); }, 200);
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
try { navigator.mediaDevices.addEventListener("devicechange", refresh); } catch (e) {}
|
||||||
|
|
||||||
|
window.__playtubeAudio = {
|
||||||
|
setTarget: function (t) { state.target = t; state.tries = 0; state.resolved = false; refresh(); },
|
||||||
|
status: function () { return { target: state.target, deviceId: state.deviceId, resolved: state.resolved, applied: state.applied, labels: state.labels, lastError: state.lastError }; }
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_router_script(device_name: str) -> str:
|
||||||
|
"""JavaScript, das die Medienelemente einer Seite auf das Ausgabegeraet `device_name`
|
||||||
|
legt (leer = Systemstandard)."""
|
||||||
|
return _ROUTER_TEMPLATE.replace("__TARGET__", json.dumps(device_name or ""))
|
||||||
+308
-139
@@ -1,183 +1,352 @@
|
|||||||
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
|
"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
|
||||||
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
|
persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
|
||||||
der aktuellen Wiedergabe fuer Discord Rich Presence."""
|
Presence.
|
||||||
|
|
||||||
|
Edge-Variante von Playtube: statt QtWebEngine (kein AAC/H.264 - hochgeladene Titel liessen sich
|
||||||
|
nicht abspielen) rendert hier WebView2. Die Schnittstelle von BrowserTab ist dieselbe wie in der
|
||||||
|
Qt-Variante, damit MainWindow, Fernsteuerung und Discord unveraendert bleiben.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
from typing import Any, Callable
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer, Signal, QUrl
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtWebEngineCore import (
|
from PySide6.QtWidgets import QWidget
|
||||||
QWebEnginePage,
|
|
||||||
QWebEngineProfile,
|
|
||||||
QWebEngineScript,
|
|
||||||
QWebEngineSettings,
|
|
||||||
QWebEngineUrlRequestInterceptor,
|
|
||||||
)
|
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
||||||
|
|
||||||
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
from . import webview2_runtime
|
||||||
|
from .audio_routing import build_router_script
|
||||||
from .config import profile_dir
|
from .config import profile_dir
|
||||||
|
from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js
|
||||||
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
||||||
|
|
||||||
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
|
# Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen
|
||||||
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
|
||||||
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
|
_REFRESH_AFTER_COMMAND_MS = 300
|
||||||
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
|
_TASK_POLL_MS = 25
|
||||||
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
|
_MEDIA_POLL_MS = 2000
|
||||||
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
|
_YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
|
||||||
# unter Umstaenden nicht sicher".
|
# Ohne Nutzergeste starten koennen (Playlist-Start per Stream Dock, Autoplay).
|
||||||
_CHROME_VERSION = CHROME_FULL
|
_BROWSER_ARGUMENTS = "--autoplay-policy=no-user-gesture-required"
|
||||||
_CHROME_MAJOR = CHROME_MAJOR
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
_user32 = ctypes.windll.user32 if os.name == "nt" else None
|
||||||
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
|
if _user32 is not None:
|
||||||
_SEC_CH_UA_PLATFORM = b'"Windows"'
|
# 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
|
||||||
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
|
_user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||||
elif sys.platform.startswith("linux"):
|
_user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
||||||
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
|
_user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||||||
_SEC_CH_UA_PLATFORM = b'"Linux"'
|
_user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
|
||||||
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
|
_user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
||||||
else:
|
_user32.MoveWindow.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_bool)
|
||||||
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
|
_user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int)
|
||||||
_SEC_CH_UA_PLATFORM = b'"macOS"'
|
|
||||||
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
|
|
||||||
|
|
||||||
_USER_AGENT = (
|
_GWL_STYLE = -16
|
||||||
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 "
|
_WS_POPUP = 0x80000000
|
||||||
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
|
_WS_CAPTION = 0x00C00000
|
||||||
|
_WS_CHILD = 0x40000000
|
||||||
|
_WS_VISIBLE = 0x10000000
|
||||||
|
_WS_CLIPSIBLINGS = 0x04000000
|
||||||
|
_WS_CLIPCHILDREN = 0x02000000
|
||||||
|
_SW_SHOW = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_script_result(raw: Any) -> Any:
|
||||||
|
"""ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst
|
||||||
|
einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(str(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _in_document(source: str) -> str:
|
||||||
|
"""Umhuellt ein Skript, das document.documentElement braucht, damit es auch beim frueh
|
||||||
|
(vor dem DOM) ausgefuehrten WebView2-Dokumentskript funktioniert."""
|
||||||
|
return (
|
||||||
|
"(function(){function go(){" + source + "}"
|
||||||
|
"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',go);}"
|
||||||
|
"else{go();}})();"
|
||||||
)
|
)
|
||||||
|
|
||||||
_SEC_CH_UA = (
|
|
||||||
f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", '
|
|
||||||
f'"Chromium";v="{_CHROME_MAJOR}"'
|
|
||||||
).encode("ascii")
|
|
||||||
_SEC_CH_UA_FULL_VERSION_LIST = (
|
|
||||||
f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", '
|
|
||||||
f'"Chromium";v="{_CHROME_VERSION}"'
|
|
||||||
).encode("ascii")
|
|
||||||
|
|
||||||
|
class BrowserTab(QWidget):
|
||||||
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
|
"""Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
||||||
"""Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit
|
|
||||||
sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise
|
|
||||||
nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles
|
|
||||||
Login-Seite ein Embedded-WebView und blockiert die Anmeldung)."""
|
|
||||||
|
|
||||||
def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override)
|
|
||||||
info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA)
|
|
||||||
info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST)
|
|
||||||
info.setHttpHeader(b"sec-ch-ua-mobile", b"?0")
|
|
||||||
info.setHttpHeader(b"sec-ch-ua-platform", _SEC_CH_UA_PLATFORM)
|
|
||||||
info.setHttpHeader(b"sec-ch-ua-platform-version", _SEC_CH_UA_PLATFORM_VERSION)
|
|
||||||
|
|
||||||
|
|
||||||
_shared_profile: QWebEngineProfile | None = None
|
|
||||||
_shared_interceptor: _ChromeBrandingInterceptor | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_shared_profile() -> QWebEngineProfile:
|
|
||||||
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
|
|
||||||
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
|
|
||||||
global _shared_profile, _shared_interceptor
|
|
||||||
if _shared_profile is None:
|
|
||||||
_shared_profile = QWebEngineProfile("playtube-profile")
|
|
||||||
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
|
|
||||||
_shared_profile.setCachePath(str(profile_dir() / "cache"))
|
|
||||||
_shared_profile.setPersistentCookiesPolicy(
|
|
||||||
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
|
||||||
)
|
|
||||||
_shared_profile.setHttpUserAgent(_USER_AGENT)
|
|
||||||
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
|
|
||||||
|
|
||||||
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
|
|
||||||
_shared_interceptor = _ChromeBrandingInterceptor()
|
|
||||||
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
|
|
||||||
|
|
||||||
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
|
|
||||||
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
|
|
||||||
shim_script = QWebEngineScript()
|
|
||||||
shim_script.setName("playtube-chrome-shim")
|
|
||||||
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
|
||||||
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
|
||||||
shim_script.setRunsOnSubFrames(True)
|
|
||||||
shim_script.setSourceCode(CHROME_SHIM_JS)
|
|
||||||
_shared_profile.scripts().insert(shim_script)
|
|
||||||
return _shared_profile
|
|
||||||
|
|
||||||
|
|
||||||
class BrowserTab(QWebEngineView):
|
|
||||||
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
|
|
||||||
|
|
||||||
mediaInfoChanged = Signal(dict)
|
mediaInfoChanged = Signal(dict)
|
||||||
titleUpdated = Signal(str)
|
titleUpdated = Signal(str)
|
||||||
|
loadFinished = Signal(bool)
|
||||||
|
urlChanged = Signal(QUrl)
|
||||||
|
|
||||||
def __init__(self, home_url: str, parent=None):
|
def __init__(self, home_url: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._home_url = home_url
|
self._home_url = home_url
|
||||||
|
self._current_url = QUrl(home_url)
|
||||||
|
self._audio_output_set = False
|
||||||
|
self._allow_device_names = False
|
||||||
|
self._router_script_id: str | None = None
|
||||||
|
self._core = None
|
||||||
|
self._pending: list[tuple[Any, Callable[[Any], None] | None]] = []
|
||||||
|
self._queued_urls: list[str] = [home_url]
|
||||||
|
self._queued_router: str | None = None
|
||||||
|
|
||||||
page = QWebEnginePage(get_shared_profile(), self)
|
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
|
||||||
self.setPage(page)
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
|
||||||
|
self.setMinimumSize(200, 150)
|
||||||
|
|
||||||
settings = page.settings()
|
runtime = webview2_runtime.load()
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
self._runtime = runtime
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
properties = runtime.CreationProperties()
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
|
properties.UserDataFolder = str(profile_dir() / "webview2")
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
|
properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
|
properties.Language = "de-DE"
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
self._view = runtime.WebView2()
|
||||||
|
self._view.CreationProperties = properties
|
||||||
|
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
|
||||||
|
|
||||||
page.fullScreenRequested.connect(self._on_full_screen_requested)
|
self._task_timer = QTimer(self)
|
||||||
|
self._task_timer.setInterval(_TASK_POLL_MS)
|
||||||
self.load(QUrl(home_url))
|
self._task_timer.timeout.connect(self._drain_tasks)
|
||||||
|
self._task_timer.start()
|
||||||
|
|
||||||
self._poll_timer = QTimer(self)
|
self._poll_timer = QTimer(self)
|
||||||
self._poll_timer.setInterval(2000)
|
self._poll_timer.setInterval(_MEDIA_POLL_MS)
|
||||||
self._poll_timer.timeout.connect(self._poll_media_state)
|
self._poll_timer.timeout.connect(self._poll_media_state)
|
||||||
self._poll_timer.start()
|
self._poll_timer.start()
|
||||||
|
|
||||||
self.titleChanged.connect(self.titleUpdated.emit)
|
# Steuerelement erst nach dem Aufbau des Fensters erzeugen.
|
||||||
|
QTimer.singleShot(0, self._create_control)
|
||||||
|
|
||||||
def go_home(self) -> None:
|
# ------------------------------------------------------------ Aufbau
|
||||||
self.load(QUrl(self._home_url))
|
|
||||||
|
|
||||||
def toggle_playback(self) -> None:
|
def _create_control(self) -> None:
|
||||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
self._view.CreateControl()
|
||||||
|
handle = self._handle()
|
||||||
|
# Aus dem frei stehenden WinForms-Fenster ein sichtbares Kindfenster des Qt-Widgets machen.
|
||||||
|
style = _user32.GetWindowLongPtrW(handle, _GWL_STYLE)
|
||||||
|
style = (style & ~_WS_POPUP & ~_WS_CAPTION) | _WS_CHILD | _WS_VISIBLE | _WS_CLIPSIBLINGS | _WS_CLIPCHILDREN
|
||||||
|
_user32.SetWindowLongPtrW(handle, _GWL_STYLE, style)
|
||||||
|
_user32.SetParent(handle, int(self.winId()))
|
||||||
|
_user32.ShowWindow(handle, _SW_SHOW)
|
||||||
|
self._fit_to_widget()
|
||||||
|
self._view.EnsureCoreWebView2Async(None)
|
||||||
|
|
||||||
def next_track(self) -> None:
|
def _handle(self) -> int:
|
||||||
self.page().runJavaScript(NEXT_TRACK_JS)
|
return int(self._view.Handle.ToInt64())
|
||||||
|
|
||||||
def previous_track(self) -> None:
|
def _fit_to_widget(self) -> None:
|
||||||
self.page().runJavaScript(PREV_TRACK_JS)
|
if _user32 is None or self._view is None or not self._view.IsHandleCreated:
|
||||||
|
return
|
||||||
|
ratio = self.devicePixelRatioF()
|
||||||
|
_user32.MoveWindow(self._handle(), 0, 0, int(self.width() * ratio), int(self.height() * ratio), True)
|
||||||
|
|
||||||
def _on_full_screen_requested(self, request) -> None:
|
def resizeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
|
||||||
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
super().resizeEvent(event)
|
||||||
request.accept()
|
self._fit_to_widget()
|
||||||
|
|
||||||
|
def _on_core_initialized(self, _sender, args) -> None:
|
||||||
|
try:
|
||||||
|
self._setup_core(args)
|
||||||
|
except Exception: # pythonnet verschluckt Fehler in Ereignis-Handlern sonst still
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def _setup_core(self, args) -> None:
|
||||||
|
if not args.IsSuccess:
|
||||||
|
print(f"[webview2] Initialisierung fehlgeschlagen: {args.InitializationException}", flush=True)
|
||||||
|
self.loadFinished.emit(False)
|
||||||
|
return
|
||||||
|
core = self._view.CoreWebView2
|
||||||
|
self._core = core
|
||||||
|
settings = core.Settings
|
||||||
|
settings.IsStatusBarEnabled = False
|
||||||
|
settings.AreDefaultScriptDialogsEnabled = True
|
||||||
|
core.NavigationCompleted += lambda _s, a: self.loadFinished.emit(bool(a.IsSuccess))
|
||||||
|
core.SourceChanged += lambda _s, _a: self._on_source_changed()
|
||||||
|
core.DocumentTitleChanged += lambda _s, _a: self.titleUpdated.emit(str(core.DocumentTitle or ""))
|
||||||
|
core.NewWindowRequested += self._on_new_window
|
||||||
|
core.ContainsFullScreenElementChanged += lambda _s, _a: self._on_fullscreen_changed()
|
||||||
|
|
||||||
|
self._apply_device_name_permission()
|
||||||
|
if self._queued_router is not None:
|
||||||
|
self._install_router(self._queued_router)
|
||||||
|
self._queued_router = None
|
||||||
|
for url in self._queued_urls:
|
||||||
|
core.Navigate(url)
|
||||||
|
self._queued_urls.clear()
|
||||||
|
|
||||||
|
def _on_source_changed(self) -> None:
|
||||||
|
self._current_url = QUrl(str(self._core.Source))
|
||||||
|
self.urlChanged.emit(self._current_url)
|
||||||
|
|
||||||
|
def _on_new_window(self, _sender, args) -> None:
|
||||||
|
# Links mit target=_blank im selben Tab oeffnen (kein zusaetzliches Fenster).
|
||||||
|
args.Handled = True
|
||||||
|
self.setUrl(QUrl(str(args.Uri)))
|
||||||
|
|
||||||
|
def _on_fullscreen_changed(self) -> None:
|
||||||
window = self.window()
|
window = self.window()
|
||||||
if request.toggleOn():
|
if self._core.ContainsFullScreenElement:
|
||||||
window.showFullScreen()
|
window.showFullScreen()
|
||||||
else:
|
else:
|
||||||
window.showNormal()
|
window.showNormal()
|
||||||
|
self._fit_to_widget()
|
||||||
|
|
||||||
|
# ------------------------------------------------ asynchrone .NET-Aufgaben
|
||||||
|
|
||||||
|
def _await(self, task: Any, callback: Callable[[Any], None] | None = None) -> None:
|
||||||
|
"""Wartet ohne Blockieren auf eine .NET-Task und ruft danach callback(Ergebnis) auf."""
|
||||||
|
self._pending.append((task, callback))
|
||||||
|
|
||||||
|
def _drain_tasks(self) -> None:
|
||||||
|
if not self._pending:
|
||||||
|
return
|
||||||
|
still_pending = []
|
||||||
|
for task, callback in self._pending:
|
||||||
|
if not task.IsCompleted:
|
||||||
|
still_pending.append((task, callback))
|
||||||
|
continue
|
||||||
|
if callback is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
callback(None if task.IsFaulted or task.IsCanceled else task.Result)
|
||||||
|
except Exception: # Fehler im Rueckruf duerfen die Warteschlange nicht blockieren
|
||||||
|
pass
|
||||||
|
self._pending = still_pending
|
||||||
|
|
||||||
|
def _run_js(self, script: str, callback: Callable[[Any], None] | None = None) -> None:
|
||||||
|
if self._core is None:
|
||||||
|
return
|
||||||
|
task = self._core.ExecuteScriptAsync(script)
|
||||||
|
self._await(task, (lambda raw: callback(_decode_script_result(raw))) if callback else None)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Navigation
|
||||||
|
|
||||||
|
def url(self) -> QUrl:
|
||||||
|
return self._current_url
|
||||||
|
|
||||||
|
def setUrl(self, url: QUrl) -> None: # noqa: N802 (Qt-kompatibler Name)
|
||||||
|
target = url.toString()
|
||||||
|
self._current_url = url
|
||||||
|
if self._core is None:
|
||||||
|
self._queued_urls = [target]
|
||||||
|
else:
|
||||||
|
self._core.Navigate(target)
|
||||||
|
|
||||||
|
def go_home(self) -> None:
|
||||||
|
self.setUrl(QUrl(self._home_url))
|
||||||
|
|
||||||
|
def back(self) -> None:
|
||||||
|
if self._core is not None and self._core.CanGoBack:
|
||||||
|
self._core.GoBack()
|
||||||
|
|
||||||
|
def forward(self) -> None:
|
||||||
|
if self._core is not None and self._core.CanGoForward:
|
||||||
|
self._core.GoForward()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
if self._core is not None:
|
||||||
|
self._core.Reload()
|
||||||
|
|
||||||
|
# ------------------------------------------------------ Audio-Ausgabe
|
||||||
|
|
||||||
|
def set_audio_output(self, device_name: str) -> None:
|
||||||
|
"""Legt den Ton dieses Tabs auf das Ausgabegeraet `device_name` (leer =
|
||||||
|
Systemstandard), siehe audio_routing.py. Wirkt sofort auf die geoeffnete Seite und
|
||||||
|
bleibt bei Seitenwechseln erhalten (Skript pro Dokument)."""
|
||||||
|
source = build_router_script(device_name)
|
||||||
|
if self._core is None:
|
||||||
|
self._queued_router = source if device_name else None
|
||||||
|
else:
|
||||||
|
self._install_router(source if device_name else None)
|
||||||
|
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard.
|
||||||
|
if device_name or self._audio_output_set:
|
||||||
|
self._run_js(source)
|
||||||
|
self._audio_output_set = bool(device_name)
|
||||||
|
|
||||||
|
def _install_router(self, source: str | None) -> None:
|
||||||
|
old_id, self._router_script_id = self._router_script_id, None
|
||||||
|
if old_id:
|
||||||
|
self._core.RemoveScriptToExecuteOnDocumentCreated(old_id)
|
||||||
|
if source:
|
||||||
|
task = self._core.AddScriptToExecuteOnDocumentCreatedAsync(_in_document(source))
|
||||||
|
self._await(task, self._remember_router_id)
|
||||||
|
|
||||||
|
def _remember_router_id(self, script_id: Any) -> None:
|
||||||
|
self._router_script_id = str(script_id) if script_id else None
|
||||||
|
|
||||||
|
def set_device_names_allowed(self, allowed: bool) -> None:
|
||||||
|
"""Erlaubt der Seite, die Namen der Ausgabegeraete zu sehen (siehe audio_routing.py)."""
|
||||||
|
self._allow_device_names = allowed
|
||||||
|
self._apply_device_name_permission()
|
||||||
|
|
||||||
|
def _apply_device_name_permission(self) -> None:
|
||||||
|
if self._core is None:
|
||||||
|
return
|
||||||
|
state = self._runtime.PermissionState.Allow if self._allow_device_names else self._runtime.PermissionState.Default
|
||||||
|
try:
|
||||||
|
for origin in _YOUTUBE_ORIGINS:
|
||||||
|
self._core.Profile.SetPermissionStateAsync(self._runtime.PermissionKind.Microphone, origin, state)
|
||||||
|
except Exception as error: # aeltere Runtime ohne diese API: Seite laedt trotzdem
|
||||||
|
print(f"[webview2] Geraetenamen-Freigabe nicht setzbar: {error}", flush=True)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- Wiedergabe
|
||||||
|
|
||||||
|
def toggle_playback(self) -> None:
|
||||||
|
self._run_js(TOGGLE_PLAYBACK_JS)
|
||||||
|
|
||||||
|
def next_track(self) -> None:
|
||||||
|
self._run_js(NEXT_TRACK_JS)
|
||||||
|
|
||||||
|
def previous_track(self) -> None:
|
||||||
|
self._run_js(PREV_TRACK_JS)
|
||||||
|
|
||||||
|
def run_media_command(self, command: str, value: float = 0) -> None:
|
||||||
|
"""Fuehrt einen Fernsteuerungsbefehl (siehe media_control.py) aus und liest den
|
||||||
|
Wiedergabestatus kurz danach neu aus, damit z.B. das Stream Dock den neuen Zustand
|
||||||
|
sofort statt erst beim naechsten 2-Sekunden-Poll sieht."""
|
||||||
|
self._run_js(build_control_js(command, value))
|
||||||
|
QTimer.singleShot(_REFRESH_AFTER_COMMAND_MS, self._poll_media_state)
|
||||||
|
|
||||||
|
def list_playlists(self, callback) -> None:
|
||||||
|
"""Ruft callback(list | None) mit den Playlists aus der Seitenleiste auf."""
|
||||||
|
if self._core is None:
|
||||||
|
callback(None)
|
||||||
|
return
|
||||||
|
self._run_js(LIST_PLAYLISTS_JS, lambda result: callback(result if isinstance(result, list) else None))
|
||||||
|
|
||||||
|
def play_playlist(self, playlist_id: str) -> None:
|
||||||
|
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
||||||
|
die Wiedergabe."""
|
||||||
|
self.setUrl(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
||||||
|
|
||||||
def _poll_media_state(self) -> None:
|
def _poll_media_state(self) -> None:
|
||||||
self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result)
|
self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result)
|
||||||
|
|
||||||
def _on_media_probe_result(self, result) -> None:
|
|
||||||
# runJavaScript() liefert JS-Objekte ueber diese Bruecke nicht zuverlaessig
|
|
||||||
# als dict (siehe media_probe.py) - das Probe-Skript gibt daher einen
|
|
||||||
# JSON-String zurueck, der hier geparst wird.
|
|
||||||
info = None
|
|
||||||
if isinstance(result, str) and result:
|
|
||||||
try:
|
|
||||||
info = json.loads(result)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
info = None
|
|
||||||
elif isinstance(result, dict):
|
|
||||||
info = result
|
|
||||||
|
|
||||||
|
def _on_media_probe_result(self, info: Any) -> None:
|
||||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||||
print(f"[media-probe:{self._home_url}] roh={result!r} geparst={info!r}", flush=True)
|
print(f"[media-probe:{self._home_url}] geparst={info!r}", flush=True)
|
||||||
|
|
||||||
if isinstance(info, dict):
|
if isinstance(info, dict):
|
||||||
self.mediaInfoChanged.emit(info)
|
self.mediaInfoChanged.emit(info)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- Aufraeumen
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
|
||||||
|
self.dispose()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def dispose(self) -> None:
|
||||||
|
self._task_timer.stop()
|
||||||
|
self._poll_timer.stop()
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.Dispose()
|
||||||
|
self._view = None
|
||||||
|
|||||||
+50
-3
@@ -3,19 +3,23 @@ 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
|
||||||
|
|
||||||
APP_NAME = "Playtube"
|
APP_NAME = "Playtube Edge"
|
||||||
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
|
APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
|
||||||
|
|
||||||
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
|
||||||
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
|
||||||
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
|
||||||
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
|
||||||
# denselben Browser-Profil-Lock geteilt haben).
|
# denselben Browser-Profil-Lock geteilt haben).
|
||||||
_DATA_DIR_NAME = APP_NAME if getattr(sys, "frozen", False) else f"{APP_NAME}Dev"
|
#
|
||||||
|
# Edge-Variante (WebView2): eigene Ordnernamen ("PlaytubeEdge"/"PlaytubeEdgeDev"), damit sie sich
|
||||||
|
# nie mit der Qt-Variante ueberschneidet (Profil, Einzelinstanz-Schutz, Fernsteuerungs-Pipe).
|
||||||
|
_DATA_DIR_NAME = "PlaytubeEdge" if getattr(sys, "frozen", False) else "PlaytubeEdgeDev"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"app_name": APP_NAME,
|
"app_name": APP_NAME,
|
||||||
@@ -27,10 +31,20 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
|
||||||
"show_idle_presence": True,
|
"show_idle_presence": True,
|
||||||
},
|
},
|
||||||
|
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
|
||||||
"updates": {
|
"updates": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"check_interval_hours": 6,
|
"check_interval_hours": 6,
|
||||||
},
|
},
|
||||||
|
# Ausgabegeraet pro Tab (Name des Geraets, wie in den Einstellungen gewaehlt; leer =
|
||||||
|
# Systemstandard) - siehe playtube/audio_routing.py.
|
||||||
|
"audio": {
|
||||||
|
"youtube_output": "",
|
||||||
|
"music_output": "",
|
||||||
|
},
|
||||||
|
# Lokale Fernsteuerung fuer das Stream-Dock-Plugin (Named Pipe, nur dieser Benutzer) -
|
||||||
|
# siehe playtube/remote_control.py.
|
||||||
|
"remote_control": {"enabled": True},
|
||||||
"start_tab": "youtube", # "youtube" oder "music"
|
"start_tab": "youtube", # "youtube" oder "music"
|
||||||
"home_youtube": "https://www.youtube.com/",
|
"home_youtube": "https://www.youtube.com/",
|
||||||
"home_music": "https://music.youtube.com/",
|
"home_music": "https://music.youtube.com/",
|
||||||
@@ -48,6 +62,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 +115,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
|
||||||
|
|||||||
@@ -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))}")
|
||||||
+114
-13
@@ -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"):
|
||||||
@@ -112,6 +112,24 @@ def build_idle_payload(session_start: int) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_DETAILS = "In den Einstellungen"
|
||||||
|
SETTINGS_DEFAULT_STATE = "Schaut sich die Einstellungen an"
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_payload(session_start: int, activity: str | None) -> dict[str, Any]:
|
||||||
|
"""Presence, solange der Einstellungen-Tab offen ist. `activity` beschreibt, was
|
||||||
|
gerade bearbeitet wird (z.B. "Bearbeitet: Discord Rich Presence › Client-ID") - es
|
||||||
|
wird bewusst nur der NAME des Feldes uebergeben, nie sein Inhalt (sonst wuerde
|
||||||
|
z.B. die eingetragene Client-ID oeffentlich im Discord-Profil stehen)."""
|
||||||
|
return {
|
||||||
|
"details": SETTINGS_DETAILS,
|
||||||
|
"state": _truncate(activity, fallback=SETTINGS_DEFAULT_STATE),
|
||||||
|
"large_image": ASSET_DEFAULT,
|
||||||
|
"large_text": APP_NAME,
|
||||||
|
"start": session_start,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DiscordRPCWorker(QThread):
|
class DiscordRPCWorker(QThread):
|
||||||
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
|
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
|
||||||
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
|
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
|
||||||
@@ -119,18 +137,35 @@ 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)."""
|
||||||
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
|
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
|
||||||
|
|
||||||
|
def submit_settings_activity(self, activity: str | None) -> None:
|
||||||
|
"""Nutzer ist im Einstellungen-Tab; `activity` = was gerade bearbeitet wird
|
||||||
|
(None = nur "schaut sich die Einstellungen an")."""
|
||||||
|
self._replace_queue(_SettingsActivity(activity))
|
||||||
|
|
||||||
def _replace_queue(self, item) -> None:
|
def _replace_queue(self, item) -> None:
|
||||||
try:
|
try:
|
||||||
self._queue.get_nowait()
|
self._queue.get_nowait()
|
||||||
@@ -188,6 +223,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,25 +231,81 @@ 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)
|
||||||
else:
|
else:
|
||||||
payload = None
|
payload = None
|
||||||
self._presence.clear()
|
self._presence.clear()
|
||||||
else:
|
elif isinstance(item, _SettingsActivity):
|
||||||
payload = build_presence_payload(item, self._session_start)
|
# Wie beim Leerlauf: der naechste echte Track bekommt einen frischen
|
||||||
|
# Zeit-Anker.
|
||||||
|
self._track_key = None
|
||||||
|
self._track_start_ts = None
|
||||||
|
payload = build_settings_payload(self._session_start, item.activity)
|
||||||
self._presence.update(**payload)
|
self._presence.update(**payload)
|
||||||
|
log_line("[send-settings] state=%r" % (payload["state"],))
|
||||||
|
else:
|
||||||
|
start_ts, end_ts = self._track_timestamps(item)
|
||||||
|
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -226,6 +318,15 @@ class DiscordRPCWorker(QThread):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _SettingsActivity:
|
||||||
|
"""Queue-Eintrag: Nutzer ist in den Einstellungen und bearbeitet `activity`."""
|
||||||
|
|
||||||
|
__slots__ = ("activity",)
|
||||||
|
|
||||||
|
def __init__(self, activity: str | None) -> None:
|
||||||
|
self.activity = activity
|
||||||
|
|
||||||
|
|
||||||
class _Sentinel:
|
class _Sentinel:
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
||||||
|
|||||||
+181
-14
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -19,15 +20,27 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
|
from .audio_routing import sync_audio_permissions
|
||||||
from .browser import BrowserTab
|
from .browser import BrowserTab
|
||||||
from .config import APP_NAME
|
from .config import APP_NAME
|
||||||
from .discord_rpc import DiscordRPCWorker
|
from .discord_rpc import DiscordRPCWorker
|
||||||
|
from .remote_control import build_state as build_remote_state
|
||||||
|
from .remote_control import resolve_target as resolve_remote_target
|
||||||
from .settings_tab import SettingsTab
|
from .settings_tab import SettingsTab
|
||||||
from .updater import UpdateChecker, UpdateInstaller
|
from .updater import UpdateChecker, UpdateInstaller
|
||||||
|
|
||||||
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _force_exit_after(seconds: float) -> None:
|
||||||
|
"""Beendet den Prozess nach `seconds` hart (os._exit), falls das normale Beenden bis
|
||||||
|
dahin noch nicht durch ist. Der Timer laeuft als Daemon-Thread und stoert das
|
||||||
|
normale, schnelle Beenden nicht."""
|
||||||
|
timer = threading.Timer(seconds, os._exit, args=(0,))
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self, config: dict[str, Any]):
|
def __init__(self, config: dict[str, Any]):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -46,12 +59,25 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||||
self._music_tab = BrowserTab(config["home_music"], self)
|
self._music_tab = BrowserTab(config["home_music"], self)
|
||||||
|
self._sync_audio_permission()
|
||||||
|
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
|
||||||
|
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
||||||
|
# erteilte schon (siehe audio_routing.py).
|
||||||
|
for tab in (self._youtube_tab, self._music_tab):
|
||||||
|
tab.loadFinished.connect(self._on_tab_load_finished)
|
||||||
self._settings_tab = SettingsTab(config, self)
|
self._settings_tab = SettingsTab(config, self)
|
||||||
self._tabs.addTab(self._youtube_tab, "YouTube")
|
self._tabs.addTab(self._youtube_tab, "YouTube")
|
||||||
self._tabs.addTab(self._music_tab, "YouTube Music")
|
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||||||
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._apply_audio_routing()
|
||||||
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
|
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
|
||||||
|
self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence())
|
||||||
|
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)
|
||||||
@@ -165,26 +191,46 @@ class MainWindow(QMainWindow):
|
|||||||
tab = self._current_browser_tab()
|
tab = self._current_browser_tab()
|
||||||
self._url_bar.setEnabled(tab is not None)
|
self._url_bar.setEnabled(tab is not None)
|
||||||
self._url_bar.setText(tab.url().toString() if tab is not None else "")
|
self._url_bar.setText(tab.url().toString() if tab is not None else "")
|
||||||
|
# Tab-Wechsel von/zu den Einstellungen aendert, was Discord anzeigen soll.
|
||||||
|
self._update_discord_presence()
|
||||||
|
|
||||||
|
def _active_media_info(self) -> dict | None:
|
||||||
|
"""Bevorzugt der Tab, der gerade wirklich abspielt, sonst der aktuell sichtbare."""
|
||||||
|
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
|
||||||
|
return playing_info or self._latest_media.get(id(self._current_tab()))
|
||||||
|
|
||||||
def _on_media_info(self, info: dict) -> None:
|
def _on_media_info(self, info: dict) -> None:
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
self._latest_media[id(sender)] = info
|
self._latest_media[id(sender)] = info
|
||||||
|
|
||||||
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
|
active_info = self._active_media_info()
|
||||||
# abspielt, sonst der aktuell sichtbare Tab.
|
|
||||||
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
|
|
||||||
active_info = playing_info or self._latest_media.get(id(self._current_tab()))
|
|
||||||
|
|
||||||
if active_info and active_info.get("title"):
|
if active_info and active_info.get("title"):
|
||||||
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
|
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
|
||||||
else:
|
else:
|
||||||
self.setWindowTitle(APP_NAME)
|
self.setWindowTitle(APP_NAME)
|
||||||
|
|
||||||
if self._rpc_worker is not None:
|
self._update_discord_presence()
|
||||||
|
|
||||||
|
def _update_discord_presence(self) -> None:
|
||||||
|
"""Entscheidet, was Discord gerade anzeigt: solange der Einstellungen-Tab offen
|
||||||
|
ist, "In den Einstellungen" samt dem gerade bearbeiteten Feld; sonst wie bisher
|
||||||
|
die laufende Wiedergabe bzw. den Leerlauf. Die Einstellungs-Anzeige folgt der
|
||||||
|
Option "Status anzeigen, wenn gerade nichts laeuft" (show_idle_presence) - wer den
|
||||||
|
Leerlauf-Status abgeschaltet hat, bekommt auch keinen Einstellungs-Status."""
|
||||||
|
worker = getattr(self, "_rpc_worker", None)
|
||||||
|
if worker is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
discord_cfg = self._config.get("discord", {})
|
||||||
|
if self._current_tab() is self._settings_tab and discord_cfg.get("show_idle_presence", True):
|
||||||
|
worker.submit_settings_activity(self._settings_tab.current_activity())
|
||||||
|
return
|
||||||
|
|
||||||
|
active_info = self._active_media_info()
|
||||||
if active_info and active_info.get("hasVideo"):
|
if active_info and active_info.get("hasVideo"):
|
||||||
self._rpc_worker.submit_media_info(active_info)
|
worker.submit_media_info(active_info)
|
||||||
else:
|
else:
|
||||||
self._rpc_worker.submit_media_info(None)
|
worker.submit_media_info(None)
|
||||||
|
|
||||||
def _start_discord_worker(self) -> None:
|
def _start_discord_worker(self) -> None:
|
||||||
discord_cfg = self._config.get("discord", {})
|
discord_cfg = self._config.get("discord", {})
|
||||||
@@ -196,14 +242,41 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._rpc_worker.start()
|
self._rpc_worker.start()
|
||||||
|
|
||||||
|
def _audio_outputs(self) -> tuple[str, str]:
|
||||||
|
"""(Geraet fuer YouTube, Geraet fuer YouTube Musik) aus der Konfiguration; leer =
|
||||||
|
Systemstandard."""
|
||||||
|
audio_cfg = self._config.get("audio", {})
|
||||||
|
return (
|
||||||
|
str(audio_cfg.get("youtube_output", "") or ""),
|
||||||
|
str(audio_cfg.get("music_output", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_audio_permission(self) -> None:
|
||||||
|
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
||||||
|
nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
|
||||||
|
sync_audio_permissions((self._youtube_tab, self._music_tab), any(self._audio_outputs()))
|
||||||
|
|
||||||
|
def _on_tab_load_finished(self, _ok: bool) -> None:
|
||||||
|
if any(self._audio_outputs()):
|
||||||
|
self._sync_audio_permission()
|
||||||
|
|
||||||
|
def _apply_audio_routing(self) -> None:
|
||||||
|
"""Wendet die in den Einstellungen gewaehlten Ausgabegeraete pro Tab an."""
|
||||||
|
youtube_output, music_output = self._audio_outputs()
|
||||||
|
self._youtube_tab.set_audio_output(youtube_output)
|
||||||
|
self._music_tab.set_audio_output(music_output)
|
||||||
|
|
||||||
def _on_settings_saved(self, new_config: dict) -> None:
|
def _on_settings_saved(self, new_config: dict) -> None:
|
||||||
self._config = new_config
|
self._config = new_config
|
||||||
|
self._sync_audio_permission()
|
||||||
|
self._apply_audio_routing()
|
||||||
|
|
||||||
if self._rpc_worker is not None:
|
if self._rpc_worker is not None:
|
||||||
self._rpc_worker.stop()
|
self._rpc_worker.stop()
|
||||||
self._rpc_worker.wait(2000)
|
self._rpc_worker.wait(2000)
|
||||||
self._rpc_worker = None
|
self._rpc_worker = None
|
||||||
self._start_discord_worker()
|
self._start_discord_worker()
|
||||||
|
self._update_discord_presence() # frischen Worker sofort mit dem aktuellen Stand versorgen
|
||||||
|
|
||||||
self._setup_auto_update()
|
self._setup_auto_update()
|
||||||
|
|
||||||
@@ -216,6 +289,48 @@ 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()
|
||||||
|
|
||||||
|
# ------------------------------------------------- Fernsteuerung (remote_control.py)
|
||||||
|
|
||||||
|
def _browser_tabs_by_name(self) -> dict[str, BrowserTab]:
|
||||||
|
return {"youtube": self._youtube_tab, "music": self._music_tab}
|
||||||
|
|
||||||
|
def _visible_tab_name(self) -> str:
|
||||||
|
current = self._current_tab()
|
||||||
|
for name, tab in self._browser_tabs_by_name().items():
|
||||||
|
if current is tab:
|
||||||
|
return name
|
||||||
|
return "settings"
|
||||||
|
|
||||||
|
def _media_by_tab_name(self) -> dict[str, dict | None]:
|
||||||
|
return {name: self._latest_media.get(id(tab)) for name, tab in self._browser_tabs_by_name().items()}
|
||||||
|
|
||||||
|
def remote_state(self) -> dict:
|
||||||
|
return build_remote_state(APP_VERSION, self._visible_tab_name(), self._media_by_tab_name())
|
||||||
|
|
||||||
|
def remote_show(self) -> None:
|
||||||
|
self._show_and_raise()
|
||||||
|
|
||||||
|
def remote_switch_tab(self, target: str) -> None:
|
||||||
|
""""youtube"/"music" waehlt den Tab; "auto" wechselt zwischen beiden hin und her."""
|
||||||
|
if target == "auto":
|
||||||
|
target = "music" if self._visible_tab_name() == "youtube" else "youtube"
|
||||||
|
self._tabs.setCurrentWidget(self._browser_tabs_by_name()[target])
|
||||||
|
|
||||||
|
def remote_media_command(self, target: str, command: str, value: float) -> None:
|
||||||
|
name = resolve_remote_target(target, self._visible_tab_name(), self._media_by_tab_name())
|
||||||
|
self._browser_tabs_by_name()[name].run_media_command(command, value)
|
||||||
|
|
||||||
|
def remote_list_playlists(self, callback) -> None:
|
||||||
|
self._music_tab.list_playlists(callback)
|
||||||
|
|
||||||
|
def remote_play_playlist(self, playlist_id: str) -> None:
|
||||||
|
self._music_tab.play_playlist(playlist_id)
|
||||||
|
|
||||||
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 +378,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 +421,53 @@ 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 …")
|
||||||
|
# Haengt sich Qt/QtWebEngine beim Beenden auf, wuerde der Update-Helfer (bzw.
|
||||||
|
# Setup, siehe /WAITPID) ewig auf das Ende dieses Prozesses warten - und die
|
||||||
|
# alte Version bliebe einfach weiterlaufen. Nach 8 s hart beenden.
|
||||||
|
_force_exit_after(8.0)
|
||||||
self._quit()
|
self._quit()
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
@@ -314,6 +479,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:
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""JavaScript fuer die Fernsteuerung (siehe remote_control.py): baut pro Befehl ein kleines
|
||||||
|
Skript, das im YouTube- bzw. YouTube-Music-Tab ausgefuehrt wird.
|
||||||
|
|
||||||
|
Es werden ausschliesslich feste Skripte aus einer Whitelist erzeugt; der einzige
|
||||||
|
variable Teil ist ein bereits validierter Zahlenwert (Sekunden/Lautstaerke), der als
|
||||||
|
Zahl eingesetzt wird - fremder Text landet nie im Skript."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Gemeinsame Helfer: der YouTube-Player (#movie_player) bietet auf youtube.com UND
|
||||||
|
# music.youtube.com eine API fuer Lautstaerke/Stummschaltung/Titelwechsel; das
|
||||||
|
# <video>-Element dient als Fallback, falls die API (noch) nicht verfuegbar ist.
|
||||||
|
_PRELUDE = r"""
|
||||||
|
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
|
||||||
|
var video = document.querySelector('video');
|
||||||
|
var player = document.getElementById('movie_player');
|
||||||
|
function has(fn) { return player && typeof player[fn] === 'function'; }
|
||||||
|
function click(selectors) {
|
||||||
|
for (var i = 0; i < selectors.length; i++) {
|
||||||
|
var el = document.querySelector(selectors[i]);
|
||||||
|
if (el) { el.click(); return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function clamp(v) { return Math.max(0, Math.min(100, Math.round(v))); }
|
||||||
|
function getVolume() {
|
||||||
|
if (has('getVolume')) { return player.getVolume(); }
|
||||||
|
return video ? video.volume * 100 : 0;
|
||||||
|
}
|
||||||
|
function unmute() {
|
||||||
|
if (has('unMute')) { player.unMute(); } else if (video) { video.muted = false; }
|
||||||
|
}
|
||||||
|
function mute() {
|
||||||
|
if (has('mute')) { player.mute(); } else if (video) { video.muted = true; }
|
||||||
|
}
|
||||||
|
function isMuted() {
|
||||||
|
if (has('isMuted')) { return player.isMuted(); }
|
||||||
|
return video ? video.muted : false;
|
||||||
|
}
|
||||||
|
function setVolume(v) {
|
||||||
|
v = clamp(v);
|
||||||
|
if (has('setVolume')) { player.setVolume(v); }
|
||||||
|
else if (video) { video.volume = v / 100; }
|
||||||
|
if (v > 0) { unmute(); }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_NEXT_SELECTORS = (
|
||||||
|
"['ytmusic-player-bar .next-button', 'ytmusic-player-bar #next-button button', "
|
||||||
|
"'tp-yt-paper-icon-button.next-button', '.ytp-next-button']"
|
||||||
|
)
|
||||||
|
_PREV_SELECTORS = (
|
||||||
|
"['ytmusic-player-bar .previous-button', 'ytmusic-player-bar #previous-button button', "
|
||||||
|
"'tp-yt-paper-icon-button.previous-button', '.ytp-prev-button']"
|
||||||
|
)
|
||||||
|
_LIKE_SELECTORS = (
|
||||||
|
"['ytmusic-player-bar ytmusic-like-button-renderer #button-shape-like button', "
|
||||||
|
"'like-button-view-model button', '#segmented-like-button button']"
|
||||||
|
)
|
||||||
|
_DISLIKE_SELECTORS = (
|
||||||
|
"['ytmusic-player-bar ytmusic-like-button-renderer #button-shape-dislike button', "
|
||||||
|
"'dislike-button-view-model button', '#segmented-dislike-button button']"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Befehl -> Skriptkoerper. "{value}" wird durch den validierten Zahlenwert ersetzt.
|
||||||
|
_BODIES: dict[str, str] = {
|
||||||
|
"play_pause": "if (video) { video.paused ? video.play() : video.pause(); }",
|
||||||
|
"play": "if (video && video.paused) { video.play(); }",
|
||||||
|
"pause": "if (video && !video.paused) { video.pause(); }",
|
||||||
|
# YouTube (ohne Playlist) blendet den "Naechster"-Knopf teils aus -> Player-API.
|
||||||
|
"next": f"if (!click({_NEXT_SELECTORS}) && has('nextVideo')) {{ player.nextVideo(); }}",
|
||||||
|
# Ohne Vorgaenger springt "Zurueck" wie bei ueblichen Playern an den Anfang.
|
||||||
|
"previous": (
|
||||||
|
f"if (!click({_PREV_SELECTORS})) {{"
|
||||||
|
" if (video) { video.currentTime = 0; } }"
|
||||||
|
),
|
||||||
|
"seek": (
|
||||||
|
"if (video && isFinite(video.currentTime)) {"
|
||||||
|
" var end = isFinite(video.duration) ? video.duration : video.currentTime + {value};"
|
||||||
|
" video.currentTime = Math.max(0, Math.min(end, video.currentTime + {value})); }"
|
||||||
|
),
|
||||||
|
"volume_change": "setVolume(getVolume() + {value});",
|
||||||
|
"set_volume": "setVolume({value});",
|
||||||
|
"mute_toggle": "if (isMuted()) { unmute(); } else { mute(); }",
|
||||||
|
"like": f"click({_LIKE_SELECTORS});",
|
||||||
|
"dislike": f"click({_DISLIKE_SELECTORS});",
|
||||||
|
"shuffle": (
|
||||||
|
"if (isMusic) { click(['ytmusic-player-bar .shuffle', "
|
||||||
|
"'ytmusic-player-bar #shuffle-button', 'ytmusic-player-bar .shuffle-button']); }"
|
||||||
|
),
|
||||||
|
# YouTube Music: Wiederholen-Modus durchschalten; YouTube: Video in Schleife an/aus.
|
||||||
|
"repeat": (
|
||||||
|
"if (isMusic) { click(['ytmusic-player-bar .repeat', "
|
||||||
|
"'ytmusic-player-bar #repeat-button', 'ytmusic-player-bar .repeat-button']); }"
|
||||||
|
" else if (video) { video.loop = !video.loop; }"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
MEDIA_COMMANDS = frozenset(_BODIES)
|
||||||
|
|
||||||
|
# Liest die Playlists aus der Seitenleiste von YouTube Music. Die Eintraege sind keine
|
||||||
|
# normalen Links: Ziel steht in den Polymer-Daten des Elements
|
||||||
|
# (data.navigationEndpoint.browseEndpoint.browseId = "VL<Playlist-ID>"); Links
|
||||||
|
# ("browse/VL<id>", "playlist?list=<id>") dienen nur als Fallback. Liefert einen
|
||||||
|
# JSON-String (Objekte kommen ueber die runJavaScript()-Bruecke nicht zuverlaessig an,
|
||||||
|
# siehe media_probe.py); die Daten gelten als fremd und werden in
|
||||||
|
# remote_control.sanitize_playlists bereinigt.
|
||||||
|
LIST_PLAYLISTS_JS = r"""
|
||||||
|
(function() {
|
||||||
|
var out = [];
|
||||||
|
function idFromBrowseId(browseId) {
|
||||||
|
return (typeof browseId === 'string' && browseId.indexOf('VL') === 0) ? browseId.slice(2) : null;
|
||||||
|
}
|
||||||
|
function idFromHref(href) {
|
||||||
|
var m = /[?&]list=([A-Za-z0-9_-]+)/.exec(href || '') || /browse\/VL([A-Za-z0-9_-]+)/.exec(href || '');
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
function textOf(formatted) {
|
||||||
|
if (!formatted) { return ''; }
|
||||||
|
if (typeof formatted.simpleText === 'string') { return formatted.simpleText; }
|
||||||
|
return (formatted.runs || []).map(function(r) { return r.text || ''; }).join('');
|
||||||
|
}
|
||||||
|
var entries = document.querySelectorAll('ytmusic-guide-entry-renderer');
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var el = entries[i];
|
||||||
|
var data = el.data || (el.__data && el.__data.data) || null;
|
||||||
|
var endpoint = data && data.navigationEndpoint;
|
||||||
|
var id = idFromBrowseId(endpoint && endpoint.browseEndpoint && endpoint.browseEndpoint.browseId);
|
||||||
|
if (!id) {
|
||||||
|
var link = el.querySelector('[href]');
|
||||||
|
id = idFromHref(link ? link.getAttribute('href') : '');
|
||||||
|
}
|
||||||
|
if (!id) { continue; }
|
||||||
|
var titleEl = el.querySelector('.title');
|
||||||
|
var title = textOf(data && data.formattedTitle) || (titleEl ? titleEl.textContent : '');
|
||||||
|
out.push({ id: id, title: (title || '').trim() });
|
||||||
|
}
|
||||||
|
return JSON.stringify(out);
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
MUSIC_PLAYLIST_URL = "https://music.youtube.com/watch?list={playlist_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_control_js(command: str, value: float = 0) -> str:
|
||||||
|
"""Liefert das Skript fuer `command`. Unbekannte Befehle oder nicht-endliche Werte
|
||||||
|
-> ValueError (die Wertebereiche prueft vorher remote_control.parse_request)."""
|
||||||
|
body = _BODIES.get(command)
|
||||||
|
if body is None:
|
||||||
|
raise ValueError(f"unbekannter Befehl: {command}")
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError("value muss eine endliche Zahl sein")
|
||||||
|
# repr() einer endlichen float ist immer ein gueltiges JS-Zahlenliteral (z.B. -10.0).
|
||||||
|
return "(function() {" + _PRELUDE + body.replace("{value}", repr(number)) + "\n})();"
|
||||||
+93
-5
@@ -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,37 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zusatzstatus fuer die Fernsteuerung (Stream-Dock-Plugin, siehe remote_control.py).
|
||||||
|
var player = document.getElementById('movie_player');
|
||||||
|
var volume = (player && typeof player.getVolume === 'function')
|
||||||
|
? player.getVolume() : (video ? Math.round(video.volume * 100) : null);
|
||||||
|
var muted = (player && typeof player.isMuted === 'function')
|
||||||
|
? player.isMuted() : (video ? video.muted : null);
|
||||||
|
function likeState() {
|
||||||
|
if (isMusic) {
|
||||||
|
var r = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer');
|
||||||
|
var s = r ? r.getAttribute('like-status') : null;
|
||||||
|
return s ? s.toLowerCase() : null; // "like" | "dislike" | "indifferent"
|
||||||
|
}
|
||||||
|
var like = document.querySelector('like-button-view-model button, #segmented-like-button button');
|
||||||
|
var dislike = document.querySelector('dislike-button-view-model button, #segmented-dislike-button button');
|
||||||
|
if (like && like.getAttribute('aria-pressed') === 'true') { return 'like'; }
|
||||||
|
if (dislike && dislike.getAttribute('aria-pressed') === 'true') { return 'dislike'; }
|
||||||
|
return like ? 'indifferent' : null;
|
||||||
|
}
|
||||||
|
function repeatMode() {
|
||||||
|
if (!isMusic) { return video && video.loop ? 'ONE' : 'NONE'; }
|
||||||
|
var bar = document.querySelector('ytmusic-player-bar');
|
||||||
|
return bar ? (bar.getAttribute('repeat-mode') || bar.getAttribute('repeat-mode_') || null) : 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,9 +145,13 @@ 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,
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
likeStatus: likeState(),
|
||||||
|
repeatMode: repeatMode()
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Lokale Fernsteuerung fuer externe Tools - z.B. das Stream-Dock-Plugin
|
||||||
|
(com.fojadrachi.playtube.sdPlugin).
|
||||||
|
|
||||||
|
Transport: ein QLocalServer (Named Pipe unter Windows, z.B. \\\\.\\pipe\\Playtube.Remote.Playtube)
|
||||||
|
- nur lokal erreichbar, kein offener Netzwerk-Port. Getrennt vom Einzelinstanz-Pipe
|
||||||
|
(single_instance.py), damit ein Steuerbefehl nie das Fenster nach vorne holt und aeltere
|
||||||
|
Playtube-Versionen nicht faelschlich reagieren.
|
||||||
|
|
||||||
|
Protokoll (eine JSON-Nachricht pro Zeile, UTF-8, "\\n"-getrennt, Verbindung bleibt offen):
|
||||||
|
|
||||||
|
-> {"id": 1, "cmd": "play_pause", "target": "auto"}
|
||||||
|
<- {"id": 1, "ok": true}
|
||||||
|
-> {"id": 2, "cmd": "status"}
|
||||||
|
<- {"id": 2, "ok": true, "state": {...}}
|
||||||
|
<- {"id": 3, "ok": false, "error": "..."}
|
||||||
|
|
||||||
|
Playlists (nur YouTube Music):
|
||||||
|
|
||||||
|
-> {"id": 4, "cmd": "list_playlists"}
|
||||||
|
<- {"id": 4, "ok": true, "playlists": [{"id": "LM", "title": "Titel, die ich mag"}, ...]}
|
||||||
|
-> {"id": 5, "cmd": "play_playlist", "playlist": "PL..."}
|
||||||
|
|
||||||
|
`cmd` stammt aus einer festen Whitelist (APP_COMMANDS + media_control.MEDIA_COMMANDS),
|
||||||
|
`target` ist "auto" | "youtube" | "music", `value` eine Zahl mit befehlsabhaengigem
|
||||||
|
Wertebereich. Beliebiges JavaScript oder URLs lassen sich bewusst NICHT senden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||||
|
|
||||||
|
from .debug_log import log_exception
|
||||||
|
from .media_control import MEDIA_COMMANDS
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
PIPE_PREFIX = "Playtube.Remote."
|
||||||
|
|
||||||
|
MAX_LINE_BYTES = 4096
|
||||||
|
MAX_CLIENTS = 8
|
||||||
|
|
||||||
|
TAB_NAMES = ("youtube", "music")
|
||||||
|
TARGETS = frozenset({"auto", *TAB_NAMES})
|
||||||
|
APP_COMMANDS = frozenset({"status", "show", "switch_tab", "list_playlists", "play_playlist"})
|
||||||
|
|
||||||
|
# Playlist-IDs (z.B. "PL...", "OLAK5uy_...", "LM") - landen in einer URL, daher streng.
|
||||||
|
PLAYLIST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{2,64}$")
|
||||||
|
LIKED_MUSIC = {"id": "LM", "title": "Titel, die ich mag"}
|
||||||
|
MAX_PLAYLISTS = 100
|
||||||
|
MAX_PLAYLIST_TITLE = 100
|
||||||
|
|
||||||
|
# Befehl -> (min, max) fuer `value`; Befehle ohne Eintrag ignorieren `value`.
|
||||||
|
_VALUE_RANGES: dict[str, tuple[float, float]] = {
|
||||||
|
"seek": (-3600, 3600),
|
||||||
|
"volume_change": (-100, 100),
|
||||||
|
"set_volume": (0, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nur diese Felder des Media-Probes (media_probe.py) werden nach aussen gegeben.
|
||||||
|
_STATE_FIELDS = (
|
||||||
|
"isMusic", "title", "subtitle", "thumbnail", "playing", "currentTime", "duration",
|
||||||
|
"hasVideo", "volume", "muted", "likeStatus", "repeatMode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(ValueError):
|
||||||
|
"""Ungueltige Anfrage - wird dem Client als {"ok": false, "error": ...} gemeldet."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Request:
|
||||||
|
id: Any
|
||||||
|
command: str
|
||||||
|
target: str = "auto"
|
||||||
|
value: float = 0
|
||||||
|
playlist: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteController(Protocol):
|
||||||
|
"""Was der Server von der App braucht (implementiert von MainWindow)."""
|
||||||
|
|
||||||
|
def remote_state(self) -> dict: ...
|
||||||
|
def remote_show(self) -> None: ...
|
||||||
|
def remote_switch_tab(self, target: str) -> None: ...
|
||||||
|
def remote_media_command(self, target: str, command: str, value: float) -> None: ...
|
||||||
|
def remote_list_playlists(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
def remote_play_playlist(self, playlist_id: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def parse_request(line: str) -> Request:
|
||||||
|
"""Parst und validiert eine Anfragezeile. Wirft ProtocolError bei jedem Fehler."""
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProtocolError("ungueltiges JSON") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ProtocolError("Anfrage muss ein JSON-Objekt sein")
|
||||||
|
|
||||||
|
request_id = data.get("id")
|
||||||
|
if isinstance(request_id, bool) or (
|
||||||
|
request_id is not None and not isinstance(request_id, (int, str))
|
||||||
|
):
|
||||||
|
raise ProtocolError("id muss Zahl oder Text sein")
|
||||||
|
|
||||||
|
command = data.get("cmd")
|
||||||
|
if not isinstance(command, str) or (
|
||||||
|
command not in APP_COMMANDS and command not in MEDIA_COMMANDS
|
||||||
|
):
|
||||||
|
raise ProtocolError(f"unbekannter Befehl: {command!r}")
|
||||||
|
|
||||||
|
target = data.get("target", "auto")
|
||||||
|
if not isinstance(target, str) or target not in TARGETS:
|
||||||
|
raise ProtocolError(f"unbekanntes Ziel: {target!r}")
|
||||||
|
|
||||||
|
playlist = None
|
||||||
|
if command == "play_playlist":
|
||||||
|
playlist = data.get("playlist")
|
||||||
|
if not isinstance(playlist, str) or not PLAYLIST_ID_RE.fullmatch(playlist):
|
||||||
|
raise ProtocolError("play_playlist braucht eine gueltige Playlist-ID")
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
id=request_id,
|
||||||
|
command=command,
|
||||||
|
target=target,
|
||||||
|
value=_parse_value(command, data),
|
||||||
|
playlist=playlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value(command: str, data: dict) -> float:
|
||||||
|
value_range = _VALUE_RANGES.get(command)
|
||||||
|
if value_range is None:
|
||||||
|
return 0
|
||||||
|
raw = data.get("value")
|
||||||
|
# bool ist in Python ein int - true/false sind hier aber kein sinnvoller Wert.
|
||||||
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw):
|
||||||
|
raise ProtocolError(f"{command} braucht einen Zahlenwert")
|
||||||
|
low, high = value_range
|
||||||
|
if not low <= raw <= high:
|
||||||
|
raise ProtocolError(f"{command}: Wert muss zwischen {low:g} und {high:g} liegen")
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
Reply = Callable[[dict], None]
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(request: Request, controller: RemoteController, reply: Reply) -> None:
|
||||||
|
"""Fuehrt eine gueltige Anfrage aus und meldet die Antwort (ohne id) ueber `reply` -
|
||||||
|
sofort, oder bei list_playlists sobald die Seite geantwortet hat."""
|
||||||
|
if request.command == "status":
|
||||||
|
reply({"ok": True, "state": controller.remote_state()})
|
||||||
|
return
|
||||||
|
if request.command == "list_playlists":
|
||||||
|
controller.remote_list_playlists(
|
||||||
|
lambda raw: reply({"ok": True, "playlists": sanitize_playlists(raw)})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if request.command == "show":
|
||||||
|
controller.remote_show()
|
||||||
|
elif request.command == "switch_tab":
|
||||||
|
controller.remote_switch_tab(request.target)
|
||||||
|
elif request.command == "play_playlist":
|
||||||
|
controller.remote_play_playlist(request.playlist)
|
||||||
|
else:
|
||||||
|
controller.remote_media_command(request.target, request.command, request.value)
|
||||||
|
reply({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_playlists(raw: Any) -> list[dict]:
|
||||||
|
"""Bereinigt die von der Seite gelieferte Liste (fremde Daten!): nur gueltige IDs,
|
||||||
|
Titel als gekuerzter Text, ohne Duplikate, "Titel, die ich mag" immer zuerst."""
|
||||||
|
result = [dict(LIKED_MUSIC)]
|
||||||
|
seen = {LIKED_MUSIC["id"]}
|
||||||
|
for item in raw if isinstance(raw, list) else []:
|
||||||
|
if len(result) >= MAX_PLAYLISTS:
|
||||||
|
break
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
playlist_id, title = item.get("id"), item.get("title")
|
||||||
|
if not isinstance(playlist_id, str) or not PLAYLIST_ID_RE.fullmatch(playlist_id):
|
||||||
|
continue
|
||||||
|
if playlist_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(playlist_id)
|
||||||
|
clean_title = " ".join(title.split())[:MAX_PLAYLIST_TITLE] if isinstance(title, str) else ""
|
||||||
|
result.append({"id": playlist_id, "title": clean_title or playlist_id})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target(target: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> str:
|
||||||
|
"""Welcher Tab einen Medienbefehl bekommt. "auto": der Tab, der gerade abspielt
|
||||||
|
(spielen beide, der sichtbare), sonst der sichtbare Browser-Tab, sonst der mit einem
|
||||||
|
Video, sonst YouTube."""
|
||||||
|
if target in TAB_NAMES:
|
||||||
|
return target
|
||||||
|
playing = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("playing")]
|
||||||
|
if len(playing) == 1:
|
||||||
|
return playing[0]
|
||||||
|
if visible_tab in TAB_NAMES:
|
||||||
|
return visible_tab
|
||||||
|
with_video = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("hasVideo")]
|
||||||
|
return with_video[0] if with_video else TAB_NAMES[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_state(version: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> dict:
|
||||||
|
"""Status-Antwort: pro Tab die oeffentlichen Probe-Felder plus der "auto"-Zieltab."""
|
||||||
|
public_tabs = {}
|
||||||
|
for name in TAB_NAMES:
|
||||||
|
info = tabs.get(name)
|
||||||
|
public_tabs[name] = {key: info.get(key) for key in _STATE_FIELDS} if info else None
|
||||||
|
return {
|
||||||
|
"protocol": PROTOCOL_VERSION,
|
||||||
|
"version": version,
|
||||||
|
"visibleTab": visible_tab,
|
||||||
|
"activeTab": resolve_target("auto", visible_tab, tabs),
|
||||||
|
"tabs": public_tabs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_response(request_id: Any, response: dict) -> bytes:
|
||||||
|
return (json.dumps({"id": request_id, **response}, ensure_ascii=False) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def process_line(line: str, controller: RemoteController, write: Callable[[bytes], None]) -> None:
|
||||||
|
"""Eine Anfragezeile -> genau eine kodierte Antwortzeile ueber `write`. Wirft nie."""
|
||||||
|
try:
|
||||||
|
request = parse_request(line)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
write(encode_response(None, {"ok": False, "error": str(exc)}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
handle_request(request, controller, lambda response: write(encode_response(request.id, response)))
|
||||||
|
except Exception as exc: # noqa: BLE001 - ein Steuerbefehl darf die App nie abstuerzen lassen
|
||||||
|
log_exception(f"remote_control: {request.command}", exc)
|
||||||
|
write(encode_response(request.id, {"ok": False, "error": "interner Fehler"}))
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteControlServer(QObject):
|
||||||
|
"""Lauscht auf der Named Pipe und beantwortet Anfragen im Qt-Hauptthread (die
|
||||||
|
Controller-Methoden fassen Widgets an und duerfen nur dort laufen)."""
|
||||||
|
|
||||||
|
def __init__(self, key: str, controller: RemoteController, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._key = key
|
||||||
|
self._controller = controller
|
||||||
|
self._server: QLocalServer | None = None
|
||||||
|
self._buffers: dict[QLocalSocket, bytearray] = {}
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
server = QLocalServer(self)
|
||||||
|
# Nur der angemeldete Benutzer darf verbinden (Windows: Pipe-ACL).
|
||||||
|
server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
|
||||||
|
if not server.listen(self._key):
|
||||||
|
log_exception("remote_control: listen", RuntimeError(server.errorString()))
|
||||||
|
return False
|
||||||
|
server.newConnection.connect(self._on_new_connection)
|
||||||
|
self._server = server
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_new_connection(self) -> None:
|
||||||
|
while self._server is not None and self._server.hasPendingConnections():
|
||||||
|
socket = self._server.nextPendingConnection()
|
||||||
|
if len(self._buffers) >= MAX_CLIENTS:
|
||||||
|
socket.disconnectFromServer()
|
||||||
|
socket.deleteLater()
|
||||||
|
continue
|
||||||
|
self._buffers[socket] = bytearray()
|
||||||
|
socket.readyRead.connect(lambda s=socket: self._on_ready_read(s))
|
||||||
|
socket.disconnected.connect(lambda s=socket: self._on_disconnected(s))
|
||||||
|
|
||||||
|
def _on_ready_read(self, socket: QLocalSocket) -> None:
|
||||||
|
buffer = self._buffers.get(socket)
|
||||||
|
if buffer is None:
|
||||||
|
return
|
||||||
|
buffer.extend(bytes(socket.readAll().data()))
|
||||||
|
while (newline := buffer.find(b"\n")) >= 0:
|
||||||
|
raw = bytes(buffer[:newline])
|
||||||
|
del buffer[: newline + 1]
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if line:
|
||||||
|
process_line(line, self._controller, lambda data, s=socket: self._write(s, data))
|
||||||
|
if len(buffer) > MAX_LINE_BYTES:
|
||||||
|
# Kein Zeilenende in Sicht: Client verhaelt sich falsch -> trennen.
|
||||||
|
buffer.clear()
|
||||||
|
socket.write(encode_response(None, {"ok": False, "error": "Nachricht zu lang"}))
|
||||||
|
socket.disconnectFromServer()
|
||||||
|
|
||||||
|
def _write(self, socket: QLocalSocket, data: bytes) -> None:
|
||||||
|
# Asynchrone Antworten (list_playlists) koennen nach dem Trennen eintreffen.
|
||||||
|
if socket in self._buffers:
|
||||||
|
socket.write(data)
|
||||||
|
|
||||||
|
def _on_disconnected(self, socket: QLocalSocket) -> None:
|
||||||
|
self._buffers.pop(socket, None)
|
||||||
|
socket.deleteLater()
|
||||||
+183
-4
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtMultimedia import QMediaDevices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
@@ -14,6 +16,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -21,15 +24,43 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
|
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
|
||||||
from .config import APP_NAME, save_config
|
from .config import APP_NAME, save_config
|
||||||
from .updater import GITHUB_REPO
|
from .updater import GITHUB_REPO
|
||||||
|
|
||||||
|
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
|
||||||
|
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
|
||||||
|
# palette(mid) war ein dunkles Grau, das auf dem dunklen Hintergrund kaum lesbar war.
|
||||||
|
_LIGHT_TEXT_STYLE = "color: palette(text);"
|
||||||
|
|
||||||
|
# Update-Fortschrittsbalken: helle Beschriftung und gut sichtbarer Balken. Sobald ein
|
||||||
|
# Stylesheet gesetzt ist, zeichnet Qt den Balken nicht mehr mit dem nativen Stil - deshalb
|
||||||
|
# hier Rahmen, Hintergrund und Balken (chunk) vollstaendig angegeben.
|
||||||
|
_PROGRESS_BAR_STYLE = """
|
||||||
|
QProgressBar {
|
||||||
|
color: palette(text);
|
||||||
|
background-color: palette(base);
|
||||||
|
border: 1px solid palette(mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #2f81f7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class SettingsTab(QWidget):
|
class SettingsTab(QWidget):
|
||||||
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
||||||
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()
|
||||||
|
# Neue Beschreibung, was gerade bearbeitet wird (fuer die Discord-Presence, siehe
|
||||||
|
# MainWindow._update_discord_presence).
|
||||||
|
activityChanged = Signal(str)
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any], parent=None):
|
def __init__(self, config: dict[str, Any], parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -45,7 +76,7 @@ class SettingsTab(QWidget):
|
|||||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
version_label = QLabel(f"Version {APP_VERSION}")
|
version_label = QLabel(f"Version {APP_VERSION}")
|
||||||
version_label.setStyleSheet("color: palette(mid);")
|
version_label.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
header.addWidget(version_label)
|
header.addWidget(version_label)
|
||||||
outer.addLayout(header)
|
outer.addLayout(header)
|
||||||
|
|
||||||
@@ -57,9 +88,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,8 +112,57 @@ 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(_LIGHT_TEXT_STYLE)
|
||||||
|
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.setStyleSheet(_PROGRESS_BAR_STYLE)
|
||||||
|
self._update_progress.setVisible(False)
|
||||||
|
update_form.addRow(self._update_progress)
|
||||||
|
|
||||||
outer.addWidget(update_box)
|
outer.addWidget(update_box)
|
||||||
|
|
||||||
|
audio_cfg = config.get("audio", {})
|
||||||
|
audio_box = QGroupBox("Audioausgabe")
|
||||||
|
audio_form = QFormLayout(audio_box)
|
||||||
|
self._youtube_output = QComboBox()
|
||||||
|
self._music_output = QComboBox()
|
||||||
|
audio_form.addRow("YouTube:", self._youtube_output)
|
||||||
|
audio_form.addRow("YouTube Musik:", self._music_output)
|
||||||
|
audio_hint = QLabel(
|
||||||
|
"Legt den Ton von YouTube und YouTube Musik auf verschiedene Ausgabegeräte, z. B. "
|
||||||
|
"auf getrennte Sonar-Kanäle. Windows zeigt beide unter „Playtube“ - getrennt wird "
|
||||||
|
"über das Gerät. Damit die Seite das Gerät finden kann, erlaubt Playtube ihr dafür "
|
||||||
|
"den Zugriff auf die Audiogeräte-Namen (es wird nichts aufgenommen); die Freigabe "
|
||||||
|
"gilt nur, solange hier ein eigenes Gerät gewählt ist."
|
||||||
|
)
|
||||||
|
audio_hint.setWordWrap(True)
|
||||||
|
audio_hint.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
|
audio_form.addRow(audio_hint)
|
||||||
|
outer.addWidget(audio_box)
|
||||||
|
|
||||||
|
# QMediaDevices meldet, wenn Geraete ein-/ausgesteckt werden -> Listen aktuell halten.
|
||||||
|
self._media_devices = QMediaDevices(self)
|
||||||
|
self._fill_output_combo(self._youtube_output, str(audio_cfg.get("youtube_output", "") or ""))
|
||||||
|
self._fill_output_combo(self._music_output, str(audio_cfg.get("music_output", "") or ""))
|
||||||
|
self._media_devices.audioOutputsChanged.connect(self._reload_output_devices)
|
||||||
|
|
||||||
general_box = QGroupBox("Allgemein")
|
general_box = QGroupBox("Allgemein")
|
||||||
general_form = QFormLayout(general_box)
|
general_form = QFormLayout(general_box)
|
||||||
self._start_tab = QComboBox()
|
self._start_tab = QComboBox()
|
||||||
@@ -99,11 +181,68 @@ class SettingsTab(QWidget):
|
|||||||
outer.addLayout(button_row)
|
outer.addLayout(button_row)
|
||||||
|
|
||||||
info = QLabel(f"github.com/{GITHUB_REPO}")
|
info = QLabel(f"github.com/{GITHUB_REPO}")
|
||||||
info.setStyleSheet("color: palette(mid);")
|
info.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
outer.addWidget(info)
|
outer.addWidget(info)
|
||||||
|
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
|
|
||||||
|
# Beschreibung je Eingabefeld fuer die Discord-Presence ("was wird bearbeitet").
|
||||||
|
# Bewusst nur Feld-NAMEN, nie Inhalte (z.B. nicht die Client-ID selbst).
|
||||||
|
self._activity_by_widget: dict[QWidget, str] = {
|
||||||
|
self._discord_enabled: "Bearbeitet: Discord Rich Presence › Aktiviert",
|
||||||
|
self._client_id: "Bearbeitet: Discord Rich Presence › Client-ID",
|
||||||
|
self._discord_interval: "Bearbeitet: Discord Rich Presence › Update-Intervall",
|
||||||
|
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
|
||||||
|
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
|
||||||
|
self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall",
|
||||||
|
self._youtube_output: "Bearbeitet: Audioausgabe › YouTube",
|
||||||
|
self._music_output: "Bearbeitet: Audioausgabe › YouTube Musik",
|
||||||
|
self._check_updates_btn: "Sucht nach Updates",
|
||||||
|
self._install_update_btn: "Installiert ein Update",
|
||||||
|
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
|
||||||
|
save_btn: "Speichert die Einstellungen",
|
||||||
|
}
|
||||||
|
self._current_activity: str | None = None
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.focusChanged.connect(self._on_focus_changed)
|
||||||
|
|
||||||
|
def _fill_output_combo(self, combo: QComboBox, saved: str) -> None:
|
||||||
|
"""Fuellt eine Ausgabe-Auswahl: "Systemstandard" + alle vorhandenen Geraete. Ist das
|
||||||
|
gespeicherte Geraet gerade nicht angeschlossen, bleibt es (als "nicht verfuegbar")
|
||||||
|
ausgewaehlt, statt die Einstellung stillschweigend zu verlieren."""
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.clear()
|
||||||
|
combo.addItem(SYSTEM_DEFAULT_LABEL, "")
|
||||||
|
names = list_output_devices()
|
||||||
|
for name in names:
|
||||||
|
combo.addItem(name, name)
|
||||||
|
if saved and saved not in names:
|
||||||
|
combo.addItem(f"{saved} (nicht verfügbar)", saved)
|
||||||
|
combo.setCurrentIndex(max(0, combo.findData(saved)))
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _reload_output_devices(self) -> None:
|
||||||
|
for combo in (self._youtube_output, self._music_output):
|
||||||
|
self._fill_output_combo(combo, combo.currentData() or "")
|
||||||
|
|
||||||
|
def current_activity(self) -> str | None:
|
||||||
|
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
|
||||||
|
return self._current_activity
|
||||||
|
|
||||||
|
def _on_focus_changed(self, _old: QWidget | None, new: QWidget | None) -> None:
|
||||||
|
# Vom fokussierten Widget nach oben suchen: bei QSpinBox/QComboBox liegt der Fokus
|
||||||
|
# auf einem internen Kind-Widget (z.B. dem Textfeld des Spinners).
|
||||||
|
widget = new
|
||||||
|
while widget is not None and widget is not self:
|
||||||
|
activity = self._activity_by_widget.get(widget)
|
||||||
|
if activity:
|
||||||
|
if activity != self._current_activity:
|
||||||
|
self._current_activity = activity
|
||||||
|
self.activityChanged.emit(activity)
|
||||||
|
return
|
||||||
|
widget = widget.parentWidget()
|
||||||
|
|
||||||
def _on_save(self) -> None:
|
def _on_save(self) -> None:
|
||||||
self._config.setdefault("discord", {})
|
self._config.setdefault("discord", {})
|
||||||
self._config.setdefault("updates", {})
|
self._config.setdefault("updates", {})
|
||||||
@@ -116,6 +255,10 @@ class SettingsTab(QWidget):
|
|||||||
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
||||||
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
||||||
|
|
||||||
|
self._config.setdefault("audio", {})
|
||||||
|
self._config["audio"]["youtube_output"] = self._youtube_output.currentData() or ""
|
||||||
|
self._config["audio"]["music_output"] = self._music_output.currentData() or ""
|
||||||
|
|
||||||
self._config["start_tab"] = self._start_tab.currentData()
|
self._config["start_tab"] = self._start_tab.currentData()
|
||||||
|
|
||||||
save_config(self._config)
|
save_config(self._config)
|
||||||
@@ -126,3 +269,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
+415
-53
@@ -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
|
||||||
@@ -34,11 +54,25 @@ from PySide6.QtCore import QThread, Signal
|
|||||||
from . import __version__ as CURRENT_VERSION
|
from . import __version__ as CURRENT_VERSION
|
||||||
|
|
||||||
GITHUB_REPO = "fojadrachi/Playtube"
|
GITHUB_REPO = "fojadrachi/Playtube"
|
||||||
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
|
||||||
_USER_AGENT = "Playtube-Updater"
|
# Qt-Version sie nie als "neueste Version" sieht) - deshalb die Liste statt /releases/latest.
|
||||||
|
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=30"
|
||||||
|
_EDGE_TAG_SUFFIX = "-edge"
|
||||||
|
_USER_AGENT = "PlaytubeEdge-Updater"
|
||||||
|
|
||||||
_LINUX_BINARY_NAME = "Playtube"
|
_LINUX_BINARY_NAME = "PlaytubeEdge"
|
||||||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
_WINDOWS_BINARY_NAME = "PlaytubeEdge.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 = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
|
||||||
|
|
||||||
|
_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, ...]:
|
||||||
@@ -54,35 +88,128 @@ 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"}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
releases = json.loads(resp.read().decode("utf-8"))
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
if not isinstance(releases, list):
|
||||||
|
return None
|
||||||
|
# Neueste Edge-Version = hoechste Versionsnummer unter den "-edge"-Releases (ohne Entwuerfe).
|
||||||
|
edge_releases = [
|
||||||
|
release for release in releases
|
||||||
|
if isinstance(release, dict) and not release.get("draft")
|
||||||
|
and str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX)
|
||||||
|
]
|
||||||
|
return max(edge_releases, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_installed_via_setup() -> bool:
|
||||||
|
"""True, wenn diese Playtube.exe aus einer Installation durch Playtube-Setup stammt
|
||||||
|
(Inno Setup legt im Installationsordner immer unins000.exe ab) - im Gegensatz zu
|
||||||
|
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()
|
||||||
|
# Zweite Absicherung neben dem "-edge"-Tag: nur Dateien der Edge-Variante kommen in Frage,
|
||||||
|
# nie ein Paket der Qt-Version ("Playtube-...").
|
||||||
|
if "playtubeedge" not in name:
|
||||||
|
return None
|
||||||
|
if sys.platform == "win32":
|
||||||
|
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:
|
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""Sucht das zur laufenden Plattform passende Release-Paket:
|
"""Sucht das zur laufenden Plattform passende Release-Paket (Reihenfolge der Arten
|
||||||
Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux" im Namen."""
|
siehe _preferred_kinds). Innerhalb einer Art wird ein Asset mit Plattformhinweis
|
||||||
assets = release.get("assets", [])
|
("win"/"linux") im Namen bevorzugt."""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
hints, exts = ("win",), (".zip",)
|
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 +239,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,6 +270,20 @@ class UpdateInstaller(QThread):
|
|||||||
# ---------------------------------------------------------- gepackter Modus
|
# ---------------------------------------------------------- gepackter Modus
|
||||||
|
|
||||||
def _run_packaged_update(self) -> None:
|
def _run_packaged_update(self) -> None:
|
||||||
|
install_dir = Path(sys.executable).resolve().parent
|
||||||
|
cleanup_old_staging()
|
||||||
|
staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
|
||||||
|
extract_dir = staging / "extracted"
|
||||||
|
|
||||||
|
if self._local_archive_path:
|
||||||
|
archive_path = Path(self._local_archive_path)
|
||||||
|
if not archive_path.exists():
|
||||||
|
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
|
||||||
|
return
|
||||||
|
archive_name = archive_path.name
|
||||||
|
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
|
||||||
|
self.progress_percent.emit(-1)
|
||||||
|
else:
|
||||||
if not self._download_url:
|
if not self._download_url:
|
||||||
self.failed.emit(
|
self.failed.emit(
|
||||||
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
|
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
|
||||||
@@ -138,51 +291,273 @@ class UpdateInstaller(QThread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.progress.emit("Lade Update herunter …")
|
self.progress.emit("Lade Update herunter …")
|
||||||
install_dir = Path(sys.executable).resolve().parent
|
self.progress_percent.emit(0)
|
||||||
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
|
||||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||||
archive_path = staging / archive_name
|
archive_path = staging / archive_name
|
||||||
extract_dir = staging / "extracted"
|
|
||||||
|
|
||||||
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
||||||
with urllib.request.urlopen(req, timeout=120) as resp, open(archive_path, "wb") as out:
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
shutil.copyfileobj(resp, out)
|
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)
|
||||||
|
|
||||||
|
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
|
||||||
|
self.progress.emit("Bereite Installation vor …")
|
||||||
|
|
||||||
|
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:
|
||||||
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
# Release-Archive enthalten meist einen einzelnen Unterordner (z.B. "Playtube/").
|
||||||
entries = list(extract_dir.iterdir())
|
entries = list(extract_dir.iterdir())
|
||||||
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
|
source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir
|
||||||
|
|
||||||
self.progress.emit("Bereite Installation vor …")
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
self._install_windows(source_dir, install_dir, staging)
|
self._install_full_windows(source_dir, install_dir, staging)
|
||||||
else:
|
else:
|
||||||
self._install_posix(source_dir, install_dir, staging)
|
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.
|
||||||
|
|
||||||
|
/WAITPID=<eigene PID>: Setup wartet, bis sich diese App wirklich beendet hat,
|
||||||
|
bevor es Dateien tauscht. /LOG (ohne Pfad): Setup schreibt ein Protokoll nach
|
||||||
|
%TEMP% ("Setup Log ....txt") - so laesst sich ein fehlgeschlagenes Update
|
||||||
|
nachtraeglich nachvollziehen, obwohl die Installation selbst still laeuft."""
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
str(setup_path),
|
||||||
|
"/SILENT",
|
||||||
|
"/SUPPRESSMSGBOXES",
|
||||||
|
"/NORESTART",
|
||||||
|
"/NOCANCEL",
|
||||||
|
"/RELAUNCH=1",
|
||||||
|
f"/WAITPID={os.getpid()}",
|
||||||
|
"/LOG",
|
||||||
|
],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
|
||||||
|
|
||||||
|
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.
|
||||||
|
# /R:3 /W:2 begrenzt die Wiederholungen bei gesperrten Dateien (Standard: praktisch endlos,
|
||||||
|
# das Update haengt dann sichtbar "fuer immer" im Fortschrittsfenster).
|
||||||
|
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
||||||
|
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
||||||
|
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 +565,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)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Laedt Microsoft Edge WebView2 (per pythonnet / .NET Framework) fuer den Browser-Tab.
|
||||||
|
|
||||||
|
Die DLLs (Microsoft.Web.WebView2.Core/WinForms, WebView2Loader) liegen im Projektordner
|
||||||
|
vendor/webview2 (offizielles NuGet-Paket "Microsoft.Web.WebView2"). Die eigentliche
|
||||||
|
Browser-Engine ist die auf Windows 10/11 vorinstallierte "WebView2 Runtime" (Edge) - sie
|
||||||
|
bringt AAC/H.264 mit, die in QtWebEngine fehlen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
class WebView2Unavailable(RuntimeError):
|
||||||
|
"""WebView2 konnte nicht geladen werden (DLLs oder Runtime fehlen)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _app_base_dir() -> Path:
|
||||||
|
return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_dir() -> Path:
|
||||||
|
return _app_base_dir() / "vendor" / "webview2"
|
||||||
|
|
||||||
|
|
||||||
|
def _unblock_downloaded_files(folders: tuple[Path, ...]) -> None:
|
||||||
|
"""Entfernt die "Mark of the Web" (Zone.Identifier) von den DLLs, die .NET laden soll.
|
||||||
|
|
||||||
|
Wer die ZIP aus dem Internet laedt und entpackt, bekommt alle Dateien mit dieser
|
||||||
|
Markierung; .NET verweigert dann das Laden von Python.Runtime.dll ("Failed to resolve
|
||||||
|
Python.Runtime.Loader.Initialize"). Die Dateien liegen im eigenen App-Ordner - das
|
||||||
|
Entfernen der Markierung betrifft nur sie."""
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
for folder in folders:
|
||||||
|
for path in folder.rglob("*"):
|
||||||
|
if path.suffix.lower() in (".dll", ".exe"):
|
||||||
|
try:
|
||||||
|
os.remove(f"{path}:Zone.Identifier")
|
||||||
|
except OSError:
|
||||||
|
pass # keine Markierung vorhanden (Normalfall) oder Dateisystem ohne Datenstroeme
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load() -> SimpleNamespace:
|
||||||
|
"""Laedt .NET + WebView2 einmalig und gibt die benoetigten Klassen zurueck."""
|
||||||
|
vendor = _vendor_dir()
|
||||||
|
core_dll = vendor / "Microsoft.Web.WebView2.Core.dll"
|
||||||
|
winforms_dll = vendor / "Microsoft.Web.WebView2.WinForms.dll"
|
||||||
|
if not core_dll.exists() or not winforms_dll.exists():
|
||||||
|
raise WebView2Unavailable(f"WebView2-DLLs fehlen in {vendor}")
|
||||||
|
|
||||||
|
base = _app_base_dir()
|
||||||
|
_unblock_downloaded_files((vendor, base / "pythonnet", base / "clr_loader"))
|
||||||
|
|
||||||
|
# WebView2Loader.dll (nativ) wird ueber den Suchpfad bzw. runtimes/win-x64/native gefunden.
|
||||||
|
os.environ["PATH"] = f"{vendor}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||||
|
try:
|
||||||
|
from pythonnet import load as load_runtime
|
||||||
|
|
||||||
|
load_runtime("netfx")
|
||||||
|
import clr
|
||||||
|
|
||||||
|
clr.AddReference("System.Windows.Forms")
|
||||||
|
clr.AddReference("System.Drawing")
|
||||||
|
clr.AddReference(str(core_dll))
|
||||||
|
clr.AddReference(str(winforms_dll))
|
||||||
|
|
||||||
|
from Microsoft.Web.WebView2.Core import (
|
||||||
|
CoreWebView2PermissionKind,
|
||||||
|
CoreWebView2PermissionState,
|
||||||
|
)
|
||||||
|
from Microsoft.Web.WebView2.WinForms import CoreWebView2CreationProperties, WebView2
|
||||||
|
except Exception as error: # pythonnet wirft je nach Fehler unterschiedliche Typen
|
||||||
|
raise WebView2Unavailable(f"WebView2 konnte nicht geladen werden: {error}") from error
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
WebView2=WebView2,
|
||||||
|
CreationProperties=CoreWebView2CreationProperties,
|
||||||
|
PermissionKind=CoreWebView2PermissionKind,
|
||||||
|
PermissionState=CoreWebView2PermissionState,
|
||||||
|
)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
PySide6>=6.10
|
PySide6>=6.10
|
||||||
pypresence>=4.3
|
pypresence>=4.3
|
||||||
|
pythonnet>=3.1
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Tests fuer die lokale Fernsteuerung (playtube/remote_control.py, media_control.py).
|
||||||
|
|
||||||
|
Start: .venv\\Scripts\\python -m unittest discover -s tests -v
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from playtube.media_control import MEDIA_COMMANDS, build_control_js # noqa: E402
|
||||||
|
from playtube.remote_control import ( # noqa: E402
|
||||||
|
ProtocolError,
|
||||||
|
Request,
|
||||||
|
build_state,
|
||||||
|
handle_request,
|
||||||
|
parse_request,
|
||||||
|
process_line,
|
||||||
|
resolve_target,
|
||||||
|
sanitize_playlists,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeController:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
def remote_state(self) -> dict:
|
||||||
|
return {"protocol": 1}
|
||||||
|
|
||||||
|
def remote_show(self) -> None:
|
||||||
|
self.calls.append(("show",))
|
||||||
|
|
||||||
|
def remote_switch_tab(self, target: str) -> None:
|
||||||
|
self.calls.append(("switch_tab", target))
|
||||||
|
|
||||||
|
def remote_media_command(self, target: str, command: str, value: float) -> None:
|
||||||
|
self.calls.append(("media", target, command, value))
|
||||||
|
|
||||||
|
def remote_list_playlists(self, callback) -> None:
|
||||||
|
self.pending_playlist_callback = callback
|
||||||
|
|
||||||
|
def remote_play_playlist(self, playlist_id: str) -> None:
|
||||||
|
self.calls.append(("play_playlist", playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
def run(request: Request, controller: FakeController) -> list[dict]:
|
||||||
|
replies: list[dict] = []
|
||||||
|
handle_request(request, controller, replies.append)
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
def run_line(line: str, controller: FakeController) -> list[dict]:
|
||||||
|
written: list[bytes] = []
|
||||||
|
process_line(line, controller, written.append)
|
||||||
|
return [json.loads(chunk) for chunk in written]
|
||||||
|
|
||||||
|
|
||||||
|
class ParseRequestTests(unittest.TestCase):
|
||||||
|
def test_parses_media_command_with_default_target(self):
|
||||||
|
request = parse_request('{"id": 7, "cmd": "play_pause"}')
|
||||||
|
self.assertEqual(request, Request(id=7, command="play_pause", target="auto", value=0))
|
||||||
|
|
||||||
|
def test_parses_ranged_value(self):
|
||||||
|
request = parse_request('{"cmd": "seek", "target": "music", "value": -10}')
|
||||||
|
self.assertEqual((request.target, request.value), ("music", -10.0))
|
||||||
|
|
||||||
|
def test_ignores_value_for_commands_without_range(self):
|
||||||
|
self.assertEqual(parse_request('{"cmd": "next", "value": "evil()"}').value, 0)
|
||||||
|
|
||||||
|
def test_rejects_invalid_input(self):
|
||||||
|
bad_lines = [
|
||||||
|
"not json",
|
||||||
|
"[1, 2]",
|
||||||
|
'{"cmd": "eval"}',
|
||||||
|
'{"cmd": ["play_pause"]}',
|
||||||
|
'{"cmd": "next", "target": "https://example.com"}',
|
||||||
|
'{"cmd": "next", "id": {"x": 1}}',
|
||||||
|
'{"cmd": "next", "id": true}',
|
||||||
|
'{"cmd": "set_volume"}',
|
||||||
|
'{"cmd": "set_volume", "value": "50"}',
|
||||||
|
'{"cmd": "set_volume", "value": true}',
|
||||||
|
'{"cmd": "set_volume", "value": 101}',
|
||||||
|
'{"cmd": "seek", "value": 99999}',
|
||||||
|
'{"cmd": "volume_change", "value": NaN}',
|
||||||
|
'{"cmd": "play_playlist"}',
|
||||||
|
'{"cmd": "play_playlist", "playlist": "x"}',
|
||||||
|
'{"cmd": "play_playlist", "playlist": "PL1&autoplay=0"}',
|
||||||
|
'{"cmd": "play_playlist", "playlist": "../evil"}',
|
||||||
|
'{"cmd": "play_playlist", "playlist": 123}',
|
||||||
|
]
|
||||||
|
for line in bad_lines:
|
||||||
|
with self.subTest(line=line), self.assertRaises(ProtocolError):
|
||||||
|
parse_request(line)
|
||||||
|
|
||||||
|
|
||||||
|
class HandleRequestTests(unittest.TestCase):
|
||||||
|
def test_status_returns_state(self):
|
||||||
|
replies = run(Request(id=1, command="status"), FakeController())
|
||||||
|
self.assertEqual(replies, [{"ok": True, "state": {"protocol": 1}}])
|
||||||
|
|
||||||
|
def test_dispatches_app_and_media_commands(self):
|
||||||
|
controller = FakeController()
|
||||||
|
run(Request(id=1, command="show"), controller)
|
||||||
|
run(Request(id=2, command="switch_tab", target="music"), controller)
|
||||||
|
run(Request(id=3, command="volume_change", target="youtube", value=5), controller)
|
||||||
|
run(parse_request('{"cmd": "play_playlist", "playlist": "PLabc_-1"}'), controller)
|
||||||
|
self.assertEqual(
|
||||||
|
controller.calls,
|
||||||
|
[
|
||||||
|
("show",),
|
||||||
|
("switch_tab", "music"),
|
||||||
|
("media", "youtube", "volume_change", 5),
|
||||||
|
("play_playlist", "PLabc_-1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_playlists_replies_when_page_answers(self):
|
||||||
|
controller = FakeController()
|
||||||
|
written: list[bytes] = []
|
||||||
|
process_line('{"id": 9, "cmd": "list_playlists"}', controller, written.append)
|
||||||
|
self.assertEqual(written, []) # Seite hat noch nicht geantwortet
|
||||||
|
|
||||||
|
controller.pending_playlist_callback([{"id": "PL1", "title": "Mix"}])
|
||||||
|
self.assertEqual(
|
||||||
|
[json.loads(chunk) for chunk in written],
|
||||||
|
[{"id": 9, "ok": True, "playlists": [
|
||||||
|
{"id": "LM", "title": "Titel, die ich mag"},
|
||||||
|
{"id": "PL1", "title": "Mix"},
|
||||||
|
]}],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_process_line_reports_protocol_errors_without_raising(self):
|
||||||
|
[reply] = run_line('{"cmd": "nope"}', FakeController())
|
||||||
|
self.assertFalse(reply["ok"])
|
||||||
|
self.assertIsNone(reply["id"])
|
||||||
|
|
||||||
|
def test_process_line_hides_internal_errors(self):
|
||||||
|
class Broken(FakeController):
|
||||||
|
def remote_show(self) -> None:
|
||||||
|
raise RuntimeError("secret detail")
|
||||||
|
|
||||||
|
[reply] = run_line('{"id": 4, "cmd": "show"}', Broken())
|
||||||
|
self.assertEqual(reply, {"id": 4, "ok": False, "error": "interner Fehler"})
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizePlaylistsTests(unittest.TestCase):
|
||||||
|
def test_keeps_valid_entries_and_puts_liked_music_first(self):
|
||||||
|
raw = [
|
||||||
|
{"id": "PL1", "title": " Mein Mix "},
|
||||||
|
{"id": "LM", "title": "Liked music"},
|
||||||
|
{"id": "PL1", "title": "Duplikat"},
|
||||||
|
{"id": "bad id!", "title": "x"},
|
||||||
|
{"id": "PL2", "title": None},
|
||||||
|
"kein Objekt",
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
sanitize_playlists(raw),
|
||||||
|
[
|
||||||
|
{"id": "LM", "title": "Titel, die ich mag"},
|
||||||
|
{"id": "PL1", "title": "Mein Mix"},
|
||||||
|
{"id": "PL2", "title": "PL2"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handles_garbage_and_limits_size(self):
|
||||||
|
self.assertEqual(len(sanitize_playlists(None)), 1)
|
||||||
|
many = [{"id": f"PL{i:03d}", "title": "t" * 500} for i in range(500)]
|
||||||
|
result = sanitize_playlists(many)
|
||||||
|
self.assertEqual(len(result), 100)
|
||||||
|
self.assertTrue(all(len(item["title"]) <= 100 for item in result))
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveTargetTests(unittest.TestCase):
|
||||||
|
def test_explicit_target_wins(self):
|
||||||
|
self.assertEqual(resolve_target("music", "youtube", {}), "music")
|
||||||
|
|
||||||
|
def test_auto_prefers_the_playing_tab(self):
|
||||||
|
tabs = {"youtube": {"playing": False}, "music": {"playing": True}}
|
||||||
|
self.assertEqual(resolve_target("auto", "youtube", tabs), "music")
|
||||||
|
|
||||||
|
def test_auto_uses_visible_tab_when_both_or_none_play(self):
|
||||||
|
both = {"youtube": {"playing": True}, "music": {"playing": True}}
|
||||||
|
self.assertEqual(resolve_target("auto", "music", both), "music")
|
||||||
|
self.assertEqual(resolve_target("auto", "youtube", {}), "youtube")
|
||||||
|
|
||||||
|
def test_auto_from_settings_falls_back_to_tab_with_video(self):
|
||||||
|
tabs = {"youtube": {"hasVideo": False}, "music": {"hasVideo": True}}
|
||||||
|
self.assertEqual(resolve_target("auto", "settings", tabs), "music")
|
||||||
|
self.assertEqual(resolve_target("auto", "settings", {}), "youtube")
|
||||||
|
|
||||||
|
|
||||||
|
class BuildStateTests(unittest.TestCase):
|
||||||
|
def test_exposes_only_public_fields(self):
|
||||||
|
tabs = {"youtube": {"title": "T", "playing": True, "url": "https://x"}, "music": None}
|
||||||
|
state = build_state("9.9.9", "youtube", tabs)
|
||||||
|
self.assertEqual(state["activeTab"], "youtube")
|
||||||
|
self.assertIsNone(state["tabs"]["music"])
|
||||||
|
self.assertEqual(state["tabs"]["youtube"]["title"], "T")
|
||||||
|
self.assertNotIn("url", state["tabs"]["youtube"])
|
||||||
|
|
||||||
|
|
||||||
|
class BuildControlJsTests(unittest.TestCase):
|
||||||
|
def test_every_command_builds_a_wrapped_script(self):
|
||||||
|
for command in MEDIA_COMMANDS:
|
||||||
|
with self.subTest(command=command):
|
||||||
|
script = build_control_js(command, 5)
|
||||||
|
self.assertTrue(script.startswith("(function() {"))
|
||||||
|
self.assertNotIn("{value}", script)
|
||||||
|
|
||||||
|
def test_value_is_inserted_as_number(self):
|
||||||
|
self.assertIn("setVolume(42.0)", build_control_js("set_volume", 42))
|
||||||
|
|
||||||
|
def test_rejects_unknown_command_and_non_finite_value(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_control_js("alert", 0)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_control_js("seek", float("inf"))
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == "win32", "Named-Pipe-Test nur unter Windows")
|
||||||
|
class NamedPipeIntegrationTests(unittest.TestCase):
|
||||||
|
"""Echte Named Pipe: Client in einem Thread, Server im Qt-Event-Loop."""
|
||||||
|
|
||||||
|
def test_round_trip_over_named_pipe(self):
|
||||||
|
from PySide6.QtCore import QCoreApplication, QTimer
|
||||||
|
|
||||||
|
from playtube.remote_control import RemoteControlServer
|
||||||
|
|
||||||
|
app = QCoreApplication.instance() or QCoreApplication([])
|
||||||
|
key = f"Playtube.Remote.Test.{uuid.uuid4().hex}"
|
||||||
|
controller = FakeController()
|
||||||
|
server = RemoteControlServer(key, controller)
|
||||||
|
self.assertTrue(server.start())
|
||||||
|
|
||||||
|
replies: list[dict] = []
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
def client() -> None:
|
||||||
|
try:
|
||||||
|
with open(rf"\\.\pipe\{key}", "r+b", buffering=0) as pipe:
|
||||||
|
pipe.write(b'{"id": 1, "cmd": "next"}\n{"id": 2, "cmd": "status"}\n')
|
||||||
|
data = b""
|
||||||
|
while data.count(b"\n") < 2:
|
||||||
|
chunk = pipe.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
replies.extend(json.loads(line) for line in data.splitlines())
|
||||||
|
except BaseException as exc: # noqa: BLE001 - im Hauptthread auswerten
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
QTimer.singleShot(0, app.quit)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=client, daemon=True)
|
||||||
|
QTimer.singleShot(0, thread.start)
|
||||||
|
QTimer.singleShot(5000, app.quit) # Sicherheitsnetz gegen Haengen
|
||||||
|
app.exec()
|
||||||
|
thread.join(2)
|
||||||
|
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(replies, [{"id": 1, "ok": True}, {"id": 2, "ok": True, "state": {"protocol": 1}}])
|
||||||
|
self.assertEqual(controller.calls, [("media", "auto", "next", 0)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
Copyright (C) Microsoft Corporation. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* The name of Microsoft Corporation, or the names of its contributors
|
||||||
|
may not be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
BIN
Binary file not shown.
Binary file not shown.
Vendored
+85
@@ -0,0 +1,85 @@
|
|||||||
|
NOTICES AND INFORMATION
|
||||||
|
Do Not Translate or Localize
|
||||||
|
|
||||||
|
This software incorporates material from third parties. Microsoft makes certain
|
||||||
|
open source code available at https://3rdpartysource.microsoft.com, or you may
|
||||||
|
send a check or money order for US $5.00, including the product name, the open
|
||||||
|
source component name, and version number, to:
|
||||||
|
|
||||||
|
Source Code Compliance Team
|
||||||
|
Microsoft Corporation
|
||||||
|
One Microsoft Way
|
||||||
|
Redmond, WA 98052
|
||||||
|
USA
|
||||||
|
|
||||||
|
Notwithstanding any other terms, you may reverse engineer this software to the
|
||||||
|
extent required to debug changes to any libraries licensed under the GNU Lesser
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
Antlr3.Runtime 3.5.2-rc1 - BSD 3-Clause
|
||||||
|
|
||||||
|
[The "BSD license"]
|
||||||
|
Copyright (c) 2011 The ANTLR Project
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
StringTemplate4 4.0.9-rc1 - BSD 3-Clause
|
||||||
|
|
||||||
|
[The "BSD license"]
|
||||||
|
Copyright (c) 2011 The ANTLR Project
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
---------------------------------------------------------
|
||||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user