Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a3d6ce65c | ||
|
|
fff1ecb5ec | ||
|
|
6a682fc49c | ||
|
|
33043f1319 | ||
|
|
c30efec612 | ||
|
|
3f3f86f597 | ||
|
|
d28b5d05da |
@@ -0,0 +1,95 @@
|
||||
name: Release
|
||||
|
||||
# Baut Playtube (Qt-Version) fuer Windows und veroeffentlicht Setup, ZIP und Patch als Release
|
||||
# auf dem eigenen Gitea-Server, sobald ein Tag im Format "vX.Y.Z" gepusht wird
|
||||
# (z.B. durch packaging/release.ps1). Playtube.exe auf den Nutzer-Rechnern erkennt neue
|
||||
# Releases danach automatisch (siehe playtube/updater.py) und bietet die Installation an.
|
||||
#
|
||||
# Voraussetzungen (einmalig, siehe README "Releases bauen"):
|
||||
# - Ein Gitea-Actions-Runner (act_runner) unter Windows mit dem Label "windows" (Host-Modus),
|
||||
# auf dem Python 3.12, git, Inno Setup 6 und PowerShell installiert sind.
|
||||
# - Im Repo unter Einstellungen > Actions > Secrets ein Secret "RELEASE_TOKEN" (Zugriffstoken
|
||||
# mit Schreibrecht auf das Repo); ersatzweise wird der automatische Token benutzt.
|
||||
# Bewusst KEINE "uses:"-Aktionen: die wuerden standardmaessig von github.com geladen.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
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: Playtube.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)
|
||||
# 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).
|
||||
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 und Patch packen
|
||||
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
|
||||
# Windows-Dateizuordnung registriert (siehe shortcuts.py). Technisch ein normales ZIP.
|
||||
# Die PySide6-Version steht im Dateinamen: hat sich die Laufzeit gegenueber der
|
||||
# installierten App geaendert, nimmt der Updater stattdessen den Setup-Installer.
|
||||
shell: pwsh
|
||||
working-directory: src
|
||||
run: |
|
||||
$tag = "${{ gitea.ref_name }}"
|
||||
Compress-Archive -Path dist\Playtube -DestinationPath "Playtube-$tag-win64.zip"
|
||||
$pyside = .\.venv\Scripts\python.exe -c "import PySide6; print(PySide6.__version__)"
|
||||
$name = "Playtube-$tag-win64-pyside$pyside-patch"
|
||||
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath "$name.zip"
|
||||
Rename-Item -Path "$name.zip" -NewName "$name.play"
|
||||
|
||||
- name: Release-Notizen aus den Commits
|
||||
shell: pwsh
|
||||
working-directory: src
|
||||
run: |
|
||||
$previous = git describe --tags --abbrev=0 "HEAD^" 2>$null
|
||||
$range = if ($previous) { "$previous..HEAD" } else { "HEAD" }
|
||||
$lines = git log $range --pretty="- %s" -n 30
|
||||
Set-Content -Path release_notes.md -Encoding UTF8 -Value (@("## Aenderungen", "") + $lines)
|
||||
|
||||
- name: Release auf Gitea veroeffentlichen
|
||||
shell: pwsh
|
||||
working-directory: src
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$tag = "${{ gitea.ref_name }}"
|
||||
$files = @((Get-ChildItem dist\installer\Playtube-Setup-v*.exe).FullName) + @((Get-ChildItem "Playtube-*.zip", "Playtube-*.play").FullName)
|
||||
.\packaging\publish_release.ps1 -Tag $tag -Title $tag -NotesFile release_notes.md -Files $files `
|
||||
-Server "${{ gitea.server_url }}" -Repo "${{ gitea.repository }}"
|
||||
@@ -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
|
||||
@@ -8,7 +8,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,7 +23,7 @@ Erweiterung) mit:
|
||||
|
||||
## Installation (Windows)
|
||||
|
||||
Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
|
||||
Lade auf der [Releases-Seite](https://git.fojadrachi.de/Fojadrachi/Playtube/releases)
|
||||
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
|
||||
|
||||
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
|
||||
@@ -63,6 +70,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 +152,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. 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 +193,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 +204,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). Der gepushte
|
||||
Tag löst automatisch die Gitea-Actions-Pipeline
|
||||
([.gitea/workflows/release.yml](.gitea/workflows/release.yml)) aus, die die
|
||||
Windows-Pakete baut und als Release 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.1.0 `
|
||||
-Files dist\installer\Playtube-Setup-v1.1.0.exe
|
||||
```
|
||||
|
||||
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 +241,10 @@ 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.
|
||||
Playtube läuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine). Die
|
||||
Pipeline auf dem Gitea baut derzeit nur Windows-Pakete; Linux-Pakete (`.tar.gz`) entstehen
|
||||
nur, wenn du zusätzlich einen Linux-Runner einrichtest oder sie selbst mit PyInstaller baust
|
||||
(`pyinstaller packaging/playtube.spec`).
|
||||
|
||||
**Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
|
||||
|
||||
@@ -192,8 +265,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 libxtst6 libxcursor1 libatk1.0-0 libdbus-1-3 libdrm2 libpango-1.0-0 libpangocairo-1.0-0 libxfixes3 libxi6 libxext6 fonts-liberation`.
|
||||
|
||||
## Hinweise
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -87,6 +88,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:
|
||||
|
||||
@@ -33,9 +33,9 @@ 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}
|
||||
DisableProgramGroupPage=yes
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
# taggt und pusht. Der eigentliche Build und die Veroeffentlichung als Release auf dem
|
||||
# eigenen Gitea-Server passiert 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
|
||||
#
|
||||
# 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 selbst veroeffentlichen.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -37,6 +38,6 @@ 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 das Windows-Paket 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "2.4.0"
|
||||
__version__ = "4.5.1"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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.QtCore import QUrl
|
||||
from PySide6.QtMultimedia import QMediaDevices
|
||||
from PySide6.QtWebEngineCore import QWebEnginePermission, QWebEngineProfile
|
||||
|
||||
# Name des eingeschleusten Skripts (pro Seite), um es beim Aendern ersetzen zu koennen.
|
||||
SCRIPT_NAME = "playtube-audio-output"
|
||||
|
||||
# Herkunft (Origin), fuer die die Berechtigung erteilt wird - nur die beiden Tabs.
|
||||
YOUTUBE_ORIGINS = ("https://www.youtube.com", "https://music.youtube.com")
|
||||
|
||||
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(profile: QWebEngineProfile, wanted: bool) -> None:
|
||||
"""Erteilt (wanted=True) bzw. entzieht (wanted=False) die Audiogeraete-Berechtigung fuer
|
||||
YouTube/YouTube Musik - damit die Seite die Namen der Ausgabegeraete sehen kann (siehe
|
||||
Moduldoku). Gilt nur fuer die laufende Sitzung, deshalb bei jedem Start aufrufen. Der
|
||||
Status wird von Qt fuer Mikrofon-Freigaben nicht zuverlaessig gemeldet (bleibt "Ask"),
|
||||
daher wird hier einfach jedes Mal erteilt bzw. zurueckgesetzt."""
|
||||
permission_type = QWebEnginePermission.PermissionType.MediaAudioCapture
|
||||
for origin in YOUTUBE_ORIGINS:
|
||||
permission = profile.queryPermission(QUrl(origin), permission_type)
|
||||
if wanted:
|
||||
permission.grant()
|
||||
else:
|
||||
permission.reset()
|
||||
|
||||
|
||||
# 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 ""))
|
||||
@@ -17,10 +17,17 @@ from PySide6.QtWebEngineCore import (
|
||||
)
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
|
||||
from .audio_routing import build_router_script
|
||||
from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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,
|
||||
@@ -116,6 +123,7 @@ class BrowserTab(QWebEngineView):
|
||||
def __init__(self, home_url: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._home_url = home_url
|
||||
self._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output)
|
||||
|
||||
page = QWebEnginePage(get_shared_profile(), self)
|
||||
self.setPage(page)
|
||||
@@ -142,6 +150,32 @@ class BrowserTab(QWebEngineView):
|
||||
def go_home(self) -> None:
|
||||
self.load(QUrl(self._home_url))
|
||||
|
||||
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/Neuladen erhalten (Skript pro Seite)."""
|
||||
page = self.page()
|
||||
scripts = page.scripts()
|
||||
for old in scripts.find(AUDIO_SCRIPT_NAME):
|
||||
scripts.remove(old)
|
||||
|
||||
source = build_router_script(device_name)
|
||||
if device_name:
|
||||
script = QWebEngineScript()
|
||||
script.setName(AUDIO_SCRIPT_NAME)
|
||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||
script.setRunsOnSubFrames(False)
|
||||
script.setSourceCode(source)
|
||||
scripts.insert(script)
|
||||
|
||||
# Bereits geladene Seite sofort umstellen - auch zurueck auf den Systemstandard,
|
||||
# falls vorher ein eigenes Geraet gewaehlt war. Ohne jemals gewaehltes Geraet wird die
|
||||
# Seite gar nicht angefasst.
|
||||
if device_name or self._audio_output_set:
|
||||
page.runJavaScript(source)
|
||||
self._audio_output_set = bool(device_name)
|
||||
|
||||
def toggle_playback(self) -> None:
|
||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||
|
||||
@@ -151,6 +185,29 @@ class BrowserTab(QWebEngineView):
|
||||
def previous_track(self) -> None:
|
||||
self.page().runJavaScript(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.page().runJavaScript(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."""
|
||||
|
||||
def on_result(result) -> None:
|
||||
try:
|
||||
callback(json.loads(result) if isinstance(result, str) and result else None)
|
||||
except json.JSONDecodeError:
|
||||
callback(None)
|
||||
|
||||
self.page().runJavaScript(LIST_PLAYLISTS_JS, on_result)
|
||||
|
||||
def play_playlist(self, playlist_id: str) -> None:
|
||||
"""Oeffnet die Playlist (ID vorher in remote_control validiert); Autoplay startet
|
||||
die Wiedergabe (PlaybackRequiresUserGesture ist aus)."""
|
||||
self.load(QUrl(MUSIC_PLAYLIST_URL.format(playlist_id=playlist_id)))
|
||||
|
||||
def _on_full_screen_requested(self, request) -> None:
|
||||
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
||||
request.accept()
|
||||
|
||||
@@ -32,6 +32,15 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"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/",
|
||||
|
||||
@@ -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__ = ()
|
||||
|
||||
|
||||
+107
-12
@@ -20,9 +20,12 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .browser import BrowserTab
|
||||
from .audio_routing import sync_audio_permissions
|
||||
from .browser import BrowserTab, get_shared_profile
|
||||
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
|
||||
|
||||
@@ -54,14 +57,22 @@ class MainWindow(QMainWindow):
|
||||
self._tabs.setDocumentMode(True)
|
||||
self.setCentralWidget(self._tabs)
|
||||
|
||||
self._sync_audio_permission()
|
||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||
self._music_tab = BrowserTab(config["home_music"], self)
|
||||
# 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:
|
||||
if active_info and active_info.get("hasVideo"):
|
||||
self._rpc_worker.submit_media_info(active_info)
|
||||
else:
|
||||
self._rpc_worker.submit_media_info(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"):
|
||||
worker.submit_media_info(active_info)
|
||||
else:
|
||||
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(get_shared_profile(), 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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
});
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
+21
-8
@@ -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,9 +53,15 @@ 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"
|
||||
# Eigener Gitea-Server (kein GitHub). Die Releases dort (Setup, Patch, ZIP) werden von der
|
||||
# Pipeline .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}"
|
||||
_API_URL = f"{UPDATE_SERVER}/api/v1/repos/{REPO_SLUG}/releases?limit=30"
|
||||
_USER_AGENT = "Playtube-Updater"
|
||||
# Releases der Edge-Variante (Tag "...-edge") gehoeren nicht zur normalen Playtube.
|
||||
_EDGE_TAG_SUFFIX = "-edge"
|
||||
|
||||
_LINUX_BINARY_NAME = "Playtube"
|
||||
_WINDOWS_BINARY_NAME = "Playtube.exe"
|
||||
@@ -100,14 +106,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 stabile Version: keine Entwuerfe/Vorabversionen und nie ein Edge-Release.
|
||||
stable = [
|
||||
release for release in releases
|
||||
if isinstance(release, dict) and not release.get("draft") and not release.get("prerelease")
|
||||
and not str(release.get("tag_name", "")).endswith(_EDGE_TAG_SUFFIX)
|
||||
]
|
||||
return max(stable, key=lambda r: _parse_version(str(r.get("tag_name", ""))), default=None)
|
||||
|
||||
|
||||
def is_installed_via_setup() -> bool:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user