21 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 a4ba6c296e Release v2.3.2
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-19 19:46:40 +02:00
Fojadrachi c1465a4e49 Einstellungen: Versionsnummer, Update-Status und GitHub-Link in heller Schrift
Die drei Texte nutzten palette(mid) (dunkles Grau) und waren im dunklen Design kaum
lesbar. Jetzt palette(text) (weiss im dunklen, dunkel im hellen Design). Der
Update-Fortschrittsbalken bekommt eine helle Beschriftung und einen gut sichtbaren
blauen Balken.
2026-09-19 19:46:28 +02:00
Fojadrachi 874d6454ec Fix spelling and grammar in README.md 2026-09-19 19:37:51 +02:00
Fojadrachi 8840ba130c Release v2.3.1
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-19 19:25:22 +02:00
Fojadrachi 04bc96d33e Setup-Installer, Einzelinstanz-Schutz und Update-System mit einer einzigen Installation
- Inno-Setup-Installer (packaging/playtube.iss): installiert pro Benutzer nach
  %LOCALAPPDATA%\Programs\Playtube, feste AppId -> jede neue Version ueberschreibt die
  vorhandene Installation an Ort und Stelle, raeumt den alten _internal-Ordner auf und
  beendet laufende Playtube-Prozesse vor dem Kopieren
- Updater: installierte Kopie nimmt das kleine Patch (Versionseintrag in "Apps &
  Features" wird nachgezogen), portable Kopie wechselt per stillem Setup in die
  regulaere Installation; Patch nur bei passender PySide6-Version (steht im Dateinamen),
  sonst Setup; Aufraeumen des heruntergeladenen Installers
- Einzelinstanz-Schutz (playtube/single_instance.py): zwei Prozesse auf demselben
  Browser-Profil liessen auf YouTube alle Icons verschwinden; ein zweiter Start holt jetzt
  das laufende Fenster nach vorn (auch .play-Patchdateien werden weitergereicht)
- build.ps1 baut den Installer mit, Release-Pipeline haengt ihn an jedes Release
- README: Installation, Update-Ablauf, Troubleshooting fuer fehlende Icons
2026-09-19 19:25:05 +02:00
Fojadrachi 31797cdabb Release v2.3.0
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 14:03:28 +02:00
FojadrachiandClaude Sonnet 5 771c438257 Eigene .play-Dateiendung fuer Windows-Patchdateien + manuelle Installation per Doppelklick
Windows-Patch-Pakete heissen jetzt Playtube-vX.Y.Z-win64-patch.play statt .zip
(technisch weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim
Entpacken auf die Magic Bytes, nicht auf die Endung). Playtube registriert .play
beim ersten Start als Windows-Dateizuordnung (HKCU, keine Admin-Rechte noetig),
sodass eine manuell heruntergeladene Patchdatei per Doppelklick installiert
werden kann, ohne dass Playtube selbst etwas herunterladen muss:

- shortcuts.py: ensure_play_file_association() registriert ProgID +
  DefaultIcon + shell/open/command und stoesst SHChangeNotify an, damit die
  Zuordnung sofort greift.
- updater.py: UpdateInstaller akzeptiert jetzt optional local_archive_path
  statt einer download_url und ueberspringt dann den Download komplett.
- main.py: erkennt eine .play-Datei als Kommandozeilenargument (Doppelklick-
  Start) und uebergibt sie an MainWindow.install_local_patch().
- release.yml: Windows-Patch wird als .zip gepackt und anschliessend zu .play
  umbenannt; _find_platform_asset() sucht fuer Windows-Patches jetzt nach .play
  statt .zip.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 14:03:23 +02:00
31 changed files with 2626 additions and 423 deletions
+49 -68
View File
@@ -1,14 +1,19 @@
name: Release
name: Release (Edge)
# Baut Playtube fuer Windows und Linux und veroeffentlicht beide als Assets an einem
# GitHub Release, sobald ein Tag im Format "vX.Y.Z" gepusht wird (z.B. durch
# packaging/release.ps1). Playtube.exe/Playtube auf den Nutzer-Rechnern erkennt neue
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
# und ZIP an einem GitHub Release, sobald ein Tag im Format "vX.Y.Z-edge" gepusht wird.
#
# 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:
push:
tags:
- "v*.*.*"
- "v*-edge"
workflow_dispatch: {}
permissions:
@@ -30,80 +35,56 @@ jobs:
pip install -r requirements.txt
pip install pyinstaller
- name: Playtube.exe bauen
- name: PlaytubeEdge.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Als .zip packen (volles Paket)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- name: Setup-Installer bauen (Inno Setup)
shell: pwsh
run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
choco install innosetup -y --no-progress
}
& $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
# Zwei getrennte Artefakte: bei mehreren Pfaden in EINEM Artefakt behielte das Setup seinen
# Unterordner (dist/installer/) und wuerde beim Veroeffentlichen nicht gefunden.
- uses: actions/upload-artifact@v4
with:
name: windows-edge-setup
path: dist/installer/PlaytubeEdge-Setup-v*.exe
- name: Als .zip packen (portabel)
run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath PlaytubeEdge-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4
with:
name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip
name: windows-edge-zip
path: PlaytubeEdge-*-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-${{ github.ref_name }}-win64-patch.zip
build-linux:
publish-release:
needs: [build-windows]
runs-on: ubuntu-latest
steps:
- 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
with:
path: artifacts
merge-multiple: true
- name: GitHub Release veroeffentlichen
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
- name: GitHub Release veroeffentlichen (Vorabversion, nie "latest")
# gh statt softprops/action-gh-release: letzteres brach beim erneuten Veroeffentlichen
# desselben Tags mit "Too many retries" ab. Ein vorhandenes Release zum selben Tag wird
# zuerst entfernt (der Tag bleibt), dann sauber neu angelegt.
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/
dist/
run*.log
*_run.log
build_edge.log
.claude/
.pytest_cache/
test_login*.log
+186 -56
View File
@@ -1,20 +1,65 @@
# Playtube
# Playtube Edge
Eigenstaendige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
> **Das ist die „Edge“-Variante von Playtube (Branch `edge`).**
> **Diese Version brauchst du, wenn du selbst hochgeladene Musik in YouTube Music hören
> willst.** Die normale Playtube (Branch `main`) zeigt bei eigenen Uploads „Dieses
> Videoformat wird nicht unterstützt“, weil ihre Browser-Engine (QtWebEngine) kein AAC und
> kein H.264 abspielen kann. Playtube Edge rendert stattdessen mit der **Microsoft-Edge-Engine
> (WebView2)** – damit laufen auch Uploads.
>
> - Download: [Releases](https://github.com/fojadrachi/Playtube/releases) → Eintrag „Playtube
> Edge vX.Y.Z-edge“ → `PlaytubeEdge-Setup-vX.Y.Z.exe`. Voraussetzung: Windows 10/11 mit
> WebView2-Runtime (Windows 11: vorinstalliert).
> - Läuft **neben** der normalen Playtube (eigener Ordner `%APPDATA%\PlaytubeEdge`, eigenes
> Login – beim ersten Start einmal bei Google anmelden).
> - Updates kommen nur über die `-edge`-Releases (die normale Playtube sieht sie nie).
> - Entwicklung: `pip install -r requirements.txt`, dann `python main.py`. Die
> WebView2-DLLs liegen in `vendor/webview2` (Microsoft, NuGet `Microsoft.Web.WebView2`).
>
> Der Rest dieser README beschreibt die gemeinsame Basis mit der normalen Playtube; wo dort
> „QtWebEngine“ steht, ist in dieser Variante WebView2 gemeint.
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
Erweiterung) mit:
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
Hintergrund weiter wenn du zu Videos wechselst.
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
Google anmelden reicht fuer beide Dienste.
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
Google anmelden reicht für beide Dienste.
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue.
einen Link-Button in deinem Discord-Profil, sobald etwas läuft. Ist der
**Einstellungen-Tab** offen, steht dort stattdessen „In den Einstellungen“ und welches
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
und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python".
und (nach dem Packaging, siehe unten) im Lautstärkemixer statt als "python".
## Installation (Windows)
Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
Admin-Rechte nötig) und legt Startmenü-Eintrag (optional Desktop-Verknüpfung) an,
- ersetzt bei jeder späteren Ausführung die vorhandene Installation **an Ort und
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
mehrere nebeneinander,
- beendet dafür ein noch laufendes Playtube selbst und räumt den alten Programmordner
auf, damit keine Reste alter Versionen übrig bleiben,
- lässt Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
über "Apps & Features" wird gefragt, ob sie mitgelöscht werden sollen.
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
Setup-Datei wird dafür nicht erneut von Hand gebraucht. Das ZIP-Paket
(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
Playtube bietet einer portablen Kopie beim nächsten Update automatisch an, in die
reguläre Installation zu wechseln (danach kann der alte Ordner gelöscht werden).
## Schnellstart (Entwicklung)
@@ -31,78 +76,147 @@ Login bleibt danach dauerhaft gespeichert.
## Discord Rich Presence einrichten
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.
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
exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
3. Optional, für eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
exakten Schlüsseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
nur ohne Bild.
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
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" -
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
.venv\Scripts\pip install pyinstaller
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
```
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser
`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube
wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es
sonst keinen Startmenue-Eintrag).
Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Ist
[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` überspringbar).
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
Verknüpfung im Windows-Startmenü an - eine per Setup installierte Kopie hat den
Eintrag bereits vom Installer.
Das Build-Skript benennt
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`
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich
auftaucht. Für eine vollständige Umbenennung inkl. Icon/Versionsinfo dieses
Hilfsprozesses (relevant für den Lautstärkemixer) zusätzlich
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
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
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
o.ae. umgehen).
Windows-versionsabhängiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
o.ä. umgehen).
## 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
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version,
Einstellungen-Tab gibt es zusätzlich einen "Jetzt nach Updates suchen"-Button mit
Status-Anzeige und Fortschrittsbalken für den Download. Gibt es eine neuere Version,
fragt ein Dialog, ob sie installiert werden soll:
- **Gepackte `Playtube.exe`/`Playtube`**: laedt bevorzugt das kleine **Patch-Paket**
herunter (nur die ausfuehrbare Datei mit unserem Anwendungscode, ca. 2-3 MB statt
~200 MB) und ersetzt ausschliesslich diese - der riesige PySide6/QtWebEngine-
Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen normalen
Patch-Releases nicht aendert. Nur wenn kein Patch-Paket verfuegbar ist (z.B. beim
allerersten Release oder nach einem Wechsel der PySide6-Version), wird automatisch
auf das volle Release-Paket zurueckgefallen und der komplette Installationsordner
ersetzt. Die App startet sich in beiden Faellen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
- **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)
und überschreibt damit die Datei im Installationsordner - der riesige PySide6/
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
normalen Patch-Releases nicht ändert. Die PySide6-Version steht im Dateinamen des
Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab,
ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den
**Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach
einem Patch ebenfalls nachgezogen.
- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): lädt den
**Setup-Installer** und führt ihn still aus. Playtube liegt danach in der regulären
Installation - Startmenü-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
Kopie ist nicht mehr nötig (und kann gelöscht werden). So entstehen nicht länger
versionierte Ordner nebeneinander. Gibt es (bei älteren Releases) keinen Installer,
wird wie früher das volle ZIP über den vorhandenen Ordner kopiert.
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
Die App startet sich in allen Fällen danach selbst neu.
- **Entwicklungsmodus** (`python main.py`): führt `git pull` + `pip install -r
requirements.txt` aus und startet den Python-Prozess neu.
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.
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
Nach einem erkannten Update wird beim nächsten Start automatisch der QtWebEngine-
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Einträge können sonst nicht mehr
zum neuen Code passen (frühere Ursache für fehlende Icons). Der Login bleibt davon
unberührt, da Cookies/LocalStorage in einem komplett getrennten Ordner
(`webprofile/storage`) liegen.
### Eine neue Version veroeffentlichen
### Patch-Dateien manuell installieren (`.play`)
Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technisch
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) lässt sich also
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
muss.
### Eine neue Version veröffentlichen
```powershell
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
@@ -110,22 +224,27 @@ powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
`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
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.
Für Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, für
Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
Alle Nutzer mit einer laufenden Playtube-Installation (Windows oder Linux) bekommen die
neue Version danach automatisch angeboten.
## 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
https://github.com/fojadrachi/Playtube/releases mitgebaut.
**Fertiges Release installieren** (richtet Startmenue-Eintrag + Icon ein):
**Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
```sh
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
@@ -133,7 +252,7 @@ cd Playtube
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:**
@@ -143,20 +262,31 @@ python3 -m venv .venv
.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
[.github/workflows/release.yml](.github/workflows/release.yml)).
## 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
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
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
Entwicklungsmodus) loeschen und neu anmelden.
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
Entwicklungsmodus) löschen und neu anmelden.
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menü ...):** Fast
sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
Fensters Playtube nur in den Tray minimiert: eine ältere Version läuft unsichtbar
weiter, während eine neu heruntergeladene zusätzlich gestartet wird. Abhilfe: alle
Playtube-Instanzen über das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
beenden und nur EINE neu starten. Ab der Version mit
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
ältere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
beendet werden.
- 4K/Premium-Videoqualität kann eingeschränkt sein, da die Open-Source-Variante von
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitäten funktionieren normal).
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
+42 -7
View File
@@ -11,8 +11,11 @@ from pathlib import Path
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, 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.shortcuts import ensure_start_menu_shortcut # noqa: E402
from playtube.updater import cleanup_old_staging # noqa: E402
app_id.set_app_user_model_id()
app_id.configure_webengine_process_path()
@@ -20,12 +23,6 @@ app_id.configure_webengine_process_path()
# 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
# 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.QtGui import QIcon # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
@@ -33,6 +30,15 @@ from PySide6.QtWidgets import QApplication # noqa: E402
from playtube.mainwindow import MainWindow # noqa: E402
def _pending_local_patch() -> str | None:
"""Falls Playtube per Doppelklick auf eine ".play"-Patchdatei gestartet wurde
(siehe shortcuts.ensure_play_file_association), liefert den Pfad dazu."""
for arg in sys.argv[1:]:
if arg.lower().endswith(".play") and Path(arg).is_file():
return str(Path(arg).resolve())
return None
def main() -> int:
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
app = QApplication(sys.argv)
@@ -45,16 +51,45 @@ def main() -> int:
if icon_path.exists():
app.setWindowIcon(QIcon(str(icon_path)))
# Nur EINE Playtube-Instanz pro Datenordner: laufen zwei gleichzeitig auf demselben
# Browser-Profil, zeigt die zweite auf YouTube keine Icons mehr (siehe
# playtube/single_instance.py). Muss vor jedem Profil-Zugriff (Cache-Reset, Browser)
# passieren. Die Instanz eines aelteren Playtube ohne diesen Schutz wird dadurch
# allerdings nicht erkannt - die muss einmalig ueber das Tray-Icon beendet werden.
local_patch = _pending_local_patch()
instance_guard = SingleInstanceGuard(f"{APP_NAME}.SingleInstance.{app_data_dir().name}", app)
if not instance_guard.acquire(local_patch):
return 0
cleanup_old_staging() # Reste eines frueheren Updates (heruntergeladenes Setup) entfernen
config = load_config()
# Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login
# bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME)
# Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates).
window = MainWindow(config)
window.show()
# Ein zweiter Start (Verknuepfung, .play-Doppelklick) landet hier in der laufenden
# Instanz: Fenster aus dem Tray holen bzw. den Patch installieren.
instance_guard.showRequested.connect(window.show_and_raise)
instance_guard.patchRequested.connect(window.install_local_patch)
# Lokale Fernsteuerung (Stream-Dock-Plugin), eigener Pipe-Name pro Datenordner - so
# steuert das Plugin die installierte Playtube.exe, nicht versehentlich einen Dev-Start.
if config.get("remote_control", {}).get("enabled", True):
remote_server = RemoteControlServer(f"{PIPE_PREFIX}{app_data_dir().name}", window, app)
remote_server.start()
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
if local_patch:
window.install_local_patch(local_patch)
return app.exec()
+32 -45
View File
@@ -1,61 +1,48 @@
# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine-
# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er
# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im
# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht.
# Baut PlaytubeEdge.exe (Playtube mit Microsoft-Edge-Engine / WebView2) mit PyInstaller und
# danach den Setup-Installer (Inno Setup 6).
#
# Aufruf (im Projekt-Root, mit aktivierter venv):
# 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(
# Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
[switch]$SkipInstaller
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
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
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 }
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
# Ein und derselbe Installer richtet Playtube Edge erstmalig ein UND aktualisiert spaeter eine
# vorhandene Installation an Ort und Stelle (feste AppId) - es gibt nie mehrere Versionen.
if (-not $SkipInstaller) {
$versionLine = Select-String -Path (Join-Path $root "playtube\__init__.py") -Pattern '__version__\s*=\s*"([^"]+)"' | Select-Object -First 1
if (-not $versionLine) { throw "Konnte __version__ in playtube\__init__.py nicht lesen." }
$version = $versionLine.Matches[0].Groups[1].Value
$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
$isccCandidates = @(
(Get-Command iscc -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }),
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
)
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
$rcedit = Get-Command rcedit -ErrorAction SilentlyContinue
$rceditLocal = Join-Path $PSScriptRoot "rcedit.exe"
if ($rcedit) {
$rceditExe = $rcedit.Source
} elseif (Test-Path $rceditLocal) {
$rceditExe = $rceditLocal
if (-not $iscc) {
Write-Host ""
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
} 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 "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
}
}
Write-Host ""
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
+151
View File
@@ -0,0 +1,151 @@
; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-Setup-vX.Y.Z.exe).
;
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
; ISCC.exe /DAppVersion=1.0.0 packaging\playtube.iss
; Voraussetzung: dist\PlaytubeEdge\ existiert bereits (PyInstaller-Build, siehe build.ps1).
; Ergebnis: dist\installer\PlaytubeEdge-Setup-v1.0.0.exe
;
; Playtube Edge ist bewusst eine EIGENE Anwendung neben der Qt-Version von Playtube:
; - Eigene AppId, eigener Installationsordner (%LOCALAPPDATA%\Programs\PlaytubeEdge), eigener
; Datenordner (%APPDATA%\PlaytubeEdge), eigene Exe (PlaytubeEdge.exe). Beide Versionen koennen
; parallel installiert sein; keine ueberschreibt die andere.
; - Feste AppId: jede neue Edge-Setup-Version erkennt darueber die vorhandene Edge-Installation
; und ueberschreibt sie im SELBEN Ordner. Muss mit _INNO_APP_ID in playtube/updater.py
; uebereinstimmen.
; - Keine ".play"-Dateizuordnung und kein Beenden von Playtube.exe / PlaytubeHelper.exe - das
; gehoert alles der Qt-Version.
;
; Hinweis zur Datei: bewusst reines ASCII (kein BOM), damit die Umlaute-Kodierung von
; Inno Setup nicht ins Spiel kommt.
#ifndef AppVersion
#define AppVersion "0.0.0"
#endif
#define AppName "Playtube Edge"
#define AppDirName "PlaytubeEdge"
#define AppExeName "PlaytubeEdge.exe"
[Setup]
; NIEMALS aendern - identifiziert die Edge-Installation ueber alle Versionen hinweg.
AppId={{4F0F3698-DAF3-4028-B0A4-896D31292C28}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher=Playtube
AppPublisherURL=https://github.com/fojadrachi/Playtube
AppSupportURL=https://github.com/fojadrachi/Playtube/issues
AppUpdatesURL=https://github.com/fojadrachi/Playtube/releases
VersionInfoVersion={#AppVersion}
DefaultDirName={localappdata}\Programs\{#AppDirName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist\installer
OutputBaseFilename=PlaytubeEdge-Setup-v{#AppVersion}
SetupIconFile=..\assets\icon.ico
UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; Prozesse beenden wir selbst (siehe [Code]) - Playtube Edge versteckt sich beim Schliessen des
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf CloseApplications.
CloseApplications=no
RestartApplications=no
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[InstallDelete]
; Vor dem Kopieren die PyInstaller-Laufzeit der alten Version entfernen, damit nach einem
; Update keine Dateien einer aelteren Version uebrig bleiben.
Type: filesandordirs; Name: "{app}\_internal"
[Files]
Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Run]
; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
[Code]
function ShouldRelaunch(): Boolean;
begin
Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
end;
function PowerShellPath(): String;
begin
Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
end;
procedure WaitForOldInstance();
var
OldPid, ResultCode: Integer;
begin
{ Der Updater startet Setup aus der laufenden App heraus und uebergibt deren Prozess-ID
(/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat (max. 20 s). }
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
if OldPid > 0 then
Exec(PowerShellPath(),
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "Wait-Process -Id ' + IntToStr(OldPid) + ' -Timeout 20 -ErrorAction SilentlyContinue"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
procedure StopLeftoverProcesses();
var
ResultCode: Integer;
begin
{ WICHTIG: bewusst OHNE "/T" - der Updater startet Setup aus PlaytubeEdge.exe heraus, Setup ist
also ein Kindprozess; "/T" wuerde den Installer selbst mit abschiessen. Es wird nur
PlaytubeEdge.exe beendet, NIE Playtube.exe der Qt-Version. }
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ WebView2-Kindprozesse (msedgewebview2.exe), die noch aus dem Installationsordner laufen,
wuerden Dateien sperren - gezielt nach Pfad beenden. }
if DirExists(ExpandConstant('{app}')) then
Exec(PowerShellPath(),
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($d, [StringComparison]::OrdinalIgnoreCase) } | ' +
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ Dateisperren freigeben lassen, bevor kopiert wird. }
Sleep(800);
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
WaitForOldInstance();
StopLeftoverProcesses();
Result := '';
end;
function InitializeUninstall(): Boolean;
begin
StopLeftoverProcesses();
Result := True;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
begin
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(ExpandConstant('{userappdata}\{#AppDirName}'), True, True, True);
end;
end;
+15 -6
View File
@@ -4,22 +4,31 @@
import sys
from pathlib import Path
from PyInstaller.utils.hooks import collect_all
block_cipher = None
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(
[str(ROOT / "main.py")],
pathex=[str(ROOT)],
binaries=[],
binaries=pythonnet_binaries + clr_binaries,
datas=[
(str(ROOT / "assets"), "assets"),
(str(ROOT / "config.json"), "."),
],
hiddenimports=["pypresence"],
(str(ROOT / "vendor" / "webview2"), "vendor/webview2"),
] + pythonnet_datas + clr_datas,
hiddenimports=["pypresence", "clr"] + pythonnet_hidden + clr_hidden,
hookspath=[],
runtime_hooks=[],
excludes=[],
# Kein QtWebEngine mehr (WebView2 statt dessen) - haelt das Paket klein.
excludes=["PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineQuick"],
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.
exe_kwargs = dict(
exclude_binaries=True,
name="Playtube",
name="PlaytubeEdge",
debug=False,
bootloader_ignore_signals=False,
strip=False,
@@ -49,5 +58,5 @@ coll = COLLECT(
a.datas,
strip=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(
u'040704B0',
[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'InternalName', u'Playtube'),
StringStruct(u'InternalName', u'PlaytubeEdge'),
StringStruct(u'LegalCopyright', u''),
StringStruct(u'OriginalFilename', u'Playtube.exe'),
StringStruct(u'ProductName', u'Playtube'),
StringStruct(u'OriginalFilename', u'PlaytubeEdge.exe'),
StringStruct(u'ProductName', u'Playtube Edge'),
StringStruct(u'ProductVersion', u'1.0.0.0')])
]),
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."""
__app_name__ = "Playtube"
__version__ = "2.2.1"
__app_name__ = "Playtube Edge"
__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 ""))
+304 -135
View File
@@ -1,183 +1,352 @@
"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login-
Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen
der aktuellen Wiedergabe fuer Discord Rich Presence."""
"""Eingebetteter Browser-Tab (Microsoft Edge WebView2) fuer YouTube bzw. YouTube Music, mit
persistentem Login-Profil und periodischem Auslesen der aktuellen Wiedergabe fuer Discord Rich
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
import ctypes
import json
import os
import sys
from typing import Any, Callable
from PySide6.QtCore import QTimer, Signal, QUrl
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineScript,
QWebEngineSettings,
QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtWidgets import QWidget
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 .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
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome")
# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges
# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist
# unter Umstaenden nicht sicher".
_CHROME_VERSION = CHROME_FULL
_CHROME_MAJOR = CHROME_MAJOR
# Nach einem Fernsteuerungsbefehl den Status kurz darauf neu auslesen (Seite braucht einen
# Moment, bis z.B. der neue Titel/Like-Status im DOM steht).
_REFRESH_AFTER_COMMAND_MS = 300
_TASK_POLL_MS = 25
_MEDIA_POLL_MS = 2000
_YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
# Ohne Nutzergeste starten koennen (Playlist-Start per Stream Dock, Autoplay).
_BROWSER_ARGUMENTS = "--autoplay-policy=no-user-gesture-required"
if sys.platform == "win32":
_UA_PLATFORM_TOKEN = "Windows NT 10.0; Win64; x64"
_SEC_CH_UA_PLATFORM = b'"Windows"'
_SEC_CH_UA_PLATFORM_VERSION = b'"15.0.0"'
elif sys.platform.startswith("linux"):
_UA_PLATFORM_TOKEN = "X11; Linux x86_64"
_SEC_CH_UA_PLATFORM = b'"Linux"'
_SEC_CH_UA_PLATFORM_VERSION = b'"6.0.0"'
else:
_UA_PLATFORM_TOKEN = "Macintosh; Intel Mac OS X 10_15_7"
_SEC_CH_UA_PLATFORM = b'"macOS"'
_SEC_CH_UA_PLATFORM_VERSION = b'"14.0.0"'
_user32 = ctypes.windll.user32 if os.name == "nt" else None
if _user32 is not None:
# 64-Bit-sichere Signaturen (Fensterstil und Handles sind pointer-gross).
_user32.GetWindowLongPtrW.restype = ctypes.c_ssize_t
_user32.GetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int)
_user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
_user32.SetWindowLongPtrW.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_ssize_t)
_user32.SetParent.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
_user32.MoveWindow.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_bool)
_user32.ShowWindow.argtypes = (ctypes.c_void_p, ctypes.c_int)
_USER_AGENT = (
f"Mozilla/5.0 ({_UA_PLATFORM_TOKEN}) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36"
)
_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")
_GWL_STYLE = -16
_WS_POPUP = 0x80000000
_WS_CAPTION = 0x00C00000
_WS_CHILD = 0x40000000
_WS_VISIBLE = 0x10000000
_WS_CLIPSIBLINGS = 0x04000000
_WS_CLIPCHILDREN = 0x02000000
_SW_SHOW = 5
class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor):
"""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)
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
_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
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();}})();"
)
_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."""
class BrowserTab(QWidget):
"""Ein WebView2-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
mediaInfoChanged = Signal(dict)
titleUpdated = Signal(str)
loadFinished = Signal(bool)
urlChanged = Signal(QUrl)
def __init__(self, home_url: str, parent=None):
super().__init__(parent)
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)
self.setPage(page)
# Natives Fenster-Handle, damit das WebView2-Steuerelement als Kindfenster einhaengt.
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setMinimumSize(200, 150)
settings = page.settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
runtime = webview2_runtime.load()
self._runtime = runtime
properties = runtime.CreationProperties()
properties.UserDataFolder = str(profile_dir() / "webview2")
properties.AdditionalBrowserArguments = _BROWSER_ARGUMENTS
properties.Language = "de-DE"
self._view = runtime.WebView2()
self._view.CreationProperties = properties
self._view.CoreWebView2InitializationCompleted += self._on_core_initialized
page.fullScreenRequested.connect(self._on_full_screen_requested)
self.load(QUrl(home_url))
self._task_timer = QTimer(self)
self._task_timer.setInterval(_TASK_POLL_MS)
self._task_timer.timeout.connect(self._drain_tasks)
self._task_timer.start()
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.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:
self.load(QUrl(self._home_url))
# ------------------------------------------------------------ Aufbau
def toggle_playback(self) -> None:
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
def _create_control(self) -> None:
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:
self.page().runJavaScript(NEXT_TRACK_JS)
def _handle(self) -> int:
return int(self._view.Handle.ToInt64())
def previous_track(self) -> None:
self.page().runJavaScript(PREV_TRACK_JS)
def _fit_to_widget(self) -> None:
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:
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
request.accept()
def resizeEvent(self, event) -> None: # noqa: N802 (Qt-Override)
super().resizeEvent(event)
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()
if request.toggleOn():
if self._core.ContainsFullScreenElement:
window.showFullScreen()
else:
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:
self.page().runJavaScript(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
self._run_js(MEDIA_PROBE_JS, self._on_media_probe_result)
def _on_media_probe_result(self, info: Any) -> None:
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):
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 typing import Any
APP_NAME = "Playtube"
APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID
APP_NAME = "Playtube Edge"
APP_AUMID = "Playtube.Edge.DesktopClient" # Windows AppUserModelID
# Der Entwicklungsmodus (python main.py) benutzt einen eigenen APPDATA-Ordner
# ("PlaytubeDev" statt "Playtube"), damit Login-Profil und Config sich NIE mit einer
# gepackten/installierten Playtube.exe ueberschneiden (frueher fuehrte das dazu, dass
# ein lokaler Test-Build und die echte Installation sich dieselbe config.json bzw.
# 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] = {
"app_name": APP_NAME,
@@ -28,10 +31,20 @@ DEFAULT_CONFIG: dict[str, Any] = {
# Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren.
"show_idle_presence": True,
},
# Die Edge-Variante aktualisiert sich nur ueber eigene Releases (Tag "...-edge", siehe updater.py).
"updates": {
"enabled": True,
"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"
"home_youtube": "https://www.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):
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
@@ -143,6 +161,11 @@ class DiscordRPCWorker(QThread):
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
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:
try:
self._queue.get_nowait()
@@ -254,6 +277,14 @@ class DiscordRPCWorker(QThread):
else:
payload = None
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:
start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
@@ -287,6 +318,15 @@ class DiscordRPCWorker(QThread):
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:
__slots__ = ()
+138 -11
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import sys
import threading
from pathlib import Path
from typing import Any
@@ -19,15 +20,27 @@ from PySide6.QtWidgets import (
)
from . import __version__ as APP_VERSION
from .audio_routing import sync_audio_permissions
from .browser import BrowserTab
from .config import APP_NAME
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 .updater import UpdateChecker, UpdateInstaller
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):
def __init__(self, config: dict[str, Any]):
super().__init__()
@@ -46,12 +59,20 @@ class MainWindow(QMainWindow):
self._youtube_tab = BrowserTab(config["home_youtube"], 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._tabs.addTab(self._youtube_tab, "YouTube")
self._tabs.addTab(self._music_tab, "YouTube Music")
self._tabs.addTab(self._settings_tab, "Einstellungen")
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.activityChanged.connect(lambda _activity: self._update_discord_presence())
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
self._settings_tab.installUpdateRequested.connect(
lambda: self._start_update_install(self._pending_download_url)
@@ -170,26 +191,46 @@ class MainWindow(QMainWindow):
tab = self._current_browser_tab()
self._url_bar.setEnabled(tab is not None)
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:
sender = self.sender()
self._latest_media[id(sender)] = info
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
# 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()))
active_info = self._active_media_info()
if active_info and active_info.get("title"):
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
else:
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"):
self._rpc_worker.submit_media_info(active_info)
worker.submit_media_info(active_info)
else:
self._rpc_worker.submit_media_info(None)
worker.submit_media_info(None)
def _start_discord_worker(self) -> None:
discord_cfg = self._config.get("discord", {})
@@ -201,14 +242,41 @@ class MainWindow(QMainWindow):
)
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:
self._config = new_config
self._sync_audio_permission()
self._apply_audio_routing()
if self._rpc_worker is not None:
self._rpc_worker.stop()
self._rpc_worker.wait(2000)
self._rpc_worker = None
self._start_discord_worker()
self._update_discord_presence() # frischen Worker sofort mit dem aktuellen Stand versorgen
self._setup_auto_update()
@@ -221,6 +289,48 @@ class MainWindow(QMainWindow):
self.raise_()
self.activateWindow()
def show_and_raise(self) -> None:
"""Oeffentlicher Einstieg fuer den Einzelinstanz-Schutz (main.py): holt das Fenster
aus dem Tray, wenn Playtube ein zweites Mal gestartet wird."""
self._show_and_raise()
# ------------------------------------------------- Fernsteuerung (remote_control.py)
def _browser_tabs_by_name(self) -> dict[str, BrowserTab]:
return {"youtube": self._youtube_tab, "music": self._music_tab}
def _visible_tab_name(self) -> str:
current = self._current_tab()
for name, tab in self._browser_tabs_by_name().items():
if current is tab:
return name
return "settings"
def _media_by_tab_name(self) -> dict[str, dict | None]:
return {name: self._latest_media.get(id(tab)) for name, tab in self._browser_tabs_by_name().items()}
def remote_state(self) -> dict:
return build_remote_state(APP_VERSION, self._visible_tab_name(), self._media_by_tab_name())
def remote_show(self) -> None:
self._show_and_raise()
def remote_switch_tab(self, target: str) -> None:
""""youtube"/"music" waehlt den Tab; "auto" wechselt zwischen beiden hin und her."""
if target == "auto":
target = "music" if self._visible_tab_name() == "youtube" else "youtube"
self._tabs.setCurrentWidget(self._browser_tabs_by_name()[target])
def remote_media_command(self, target: str, command: str, value: float) -> None:
name = resolve_remote_target(target, self._visible_tab_name(), self._media_by_tab_name())
self._browser_tabs_by_name()[name].run_media_command(command, value)
def remote_list_playlists(self, callback) -> None:
self._music_tab.list_playlists(callback)
def remote_play_playlist(self, playlist_id: str) -> None:
self._music_tab.play_playlist(playlist_id)
def _quit(self) -> None:
if self._rpc_worker is not None:
self._rpc_worker.stop()
@@ -311,20 +421,33 @@ class MainWindow(QMainWindow):
if answer == QMessageBox.StandardButton.Yes:
self._start_update_install(download_url)
def _start_update_install(self, download_url: str) -> None:
if not download_url:
def _start_update_install(self, download_url: str = "", local_path: str | None = None) -> None:
if not download_url and not local_path:
return
self._settings_tab.hide_install_button()
if local_path:
self._settings_tab.set_update_status("Installiere lokale Patch-Datei …")
self._settings_tab.set_download_progress(-1)
else:
self._settings_tab.set_update_status("Lade Update herunter …")
self._settings_tab.set_download_progress(0)
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
self._update_installer = UpdateInstaller(download_url, self)
self._update_installer = UpdateInstaller(
download_url, local_archive_path=local_path, parent=self
)
self._update_installer.progress.connect(self._on_install_progress_text)
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
self._update_installer.finished_ok.connect(self._on_update_finished)
self._update_installer.failed.connect(self._on_update_failed)
self._update_installer.start()
def install_local_patch(self, path: str) -> None:
"""Wird von main.py aufgerufen, wenn Playtube per Doppelklick auf eine
heruntergeladene .play-Patchdatei gestartet wurde (siehe
shortcuts.ensure_play_file_association)."""
self._tabs.setCurrentWidget(self._settings_tab)
self._start_update_install(local_path=path)
def _on_install_progress_text(self, msg: str) -> None:
self._tray.setToolTip(f"{APP_NAME} – {msg}")
self._settings_tab.set_update_status(msg)
@@ -341,6 +464,10 @@ class MainWindow(QMainWindow):
"Installation abgeschlossen. Playtube wird neu gestartet …"
)
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
# Haengt sich Qt/QtWebEngine beim Beenden auf, wuerde der Update-Helfer (bzw.
# Setup, siehe /WAITPID) ewig auf das Ende dieses Prozesses warten - und die
# alte Version bliebe einfach weiterlaufen. Nach 8 s hart beenden.
_force_exit_after(8.0)
self._quit()
else:
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);
}
// Zusatzstatus fuer die Fernsteuerung (Stream-Dock-Plugin, siehe remote_control.py).
var player = document.getElementById('movie_player');
var volume = (player && typeof player.getVolume === 'function')
? player.getVolume() : (video ? Math.round(video.volume * 100) : null);
var muted = (player && typeof player.isMuted === 'function')
? player.isMuted() : (video ? video.muted : null);
function likeState() {
if (isMusic) {
var r = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer');
var s = r ? r.getAttribute('like-status') : null;
return s ? s.toLowerCase() : null; // "like" | "dislike" | "indifferent"
}
var like = document.querySelector('like-button-view-model button, #segmented-like-button button');
var dislike = document.querySelector('dislike-button-view-model button, #segmented-dislike-button button');
if (like && like.getAttribute('aria-pressed') === 'true') { return 'like'; }
if (dislike && dislike.getAttribute('aria-pressed') === 'true') { return 'dislike'; }
return like ? 'indifferent' : null;
}
function repeatMode() {
if (!isMusic) { return video && video.loop ? 'ONE' : 'NONE'; }
var bar = document.querySelector('ytmusic-player-bar');
return bar ? (bar.getAttribute('repeat-mode') || bar.getAttribute('repeat-mode_') || null) : null;
}
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
@@ -123,7 +147,11 @@ MEDIA_PROBE_JS = r"""
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: currentTime,
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()
+118 -3
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
from typing import Any
from PySide6.QtCore import Signal
from PySide6.QtMultimedia import QMediaDevices
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFormLayout,
@@ -22,9 +24,32 @@ from PySide6.QtWidgets import (
)
from . import __version__ as APP_VERSION
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
from .config import APP_NAME, save_config
from .updater import GITHUB_REPO
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
# palette(mid) war ein dunkles Grau, das auf dem dunklen Hintergrund kaum lesbar war.
_LIGHT_TEXT_STYLE = "color: palette(text);"
# Update-Fortschrittsbalken: helle Beschriftung und gut sichtbarer Balken. Sobald ein
# Stylesheet gesetzt ist, zeichnet Qt den Balken nicht mehr mit dem nativen Stil - deshalb
# hier Rahmen, Hintergrund und Balken (chunk) vollstaendig angegeben.
_PROGRESS_BAR_STYLE = """
QProgressBar {
color: palette(text);
background-color: palette(base);
border: 1px solid palette(mid);
border-radius: 4px;
text-align: center;
}
QProgressBar::chunk {
background-color: #2f81f7;
border-radius: 3px;
}
"""
class SettingsTab(QWidget):
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
@@ -33,6 +58,9 @@ class SettingsTab(QWidget):
settingsSaved = Signal(dict)
checkUpdatesRequested = Signal()
installUpdateRequested = Signal()
# Neue Beschreibung, was gerade bearbeitet wird (fuer die Discord-Presence, siehe
# MainWindow._update_discord_presence).
activityChanged = Signal(str)
def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent)
@@ -48,7 +76,7 @@ class SettingsTab(QWidget):
title.setStyleSheet("font-size: 20px; font-weight: 600;")
header.addWidget(title)
version_label = QLabel(f"Version {APP_VERSION}")
version_label.setStyleSheet("color: palette(mid);")
version_label.setStyleSheet(_LIGHT_TEXT_STYLE)
header.addWidget(version_label)
outer.addLayout(header)
@@ -97,18 +125,44 @@ class SettingsTab(QWidget):
update_form.addRow(update_actions)
self._update_status_label = QLabel("")
self._update_status_label.setStyleSheet("color: palette(mid);")
self._update_status_label.setStyleSheet(_LIGHT_TEXT_STYLE)
self._update_status_label.setWordWrap(True)
update_form.addRow(self._update_status_label)
self._update_progress = QProgressBar()
self._update_progress.setRange(0, 100)
self._update_progress.setTextVisible(True)
self._update_progress.setStyleSheet(_PROGRESS_BAR_STYLE)
self._update_progress.setVisible(False)
update_form.addRow(self._update_progress)
outer.addWidget(update_box)
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_form = QFormLayout(general_box)
self._start_tab = QComboBox()
@@ -127,11 +181,68 @@ class SettingsTab(QWidget):
outer.addLayout(button_row)
info = QLabel(f"github.com/{GITHUB_REPO}")
info.setStyleSheet("color: palette(mid);")
info.setStyleSheet(_LIGHT_TEXT_STYLE)
outer.addWidget(info)
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:
self._config.setdefault("discord", {})
self._config.setdefault("updates", {})
@@ -144,6 +255,10 @@ class SettingsTab(QWidget):
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
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()
save_config(self._config)
+55 -5
View File
@@ -1,9 +1,12 @@
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung.
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung sowie die Dateizuordnung
fuer die eigene ".play"-Patchdateiendung.
Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es
also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten"
Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent -
prueft vorher, ob die Verknuepfung schon existiert)."""
Playtube-Setup (packaging/playtube.iss) legt Startmenue-Eintrag und Dateizuordnung
selbst an. Fuer eine portable, aus einem ZIP entpackte Kopie gaebe es sonst nie einen
Eintrag im Windows-Startmenue - der wird beim Start der gepackten .exe einmalig
nachgeholt (idempotent - prueft vorher, ob die Verknuepfung schon existiert). Eine durch
Setup installierte Kopie fasst die Verknuepfung nicht an, damit sie nicht versehentlich
wieder auf eine andere (portable) Kopie umgebogen wird."""
from __future__ import annotations
import os
@@ -20,6 +23,12 @@ def _find_icon(exe_dir: Path) -> Path | None:
def ensure_start_menu_shortcut(app_name: str) -> None:
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
# Import erst hier: shortcuts.py wird vor dem restlichen Qt-Setup importiert (siehe
# main.py), updater.py zieht PySide6 nach.
from .updater import is_installed_via_setup
if is_installed_via_setup():
return # Verknuepfung gehoert dem Installer (siehe Moduldoku)
try:
appdata = os.environ.get("APPDATA")
if not appdata:
@@ -50,3 +59,44 @@ def ensure_start_menu_shortcut(app_name: str) -> None:
except Exception:
# Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen.
pass
def ensure_play_file_association(app_name: str) -> None:
"""Registriert ".play" (unsere eigene Endung fuer Patch-Pakete, siehe updater.py)
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann
danach per Doppelklick installiert werden (Playtube startet dann mit dem Dateipfad
als Kommandozeilenargument, siehe main.py). Nur unter HKEY_CURRENT_USER, damit keine
Admin-Rechte noetig sind. Wird bei jedem Start erneut geschrieben (billig, idempotent
und heilt sich selbst, falls die .exe z.B. nach einem Update an einem neuen Pfad
liegt)."""
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return
try:
import winreg
exe_path = str(Path(sys.executable).resolve())
prog_id = f"{app_name}.PatchFile"
icon_path = _find_icon(Path(exe_path).parent) or Path(exe_path)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\.play") as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, prog_id)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}") as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f"{app_name}-Patchdatei")
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\DefaultIcon"
) as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(icon_path))
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\shell\open\command"
) as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f'"{exe_path}" "%1"')
# Explorer informieren, damit die neue Zuordnung sofort (ohne Neustart) greift.
import ctypes
SHCNE_ASSOCCHANGED = 0x08000000
SHCNF_IDLIST = 0x0000
ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None)
except Exception:
# Keine Dateizuordnung ist kein Grund, den App-Start scheitern zu lassen.
pass
+86
View File
@@ -0,0 +1,86 @@
"""Einzelinstanz-Schutz: verhindert, dass zwei Playtube-Prozesse gleichzeitig auf
demselben Browser-Profil (webprofile/) laufen.
Hintergrund: Laufen zwei Instanzen auf demselben QtWebEngine-Profil (Cache, GPUCache,
Service Worker, Cookies), zeigt die ZWEITE Instanz auf YouTube/YouTube Music sichtbar
keine Icons mehr (Play/Pause, Suche, Menue ...). Das passiert leicht, weil das Schliessen
des Fensters Playtube nur in den Tray minimiert - eine alte Version laeuft dann
unsichtbar weiter, waehrend eine neu heruntergeladene Version zusaetzlich gestartet wird.
Ansatz: ein lokaler Server (Named Pipe unter Windows, Unix-Socket unter Linux) pro
Datenordner. Der erste Prozess lauscht darauf; jeder weitere verbindet sich damit,
bittet die laufende Instanz, ihr Fenster zu zeigen, und beendet sich selbst.
"""
from __future__ import annotations
from PySide6.QtCore import QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
_CONNECT_TIMEOUT_MS = 500
_WRITE_TIMEOUT_MS = 500
_MSG_SHOW = "show"
_MSG_PATCH = "patch"
_SEPARATOR = "\t"
class SingleInstanceGuard(QObject):
"""Der erste Prozess mit einem bestimmten `key` "besitzt" die Instanz; jeder weitere
Aufruf von acquire() liefert False und reicht seine Anfrage an den Besitzer weiter."""
showRequested = Signal()
patchRequested = Signal(str)
def __init__(self, key: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._key = key
self._server: QLocalServer | None = None
def acquire(self, local_patch: str | None = None) -> bool:
"""True, wenn dieser Prozess die (einzige) Instanz sein darf. False, wenn bereits
eine laeuft - dann wurde ihr "Fenster zeigen" (bzw. der Patch-Pfad) gemeldet."""
if self._notify_running_instance(local_patch):
return False
# Kein Besitzer erreichbar: ggf. verwaisten Socket (Linux) aufraeumen und selbst
# lauschen.
QLocalServer.removeServer(self._key)
server = QLocalServer(self)
if not server.listen(self._key):
# Knappes Rennen zweier gleichzeitiger Starts: der andere war einen Tick
# frueher - nochmal versuchen, ihn zu erreichen, sonst trotzdem starten
# (lieber ohne Schutz laufen als gar nicht).
return not self._notify_running_instance(local_patch)
server.newConnection.connect(self._on_new_connection)
self._server = server
return True
def _notify_running_instance(self, local_patch: str | None) -> bool:
socket = QLocalSocket(self)
socket.connectToServer(self._key)
if not socket.waitForConnected(_CONNECT_TIMEOUT_MS):
socket.abort()
return False
message = f"{_MSG_PATCH}{_SEPARATOR}{local_patch}" if local_patch else _MSG_SHOW
socket.write(message.encode("utf-8"))
socket.waitForBytesWritten(_WRITE_TIMEOUT_MS)
socket.disconnectFromServer()
return True
def _on_new_connection(self) -> None:
if self._server is None:
return
while self._server.hasPendingConnections():
socket = self._server.nextPendingConnection()
socket.waitForReadyRead(_CONNECT_TIMEOUT_MS)
message = bytes(socket.readAll().data()).decode("utf-8", errors="replace")
socket.disconnectFromServer()
socket.deleteLater()
self._dispatch(message)
def _dispatch(self, message: str) -> None:
command, _, argument = message.partition(_SEPARATOR)
if command == _MSG_PATCH and argument:
self.patchRequested.emit(argument)
self.showRequested.emit()
+240 -44
View File
@@ -5,23 +5,39 @@ Ablauf:
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
- Gepackte App (Windows Playtube.exe oder Linux Playtube-Binary): laedt
bevorzugt das kleine "Patch"-Paket herunter (enthaelt NUR die eigentliche
.exe/Binary mit unserem Anwendungscode, ca. 2-3 MB statt ~200 MB) und
ersetzt lediglich diese Datei - der riesige PySide6/QtWebEngine-Laufzeit-
Ordner (_internal/) bleibt unangetastet, da er sich zwischen Patch-Releases
normalerweise nicht aendert. Gibt es kein Patch-Paket (z.B. beim allerersten
Release oder wenn CI es nicht gebaut hat), faellt es automatisch auf das
volle Release-Paket zurueck und ersetzt den kompletten Installationsordner.
- Gepackte App unter Windows: Es gibt EINE Installation (Playtube-Setup, siehe
packaging/playtube.iss, Ordner %LOCALAPPDATA%/Programs/Playtube), die jedes
Update an Ort und Stelle ueberschreibt - keine parallelen Versionsordner.
* Installierte Kopie: laedt bevorzugt das kleine "Patch"-Paket (nur die
Playtube.exe, ca. 2-3 MB) und ersetzt lediglich diese Datei; der grosse
PySide6/QtWebEngine-Ordner (_internal/) bleibt unangetastet. Passt die
PySide6-Version des Patches nicht zur installierten Laufzeit (siehe
_patch_is_compatible), wird stattdessen der Setup-Installer benutzt.
* Portable Kopie (aus einem ZIP entpackt, z.B. in Downloads): laedt den
Setup-Installer und fuehrt ihn still aus. Danach liegt Playtube in der
regulaeren Installation und Startmenue/Desktop zeigen dorthin - die alte
portable Kopie wird nicht mehr gebraucht.
* Ohne Setup-Asset (aeltere Releases): Fallback auf das volle ZIP wie frueher.
- Gepackte App unter Linux: Patch-Paket (nur das Binary) bzw. volles .tar.gz.
Ein kurzes Skript wartet dafuer (nach Prozessende) und startet die App neu -
PowerShell unter Windows, ein Shell-Skript unter Linux.
PowerShell unter Windows (Patch/ZIP), Inno Setup selbst (Setup), Shell unter Linux.
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
Windows-Patch-Pakete tragen die eigene Dateiendung ".play" statt ".zip" (technisch
weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die
Magic Bytes, nicht auf die Endung). shortcuts.py registriert ".play" beim ersten Start
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann so
auch per Doppelklick installiert werden, ohne dass Playtube selbst etwas herunterladen
muss (siehe main.py: wird eine .play-Datei als Kommandozeilenargument uebergeben,
installiert MainWindow.install_local_patch() sie direkt ueber UpdateInstaller).
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
import sys
@@ -38,11 +54,25 @@ from PySide6.QtCore import QThread, Signal
from . import __version__ as CURRENT_VERSION
GITHUB_REPO = "fojadrachi/Playtube"
_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
_USER_AGENT = "Playtube-Updater"
# Edge-Variante: eigene Releases mit Tag "vX.Y.Z-edge" (als Vorabversion markiert, damit die
# 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"
_WINDOWS_BINARY_NAME = "Playtube.exe"
_LINUX_BINARY_NAME = "PlaytubeEdge"
_WINDOWS_BINARY_NAME = "PlaytubeEdge.exe"
# Muss mit "AppId" in packaging/playtube.iss uebereinstimmen. Unter dieser ID legt Inno
# Setup den Deinstallations-Eintrag ("Apps & Features") an - nach einem Patch-Update
# wird dort die angezeigte Versionsnummer nachgezogen (siehe _install_patch_windows).
_INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
_STAGING_PREFIX = "playtube_update_"
# Release-Asset-Arten (siehe _asset_kind): "setup" = Inno-Installer (nur Windows),
# "patch" = kleines Paket mit nur der .exe/dem Binary, "full" = komplettes ZIP/.tar.gz.
_KIND_SETUP, _KIND_PATCH, _KIND_FULL = "setup", "patch", "full"
def _parse_version(v: str) -> tuple[int, ...]:
@@ -58,47 +88,129 @@ def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool:
return _parse_version(remote) > _parse_version(local)
def _version_from_asset_name(name: str) -> str | None:
"""Zieht "X.Y.Z" aus einem Release-Asset-Namen wie "Playtube-v2.4.0-win64-patch.play"
(die PySide6-Version im Patch-Namen, "pyside6.11.2", wird dabei uebersprungen)."""
match = re.search(r"v(\d+\.\d+\.\d+)", name, flags=re.IGNORECASE)
return match.group(1) if match else None
def cleanup_old_staging() -> None:
"""Loescht Reste frueherer Update-Laeufe im Temp-Ordner (u.a. den heruntergeladenen
Setup-Installer - er laesst sich nicht selbst loeschen, solange er laeuft)."""
for old in Path(tempfile.gettempdir()).glob(f"{_STAGING_PREFIX}*"):
shutil.rmtree(old, ignore_errors=True)
def fetch_latest_release() -> dict[str, Any] | None:
req = urllib.request.Request(
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
)
try:
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):
return None
if not isinstance(releases, list):
return None
# Neueste Edge-Version = hoechste Versionsnummer unter den "-edge"-Releases (ohne Entwuerfe).
edge_releases = [
release for release in releases
if isinstance(release, dict) and not release.get("draft")
and str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX)
]
return max(edge_releases, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None)
def is_installed_via_setup() -> bool:
"""True, wenn diese Playtube.exe aus einer Installation durch Playtube-Setup stammt
(Inno Setup legt im Installationsordner immer unins000.exe ab) - im Gegensatz zu
einer portablen, aus einem ZIP entpackten Kopie."""
if sys.platform != "win32" or not getattr(sys, "frozen", False):
return False
return (Path(sys.executable).resolve().parent / "unins000.exe").exists()
def _asset_kind(name: str) -> str | None:
"""Ordnet ein Release-Asset (nach Dateiname) einer Art zu - oder None, wenn es fuer
die laufende Plattform nicht in Frage kommt. Windows: Setup -> *Setup*.exe, Patch ->
*.play (unsere eigene Dateiendung, technisch ein ganz normales .zip), Voll -> *.zip.
Linux: *.tar.gz, "patch" im Namen kennzeichnet das Patch-Paket."""
name = name.lower()
# Zweite Absicherung neben dem "-edge"-Tag: nur Dateien der Edge-Variante kommen in Frage,
# nie ein Paket der Qt-Version ("Playtube-...").
if "playtubeedge" not in name:
return None
if sys.platform == "win32":
if name.endswith(".exe") and "setup" in name:
return _KIND_SETUP
if name.endswith(".play"):
return _KIND_PATCH
if name.endswith(".zip") and "patch" not in name:
return _KIND_FULL
return None
if sys.platform.startswith("linux") and name.endswith((".tar.gz", ".tgz")):
return _KIND_PATCH if "patch" in name else _KIND_FULL
return None
def _patch_is_compatible(name: str) -> bool:
"""Patch-Pakete tragen die PySide6-Version, gegen die sie gebaut wurden, im Namen
(z.B. "Playtube-v2.4.0-win64-pyside6.11.2-patch.play"). Weicht sie von der Laufzeit
der installierten App ab, waere die neue Playtube.exe mit dem vorhandenen _internal/
inkompatibel - dann muss stattdessen der Setup-Installer die Laufzeit mit erneuern.
Ohne Angabe im Namen (aeltere Releases) wird wie frueher Kompatibilitaet angenommen."""
match = re.search(r"pyside(\d+\.\d+\.\d+)", name.lower())
if not match:
return True
try:
import PySide6
return match.group(1) == PySide6.__version__
except Exception: # noqa: BLE001 - im Zweifel wie frueher patchen
return True
def _preferred_kinds() -> tuple[str, ...]:
"""Reihenfolge, in der Release-Asset-Arten ausprobiert werden."""
if sys.platform == "win32":
if is_installed_via_setup():
# Installierte Kopie: kleines Patch zuerst (schnell, ueberschreibt nur die
# .exe im Installationsordner).
return (_KIND_PATCH, _KIND_SETUP, _KIND_FULL)
# Portable Kopie: Setup zuerst - danach liegt Playtube in der regulaeren
# Installation, statt dass weiter versionierte Ordner nebeneinander entstehen.
return (_KIND_SETUP, _KIND_PATCH, _KIND_FULL)
return (_KIND_PATCH, _KIND_FULL)
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
assets = release.get("assets", [])
"""Sucht das zur laufenden Plattform passende Release-Paket (Reihenfolge der Arten
siehe _preferred_kinds). Innerhalb einer Art wird ein Asset mit Plattformhinweis
("win"/"linux") im Namen bevorzugt."""
if sys.platform == "win32":
hints, exts = ("win",), (".zip",)
hint = "win"
elif sys.platform.startswith("linux"):
hints, exts = ("linux",), (".tar.gz", ".tgz")
hint = "linux"
else:
return None
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
for asset in assets:
name = asset.get("name", "").lower()
if not name.endswith(exts):
candidates: list[tuple[str, bool, dict[str, Any]]] = []
for asset in release.get("assets", []):
name = asset.get("name", "")
kind = _asset_kind(name)
if kind is None:
continue
if require_hint and not any(h in name for h in hints):
if kind == _KIND_PATCH and not _patch_is_compatible(name):
continue
if ("patch" in name) != want_patch:
continue
return asset
return None
candidates.append((kind, hint in name.lower(), asset))
return (
find(True, True)
or find(True, False)
or find(False, True)
or find(False, False)
)
for kind in _preferred_kinds():
matching = [(has_hint, asset) for k, has_hint, asset in candidates if k == kind]
if matching:
matching.sort(key=lambda item: not item[0]) # stabil: Treffer mit Hinweis zuerst
return matching[0][1]
return None
class UpdateChecker(QThread):
@@ -133,9 +245,18 @@ class UpdateInstaller(QThread):
finished_ok = Signal()
failed = Signal(str)
def __init__(self, download_url: str, parent=None):
def __init__(
self,
download_url: str = "",
local_archive_path: str | None = None,
parent=None,
):
super().__init__(parent)
self._download_url = download_url
# Gesetzt, wenn der Nutzer eine bereits heruntergeladene .play-Patchdatei per
# Doppelklick geoeffnet hat (siehe shortcuts.py-Dateiverknuepfung) - dann wird
# nichts heruntergeladen, sondern direkt diese lokale Datei installiert.
self._local_archive_path = local_archive_path
def run(self) -> None:
try:
@@ -149,6 +270,20 @@ class UpdateInstaller(QThread):
# ---------------------------------------------------------- gepackter Modus
def _run_packaged_update(self) -> None:
install_dir = Path(sys.executable).resolve().parent
cleanup_old_staging()
staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX))
extract_dir = staging / "extracted"
if self._local_archive_path:
archive_path = Path(self._local_archive_path)
if not archive_path.exists():
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
return
archive_name = archive_path.name
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
self.progress_percent.emit(-1)
else:
if not self._download_url:
self.failed.emit(
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
@@ -157,11 +292,8 @@ class UpdateInstaller(QThread):
self.progress.emit("Lade Update herunter …")
self.progress_percent.emit(0)
install_dir = Path(sys.executable).resolve().parent
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
archive_name = self._download_url.rsplit("/", 1)[-1]
archive_path = staging / archive_name
extract_dir = staging / "extracted"
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp:
@@ -180,8 +312,18 @@ class UpdateInstaller(QThread):
if percent != last_emitted:
self.progress_percent.emit(percent)
last_emitted = percent
self.progress_percent.emit(100)
if sys.platform == "win32" and archive_name.lower().endswith(".exe"):
# Setup-Installer: nichts zu entpacken - er installiert selbst ueber die
# vorhandene Installation (bzw. richtet bei einer portablen Kopie die
# regulaere Installation ein) und startet Playtube danach neu.
self.progress.emit("Starte Installer …")
self.progress_percent.emit(-1)
self._install_setup_windows(archive_path)
self.finished_ok.emit()
return
self.progress.emit("Entpacke Update …")
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
@@ -190,10 +332,13 @@ class UpdateInstaller(QThread):
with tarfile.open(archive_path) as tf:
tf.extractall(extract_dir)
else:
# .zip UND .play (unsere eigene Dateiendung fuer per Doppelklick startbare
# Patchdateien - technisch ein ganz normales .zip, siehe Moduldoku) werden
# beide als ZIP entpackt.
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(extract_dir)
is_patch = "patch" in archive_name.lower()
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
self.progress.emit("Bereite Installation vor …")
if is_patch:
@@ -208,7 +353,9 @@ class UpdateInstaller(QThread):
)
return
if sys.platform == "win32":
self._install_patch_windows(matches[0], install_dir, staging)
self._install_patch_windows(
matches[0], install_dir, staging, _version_from_asset_name(archive_name)
)
else:
self._install_patch_posix(matches[0], install_dir, staging)
else:
@@ -256,8 +403,23 @@ $form.Refresh()
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
def _install_patch_windows(
self, new_exe: Path, install_dir: Path, staging: Path, version: str | None = None
) -> None:
exe_path = install_dir / _WINDOWS_BINARY_NAME
# Nach einem Patch zeigt "Apps & Features" sonst weiter die Version des zuletzt
# ausgefuehrten Setups an. Nur relevant fuer Installationen durch Playtube-Setup
# (der Eintrag existiert bei portablen Kopien nicht - dann ist Test-Path $false).
registry_update = ""
if version and is_installed_via_setup():
registry_update = f"""
if ($copied) {{
$uninstallKey = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{_INNO_APP_ID}_is1"
if (Test-Path -LiteralPath $uninstallKey) {{
Set-ItemProperty -LiteralPath $uninstallKey -Name DisplayVersion -Value "{version}"
}}
}}
"""
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
$ErrorActionPreference = "SilentlyContinue"
$targetPid = {os.getpid()}
@@ -269,6 +431,8 @@ $label.Text = "Kopiere aktualisierte Datei …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
$copied = $?
{registry_update}
$label.Text = "Fertig – Playtube wird neu gestartet …"
$form.Refresh()
[System.Windows.Forms.Application]::DoEvents()
@@ -309,6 +473,36 @@ rm -rf "{staging}"
"""
self._spawn_posix_script(script, staging)
# -- Setup-Installer (Windows): eine einzige, ueberschriebene Installation --
def _install_setup_windows(self, setup_path: Path) -> None:
"""Startet Playtube-Setup still (nur Fortschrittsfenster, keine Rueckfragen).
Der Installer beendet laufende Playtube-Prozesse selbst, ueberschreibt die
vorhandene Installation im selben Ordner (gleiche AppId, siehe
packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die
laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss
hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das
Beenden dieses Prozesses ueberlebt.
/WAITPID=<eigene PID>: Setup wartet, bis sich diese App wirklich beendet hat,
bevor es Dateien tauscht. /LOG (ohne Pfad): Setup schreibt ein Protokoll nach
%TEMP% ("Setup Log ....txt") - so laesst sich ein fehlgeschlagenes Update
nachtraeglich nachvollziehen, obwohl die Installation selbst still laeuft."""
subprocess.Popen(
[
str(setup_path),
"/SILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/NOCANCEL",
"/RELAUNCH=1",
f"/WAITPID={os.getpid()}",
"/LOG",
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True,
)
# -- Volles Paket (kein Patch verfuegbar/passend) - kompletten Ordner ersetzen --
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
@@ -326,7 +520,9 @@ $form.Refresh()
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
# /R:3 /W:2 begrenzt die Wiederholungen bei gesperrten Dateien (Standard: praktisch endlos,
# das Update haengt dann sichtbar "fuer immer" im Fortschrittsfenster).
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
while (-not $roboProc.HasExited) {{
Start-Sleep -Milliseconds 200
+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
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.