15 Commits
Author SHA1 Message Date
Fojadrachi d14272991c fix: Release mit gh statt softprops veroeffentlichen, Notizen aus Datei
Release (Edge) / build-windows (push) Waiting to run
Release (Edge) / publish-release (push) Blocked by required conditions
2026-09-26 20:40:09 +02:00
Fojadrachi bb15ff3316 fix: Mark of the Web von den .NET-DLLs entfernen (ZIP aus dem Internet startete nicht) 2026-09-26 20:31:56 +02:00
Fojadrachi f1bde49cf7 fix: Setup und ZIP als getrennte Artefakte hochladen (Setup fehlte im Release) 2026-09-26 20:27:08 +02:00
Fojadrachi c3e69bd5da feat: Playtube Edge 1.0.0 - WebView2 statt QtWebEngine (eigene Musik/AAC abspielbar)
Eigene Anwendung neben der Qt-Version: PlaytubeEdge.exe, eigene Installer-ID,
eigener Datenordner, Updates nur ueber Releases mit Tag *-edge.
2026-09-26 20:23:24 +02:00
Fojadrachi 6a682fc49c chore: Release v4.5.1
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-25 23:44:05 +02:00
Fojadrachi 33043f1319 feat: lokale Fernsteuerung per Named Pipe fuer das Stream Dock Plugin
Playback, Lautstaerke, Tab-Wechsel sowie list_playlists/play_playlist ueber
\.\pipe\Playtube.Remote.<Name>; Tests inklusive.
2026-09-25 23:43:17 +02:00
Fojadrachi c30efec612 Release v4.5.0 2026-09-19 21:14:37 +02:00
Fojadrachi 3f3f86f597 Audioausgabe pro Tab: YouTube und YouTube Musik auf verschiedene Geraete legen
In den Einstellungen (Gruppe "Audioausgabe", Felder "YouTube" und "YouTube Musik") laesst
sich getrennt waehlen, ueber welches Ausgabegeraet der Ton jedes Tabs laeuft - z.B. auf
getrennte Sonar-Kanaele. Die Auswahl gilt sofort, auch fuer bereits geoeffnete Seiten.

Warum ueber die Seite: QtWebEngine spielt beide Tabs ueber einen gemeinsamen Audio-Prozess
ab, Windows kann sie daher nicht einzeln routen. Chromium kann aber pro Medienelement ein
Geraet waehlen (setSinkId).

- audio_routing.py (neu): Router-Skript (findet das Geraet ueber seinen Namen, kennt den
  Hardware-ID-Suffix von Chromium, behandelt dynamisch erzeugte Elemente, Zielwechsel,
  Zuruecksetzen und fehlende Geraete), Geraeteliste (QMediaDevices), Freigabe der
  Geraetenamen (Chromium blendet sie ohne Mikrofon-Berechtigung aus)
- Freigabe nur solange ein Tab ein eigenes Geraet nutzt, nur fuer die beiden YouTube-
  Seiten, nur fuer die laufende Sitzung (Qt speichert sie nicht) - bei jedem Start und nach
  jedem Seitenaufbau neu erteilt, weil sie auf der YouTube-Startseite sonst nicht greift
- browser: BrowserTab.set_audio_output (Skript pro Seite; ohne Auswahl wird die Seite gar
  nicht angefasst)
- settings_tab: neue Gruppe, Hot-Plug-Aktualisierung, fehlendes Geraet bleibt als "nicht
  verfuegbar" gespeichert, Discord-Anzeige nennt das bearbeitete Feld
- config: audio.youtube_output / audio.music_output (leer = Systemstandard)
- README: Abschnitt inkl. Hinweis, dass Windows weiter einen Eintrag "Playtube" zeigt
2026-09-19 21:14:36 +02:00
Fojadrachi d28b5d05da Discord-Presence: zeigt "In den Einstellungen" und das gerade bearbeitete Feld
Solange der Einstellungen-Tab offen ist, zeigt Discord "In den Einstellungen" mit dem
Feld, das gerade den Fokus hat (z.B. "Bearbeitet: Discord Rich Presence > Client-ID").
Es werden nur Feldnamen uebermittelt, nie Inhalte (z.B. nicht die Client-ID selbst).

- discord_rpc: build_settings_payload + DiscordRPCWorker.submit_settings_activity
- settings_tab: Signal activityChanged, verfolgt den Fokus (auch bei Spinner/Combobox,
  deren Fokus auf einem internen Kind-Widget liegt)
- mainwindow: _update_discord_presence waehlt zwischen Einstellungen, Wiedergabe und
  Leerlauf; Einstellungs-Status folgt der Option "Status anzeigen, wenn gerade nichts
  laeuft" (show_idle_presence); bei Tab-Wechsel und nach dem Speichern aktualisiert
- README: Hinweis zur neuen Anzeige
2026-09-19 20:30:33 +02:00
Fojadrachi 61b38fe6b8 Merge pull request #1 from fojadrachi/Preview
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
Preview
2026-09-19 20:13:33 +02:00
Fojadrachi cf6b6733e4 Release v2.4.0 2026-09-19 20:11:59 +02:00
Fojadrachi 5f93be4325 Update-Fix: Installer beendet sich nicht mehr selbst, Neustart in die neue Version
Ursache: Der Installer beendete laufende Playtube-Prozesse per "taskkill /F /T". Der
Updater startet ihn aus Playtube.exe heraus, er ist also ein Kindprozess - solange
Playtube noch beendet wurde, schoss "/T" den Installer selbst mit ab, bevor er etwas
installiert hatte (kein Kopieren, kein Neustart, nur der Download blieb im Temp-Ordner).

- playtube.iss: taskkill ohne /T; Setup wartet per /WAITPID=<PID> (max. 20 s) darauf, dass
  sich die alte App selbst beendet; uebrig gebliebene QtWebEngine-Prozesse aus dem
  Installationsordner werden gezielt nach Pfad beendet
- updater: uebergibt /WAITPID und /LOG (Setup-Protokoll nach %TEMP%); robocopy im
  ZIP-Fallback mit /R:3 /W:2, damit gesperrte Dateien das Update nicht endlos haengen
- mainwindow: hartes Beenden nach 8 s, falls Qt/QtWebEngine beim Beenden haengt
  (sonst wartet der Update-Helfer ewig und die alte Version bleibt laufen)
2026-09-19 20:11:32 +02:00
Fojadrachi e655cbceea Fix typos in README.md 2026-09-19 19:52:11 +02:00
Fojadrachi 1acd08934e Merge remote-tracking branch 'origin/main' 2026-09-19 19:47:22 +02:00
Fojadrachi 874d6454ec Fix spelling and grammar in README.md 2026-09-19 19:37:51 +02:00
29 changed files with 1996 additions and 441 deletions
+35 -86
View File
@@ -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,14 +35,10 @@ 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: Setup-Installer bauen (Inno Setup) - name: Setup-Installer bauen (Inno Setup)
# Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was
# Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige
# Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in
# packaging/playtube.iss) - keine parallelen Versionsordner mehr.
shell: pwsh shell: pwsh
run: | run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value $version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
@@ -48,94 +49,42 @@ jobs:
& $iscc "/DAppVersion=$version" packaging\playtube.iss & $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } 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 - uses: actions/upload-artifact@v4
with: with:
name: windows-setup name: windows-edge-setup
path: dist/installer/Playtube-Setup-v*.exe path: dist/installer/PlaytubeEdge-Setup-v*.exe
- name: Als .zip packen (volles Paket, portabel) - name: Als .zip packen (portabel)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip 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
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates) publish-release:
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als needs: [build-windows]
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
# Technisch bleibt es ein ganz normales ZIP-Archiv.
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
# installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und
# der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible).
shell: pwsh
run: |
$pyside = python -c "import PySide6; print(PySide6.__version__)"
$name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch"
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
Rename-Item -Path "$name.zip" -NewName "$name.play"
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-*-win64-pyside*-patch.play
build-linux:
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 (volles Paket)
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
- name: Patch-Paket packen (nur Playtube-Binary, fuer schnelle Updates)
run: tar -C dist/Playtube -czf Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz Playtube
- uses: actions/upload-artifact@v4
with:
name: linux-patch
path: Playtube-${{ github.ref_name }}-linux-x86_64-patch.tar.gz
publish-release:
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
View File
@@ -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
+141 -72
View File
@@ -1,20 +1,44 @@
# 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) ## Installation (Windows)
@@ -22,20 +46,20 @@ Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer `Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine - installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
Admin-Rechte noetig) und legt Startmenue-Eintrag (optional Desktop-Verknuepfung) an, Admin-Rechte nötig) und legt Startmenü-Eintrag (optional Desktop-Verknüpfung) an,
- ersetzt bei jeder spaeteren Ausfuehrung die vorhandene Installation **an Ort und - 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 Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
mehrere nebeneinander, mehrere nebeneinander,
- beendet dafuer ein noch laufendes Playtube selbst und raeumt den alten Programmordner - beendet dafür ein noch laufendes Playtube selbst und räumt den alten Programmordner
auf, damit keine Reste alter Versionen uebrig bleiben, auf, damit keine Reste alter Versionen übrig bleiben,
- laesst Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren - lässt Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
ueber "Apps & Features" wird gefragt, ob sie mitgeloescht werden sollen. über "Apps & Features" wird gefragt, ob sie mitgelöscht werden sollen.
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
Setup-Datei wird dafuer nicht erneut von Hand gebraucht. Das ZIP-Paket 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-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
Playtube bietet einer portablen Kopie beim naechsten Update automatisch an, in die Playtube bietet einer portablen Kopie beim nächsten Update automatisch an, in die
regulaere Installation zu wechseln (danach kann der alte Ordner geloescht werden). reguläre Installation zu wechseln (danach kann der alte Ordner gelöscht werden).
## Schnellstart (Entwicklung) ## Schnellstart (Entwicklung)
@@ -52,24 +76,69 @@ 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
@@ -80,61 +149,61 @@ Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Ist
[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert [Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den (`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript: Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` ueberspringbar). [packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` überspringbar).
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
Verknuepfung im Windows-Startmenue an - eine per Setup installierte Kopie hat den Verknüpfung im Windows-Startmenü an - eine per Setup installierte Kopie hat den
Eintrag bereits vom Installer. Eintrag bereits vom Installer.
Das Build-Skript benennt Das Build-Skript benennt
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu zusätzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess` `PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses auftaucht. Für eine vollständige Umbenennung inkl. Icon/Versionsinfo dieses
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich Hilfsprozesses (relevant für den Lautstärkemixer) zusätzlich
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe` [rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach alles genauso, nur zeigt der Lautstärkemixer für den Ton-Unterprozess je nach
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes, kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit Windows-versionsabhängiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
o.ae. umgehen). o.ä. umgehen).
## Automatische Updates ## Automatische Updates
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden Playtube prüft beim Start und danach alle `updates.check_interval_hours` Stunden
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die (Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit Einstellungen-Tab gibt es zusätzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version, Status-Anzeige und Fortschrittsbalken für den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll: fragt ein Dialog, ob sie installiert werden soll:
- **Windows, per Setup installiert** (siehe "Installation"): laedt bevorzugt das kleine - **Windows, per Setup installiert** (siehe "Installation"): lädt bevorzugt das kleine
**Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB) **Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB)
und ueberschreibt damit die Datei im Installationsordner - der riesige PySide6/ und überschreibt damit die Datei im Installationsordner - der riesige PySide6/
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
normalen Patch-Releases nicht aendert. Die PySide6-Version steht im Dateinamen des 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, 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 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 **Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach
einem Patch ebenfalls nachgezogen. einem Patch ebenfalls nachgezogen.
- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): laedt den - **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): lädt den
**Setup-Installer** und fuehrt ihn still aus. Playtube liegt danach in der regulaeren **Setup-Installer** und führt ihn still aus. Playtube liegt danach in der regulären
Installation - Startmenue-Eintrag und Dateizuordnung zeigen dorthin, die alte portable Installation - Startmenü-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
Kopie ist nicht mehr noetig (und kann geloescht werden). So entstehen nicht laenger Kopie ist nicht mehr nötig (und kann gelöscht werden). So entstehen nicht länger
versionierte Ordner nebeneinander. Gibt es (bei aelteren Releases) keinen Installer, versionierte Ordner nebeneinander. Gibt es (bei älteren Releases) keinen Installer,
wird wie frueher das volle ZIP ueber den vorhandenen Ordner kopiert. wird wie früher das volle ZIP über den vorhandenen Ordner kopiert.
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`. - **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
Die App startet sich in allen Faellen danach selbst neu. Die App startet sich in allen Fällen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r - **Entwicklungsmodus** (`python main.py`): führt `git pull` + `pip install -r
requirements.txt` aus und startet den Python-Prozess neu. requirements.txt` aus und startet den Python-Prozess neu.
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter Auto-Update lässt sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren. `updates.enabled` deaktivieren.
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine- Nach einem erkannten Update wird beim nächsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Einträge können sonst nicht mehr
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon zum neuen Code passen (frühere Ursache für fehlende Icons). Der Login bleibt davon
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner unberührt, da Cookies/LocalStorage in einem komplett getrennten Ordner
(`webprofile/storage`) liegen. (`webprofile/storage`) liegen.
### Patch-Dateien manuell installieren (`.play`) ### Patch-Dateien manuell installieren (`.play`)
@@ -143,11 +212,11 @@ Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technis
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der `Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) laesst sich also [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) lässt sich also
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
muss. muss.
### Eine neue Version veroeffentlichen ### 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
@@ -155,13 +224,13 @@ 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.
Fuer Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, fuer 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 Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine `...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt). Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
@@ -171,11 +240,11 @@ 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
@@ -183,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:**
@@ -193,31 +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.
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menue ...):** Fast - **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menü ...):** Fast
sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
Fensters Playtube nur in den Tray minimiert: eine aeltere Version laeuft unsichtbar Fensters Playtube nur in den Tray minimiert: eine ältere Version läuft unsichtbar
weiter, waehrend eine neu heruntergeladene zusaetzlich gestartet wird. Abhilfe: alle weiter, während eine neu heruntergeladene zusätzlich gestartet wird. Abhilfe: alle
Playtube-Instanzen ueber das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager Playtube-Instanzen über das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
beenden und nur EINE neu starten. Ab der Version mit beenden und nur EINE neu starten. Ab der Version mit
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den [playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
aeltere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell ältere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
beendet werden. beendet werden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von - 4K/Premium-Videoqualität kann eingeschränkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). 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.
+9 -11
View File
@@ -12,11 +12,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import __version__, app_id # noqa: E402 from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, 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.single_instance import SingleInstanceGuard # noqa: E402
from playtube.shortcuts import ( # noqa: E402 from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
ensure_play_file_association,
ensure_start_menu_shortcut,
)
from playtube.updater import cleanup_old_staging # 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()
@@ -25,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
@@ -77,7 +69,7 @@ def main() -> int:
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird. # komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__) clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME) ensure_start_menu_shortcut(APP_NAME)
ensure_play_file_association(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()
@@ -87,6 +79,12 @@ def main() -> int:
instance_guard.showRequested.connect(window.show_and_raise) instance_guard.showRequested.connect(window.show_and_raise)
instance_guard.patchRequested.connect(window.install_local_patch) 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 # Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
# gestartet -> direkt installieren statt selbst etwas herunterzuladen. # gestartet -> direkt installieren statt selbst etwas herunterzuladen.
if local_patch: if local_patch:
+7 -56
View File
@@ -1,19 +1,11 @@
# 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
# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten
# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess"
# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den
# Dateinamen "PlaytubeHelper" an).
param( param(
# Ueberspringt den Bau des Setup-Installers (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6). # Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
[switch]$SkipInstaller [switch]$SkipInstaller
) )
@@ -21,53 +13,13 @@ $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"
$internalDir = Join-Path $distDir "_internal"
$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir }
$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $engineProcess) {
Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen."
} else {
$helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe"
Copy-Item $engineProcess.FullName $helperPath -Force
Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan
$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
}
}
Write-Host ""
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
# --- Setup-Installer (Inno Setup) ------------------------------------------------------ # --- Setup-Installer (Inno Setup) ------------------------------------------------------
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und # Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen. # vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
if (-not $SkipInstaller) { if (-not $SkipInstaller) {
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1 $versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
@@ -86,12 +38,11 @@ if (-not $SkipInstaller) {
Write-Host "" Write-Host ""
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
} else { } else {
Write-Host "" Write-Host ""
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss") & $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." } if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
} }
} }
+63 -46
View File
@@ -1,18 +1,19 @@
; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe). ; 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): ; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss ; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1). ; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe ; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
; ;
; Wichtig fuer "es soll nie mehrere Versionen geben": ; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene ; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen). ; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte ; parallel installiert sein; keine ueberschreibt die andere.
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar). ; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner ; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben. ; uebereinstimmen.
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten. ; - 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 ; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
; Inno Setup nicht ins Spiel kommt. ; Inno Setup nicht ins Spiel kommt.
@@ -21,14 +22,13 @@
#define AppVersion "0.0.0" #define AppVersion "0.0.0"
#endif #endif
#define AppName "Playtube" #define AppName "Playtube Edge"
#define AppExeName "Playtube.exe" #define AppDirName "PlaytubeEdge"
#define AppHelperName "PlaytubeHelper.exe" #define AppExeName "PlaytubeEdge.exe"
[Setup] [Setup]
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit ; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
; _INNO_APP_ID in playtube/updater.py uebereinstimmen. AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
AppName={#AppName} AppName={#AppName}
AppVersion={#AppVersion} AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion} AppVerName={#AppName} {#AppVersion}
@@ -37,22 +37,21 @@ AppPublisherURL=https://github.com/fojadrachi/Playtube
AppSupportURL=https://github.com/fojadrachi/Playtube/issues AppSupportURL=https://github.com/fojadrachi/Playtube/issues
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
VersionInfoVersion={#AppVersion} VersionInfoVersion={#AppVersion}
DefaultDirName={localappdata}\Programs\{#AppName} DefaultDirName={localappdata}\Programs\{#AppDirName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
PrivilegesRequired=lowest PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist\installer OutputDir=..\dist\installer
OutputBaseFilename=Playtube-Setup-v{#AppVersion} OutputBaseFilename=PlaytubeEdge-Setup-v{#AppVersion}
SetupIconFile=..\assets\icon.ico SetupIconFile=..\assets\icon.ico
UninstallDisplayIcon={app}\{#AppExeName} UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName} UninstallDisplayName={#AppName}
Compression=lzma2/max Compression=lzma2/max
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
; Prozesse beenden wir selbst (siehe [Code]) - Playtube versteckt sich beim Schliessen des ; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu ; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
; schliessen, die Inno per CloseApplications senden wuerde.
CloseApplications=no CloseApplications=no
RestartApplications=no RestartApplications=no
@@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Type: filesandordirs; Name: "{app}\_internal" Type: filesandordirs; Name: "{app}\_internal"
[Files] [Files]
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons] [Icons]
; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die
; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die
; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt.
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py).
Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}"
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1"""
[Run] [Run]
; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende. ; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch ; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
; wieder starten.
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
[Code] [Code]
@@ -98,27 +86,56 @@ begin
Result := ExpandConstant('{param:RELAUNCH|0}') = '1'; Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
end; end;
procedure StopRunningPlaytube(); 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 var
ResultCode: Integer; ResultCode: Integer;
begin begin
{ /T beendet auch die Kindprozesse (QtWebEngine); der Hilfsprozess wird zusaetzlich { WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
einzeln beendet, falls er als Waise ueberlebt hat. } also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /T /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); 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. } { Dateisperren freigeben lassen, bevor kopiert wird. }
Sleep(800); Sleep(800);
end; end;
function PrepareToInstall(var NeedsRestart: Boolean): String; function PrepareToInstall(var NeedsRestart: Boolean): String;
begin begin
StopRunningPlaytube(); WaitForOldInstance();
StopLeftoverProcesses();
Result := ''; Result := '';
end; end;
function InitializeUninstall(): Boolean; function InitializeUninstall(): Boolean;
begin begin
StopRunningPlaytube(); StopLeftoverProcesses();
Result := True; Result := True;
end; end;
@@ -127,8 +144,8 @@ begin
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
begin begin
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 + if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')', '(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True); DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
end; end;
end; end;
+15 -6
View File
@@ -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",
) )
+19
View File
@@ -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.
+4 -4
View File
@@ -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])])
+2 -2
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube Edge"
__version__ = "2.3.2" __version__ = "1.0.0"
+137
View File
@@ -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
View File
@@ -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
+16 -3
View File
@@ -8,15 +8,18 @@ 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,
@@ -28,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/",
+40
View File
@@ -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."""
@@ -143,6 +161,11 @@ class DiscordRPCWorker(QThread):
"""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()
@@ -254,6 +277,14 @@ class DiscordRPCWorker(QThread):
else: else:
payload = None payload = None
self._presence.clear() self._presence.clear()
elif isinstance(item, _SettingsActivity):
# 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)
log_line("[send-settings] state=%r" % (payload["state"],))
else: else:
start_ts, end_ts = self._track_timestamps(item) start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts) payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
@@ -287,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__ = ()
+117 -8
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os import os
import sys import sys
import threading
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -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,20 @@ 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.checkUpdatesRequested.connect(self._check_for_updates)
self._settings_tab.installUpdateRequested.connect( self._settings_tab.installUpdateRequested.connect(
lambda: self._start_update_install(self._pending_download_url) lambda: self._start_update_install(self._pending_download_url)
@@ -170,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", {})
@@ -201,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()
@@ -226,6 +294,43 @@ class MainWindow(QMainWindow):
aus dem Tray, wenn Playtube ein zweites Mal gestartet wird.""" aus dem Tray, wenn Playtube ein zweites Mal gestartet wird."""
self._show_and_raise() 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()
@@ -359,6 +464,10 @@ class MainWindow(QMainWindow):
"Installation abgeschlossen. Playtube wird neu gestartet …" "Installation abgeschlossen. Playtube wird neu gestartet …"
) )
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …") self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
# Haengt sich Qt/QtWebEngine beim Beenden auf, wuerde der Update-Helfer (bzw.
# Setup, siehe /WAITPID) ewig auf das Ende dieses Prozesses warten - und die
# alte Version bliebe einfach weiterlaufen. Nach 8 s hart beenden.
_force_exit_after(8.0)
self._quit() self._quit()
else: else:
QMessageBox.information( QMessageBox.information(
+156
View File
@@ -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})();"
+29 -1
View File
@@ -105,6 +105,30 @@ MEDIA_PROBE_JS = r"""
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null); 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 timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0); var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0); var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
@@ -123,7 +147,11 @@ MEDIA_PROBE_JS = r"""
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: currentTime, currentTime: currentTime,
duration: duration, duration: duration,
hasVideo: !!video hasVideo: !!video,
volume: volume,
muted: muted,
likeStatus: likeState(),
repeatMode: repeatMode()
}); });
})(); })();
""" """
+300
View File
@@ -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()
+92
View File
@@ -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,
@@ -22,6 +24,7 @@ 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
@@ -55,6 +58,9 @@ class SettingsTab(QWidget):
settingsSaved = Signal(dict) settingsSaved = Signal(dict)
checkUpdatesRequested = Signal() checkUpdatesRequested = Signal()
installUpdateRequested = 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)
@@ -132,6 +138,31 @@ class SettingsTab(QWidget):
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()
@@ -155,6 +186,63 @@ class SettingsTab(QWidget):
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", {})
@@ -167,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)
+33 -8
View File
@@ -54,16 +54,19 @@ 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 # Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update # Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows). # wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
_INNO_APP_ID = "{87FBA502-2B84-4C42-A66B-2F03F490912D}" _INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
_STAGING_PREFIX = "playtube_update_" _STAGING_PREFIX = "playtube_update_"
@@ -105,9 +108,18 @@ def fetch_latest_release() -> dict[str, Any] | None:
) )
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: def is_installed_via_setup() -> bool:
@@ -125,6 +137,10 @@ def _asset_kind(name: str) -> str | None:
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip. *.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket.""" Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
name = name.lower() 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 sys.platform == "win32":
if name.endswith(".exe") and "setup" in name: if name.endswith(".exe") and "setup" in name:
return _KIND_SETUP return _KIND_SETUP
@@ -466,7 +482,12 @@ rm -rf "{staging}"
packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die
laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss
hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das
Beenden dieses Prozesses ueberlebt.""" 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( subprocess.Popen(
[ [
str(setup_path), str(setup_path),
@@ -475,6 +496,8 @@ rm -rf "{staging}"
"/NORESTART", "/NORESTART",
"/NOCANCEL", "/NOCANCEL",
"/RELAUNCH=1", "/RELAUNCH=1",
f"/WAITPID={os.getpid()}",
"/LOG",
], ],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True, close_fds=True,
@@ -497,7 +520,9 @@ $form.Refresh()
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait # Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft - # abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen. # sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP") # /R:3 /W:2 begrenzt die Wiederholungen bei gesperrten Dateien (Standard: praktisch endlos,
# das Update haengt dann sichtbar "fuer immer" im Fortschrittsfenster).
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru $roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
while (-not $roboProc.HasExited) {{ while (-not $roboProc.HasExited) {{
Start-Sleep -Milliseconds 200 Start-Sleep -Milliseconds 200
+85
View File
@@ -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
View File
@@ -1,2 +1,3 @@
PySide6>=6.10 PySide6>=6.10
pypresence>=4.3 pypresence>=4.3
pythonnet>=3.1
+272
View File
@@ -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()
+27
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+85
View File
@@ -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.
---------------------------------------------------------
Binary file not shown.
Binary file not shown.