11 Commits
Author SHA1 Message Date
Fojadrachi 94f4514d08 fix: publish_release liest Token auch aus %USERPROFILE%\.gitea-token, Notizen als Text 2026-09-26 21:44:40 +02:00
Fojadrachi 9e8e9caafc feat: unabhaengig von GitHub - Updater und Releases ueber eigenes Gitea
Release (Edge) / release (push) Waiting to run
2026-09-26 21:32:43 +02:00
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
32 changed files with 2053 additions and 470 deletions
+78
View File
@@ -0,0 +1,78 @@
name: Release (Edge)
# Baut Playtube Edge (Microsoft-Edge-Engine / WebView2) fuer Windows und veroeffentlicht Setup
# und ZIP als Release auf dem eigenen Gitea-Server, sobald ein Tag im Format "vX.Y.Z-edge"
# gepusht wird (z.B. durch packaging/release.ps1).
#
# 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. Der Updater der normalen Playtube
# ignoriert Vorabversionen und Tags mit "-edge"; der Updater der Edge-Version sucht seinerseits
# nur nach Tags "...-edge" (siehe playtube/updater.py).
#
# Voraussetzungen (einmalig, siehe README "Releases bauen"):
# - Gitea-Actions-Runner unter Windows mit Label "windows" (Host-Modus) mit Python 3.12, git,
# PowerShell und Inno Setup 6.
# - Repo-Secret "RELEASE_TOKEN" (Zugriffstoken mit Schreibrecht); ersatzweise der automatische Token.
# Bewusst KEINE "uses:"-Aktionen: die wuerden standardmaessig von github.com geladen.
on:
push:
tags:
- "v*-edge"
jobs:
release:
runs-on: windows
steps:
- name: Quellcode holen
shell: pwsh
run: |
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" src
git -C src checkout "${{ gitea.ref_name }}"
- name: Abhaengigkeiten installieren
shell: pwsh
working-directory: src
run: |
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install -r requirements.txt pyinstaller
- name: PlaytubeEdge.exe bauen
shell: pwsh
working-directory: src
run: .\.venv\Scripts\python.exe -m PyInstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Setup-Installer bauen (Inno Setup)
shell: pwsh
working-directory: src
run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$candidates = @(
(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 = $candidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $iscc) { throw "Inno Setup 6 ist auf dem Runner nicht installiert." }
& $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
- name: ZIP packen (portabel)
shell: pwsh
working-directory: src
run: Compress-Archive -Path dist\PlaytubeEdge -DestinationPath "PlaytubeEdge-${{ gitea.ref_name }}-win64.zip"
- name: Release auf Gitea veroeffentlichen (Vorabversion)
shell: pwsh
working-directory: src
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: |
$tag = "${{ gitea.ref_name }}"
$files = @((Get-ChildItem dist\installer\PlaytubeEdge-Setup-v*.exe).FullName) + @((Get-ChildItem "PlaytubeEdge-*-win64.zip").FullName)
.\packaging\publish_release.ps1 -Tag $tag -Title "Playtube Edge $tag" -NotesFile packaging\release_notes.md `
-Files $files -Prerelease -Server "${{ gitea.server_url }}" -Repo "${{ gitea.repository }}"
-141
View File
@@ -1,141 +0,0 @@
name: Release
# 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.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch: {}
permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Abhaengigkeiten installieren
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Playtube.exe bauen
run: pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build
- name: Setup-Installer bauen (Inno Setup)
# Der Installer ist der empfohlene Download fuer Neueinsteiger UND das, was
# Playtube fuer volle Updates benutzt (siehe playtube/updater.py): eine einzige
# Installation, die jedes Update an Ort und Stelle ueberschreibt (feste AppId in
# packaging/playtube.iss) - keine parallelen Versionsordner mehr.
shell: pwsh
run: |
$version = (Select-String -Path playtube\__init__.py -Pattern '__version__\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
choco install innosetup -y --no-progress
}
& $iscc "/DAppVersion=$version" packaging\playtube.iss
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
- uses: actions/upload-artifact@v4
with:
name: windows-setup
path: dist/installer/Playtube-Setup-v*.exe
- name: Als .zip packen (volles Paket, portabel)
run: Compress-Archive -Path dist\Playtube -DestinationPath Playtube-${{ github.ref_name }}-win64.zip
- uses: actions/upload-artifact@v4
with:
name: windows-build
path: Playtube-${{ github.ref_name }}-win64.zip
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
# Technisch bleibt es ein ganz normales ZIP-Archiv.
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
# installierten App geaendert, ist ein reines .exe-Patch nicht mehr kompatibel und
# der Updater nimmt stattdessen den Setup-Installer (siehe _patch_is_compatible).
shell: pwsh
run: |
$pyside = python -c "import PySide6; print(PySide6.__version__)"
$name = "Playtube-${{ github.ref_name }}-win64-pyside${pyside}-patch"
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
Rename-Item -Path "$name.zip" -NewName "$name.play"
- uses: actions/upload-artifact@v4
with:
name: windows-patch
path: Playtube-*-win64-pyside*-patch.play
build-linux:
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
+4
View File
@@ -4,4 +4,8 @@ __pycache__/
build/
dist/
run*.log
*_run.log
build_edge.log
.claude/
.pytest_cache/
test_login*.log
+107 -17
View File
@@ -1,4 +1,23 @@
# Playtube
# Playtube Edge
> **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://git.fojadrachi.de/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:
@@ -8,7 +27,14 @@ Erweiterung) mit:
- **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 läuft.
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
@@ -16,8 +42,8 @@ Erweiterung) mit:
## 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
Lade auf der [Releases-Seite](https://git.fojadrachi.de/Fojadrachi/Playtube/releases)
`PlaytubeEdge-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,
@@ -63,6 +89,51 @@ sie ändern oder eine eigene Anwendung nutzen willst:
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
## Audioausgabe pro Tab (YouTube / YouTube Musik)
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
@@ -100,7 +171,7 @@ o.ä. umgehen).
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
[Releases](https://git.fojadrachi.de/Fojadrachi/Playtube/releases) auf dem eigenen Gitea-Server (nur Tags `-edge`). Im
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:
@@ -141,7 +212,7 @@ Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technis
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
[Releases-Seite](https://git.fojadrachi.de/Fojadrachi/Playtube/releases)) lässt sich also
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
muss.
@@ -152,12 +223,32 @@ 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
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 veröffentlicht - Fortschritt unter
https://github.com/fojadrachi/Playtube/actions.
`origin` (dein Gitea unter https://git.fojadrachi.de/Fojadrachi/Playtube). Für Playtube Edge
trägt der Tag den Zusatz `-edge` (z. B. `v1.0.1-edge`). Der gepushte Tag löst automatisch die
Gitea-Actions-Pipeline ([.gitea/workflows/release.yml](.gitea/workflows/release.yml)) aus, die
Playtube Edge für Windows baut und als **Vorabversion** auf dem Gitea veröffentlicht -
Fortschritt unter https://git.fojadrachi.de/Fojadrachi/Playtube/actions.
#### Releases bauen (einmalige Einrichtung)
GitHub wird nirgends mehr benutzt. Die Pipeline braucht:
1. **Einen Gitea-Actions-Runner unter Windows** (`act_runner`, Label `windows`, Host-Modus)
mit Python 3.12, git, PowerShell und [Inno Setup 6](https://jrsoftware.org/isinfo.php).
Registrierung z. B.:
`act_runner register --instance https://git.fojadrachi.de --token <Runner-Token> --labels windows:host`
2. **Ein Secret `RELEASE_TOKEN`** im Repo (Einstellungen → Actions → Secrets): ein
Zugriffstoken (Gitea → Einstellungen → Anwendungen) mit Schreibrecht auf das Repo.
3. In Gitea **Actions für das Repo aktivieren** (Einstellungen → Erweitert → Repository-Einheiten).
**Ohne Runner** kannst du selbst bauen und veröffentlichen:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build.ps1
$env:GITEA_TOKEN = "<Zugriffstoken>"
powershell -ExecutionPolicy Bypass -File packaging\publish_release.ps1 -Tag v1.0.1-edge -Prerelease `
-Files dist\installer\PlaytubeEdge-Setup-v1.0.1.exe -NotesFile packaging\release_notes.md
```
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
@@ -169,9 +260,9 @@ neue Version danach automatisch angeboten.
## Native Linux-Version
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.
**Hinweis:** Playtube Edge nutzt WebView2 und läuft nur unter Windows. Die Linux-Beschreibung
unten gehört zur normalen Playtube (Branch `main`, QtWebEngine); für Edge entstehen keine
Linux-Pakete.
**Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
@@ -192,8 +283,7 @@ python3 -m venv .venv
```
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)).
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2`.
## Hinweise
+9 -11
View File
@@ -12,11 +12,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402
from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
from playtube.single_instance import SingleInstanceGuard # noqa: E402
from playtube.shortcuts import ( # noqa: E402
ensure_play_file_association,
ensure_start_menu_shortcut,
)
from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402
from playtube.updater import cleanup_old_staging # noqa: E402
app_id.set_app_user_model_id()
@@ -25,12 +23,6 @@ app_id.configure_webengine_process_path()
# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in
# 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
@@ -77,7 +69,7 @@ def main() -> int:
# komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird.
clear_cache_on_update(__version__)
ensure_start_menu_shortcut(APP_NAME)
ensure_play_file_association(APP_NAME)
# Keine ".play"-Dateizuordnung: die gehoert der Qt-Version von Playtube (Edge nutzt nur Setup-Updates).
window = MainWindow(config)
window.show()
@@ -87,6 +79,12 @@ def main() -> int:
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:
+7 -56
View File
@@ -1,19 +1,11 @@
# 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 (Playtube-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
# Ueberspringt den Bau des Setup-Installers (PlaytubeEdge-Setup-vX.Y.Z.exe, benoetigt Inno Setup 6).
[switch]$SkipInstaller
)
@@ -21,53 +13,13 @@ $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." }
$distDir = Join-Path $root "dist\Playtube"
$internalDir = Join-Path $distDir "_internal"
$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir }
$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $engineProcess) {
Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen."
} else {
$helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe"
Copy-Item $engineProcess.FullName $helperPath -Force
Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan
$rcedit = Get-Command rcedit -ErrorAction SilentlyContinue
$rceditLocal = Join-Path $PSScriptRoot "rcedit.exe"
if ($rcedit) {
$rceditExe = $rcedit.Source
} elseif (Test-Path $rceditLocal) {
$rceditExe = $rceditLocal
} else {
$rceditExe = $null
}
if ($rceditExe) {
Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan
& $rceditExe $helperPath --set-icon "$root\assets\icon.ico"
& $rceditExe $helperPath --set-version-string "FileDescription" "Playtube"
& $rceditExe $helperPath --set-version-string "ProductName" "Playtube"
& $rceditExe $helperPath --set-version-string "CompanyName" "Playtube"
& $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe"
} else {
Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow
Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow
Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow
Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green
Write-Host "Fertig! Die App liegt unter dist\PlaytubeEdge\PlaytubeEdge.exe" -ForegroundColor Green
# --- Setup-Installer (Inno Setup) ------------------------------------------------------
# Baut aus dist\Playtube den Installer dist\installer\Playtube-Setup-vX.Y.Z.exe. Ein und
# derselbe Installer richtet Playtube erstmalig ein UND aktualisiert spaeter eine
# 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
@@ -86,12 +38,11 @@ if (-not $SkipInstaller) {
Write-Host ""
Write-Host "Hinweis: Inno Setup 6 nicht gefunden - Setup-Installer wird uebersprungen." -ForegroundColor Yellow
Write-Host "Installieren: winget install --id JRSoftware.InnoSetup -e" -ForegroundColor Yellow
Write-Host "(oder mit -SkipInstaller diesen Schritt bewusst auslassen)" -ForegroundColor Yellow
} else {
Write-Host ""
Write-Host "==> Baue Setup-Installer (Version $version) mit Inno Setup ..." -ForegroundColor Cyan
& $iscc "/DAppVersion=$version" (Join-Path $PSScriptRoot "playtube.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno-Setup-Build fehlgeschlagen." }
Write-Host "Fertig! Installer: dist\installer\Playtube-Setup-v$version.exe" -ForegroundColor Green
Write-Host "Fertig! Installer: dist\installer\PlaytubeEdge-Setup-v$version.exe" -ForegroundColor Green
}
}
+37 -54
View File
@@ -1,18 +1,19 @@
; Inno-Setup-Skript fuer den Playtube-Windows-Installer (Playtube-Setup-vX.Y.Z.exe).
; Inno-Setup-Skript fuer den Playtube-Edge-Windows-Installer (PlaytubeEdge-Setup-vX.Y.Z.exe).
;
; Bauen (Inno Setup 6.3+ noetig, https://jrsoftware.org/isinfo.php):
; ISCC.exe /DAppVersion=2.4.0 packaging\playtube.iss
; Voraussetzung: dist\Playtube\ existiert bereits (PyInstaller-Build, siehe build.ps1).
; Ergebnis: dist\installer\Playtube-Setup-v2.4.0.exe
; 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
;
; Wichtig fuer "es soll nie mehrere Versionen geben":
; - Feste AppId (unten): jede neue Setup-Version erkennt darueber die vorhandene
; Installation und ueberschreibt sie im SELBEN Ordner (statt eine zweite anzulegen).
; - Installiert pro Benutzer nach %LOCALAPPDATA%\Programs\Playtube (keine Admin-Rechte
; noetig, Ordner ist fuer Playtubes eigenen Updater beschreibbar).
; - Laufende Playtube-Prozesse werden vor dem Kopieren beendet, der alte Programmordner
; (_internal) wird vor dem Kopieren geleert, damit keine Reste alter Versionen bleiben.
; - Login/Einstellungen liegen in %APPDATA%\Playtube und bleiben bei Updates erhalten.
; 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.
@@ -21,38 +22,36 @@
#define AppVersion "0.0.0"
#endif
#define AppName "Playtube"
#define AppExeName "Playtube.exe"
#define AppHelperName "PlaytubeHelper.exe"
#define AppName "Playtube Edge"
#define AppDirName "PlaytubeEdge"
#define AppExeName "PlaytubeEdge.exe"
[Setup]
; NIEMALS aendern - identifiziert die Installation ueber alle Versionen hinweg. Muss mit
; _INNO_APP_ID in playtube/updater.py uebereinstimmen.
AppId={{87FBA502-2B84-4C42-A66B-2F03F490912D}
; 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
AppPublisherURL=https://git.fojadrachi.de/Fojadrachi/Playtube
AppSupportURL=https://git.fojadrachi.de/Fojadrachi/Playtube/issues
AppUpdatesURL=https://git.fojadrachi.de/Fojadrachi/Playtube/releases
VersionInfoVersion={#AppVersion}
DefaultDirName={localappdata}\Programs\{#AppName}
DefaultDirName={localappdata}\Programs\{#AppDirName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist\installer
OutputBaseFilename=Playtube-Setup-v{#AppVersion}
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 versteckt sich beim Schliessen des
; Fensters nur im Tray und reagiert daher nicht zuverlaessig auf die Aufforderung, sich zu
; schliessen, die Inno per CloseApplications senden wuerde.
; 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
@@ -69,27 +68,16 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
Type: filesandordirs; Name: "{app}\_internal"
[Files]
Source: "..\dist\Playtube\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "..\dist\PlaytubeEdge\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
; {autoprograms} = %APPDATA%\Microsoft\Windows\Start Menu\Programs - derselbe Pfad, den die
; App frueher selbst fuer eine portable Kopie angelegt hat; eine alte Verknuepfung, die
; noch auf einen Downloads-Ordner zeigt, wird dadurch ersetzt.
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Dateizuordnung fuer ".play" (Playtube-Patchdateien, siehe playtube/shortcuts.py).
Root: HKCU; Subkey: "Software\Classes\.play"; ValueType: string; ValueName: ""; ValueData: "{#AppName}.PatchFile"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile"; ValueType: string; ValueName: ""; ValueData: "{#AppName}-Patchdatei"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#AppExeName}"
Root: HKCU; Subkey: "Software\Classes\{#AppName}.PatchFile\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#AppExeName}"" ""%1"""
[Run]
; Normale (sichtbare) Installation: Haekchen "Playtube jetzt starten" am Ende.
; Normale (sichtbare) Installation: Haekchen "Playtube Edge jetzt starten" am Ende.
Filename: "{app}\{#AppExeName}"; Description: "{#AppName} jetzt starten"; Flags: nowait postinstall skipifsilent
; Stilles Update durch Playtubes eigenen Updater (/RELAUNCH=1): App danach automatisch
; wieder starten.
; Stilles Update durch den eigenen Updater (/RELAUNCH=1): App danach automatisch wieder starten.
Filename: "{app}\{#AppExeName}"; Flags: nowait; Check: ShouldRelaunch
[Code]
@@ -107,10 +95,8 @@ procedure WaitForOldInstance();
var
OldPid, ResultCode: Integer;
begin
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat,
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
hier nichts. }
{ 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(),
@@ -122,16 +108,13 @@ procedure StopLeftoverProcesses();
var
ResultCode: Integer;
begin
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
{ 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);
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
{ 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}') + '\''; ' +
@@ -161,8 +144,8 @@ begin
if (CurUninstallStep = usPostUninstall) and (not UninstallSilent()) then
begin
if MsgBox('Sollen auch die Einstellungen und der gespeicherte Login von {#AppName} geloescht werden?' + #13#10 +
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppName}') + ')',
'(Ordner: ' + ExpandConstant('{userappdata}\{#AppDirName}') + ')',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(ExpandConstant('{userappdata}\{#AppName}'), True, True, True);
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",
)
+73
View File
@@ -0,0 +1,73 @@
# Veroeffentlicht ein Release auf dem eigenen Gitea-Server (Installer, ZIP, Patch als Anhaenge).
# Wird von der Pipeline (.gitea/workflows/release.yml) benutzt und laesst sich auch von Hand
# aufrufen, z.B. nach einem lokalen Build (packaging\build.ps1):
#
# $env:GITEA_TOKEN = "<Zugriffstoken>"
# powershell -ExecutionPolicy Bypass -File packaging\publish_release.ps1 `
# -Tag v4.6.0 -Files dist\installer\Playtube-Setup-v4.6.0.exe,Playtube-v4.6.0-win64.zip
#
# Der Token wird in Gitea unter Einstellungen > Anwendungen erzeugt (Recht "repository: Lesen und
# Schreiben"). Er steht NIE im Skript, sondern nur in der Umgebungsvariable GITEA_TOKEN.
# Gab es zum selben Tag schon ein Release, wird es vorher entfernt (der Git-Tag bleibt).
param(
[Parameter(Mandatory = $true)] [string]$Tag,
[Parameter(Mandatory = $true)] [string[]]$Files,
[string]$Title = "",
[string]$NotesFile = "",
[switch]$Prerelease,
[string]$Server = "https://git.fojadrachi.de",
[string]$Repo = "Fojadrachi/Playtube"
)
$ErrorActionPreference = "Stop"
$token = $env:GITEA_TOKEN
if (-not $token) {
# Alternativ aus einer lokalen Datei (nur fuer deinen Benutzer): %USERPROFILE%\.gitea-token
$tokenFile = Join-Path $env:USERPROFILE ".gitea-token"
if (Test-Path $tokenFile) { $token = (Get-Content -Raw $tokenFile).Trim() }
}
if (-not $token) {
throw "Kein Token: GITEA_TOKEN setzen oder ihn in %USERPROFILE%\.gitea-token ablegen (Zugriffstoken aus Gitea > Einstellungen > Anwendungen)."
}
if (-not $Title) { $Title = $Tag }
foreach ($file in $Files) {
if (-not (Test-Path $file)) { throw "Datei nicht gefunden: $file" }
}
$api = "$Server/api/v1/repos/$Repo"
$headers = @{ Authorization = "token $token" }
# Vorhandenes Release zum selben Tag entfernen (404 = gab keins).
try {
$old = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$Tag"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($old.id)" | Out-Null
Write-Host "Vorhandenes Release zu $Tag ersetzt."
} catch {
if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -ne 404) { throw }
}
$notes = ""
# [string]: sonst serialisiert ConvertTo-Json die PowerShell-Zusatzeigenschaften mit (Objekt statt Text).
if ($NotesFile -and (Test-Path $NotesFile)) { $notes = [string](Get-Content -Raw -Encoding UTF8 $NotesFile) }
$payload = @{
tag_name = $Tag
name = $Title
body = $notes
draft = $false
prerelease = [bool]$Prerelease
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases" `
-ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes($payload))
foreach ($file in $Files) {
$name = Split-Path $file -Leaf
Write-Host "Lade hoch: $name"
& curl.exe -sS --fail -X POST -H "Authorization: token $token" -F "attachment=@$file" `
"$api/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Upload fehlgeschlagen: $name" }
}
Write-Host "Fertig: $Server/$Repo/releases/tag/$Tag" -ForegroundColor Green
+15 -14
View File
@@ -1,13 +1,14 @@
# Veroeffentlicht eine neue Playtube-Version: setzt die Versionsnummer, committet,
# taggt und pusht. Der eigentliche Build fuer Windows UND Linux sowie die
# Veroeffentlichung als GitHub Release passiert danach automatisch per GitHub Actions
# (.github/workflows/release.yml), ausgeloest durch den gepushten Tag.
# Veroeffentlicht eine neue Playtube-Edge-Version: setzt die Versionsnummer, committet,
# taggt ("vX.Y.Z-edge") und pusht. Build und Veroeffentlichung als Vorabversion auf dem
# eigenen Gitea-Server passieren danach automatisch per Gitea Actions
# (.gitea/workflows/release.yml), ausgeloest durch den gepushten Tag.
#
# Aufruf (im Projekt-Root):
# powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
# Aufruf (im Projekt-Root, auf dem Branch "edge"):
# powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.0.1
#
# Voraussetzung: git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube
# und du bist dort push-berechtigt.
# Voraussetzung: git remote "origin" zeigt auf https://git.fojadrachi.de/Fojadrachi/Playtube
# und du bist dort push-berechtigt. Ohne Actions-Runner kannst du lokal bauen
# (packaging\build.ps1) und mit packaging\publish_release.ps1 -Prerelease selbst veroeffentlichen.
param(
[Parameter(Mandatory = $true)]
@@ -19,9 +20,9 @@ $root = Split-Path -Parent $PSScriptRoot
Set-Location $root
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Version muss im Format X.Y.Z angegeben werden, z.B. 1.1.0"
throw "Version muss im Format X.Y.Z angegeben werden, z.B. 1.0.1"
}
$tag = "v$Version"
$tag = "v$Version-edge"
Write-Host "==> Setze Version auf $Version in playtube/__init__.py" -ForegroundColor Cyan
$initFile = Join-Path $root "playtube\__init__.py"
@@ -30,13 +31,13 @@ $initFile = Join-Path $root "playtube\__init__.py"
Write-Host "==> Commit + Tag $tag ..." -ForegroundColor Cyan
git add -A
git commit -m "Release $tag" --allow-empty
git tag -a $tag -m "Playtube $tag"
git tag -a $tag -m "Playtube Edge $tag"
Write-Host "==> Push zu origin ..." -ForegroundColor Cyan
git push origin HEAD
git push origin $tag
Write-Host ""
Write-Host "Fertig. GitHub Actions baut jetzt Windows- und Linux-Pakete und" -ForegroundColor Green
Write-Host "veroeffentlicht sie als Release $tag - Fortschritt unter:" -ForegroundColor Green
Write-Host "https://github.com/fojadrachi/Playtube/actions" -ForegroundColor Green
Write-Host "Fertig. Gitea Actions baut jetzt Playtube Edge und" -ForegroundColor Green
Write-Host "veroeffentlicht es als Release $tag - Fortschritt unter:" -ForegroundColor Green
Write-Host "https://git.fojadrachi.de/Fojadrachi/Playtube/actions" -ForegroundColor Green
+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.4.0"
__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 ""))
+308 -139
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"
_GWL_STYLE = -16
_WS_POPUP = 0x80000000
_WS_CAPTION = 0x00C00000
_WS_CHILD = 0x40000000
_WS_VISIBLE = 0x10000000
_WS_CLIPSIBLINGS = 0x04000000
_WS_CLIPCHILDREN = 0x02000000
_SW_SHOW = 5
def _decode_script_result(raw: Any) -> Any:
"""ExecuteScriptAsync liefert das Ergebnis JSON-kodiert; unsere Skripte geben selbst
einen JSON-Text zurueck, deshalb wird bei Bedarf ein zweites Mal dekodiert."""
if raw is None:
return None
try:
value = json.loads(str(raw))
except (TypeError, ValueError):
return None
if isinstance(value, str):
try:
return json.loads(value)
except ValueError:
return value
return value
def _in_document(source: str) -> str:
"""Umhuellt ein Skript, das document.documentElement braucht, damit es auch beim frueh
(vor dem DOM) ausgefuehrten WebView2-Dokumentskript funktioniert."""
return (
"(function(){function go(){" + source + "}"
"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',go);}"
"else{go();}})();"
)
_SEC_CH_UA = (
f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", '
f'"Chromium";v="{_CHROME_MAJOR}"'
).encode("ascii")
_SEC_CH_UA_FULL_VERSION_LIST = (
f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", '
f'"Chromium";v="{_CHROME_VERSION}"'
).encode("ascii")
class _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)
_shared_profile: QWebEngineProfile | None = None
_shared_interceptor: _ChromeBrandingInterceptor | None = None
def get_shared_profile() -> QWebEngineProfile:
"""Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges
Google-Login fuer YouTube und YouTube Music gleichermassen gilt."""
global _shared_profile, _shared_interceptor
if _shared_profile is None:
_shared_profile = QWebEngineProfile("playtube-profile")
_shared_profile.setPersistentStoragePath(str(profile_dir() / "storage"))
_shared_profile.setCachePath(str(profile_dir() / "cache"))
_shared_profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
)
_shared_profile.setHttpUserAgent(_USER_AGENT)
_shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
# Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein.
_shared_interceptor = _ChromeBrandingInterceptor()
_shared_profile.setUrlRequestInterceptor(_shared_interceptor)
# JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) -
# muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld.
shim_script = QWebEngineScript()
shim_script.setName("playtube-chrome-shim")
shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
shim_script.setRunsOnSubFrames(True)
shim_script.setSourceCode(CHROME_SHIM_JS)
_shared_profile.scripts().insert(shim_script)
return _shared_profile
class BrowserTab(QWebEngineView):
"""Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence."""
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__ = ()
+104 -9
View File
@@ -20,9 +20,12 @@ 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
@@ -56,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)
@@ -180,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", {})
@@ -211,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()
@@ -236,6 +294,43 @@ class MainWindow(QMainWindow):
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()
@@ -297,7 +392,7 @@ class MainWindow(QMainWindow):
def _on_update_check_failed(self) -> None:
self._settings_tab.set_check_done()
self._settings_tab.set_update_status(
"Update-Prüfung fehlgeschlagen (keine Internetverbindung oder GitHub nicht erreichbar)."
"Update-Prüfung fehlgeschlagen (keine Internetverbindung oder Update-Server nicht erreichbar)."
)
def _on_update_available(self, version: str, notes: str, download_url: str) -> None:
+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()
+94 -2
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,8 +24,9 @@ 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
from .updater import REPO_URL
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
@@ -55,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)
@@ -132,6 +138,31 @@ class SettingsTab(QWidget):
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()
@@ -149,12 +180,69 @@ class SettingsTab(QWidget):
button_row.addStretch(1)
outer.addLayout(button_row)
info = QLabel(f"github.com/{GITHUB_REPO}")
info = QLabel(REPO_URL.removeprefix("https://"))
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", {})
@@ -167,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)
+30 -12
View File
@@ -1,7 +1,7 @@
"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube).
"""Auto-Update ueber die Releases des eigenen Gitea-Servers (siehe UPDATE_SERVER).
Ablauf:
1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere
1. UpdateChecker prueft im Hintergrund die Gitea-Releases-API auf eine neuere
Version als die aktuell laufende (playtube.__version__).
2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung.
3. UpdateInstaller installiert das Update:
@@ -53,17 +53,24 @@ 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"
# Eigener Gitea-Server (kein GitHub). Die Releases dort werden von .gitea/workflows/release.yml
# bzw. packaging/publish_release.ps1 veroeffentlicht.
UPDATE_SERVER = "https://git.fojadrachi.de"
REPO_SLUG = "Fojadrachi/Playtube"
REPO_URL = f"{UPDATE_SERVER}/{REPO_SLUG}"
# 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"{UPDATE_SERVER}/api/v1/repos/{REPO_SLUG}/releases?limit=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 = "{87FBA502-2B84-4C42-A66B-2F03F490912D}"
_INNO_APP_ID = "{4F0F3698-DAF3-4028-B0A4-896D31292C28}"
_STAGING_PREFIX = "playtube_update_"
@@ -100,14 +107,21 @@ def cleanup_old_staging() -> None:
def fetch_latest_release() -> dict[str, Any] | None:
req = urllib.request.Request(
_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"}
)
req = urllib.request.Request(_API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/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:
@@ -125,6 +139,10 @@ def _asset_kind(name: str) -> str | None:
*.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
+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.