Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a682fc49c | ||
|
|
33043f1319 | ||
|
|
c30efec612 | ||
|
|
3f3f86f597 | ||
|
|
d28b5d05da | ||
|
|
61b38fe6b8 | ||
|
|
cf6b6733e4 | ||
|
|
5f93be4325 | ||
|
|
e655cbceea | ||
|
|
1acd08934e | ||
|
|
a4ba6c296e | ||
|
|
c1465a4e49 | ||
|
|
874d6454ec |
@@ -1,20 +1,25 @@
|
|||||||
# Playtube
|
# Playtube
|
||||||
|
|
||||||
Eigenstaendige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
Eigenständige Desktop-App für YouTube & YouTube Music (kein Browser-Fenster, keine
|
||||||
Erweiterung) mit:
|
Erweiterung) mit:
|
||||||
|
|
||||||
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im
|
||||||
Hintergrund weiter wenn du zu Videos wechselst.
|
Hintergrund weiter wenn du zu Videos wechselst.
|
||||||
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`, im
|
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
|
||||||
Entwicklungsmodus `%APPDATA%\PlaytubeDev` – bewusst getrennt, damit sich lokale
|
Google anmelden reicht für beide Dienste.
|
||||||
Test-Builds nie mit einer installierten Version in die Quere kommen), einmal bei
|
|
||||||
Google anmelden reicht fuer beide Dienste.
|
|
||||||
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und
|
||||||
einen Link-Button in deinem Discord-Profil, sobald etwas laeuft.
|
einen Link-Button in deinem Discord-Profil, sobald etwas läuft. Ist der
|
||||||
- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick
|
**Einstellungen-Tab** offen, steht dort stattdessen „In den Einstellungen“ und welches
|
||||||
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue.
|
Feld du gerade bearbeitest (z.B. „Discord Rich Presence › Client-ID“) - nur der
|
||||||
|
Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts
|
||||||
|
läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens
|
||||||
|
alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher.
|
||||||
|
- **Audioausgabe pro Tab** – YouTube und YouTube Musik lassen sich in den Einstellungen
|
||||||
|
auf verschiedene Ausgabegeräte legen (z.B. getrennte Sonar-Kanäle), siehe unten.
|
||||||
|
- **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
|
||||||
|
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü.
|
||||||
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab
|
||||||
und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python".
|
und (nach dem Packaging, siehe unten) im Lautstärkemixer statt als "python".
|
||||||
|
|
||||||
## Installation (Windows)
|
## Installation (Windows)
|
||||||
|
|
||||||
@@ -22,20 +27,20 @@ Lade auf der [Releases-Seite](https://github.com/fojadrachi/Playtube/releases)
|
|||||||
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
|
`Playtube-Setup-vX.Y.Z.exe` herunter und starte sie. Der Installer
|
||||||
|
|
||||||
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
|
- installiert Playtube **pro Benutzer** nach `%LOCALAPPDATA%\Programs\Playtube` (keine
|
||||||
Admin-Rechte noetig) und legt Startmenue-Eintrag (optional Desktop-Verknuepfung) an,
|
Admin-Rechte nötig) und legt Startmenü-Eintrag (optional Desktop-Verknüpfung) an,
|
||||||
- ersetzt bei jeder spaeteren Ausfuehrung die vorhandene Installation **an Ort und
|
- ersetzt bei jeder späteren Ausführung die vorhandene Installation **an Ort und
|
||||||
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
|
Stelle** (feste Installer-ID) - es gibt also immer genau EINE installierte Version, nie
|
||||||
mehrere nebeneinander,
|
mehrere nebeneinander,
|
||||||
- beendet dafuer ein noch laufendes Playtube selbst und raeumt den alten Programmordner
|
- beendet dafür ein noch laufendes Playtube selbst und räumt den alten Programmordner
|
||||||
auf, damit keine Reste alter Versionen uebrig bleiben,
|
auf, damit keine Reste alter Versionen übrig bleiben,
|
||||||
- laesst Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
|
- lässt Login und Einstellungen (`%APPDATA%\Playtube`) unangetastet; beim Deinstallieren
|
||||||
ueber "Apps & Features" wird gefragt, ob sie mitgeloescht werden sollen.
|
über "Apps & Features" wird gefragt, ob sie mitgelöscht werden sollen.
|
||||||
|
|
||||||
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
|
Ab dann aktualisiert sich Playtube selbst (siehe "Automatische Updates") - die
|
||||||
Setup-Datei wird dafuer nicht erneut von Hand gebraucht. Das ZIP-Paket
|
Setup-Datei wird dafür nicht erneut von Hand gebraucht. Das ZIP-Paket
|
||||||
(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
|
(`Playtube-vX.Y.Z-win64.zip`) bleibt als portable Variante ohne Installation erhalten;
|
||||||
Playtube bietet einer portablen Kopie beim naechsten Update automatisch an, in die
|
Playtube bietet einer portablen Kopie beim nächsten Update automatisch an, in die
|
||||||
regulaere Installation zu wechseln (danach kann der alte Ordner geloescht werden).
|
reguläre Installation zu wechseln (danach kann der alte Ordner gelöscht werden).
|
||||||
|
|
||||||
## Schnellstart (Entwicklung)
|
## Schnellstart (Entwicklung)
|
||||||
|
|
||||||
@@ -52,24 +57,69 @@ Login bleibt danach dauerhaft gespeichert.
|
|||||||
## Discord Rich Presence einrichten
|
## Discord Rich Presence einrichten
|
||||||
|
|
||||||
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
|
Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du
|
||||||
sie aendern oder eine eigene Anwendung nutzen willst:
|
sie ändern oder eine eigene Anwendung nutzen willst:
|
||||||
|
|
||||||
1. Auf https://discord.com/developers/applications eine neue Application anlegen.
|
1. Auf https://discord.com/developers/applications eine neue Application anlegen.
|
||||||
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
|
2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen.
|
||||||
3. Optional, fuer eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
|
3. Optional, für eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den
|
||||||
exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
|
exakten Schlüsseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau
|
||||||
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
|
diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem,
|
||||||
nur ohne Bild.
|
nur ohne Bild.
|
||||||
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
|
4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird.
|
||||||
|
|
||||||
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen
|
||||||
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts laeuft) anpassen.
|
Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
|
||||||
|
|
||||||
## App als eigenstaendige Playtube.exe packen
|
## Audioausgabe pro Tab (YouTube / YouTube Musik)
|
||||||
|
|
||||||
Fuer die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
Im **Einstellungen-Tab** legst du unter „Audioausgabe“ getrennt fest, über welches
|
||||||
|
Ausgabegerät der Ton von **YouTube** und von **YouTube Musik** läuft (Standard:
|
||||||
|
Systemstandard). So kannst du z.B. YouTube auf „Sonar - Media“ und YouTube Musik auf
|
||||||
|
„Sonar - Aux“ legen und beide getrennt regeln. Nach „Speichern“ gilt die Auswahl sofort,
|
||||||
|
auch für bereits geöffnete Seiten (kein Neuladen nötig).
|
||||||
|
|
||||||
|
Wichtig zu wissen:
|
||||||
|
|
||||||
|
- **Windows zeigt weiterhin einen Eintrag „Playtube“.** Chromium (QtWebEngine) spielt den
|
||||||
|
Ton beider Tabs über *einen* gemeinsamen Audio-Prozess ab, deshalb kann Windows (auch
|
||||||
|
„App-Lautstärke und Geräteeinstellungen“) die Tabs nicht einzeln benennen oder routen.
|
||||||
|
Getrennt wird stattdessen über das **Gerät** - in Sonar & Co. also über den gewählten
|
||||||
|
Kanal/das gewählte Gerät.
|
||||||
|
- **Freigabe der Gerätenamen:** Chromium blendet Gerätenamen aus, solange die Seite keine
|
||||||
|
Mikrofon-Berechtigung hat. Damit die Seite das gewählte Gerät findet, erteilt Playtube
|
||||||
|
YouTube/YouTube Musik diese Freigabe - nur, solange mindestens ein Tab ein eigenes Gerät
|
||||||
|
nutzt, nur für diese beiden Seiten und nur für die laufende Sitzung (sie wird nicht
|
||||||
|
gespeichert und bei jedem Start neu erteilt). Es wird nichts aufgenommen.
|
||||||
|
- Ist das gewählte Gerät gerade nicht angeschlossen, bleibt die Auswahl (als „nicht
|
||||||
|
verfügbar“) gespeichert und der Ton läuft solange über den Systemstandard.
|
||||||
|
- Technik: [playtube/audio_routing.py](playtube/audio_routing.py) legt per
|
||||||
|
`HTMLMediaElement.setSinkId` die Medienelemente der Seite auf das Gerät (Suche über den
|
||||||
|
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
|
||||||
|
`audio.music_output` (leer = Systemstandard).
|
||||||
|
|
||||||
|
## Fernsteuerung (Stream Dock)
|
||||||
|
|
||||||
|
Playtube lässt sich lokal fernsteuern, z.B. über das Stream-Dock-Plugin
|
||||||
|
`com.fojadrachi.playtube.sdPlugin` (Ajazz/Mirabox AKP153E & Co.). Dafür lauscht Playtube
|
||||||
|
auf einer Named Pipe (`\\.\pipe\Playtube.Remote.Playtube`, im Entwicklungsmodus
|
||||||
|
`...PlaytubeDev`). Es gibt keinen Netzwerk-Port, verbinden darf nur der angemeldete
|
||||||
|
Benutzer.
|
||||||
|
|
||||||
|
- Protokoll: eine JSON-Nachricht pro Zeile, z.B. `{"id":1,"cmd":"play_pause","target":"auto"}`,
|
||||||
|
Antwort `{"id":1,"ok":true}`. Details in [playtube/remote_control.py](playtube/remote_control.py).
|
||||||
|
- Befehle: `status`, `show`, `switch_tab`, `play_pause`, `play`, `pause`, `next`,
|
||||||
|
`previous`, `seek`, `volume_change`, `set_volume`, `mute_toggle`, `like`, `dislike`,
|
||||||
|
`shuffle`, `repeat`, `list_playlists`, `play_playlist`. Es gibt eine feste Whitelist;
|
||||||
|
beliebiges JavaScript oder URLs lassen sich nicht senden.
|
||||||
|
- `target: "auto"` steuert den Tab, der gerade abspielt, sonst den sichtbaren.
|
||||||
|
- Abschalten: in `config.json` `"remote_control": {"enabled": false}`.
|
||||||
|
- Tests: `.venv\Scripts\python -m unittest discover -s tests -v`
|
||||||
|
|
||||||
|
## App als eigenständige Playtube.exe packen
|
||||||
|
|
||||||
|
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
|
||||||
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
|
(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" -
|
||||||
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das aendern):
|
nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ändern):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.venv\Scripts\pip install pyinstaller
|
.venv\Scripts\pip install pyinstaller
|
||||||
@@ -80,61 +130,61 @@ Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Ist
|
|||||||
[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
|
[Inno Setup 6](https://jrsoftware.org/isinfo.php) installiert
|
||||||
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
|
(`winget install --id JRSoftware.InnoSetup -e`), baut das Skript daraus ausserdem den
|
||||||
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
|
Installer `dist\installer\Playtube-Setup-vX.Y.Z.exe` (Skript:
|
||||||
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` ueberspringbar).
|
[packaging/playtube.iss](packaging/playtube.iss); mit `-SkipInstaller` überspringbar).
|
||||||
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
|
Eine portable, nicht per Setup installierte `.exe` legt beim ersten Start automatisch eine
|
||||||
Verknuepfung im Windows-Startmenue an - eine per Setup installierte Kopie hat den
|
Verknüpfung im Windows-Startmenü an - eine per Setup installierte Kopie hat den
|
||||||
Eintrag bereits vom Installer.
|
Eintrag bereits vom Installer.
|
||||||
|
|
||||||
Das Build-Skript benennt
|
Das Build-Skript benennt
|
||||||
zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
zusätzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
|
||||||
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
|
||||||
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses
|
auftaucht. Für eine vollständige Umbenennung inkl. Icon/Versionsinfo dieses
|
||||||
Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich
|
Hilfsprozesses (relevant für den Lautstärkemixer) zusätzlich
|
||||||
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
|
[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe`
|
||||||
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
|
ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert
|
||||||
alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach
|
alles genauso, nur zeigt der Lautstärkemixer für den Ton-Unterprozess je nach
|
||||||
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
|
Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein
|
||||||
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
|
kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes,
|
||||||
Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
|
Windows-versionsabhängiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit
|
||||||
o.ae. umgehen).
|
o.ä. umgehen).
|
||||||
|
|
||||||
## Automatische Updates
|
## Automatische Updates
|
||||||
|
|
||||||
Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden
|
Playtube prüft beim Start und danach alle `updates.check_interval_hours` Stunden
|
||||||
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
|
(Standard 6, im **Einstellungen-Tab** oder in `config.json` einstellbar) die
|
||||||
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
|
[GitHub Releases](https://github.com/fojadrachi/Playtube/releases) des Projekts. Im
|
||||||
Einstellungen-Tab gibt es zusaetzlich einen "Jetzt nach Updates suchen"-Button mit
|
Einstellungen-Tab gibt es zusätzlich einen "Jetzt nach Updates suchen"-Button mit
|
||||||
Status-Anzeige und Fortschrittsbalken fuer den Download. Gibt es eine neuere Version,
|
Status-Anzeige und Fortschrittsbalken für den Download. Gibt es eine neuere Version,
|
||||||
fragt ein Dialog, ob sie installiert werden soll:
|
fragt ein Dialog, ob sie installiert werden soll:
|
||||||
|
|
||||||
- **Windows, per Setup installiert** (siehe "Installation"): laedt bevorzugt das kleine
|
- **Windows, per Setup installiert** (siehe "Installation"): lädt bevorzugt das kleine
|
||||||
**Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB)
|
**Patch-Paket** herunter (nur die `Playtube.exe` mit unserem Anwendungscode, ca. 2-3 MB)
|
||||||
und ueberschreibt damit die Datei im Installationsordner - der riesige PySide6/
|
und überschreibt damit die Datei im Installationsordner - der riesige PySide6/
|
||||||
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
|
QtWebEngine-Laufzeitordner (`_internal/`) bleibt unangetastet, da er sich zwischen
|
||||||
normalen Patch-Releases nicht aendert. Die PySide6-Version steht im Dateinamen des
|
normalen Patch-Releases nicht ändert. Die PySide6-Version steht im Dateinamen des
|
||||||
Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab,
|
Patches (`...-pyside6.11.2-patch.play`); weicht sie von der installierten Laufzeit ab,
|
||||||
ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den
|
ist ein reines .exe-Patch nicht mehr passend und Playtube nimmt stattdessen den
|
||||||
**Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
|
**Setup-Installer**, der alles (auch die Laufzeit) an Ort und Stelle erneuert und
|
||||||
Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach
|
Playtube danach wieder startet. Der Versionseintrag in "Apps & Features" wird nach
|
||||||
einem Patch ebenfalls nachgezogen.
|
einem Patch ebenfalls nachgezogen.
|
||||||
- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): laedt den
|
- **Windows, portable Kopie** (aus dem ZIP entpackt, z.B. in Downloads): lädt den
|
||||||
**Setup-Installer** und fuehrt ihn still aus. Playtube liegt danach in der regulaeren
|
**Setup-Installer** und führt ihn still aus. Playtube liegt danach in der regulären
|
||||||
Installation - Startmenue-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
|
Installation - Startmenü-Eintrag und Dateizuordnung zeigen dorthin, die alte portable
|
||||||
Kopie ist nicht mehr noetig (und kann geloescht werden). So entstehen nicht laenger
|
Kopie ist nicht mehr nötig (und kann gelöscht werden). So entstehen nicht länger
|
||||||
versionierte Ordner nebeneinander. Gibt es (bei aelteren Releases) keinen Installer,
|
versionierte Ordner nebeneinander. Gibt es (bei älteren Releases) keinen Installer,
|
||||||
wird wie frueher das volle ZIP ueber den vorhandenen Ordner kopiert.
|
wird wie früher das volle ZIP über den vorhandenen Ordner kopiert.
|
||||||
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
|
- **Linux** (`Playtube`-Binary): Patch-Paket (nur das Binary) bzw. volles `.tar.gz`.
|
||||||
Die App startet sich in allen Faellen danach selbst neu.
|
Die App startet sich in allen Fällen danach selbst neu.
|
||||||
- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r
|
- **Entwicklungsmodus** (`python main.py`): führt `git pull` + `pip install -r
|
||||||
requirements.txt` aus und startet den Python-Prozess neu.
|
requirements.txt` aus und startet den Python-Prozess neu.
|
||||||
|
|
||||||
Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
|
Auto-Update lässt sich im Einstellungen-Tab oder in `config.json` unter
|
||||||
`updates.enabled` deaktivieren.
|
`updates.enabled` deaktivieren.
|
||||||
|
|
||||||
Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine-
|
Nach einem erkannten Update wird beim nächsten Start automatisch der QtWebEngine-
|
||||||
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr
|
HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Einträge können sonst nicht mehr
|
||||||
zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon
|
zum neuen Code passen (frühere Ursache für fehlende Icons). Der Login bleibt davon
|
||||||
unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner
|
unberührt, da Cookies/LocalStorage in einem komplett getrennten Ordner
|
||||||
(`webprofile/storage`) liegen.
|
(`webprofile/storage`) liegen.
|
||||||
|
|
||||||
### Patch-Dateien manuell installieren (`.play`)
|
### Patch-Dateien manuell installieren (`.play`)
|
||||||
@@ -143,11 +193,11 @@ Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technis
|
|||||||
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
|
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
|
||||||
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
|
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
|
||||||
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
|
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
|
||||||
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) laesst sich also
|
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) lässt sich also
|
||||||
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
|
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
|
||||||
muss.
|
muss.
|
||||||
|
|
||||||
### Eine neue Version veroeffentlichen
|
### Eine neue Version veröffentlichen
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
||||||
@@ -155,13 +205,13 @@ powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0
|
|||||||
|
|
||||||
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
|
Das Skript setzt die Versionsnummer, committet, erstellt Git-Tag `v1.1.0` und pusht zu
|
||||||
`origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag
|
`origin` (dein Repo unter https://github.com/fojadrachi/Playtube). Der gepushte Tag
|
||||||
loest automatisch die GitHub-Actions-Pipeline
|
löst automatisch die GitHub-Actions-Pipeline
|
||||||
([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl
|
([.github/workflows/release.yml](.github/workflows/release.yml)) aus, die **sowohl
|
||||||
eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub
|
eine Windows- als auch eine Linux-Version baut** und beide als Assets an einem GitHub
|
||||||
Release veroeffentlicht - Fortschritt unter
|
Release veröffentlicht - Fortschritt unter
|
||||||
https://github.com/fojadrachi/Playtube/actions.
|
https://github.com/fojadrachi/Playtube/actions.
|
||||||
|
|
||||||
Fuer Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, fuer
|
Für Windows entstehen dabei drei Dateien: `Playtube-Setup-vX.Y.Z.exe` (Installer, für
|
||||||
Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
|
Neueinsteiger und volle Updates), `Playtube-vX.Y.Z-win64.zip` (portabel) und das kleine
|
||||||
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
|
`...-pyside<Version>-patch.play` (schnelles Update). Der Installer bekommt seine
|
||||||
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
|
Versionsnummer aus `playtube/__init__.py` (das `release.ps1` vor dem Tag setzt).
|
||||||
@@ -171,11 +221,11 @@ neue Version danach automatisch angeboten.
|
|||||||
|
|
||||||
## Native Linux-Version
|
## Native Linux-Version
|
||||||
|
|
||||||
Playtube laeuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und
|
Playtube läuft genauso unter Linux (gleicher Code, gleiches PySide6/QtWebEngine) und
|
||||||
wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter
|
wird bei jedem Release automatisch als `Playtube-vX.Y.Z-linux-x86_64.tar.gz` unter
|
||||||
https://github.com/fojadrachi/Playtube/releases mitgebaut.
|
https://github.com/fojadrachi/Playtube/releases mitgebaut.
|
||||||
|
|
||||||
**Fertiges Release installieren** (richtet Startmenue-Eintrag + Icon ein):
|
**Fertiges Release installieren** (richtet Startmenü-Eintrag + Icon ein):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
|
tar -xzf Playtube-vX.Y.Z-linux-x86_64.tar.gz
|
||||||
@@ -183,7 +233,7 @@ cd Playtube
|
|||||||
sh install-linux.sh
|
sh install-linux.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach ist Playtube ueber das Anwendungsmenue oder den Befehl `playtube` startbar.
|
Danach ist Playtube über das Anwendungsmenü oder den Befehl `playtube` startbar.
|
||||||
|
|
||||||
**Aus dem Quellcode starten:**
|
**Aus dem Quellcode starten:**
|
||||||
|
|
||||||
@@ -193,31 +243,31 @@ python3 -m venv .venv
|
|||||||
.venv/bin/python main.py
|
.venv/bin/python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
QtWebEngine benoetigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu):
|
QtWebEngine benötigt unter Linux ein paar System-Bibliotheken (auf Debian/Ubuntu):
|
||||||
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in
|
`sudo apt install libxkbcommon0 libegl1 libnss3 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 libatk-bridge2.0-0 libcups2` (siehe auch die vollstaendige Liste in
|
||||||
[.github/workflows/release.yml](.github/workflows/release.yml)).
|
[.github/workflows/release.yml](.github/workflows/release.yml)).
|
||||||
|
|
||||||
## Hinweise
|
## Hinweise
|
||||||
|
|
||||||
- **"Dieser Browser ist unter Umstaenden nicht sicher" beim Google-Login:** Playtube
|
- **"Dieser Browser ist unter Umständen nicht sicher" beim Google-Login:** Playtube
|
||||||
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
|
setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google
|
||||||
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte
|
||||||
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu
|
||||||
starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den
|
starten (die Header greifen erst ab dem nächsten Prozessstart), notfalls einmal den
|
||||||
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
Profilordner `%APPDATA%\Playtube\webprofile` (bzw. `PlaytubeDev` im
|
||||||
Entwicklungsmodus) loeschen und neu anmelden.
|
Entwicklungsmodus) löschen und neu anmelden.
|
||||||
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menue ...):** Fast
|
- **Auf YouTube/YouTube Music fehlen alle Icons (Play/Pause, Suche, Menü ...):** Fast
|
||||||
sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
|
sicher laufen zwei Playtube-Prozesse gleichzeitig auf demselben Browser-Profil - die
|
||||||
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
|
zweite Instanz rendert dann keine Icons mehr. Passiert leicht, weil das Schliessen des
|
||||||
Fensters Playtube nur in den Tray minimiert: eine aeltere Version laeuft unsichtbar
|
Fensters Playtube nur in den Tray minimiert: eine ältere Version läuft unsichtbar
|
||||||
weiter, waehrend eine neu heruntergeladene zusaetzlich gestartet wird. Abhilfe: alle
|
weiter, während eine neu heruntergeladene zusätzlich gestartet wird. Abhilfe: alle
|
||||||
Playtube-Instanzen ueber das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
|
Playtube-Instanzen über das Tray-Icon (Rechtsklick -> Beenden) bzw. im Taskmanager
|
||||||
beenden und nur EINE neu starten. Ab der Version mit
|
beenden und nur EINE neu starten. Ab der Version mit
|
||||||
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
|
[playtube/single_instance.py](playtube/single_instance.py) verhindert Playtube den
|
||||||
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
|
Doppelstart selbst (ein zweiter Start holt das laufende Fenster nach vorn); eine
|
||||||
aeltere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
|
ältere Instanz ohne diesen Schutz wird dabei nicht erkannt und muss einmalig manuell
|
||||||
beendet werden.
|
beendet werden.
|
||||||
- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von
|
- 4K/Premium-Videoqualität kann eingeschränkt sein, da die Open-Source-Variante von
|
||||||
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal).
|
QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitäten funktionieren normal).
|
||||||
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py`
|
||||||
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
|
erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from playtube import __version__, app_id # noqa: E402
|
from playtube import __version__, app_id # noqa: E402
|
||||||
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402
|
from playtube.config import APP_NAME, app_data_dir, clear_cache_on_update, load_config # noqa: E402
|
||||||
|
from playtube.remote_control import PIPE_PREFIX, RemoteControlServer # noqa: E402
|
||||||
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
from playtube.single_instance import SingleInstanceGuard # noqa: E402
|
||||||
from playtube.shortcuts import ( # noqa: E402
|
from playtube.shortcuts import ( # noqa: E402
|
||||||
ensure_play_file_association,
|
ensure_play_file_association,
|
||||||
@@ -87,6 +88,12 @@ def main() -> int:
|
|||||||
instance_guard.showRequested.connect(window.show_and_raise)
|
instance_guard.showRequested.connect(window.show_and_raise)
|
||||||
instance_guard.patchRequested.connect(window.install_local_patch)
|
instance_guard.patchRequested.connect(window.install_local_patch)
|
||||||
|
|
||||||
|
# Lokale Fernsteuerung (Stream-Dock-Plugin), eigener Pipe-Name pro Datenordner - so
|
||||||
|
# steuert das Plugin die installierte Playtube.exe, nicht versehentlich einen Dev-Start.
|
||||||
|
if config.get("remote_control", {}).get("enabled", True):
|
||||||
|
remote_server = RemoteControlServer(f"{PIPE_PREFIX}{app_data_dir().name}", window, app)
|
||||||
|
remote_server.start()
|
||||||
|
|
||||||
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
|
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
|
||||||
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
|
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
|
||||||
if local_patch:
|
if local_patch:
|
||||||
|
|||||||
+40
-6
@@ -98,27 +98,61 @@ begin
|
|||||||
Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
|
Result := ExpandConstant('{param:RELAUNCH|0}') = '1';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure StopRunningPlaytube();
|
function PowerShellPath(): String;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure WaitForOldInstance();
|
||||||
|
var
|
||||||
|
OldPid, ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
{ Playtubes Updater startet Setup aus der laufenden App heraus und uebergibt deren
|
||||||
|
Prozess-ID (/WAITPID=...). Setup wartet, bis sich diese Instanz selbst beendet hat,
|
||||||
|
statt sie hart abzuschiessen (max. 20 s). Ohne Angabe (Setup per Doppelklick) passiert
|
||||||
|
hier nichts. }
|
||||||
|
OldPid := StrToIntDef(ExpandConstant('{param:WAITPID|0}'), 0);
|
||||||
|
if OldPid > 0 then
|
||||||
|
Exec(PowerShellPath(),
|
||||||
|
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "Wait-Process -Id ' + IntToStr(OldPid) + ' -Timeout 20 -ErrorAction SilentlyContinue"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure StopLeftoverProcesses();
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
begin
|
begin
|
||||||
{ /T beendet auch die Kindprozesse (QtWebEngine); der Hilfsprozess wird zusaetzlich
|
{ WICHTIG: bewusst OHNE "/T" (Prozessbaum mitbeenden). Der Updater startet Setup aus
|
||||||
einzeln beendet, falls er als Waise ueberlebt hat. }
|
Playtube.exe heraus - Setup ist also ein Kindprozess. Solange Playtube noch beendet
|
||||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /T /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
wird, wuerde "/T" den Installer selbst mit abschiessen, bevor er etwas installiert hat
|
||||||
|
(so ging frueher ein Update still verloren: kein Kopieren, kein Neustart). }
|
||||||
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppExeName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM {#AppHelperName}', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
|
||||||
|
{ QtWebEngine-Kindprozesse, die nach dem Ende von Playtube noch aus dem
|
||||||
|
Installationsordner laufen, wuerden Dateien sperren - gezielt nach Pfad beenden
|
||||||
|
(nicht per Namen: QtWebEngineProcess.exe heisst auch bei anderen Qt-Programmen). }
|
||||||
|
if DirExists(ExpandConstant('{app}')) then
|
||||||
|
Exec(PowerShellPath(),
|
||||||
|
'-NoProfile -NonInteractive -WindowStyle Hidden -Command "$d = ''' + ExpandConstant('{app}') + '\''; ' +
|
||||||
|
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($d, [StringComparison]::OrdinalIgnoreCase) } | ' +
|
||||||
|
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"',
|
||||||
|
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
|
||||||
{ Dateisperren freigeben lassen, bevor kopiert wird. }
|
{ Dateisperren freigeben lassen, bevor kopiert wird. }
|
||||||
Sleep(800);
|
Sleep(800);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
begin
|
begin
|
||||||
StopRunningPlaytube();
|
WaitForOldInstance();
|
||||||
|
StopLeftoverProcesses();
|
||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function InitializeUninstall(): Boolean;
|
function InitializeUninstall(): Boolean;
|
||||||
begin
|
begin
|
||||||
StopRunningPlaytube();
|
StopLeftoverProcesses();
|
||||||
Result := True;
|
Result := True;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||||
|
|
||||||
__app_name__ = "Playtube"
|
__app_name__ = "Playtube"
|
||||||
__version__ = "2.3.1"
|
__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 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 .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS
|
||||||
from .config import profile_dir
|
from .config import profile_dir
|
||||||
|
from .media_control import LIST_PLAYLISTS_JS, MUSIC_PLAYLIST_URL, build_control_js
|
||||||
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS
|
||||||
|
|
||||||
|
# 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
|
# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten
|
||||||
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy-
|
||||||
# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS,
|
# 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):
|
def __init__(self, home_url: str, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._home_url = home_url
|
self._home_url = home_url
|
||||||
|
self._audio_output_set = False # war schon ein eigenes Ausgabegeraet gewaehlt? (siehe set_audio_output)
|
||||||
|
|
||||||
page = QWebEnginePage(get_shared_profile(), self)
|
page = QWebEnginePage(get_shared_profile(), self)
|
||||||
self.setPage(page)
|
self.setPage(page)
|
||||||
@@ -142,6 +150,32 @@ class BrowserTab(QWebEngineView):
|
|||||||
def go_home(self) -> None:
|
def go_home(self) -> None:
|
||||||
self.load(QUrl(self._home_url))
|
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:
|
def toggle_playback(self) -> None:
|
||||||
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
self.page().runJavaScript(TOGGLE_PLAYBACK_JS)
|
||||||
|
|
||||||
@@ -151,6 +185,29 @@ class BrowserTab(QWebEngineView):
|
|||||||
def previous_track(self) -> None:
|
def previous_track(self) -> None:
|
||||||
self.page().runJavaScript(PREV_TRACK_JS)
|
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:
|
def _on_full_screen_requested(self, request) -> None:
|
||||||
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
# Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button).
|
||||||
request.accept()
|
request.accept()
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"check_interval_hours": 6,
|
"check_interval_hours": 6,
|
||||||
},
|
},
|
||||||
|
# Ausgabegeraet pro Tab (Name des Geraets, wie in den Einstellungen gewaehlt; leer =
|
||||||
|
# Systemstandard) - siehe playtube/audio_routing.py.
|
||||||
|
"audio": {
|
||||||
|
"youtube_output": "",
|
||||||
|
"music_output": "",
|
||||||
|
},
|
||||||
|
# Lokale Fernsteuerung fuer das Stream-Dock-Plugin (Named Pipe, nur dieser Benutzer) -
|
||||||
|
# siehe playtube/remote_control.py.
|
||||||
|
"remote_control": {"enabled": True},
|
||||||
"start_tab": "youtube", # "youtube" oder "music"
|
"start_tab": "youtube", # "youtube" oder "music"
|
||||||
"home_youtube": "https://www.youtube.com/",
|
"home_youtube": "https://www.youtube.com/",
|
||||||
"home_music": "https://music.youtube.com/",
|
"home_music": "https://music.youtube.com/",
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ def build_idle_payload(session_start: int) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_DETAILS = "In den Einstellungen"
|
||||||
|
SETTINGS_DEFAULT_STATE = "Schaut sich die Einstellungen an"
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_payload(session_start: int, activity: str | None) -> dict[str, Any]:
|
||||||
|
"""Presence, solange der Einstellungen-Tab offen ist. `activity` beschreibt, was
|
||||||
|
gerade bearbeitet wird (z.B. "Bearbeitet: Discord Rich Presence › Client-ID") - es
|
||||||
|
wird bewusst nur der NAME des Feldes uebergeben, nie sein Inhalt (sonst wuerde
|
||||||
|
z.B. die eingetragene Client-ID oeffentlich im Discord-Profil stehen)."""
|
||||||
|
return {
|
||||||
|
"details": SETTINGS_DETAILS,
|
||||||
|
"state": _truncate(activity, fallback=SETTINGS_DEFAULT_STATE),
|
||||||
|
"large_image": ASSET_DEFAULT,
|
||||||
|
"large_text": APP_NAME,
|
||||||
|
"start": session_start,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DiscordRPCWorker(QThread):
|
class DiscordRPCWorker(QThread):
|
||||||
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
|
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
|
||||||
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
|
debounced (max. 1 Update pro `interval` Sekunden) versendet."""
|
||||||
@@ -143,6 +161,11 @@ class DiscordRPCWorker(QThread):
|
|||||||
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
"""Neuester bekannter Zustand (None = nichts spielt / idle)."""
|
||||||
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
|
self._replace_queue(info if info is not None else _IDLE_SENTINEL)
|
||||||
|
|
||||||
|
def submit_settings_activity(self, activity: str | None) -> None:
|
||||||
|
"""Nutzer ist im Einstellungen-Tab; `activity` = was gerade bearbeitet wird
|
||||||
|
(None = nur "schaut sich die Einstellungen an")."""
|
||||||
|
self._replace_queue(_SettingsActivity(activity))
|
||||||
|
|
||||||
def _replace_queue(self, item) -> None:
|
def _replace_queue(self, item) -> None:
|
||||||
try:
|
try:
|
||||||
self._queue.get_nowait()
|
self._queue.get_nowait()
|
||||||
@@ -254,6 +277,14 @@ class DiscordRPCWorker(QThread):
|
|||||||
else:
|
else:
|
||||||
payload = None
|
payload = None
|
||||||
self._presence.clear()
|
self._presence.clear()
|
||||||
|
elif isinstance(item, _SettingsActivity):
|
||||||
|
# Wie beim Leerlauf: der naechste echte Track bekommt einen frischen
|
||||||
|
# Zeit-Anker.
|
||||||
|
self._track_key = None
|
||||||
|
self._track_start_ts = None
|
||||||
|
payload = build_settings_payload(self._session_start, item.activity)
|
||||||
|
self._presence.update(**payload)
|
||||||
|
log_line("[send-settings] state=%r" % (payload["state"],))
|
||||||
else:
|
else:
|
||||||
start_ts, end_ts = self._track_timestamps(item)
|
start_ts, end_ts = self._track_timestamps(item)
|
||||||
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
||||||
@@ -287,6 +318,15 @@ class DiscordRPCWorker(QThread):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _SettingsActivity:
|
||||||
|
"""Queue-Eintrag: Nutzer ist in den Einstellungen und bearbeitet `activity`."""
|
||||||
|
|
||||||
|
__slots__ = ("activity",)
|
||||||
|
|
||||||
|
def __init__(self, activity: str | None) -> None:
|
||||||
|
self.activity = activity
|
||||||
|
|
||||||
|
|
||||||
class _Sentinel:
|
class _Sentinel:
|
||||||
__slots__ = ()
|
__slots__ = ()
|
||||||
|
|
||||||
|
|||||||
+118
-9
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -19,15 +20,27 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
from .browser import BrowserTab
|
from .audio_routing import sync_audio_permissions
|
||||||
|
from .browser import BrowserTab, get_shared_profile
|
||||||
from .config import APP_NAME
|
from .config import APP_NAME
|
||||||
from .discord_rpc import DiscordRPCWorker
|
from .discord_rpc import DiscordRPCWorker
|
||||||
|
from .remote_control import build_state as build_remote_state
|
||||||
|
from .remote_control import resolve_target as resolve_remote_target
|
||||||
from .settings_tab import SettingsTab
|
from .settings_tab import SettingsTab
|
||||||
from .updater import UpdateChecker, UpdateInstaller
|
from .updater import UpdateChecker, UpdateInstaller
|
||||||
|
|
||||||
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _force_exit_after(seconds: float) -> None:
|
||||||
|
"""Beendet den Prozess nach `seconds` hart (os._exit), falls das normale Beenden bis
|
||||||
|
dahin noch nicht durch ist. Der Timer laeuft als Daemon-Thread und stoert das
|
||||||
|
normale, schnelle Beenden nicht."""
|
||||||
|
timer = threading.Timer(seconds, os._exit, args=(0,))
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self, config: dict[str, Any]):
|
def __init__(self, config: dict[str, Any]):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -44,14 +57,22 @@ class MainWindow(QMainWindow):
|
|||||||
self._tabs.setDocumentMode(True)
|
self._tabs.setDocumentMode(True)
|
||||||
self.setCentralWidget(self._tabs)
|
self.setCentralWidget(self._tabs)
|
||||||
|
|
||||||
|
self._sync_audio_permission()
|
||||||
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
self._youtube_tab = BrowserTab(config["home_youtube"], self)
|
||||||
self._music_tab = BrowserTab(config["home_music"], self)
|
self._music_tab = BrowserTab(config["home_music"], self)
|
||||||
|
# Zusaetzlich nach JEDEM Seitenaufbau erneut erteilen: eine nur VOR dem Laden erteilte
|
||||||
|
# Freigabe greift auf der YouTube-Startseite nicht (per Test ermittelt), eine danach
|
||||||
|
# erteilte schon (siehe audio_routing.py).
|
||||||
|
for tab in (self._youtube_tab, self._music_tab):
|
||||||
|
tab.loadFinished.connect(self._on_tab_load_finished)
|
||||||
self._settings_tab = SettingsTab(config, self)
|
self._settings_tab = SettingsTab(config, self)
|
||||||
self._tabs.addTab(self._youtube_tab, "YouTube")
|
self._tabs.addTab(self._youtube_tab, "YouTube")
|
||||||
self._tabs.addTab(self._music_tab, "YouTube Music")
|
self._tabs.addTab(self._music_tab, "YouTube Music")
|
||||||
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
self._tabs.addTab(self._settings_tab, "Einstellungen")
|
||||||
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1)
|
||||||
|
self._apply_audio_routing()
|
||||||
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
|
self._settings_tab.settingsSaved.connect(self._on_settings_saved)
|
||||||
|
self._settings_tab.activityChanged.connect(lambda _activity: self._update_discord_presence())
|
||||||
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
|
self._settings_tab.checkUpdatesRequested.connect(self._check_for_updates)
|
||||||
self._settings_tab.installUpdateRequested.connect(
|
self._settings_tab.installUpdateRequested.connect(
|
||||||
lambda: self._start_update_install(self._pending_download_url)
|
lambda: self._start_update_install(self._pending_download_url)
|
||||||
@@ -170,26 +191,46 @@ class MainWindow(QMainWindow):
|
|||||||
tab = self._current_browser_tab()
|
tab = self._current_browser_tab()
|
||||||
self._url_bar.setEnabled(tab is not None)
|
self._url_bar.setEnabled(tab is not None)
|
||||||
self._url_bar.setText(tab.url().toString() if tab is not None else "")
|
self._url_bar.setText(tab.url().toString() if tab is not None else "")
|
||||||
|
# Tab-Wechsel von/zu den Einstellungen aendert, was Discord anzeigen soll.
|
||||||
|
self._update_discord_presence()
|
||||||
|
|
||||||
|
def _active_media_info(self) -> dict | None:
|
||||||
|
"""Bevorzugt der Tab, der gerade wirklich abspielt, sonst der aktuell sichtbare."""
|
||||||
|
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
|
||||||
|
return playing_info or self._latest_media.get(id(self._current_tab()))
|
||||||
|
|
||||||
def _on_media_info(self, info: dict) -> None:
|
def _on_media_info(self, info: dict) -> None:
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
self._latest_media[id(sender)] = info
|
self._latest_media[id(sender)] = info
|
||||||
|
|
||||||
# Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich
|
active_info = self._active_media_info()
|
||||||
# abspielt, sonst der aktuell sichtbare Tab.
|
|
||||||
playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None)
|
|
||||||
active_info = playing_info or self._latest_media.get(id(self._current_tab()))
|
|
||||||
|
|
||||||
if active_info and active_info.get("title"):
|
if active_info and active_info.get("title"):
|
||||||
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
|
self.setWindowTitle(f"{active_info['title']} – {APP_NAME}")
|
||||||
else:
|
else:
|
||||||
self.setWindowTitle(APP_NAME)
|
self.setWindowTitle(APP_NAME)
|
||||||
|
|
||||||
if self._rpc_worker is not None:
|
self._update_discord_presence()
|
||||||
|
|
||||||
|
def _update_discord_presence(self) -> None:
|
||||||
|
"""Entscheidet, was Discord gerade anzeigt: solange der Einstellungen-Tab offen
|
||||||
|
ist, "In den Einstellungen" samt dem gerade bearbeiteten Feld; sonst wie bisher
|
||||||
|
die laufende Wiedergabe bzw. den Leerlauf. Die Einstellungs-Anzeige folgt der
|
||||||
|
Option "Status anzeigen, wenn gerade nichts laeuft" (show_idle_presence) - wer den
|
||||||
|
Leerlauf-Status abgeschaltet hat, bekommt auch keinen Einstellungs-Status."""
|
||||||
|
worker = getattr(self, "_rpc_worker", None)
|
||||||
|
if worker is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
discord_cfg = self._config.get("discord", {})
|
||||||
|
if self._current_tab() is self._settings_tab and discord_cfg.get("show_idle_presence", True):
|
||||||
|
worker.submit_settings_activity(self._settings_tab.current_activity())
|
||||||
|
return
|
||||||
|
|
||||||
|
active_info = self._active_media_info()
|
||||||
if active_info and active_info.get("hasVideo"):
|
if active_info and active_info.get("hasVideo"):
|
||||||
self._rpc_worker.submit_media_info(active_info)
|
worker.submit_media_info(active_info)
|
||||||
else:
|
else:
|
||||||
self._rpc_worker.submit_media_info(None)
|
worker.submit_media_info(None)
|
||||||
|
|
||||||
def _start_discord_worker(self) -> None:
|
def _start_discord_worker(self) -> None:
|
||||||
discord_cfg = self._config.get("discord", {})
|
discord_cfg = self._config.get("discord", {})
|
||||||
@@ -201,14 +242,41 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._rpc_worker.start()
|
self._rpc_worker.start()
|
||||||
|
|
||||||
|
def _audio_outputs(self) -> tuple[str, str]:
|
||||||
|
"""(Geraet fuer YouTube, Geraet fuer YouTube Musik) aus der Konfiguration; leer =
|
||||||
|
Systemstandard."""
|
||||||
|
audio_cfg = self._config.get("audio", {})
|
||||||
|
return (
|
||||||
|
str(audio_cfg.get("youtube_output", "") or ""),
|
||||||
|
str(audio_cfg.get("music_output", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_audio_permission(self) -> None:
|
||||||
|
"""Erteilt bzw. entzieht die Freigabe der Geraetenamen (siehe audio_routing.py) -
|
||||||
|
nur solange mindestens ein Tab ein eigenes Geraet nutzt."""
|
||||||
|
sync_audio_permissions(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:
|
def _on_settings_saved(self, new_config: dict) -> None:
|
||||||
self._config = new_config
|
self._config = new_config
|
||||||
|
self._sync_audio_permission()
|
||||||
|
self._apply_audio_routing()
|
||||||
|
|
||||||
if self._rpc_worker is not None:
|
if self._rpc_worker is not None:
|
||||||
self._rpc_worker.stop()
|
self._rpc_worker.stop()
|
||||||
self._rpc_worker.wait(2000)
|
self._rpc_worker.wait(2000)
|
||||||
self._rpc_worker = None
|
self._rpc_worker = None
|
||||||
self._start_discord_worker()
|
self._start_discord_worker()
|
||||||
|
self._update_discord_presence() # frischen Worker sofort mit dem aktuellen Stand versorgen
|
||||||
|
|
||||||
self._setup_auto_update()
|
self._setup_auto_update()
|
||||||
|
|
||||||
@@ -226,6 +294,43 @@ class MainWindow(QMainWindow):
|
|||||||
aus dem Tray, wenn Playtube ein zweites Mal gestartet wird."""
|
aus dem Tray, wenn Playtube ein zweites Mal gestartet wird."""
|
||||||
self._show_and_raise()
|
self._show_and_raise()
|
||||||
|
|
||||||
|
# ------------------------------------------------- Fernsteuerung (remote_control.py)
|
||||||
|
|
||||||
|
def _browser_tabs_by_name(self) -> dict[str, BrowserTab]:
|
||||||
|
return {"youtube": self._youtube_tab, "music": self._music_tab}
|
||||||
|
|
||||||
|
def _visible_tab_name(self) -> str:
|
||||||
|
current = self._current_tab()
|
||||||
|
for name, tab in self._browser_tabs_by_name().items():
|
||||||
|
if current is tab:
|
||||||
|
return name
|
||||||
|
return "settings"
|
||||||
|
|
||||||
|
def _media_by_tab_name(self) -> dict[str, dict | None]:
|
||||||
|
return {name: self._latest_media.get(id(tab)) for name, tab in self._browser_tabs_by_name().items()}
|
||||||
|
|
||||||
|
def remote_state(self) -> dict:
|
||||||
|
return build_remote_state(APP_VERSION, self._visible_tab_name(), self._media_by_tab_name())
|
||||||
|
|
||||||
|
def remote_show(self) -> None:
|
||||||
|
self._show_and_raise()
|
||||||
|
|
||||||
|
def remote_switch_tab(self, target: str) -> None:
|
||||||
|
""""youtube"/"music" waehlt den Tab; "auto" wechselt zwischen beiden hin und her."""
|
||||||
|
if target == "auto":
|
||||||
|
target = "music" if self._visible_tab_name() == "youtube" else "youtube"
|
||||||
|
self._tabs.setCurrentWidget(self._browser_tabs_by_name()[target])
|
||||||
|
|
||||||
|
def remote_media_command(self, target: str, command: str, value: float) -> None:
|
||||||
|
name = resolve_remote_target(target, self._visible_tab_name(), self._media_by_tab_name())
|
||||||
|
self._browser_tabs_by_name()[name].run_media_command(command, value)
|
||||||
|
|
||||||
|
def remote_list_playlists(self, callback) -> None:
|
||||||
|
self._music_tab.list_playlists(callback)
|
||||||
|
|
||||||
|
def remote_play_playlist(self, playlist_id: str) -> None:
|
||||||
|
self._music_tab.play_playlist(playlist_id)
|
||||||
|
|
||||||
def _quit(self) -> None:
|
def _quit(self) -> None:
|
||||||
if self._rpc_worker is not None:
|
if self._rpc_worker is not None:
|
||||||
self._rpc_worker.stop()
|
self._rpc_worker.stop()
|
||||||
@@ -359,6 +464,10 @@ class MainWindow(QMainWindow):
|
|||||||
"Installation abgeschlossen. Playtube wird neu gestartet …"
|
"Installation abgeschlossen. Playtube wird neu gestartet …"
|
||||||
)
|
)
|
||||||
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
|
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
|
||||||
|
# Haengt sich Qt/QtWebEngine beim Beenden auf, wuerde der Update-Helfer (bzw.
|
||||||
|
# Setup, siehe /WAITPID) ewig auf das Ende dieses Prozesses warten - und die
|
||||||
|
# alte Version bliebe einfach weiterlaufen. Nach 8 s hart beenden.
|
||||||
|
_force_exit_after(8.0)
|
||||||
self._quit()
|
self._quit()
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
|
|||||||
@@ -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);
|
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zusatzstatus fuer die Fernsteuerung (Stream-Dock-Plugin, siehe remote_control.py).
|
||||||
|
var player = document.getElementById('movie_player');
|
||||||
|
var volume = (player && typeof player.getVolume === 'function')
|
||||||
|
? player.getVolume() : (video ? Math.round(video.volume * 100) : null);
|
||||||
|
var muted = (player && typeof player.isMuted === 'function')
|
||||||
|
? player.isMuted() : (video ? video.muted : null);
|
||||||
|
function likeState() {
|
||||||
|
if (isMusic) {
|
||||||
|
var r = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer');
|
||||||
|
var s = r ? r.getAttribute('like-status') : null;
|
||||||
|
return s ? s.toLowerCase() : null; // "like" | "dislike" | "indifferent"
|
||||||
|
}
|
||||||
|
var like = document.querySelector('like-button-view-model button, #segmented-like-button button');
|
||||||
|
var dislike = document.querySelector('dislike-button-view-model button, #segmented-dislike-button button');
|
||||||
|
if (like && like.getAttribute('aria-pressed') === 'true') { return 'like'; }
|
||||||
|
if (dislike && dislike.getAttribute('aria-pressed') === 'true') { return 'dislike'; }
|
||||||
|
return like ? 'indifferent' : null;
|
||||||
|
}
|
||||||
|
function repeatMode() {
|
||||||
|
if (!isMusic) { return video && video.loop ? 'ONE' : 'NONE'; }
|
||||||
|
var bar = document.querySelector('ytmusic-player-bar');
|
||||||
|
return bar ? (bar.getAttribute('repeat-mode') || bar.getAttribute('repeat-mode_') || null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
|
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
|
||||||
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
|
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
|
||||||
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
|
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
|
||||||
@@ -123,7 +147,11 @@ MEDIA_PROBE_JS = r"""
|
|||||||
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
|
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
|
||||||
currentTime: currentTime,
|
currentTime: currentTime,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
hasVideo: !!video
|
hasVideo: !!video,
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
likeStatus: likeState(),
|
||||||
|
repeatMode: repeatMode()
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Lokale Fernsteuerung fuer externe Tools - z.B. das Stream-Dock-Plugin
|
||||||
|
(com.fojadrachi.playtube.sdPlugin).
|
||||||
|
|
||||||
|
Transport: ein QLocalServer (Named Pipe unter Windows, z.B. \\\\.\\pipe\\Playtube.Remote.Playtube)
|
||||||
|
- nur lokal erreichbar, kein offener Netzwerk-Port. Getrennt vom Einzelinstanz-Pipe
|
||||||
|
(single_instance.py), damit ein Steuerbefehl nie das Fenster nach vorne holt und aeltere
|
||||||
|
Playtube-Versionen nicht faelschlich reagieren.
|
||||||
|
|
||||||
|
Protokoll (eine JSON-Nachricht pro Zeile, UTF-8, "\\n"-getrennt, Verbindung bleibt offen):
|
||||||
|
|
||||||
|
-> {"id": 1, "cmd": "play_pause", "target": "auto"}
|
||||||
|
<- {"id": 1, "ok": true}
|
||||||
|
-> {"id": 2, "cmd": "status"}
|
||||||
|
<- {"id": 2, "ok": true, "state": {...}}
|
||||||
|
<- {"id": 3, "ok": false, "error": "..."}
|
||||||
|
|
||||||
|
Playlists (nur YouTube Music):
|
||||||
|
|
||||||
|
-> {"id": 4, "cmd": "list_playlists"}
|
||||||
|
<- {"id": 4, "ok": true, "playlists": [{"id": "LM", "title": "Titel, die ich mag"}, ...]}
|
||||||
|
-> {"id": 5, "cmd": "play_playlist", "playlist": "PL..."}
|
||||||
|
|
||||||
|
`cmd` stammt aus einer festen Whitelist (APP_COMMANDS + media_control.MEDIA_COMMANDS),
|
||||||
|
`target` ist "auto" | "youtube" | "music", `value` eine Zahl mit befehlsabhaengigem
|
||||||
|
Wertebereich. Beliebiges JavaScript oder URLs lassen sich bewusst NICHT senden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||||
|
|
||||||
|
from .debug_log import log_exception
|
||||||
|
from .media_control import MEDIA_COMMANDS
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
PIPE_PREFIX = "Playtube.Remote."
|
||||||
|
|
||||||
|
MAX_LINE_BYTES = 4096
|
||||||
|
MAX_CLIENTS = 8
|
||||||
|
|
||||||
|
TAB_NAMES = ("youtube", "music")
|
||||||
|
TARGETS = frozenset({"auto", *TAB_NAMES})
|
||||||
|
APP_COMMANDS = frozenset({"status", "show", "switch_tab", "list_playlists", "play_playlist"})
|
||||||
|
|
||||||
|
# Playlist-IDs (z.B. "PL...", "OLAK5uy_...", "LM") - landen in einer URL, daher streng.
|
||||||
|
PLAYLIST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{2,64}$")
|
||||||
|
LIKED_MUSIC = {"id": "LM", "title": "Titel, die ich mag"}
|
||||||
|
MAX_PLAYLISTS = 100
|
||||||
|
MAX_PLAYLIST_TITLE = 100
|
||||||
|
|
||||||
|
# Befehl -> (min, max) fuer `value`; Befehle ohne Eintrag ignorieren `value`.
|
||||||
|
_VALUE_RANGES: dict[str, tuple[float, float]] = {
|
||||||
|
"seek": (-3600, 3600),
|
||||||
|
"volume_change": (-100, 100),
|
||||||
|
"set_volume": (0, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nur diese Felder des Media-Probes (media_probe.py) werden nach aussen gegeben.
|
||||||
|
_STATE_FIELDS = (
|
||||||
|
"isMusic", "title", "subtitle", "thumbnail", "playing", "currentTime", "duration",
|
||||||
|
"hasVideo", "volume", "muted", "likeStatus", "repeatMode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(ValueError):
|
||||||
|
"""Ungueltige Anfrage - wird dem Client als {"ok": false, "error": ...} gemeldet."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Request:
|
||||||
|
id: Any
|
||||||
|
command: str
|
||||||
|
target: str = "auto"
|
||||||
|
value: float = 0
|
||||||
|
playlist: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteController(Protocol):
|
||||||
|
"""Was der Server von der App braucht (implementiert von MainWindow)."""
|
||||||
|
|
||||||
|
def remote_state(self) -> dict: ...
|
||||||
|
def remote_show(self) -> None: ...
|
||||||
|
def remote_switch_tab(self, target: str) -> None: ...
|
||||||
|
def remote_media_command(self, target: str, command: str, value: float) -> None: ...
|
||||||
|
def remote_list_playlists(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
def remote_play_playlist(self, playlist_id: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def parse_request(line: str) -> Request:
|
||||||
|
"""Parst und validiert eine Anfragezeile. Wirft ProtocolError bei jedem Fehler."""
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProtocolError("ungueltiges JSON") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ProtocolError("Anfrage muss ein JSON-Objekt sein")
|
||||||
|
|
||||||
|
request_id = data.get("id")
|
||||||
|
if isinstance(request_id, bool) or (
|
||||||
|
request_id is not None and not isinstance(request_id, (int, str))
|
||||||
|
):
|
||||||
|
raise ProtocolError("id muss Zahl oder Text sein")
|
||||||
|
|
||||||
|
command = data.get("cmd")
|
||||||
|
if not isinstance(command, str) or (
|
||||||
|
command not in APP_COMMANDS and command not in MEDIA_COMMANDS
|
||||||
|
):
|
||||||
|
raise ProtocolError(f"unbekannter Befehl: {command!r}")
|
||||||
|
|
||||||
|
target = data.get("target", "auto")
|
||||||
|
if not isinstance(target, str) or target not in TARGETS:
|
||||||
|
raise ProtocolError(f"unbekanntes Ziel: {target!r}")
|
||||||
|
|
||||||
|
playlist = None
|
||||||
|
if command == "play_playlist":
|
||||||
|
playlist = data.get("playlist")
|
||||||
|
if not isinstance(playlist, str) or not PLAYLIST_ID_RE.fullmatch(playlist):
|
||||||
|
raise ProtocolError("play_playlist braucht eine gueltige Playlist-ID")
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
id=request_id,
|
||||||
|
command=command,
|
||||||
|
target=target,
|
||||||
|
value=_parse_value(command, data),
|
||||||
|
playlist=playlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value(command: str, data: dict) -> float:
|
||||||
|
value_range = _VALUE_RANGES.get(command)
|
||||||
|
if value_range is None:
|
||||||
|
return 0
|
||||||
|
raw = data.get("value")
|
||||||
|
# bool ist in Python ein int - true/false sind hier aber kein sinnvoller Wert.
|
||||||
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw):
|
||||||
|
raise ProtocolError(f"{command} braucht einen Zahlenwert")
|
||||||
|
low, high = value_range
|
||||||
|
if not low <= raw <= high:
|
||||||
|
raise ProtocolError(f"{command}: Wert muss zwischen {low:g} und {high:g} liegen")
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
Reply = Callable[[dict], None]
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(request: Request, controller: RemoteController, reply: Reply) -> None:
|
||||||
|
"""Fuehrt eine gueltige Anfrage aus und meldet die Antwort (ohne id) ueber `reply` -
|
||||||
|
sofort, oder bei list_playlists sobald die Seite geantwortet hat."""
|
||||||
|
if request.command == "status":
|
||||||
|
reply({"ok": True, "state": controller.remote_state()})
|
||||||
|
return
|
||||||
|
if request.command == "list_playlists":
|
||||||
|
controller.remote_list_playlists(
|
||||||
|
lambda raw: reply({"ok": True, "playlists": sanitize_playlists(raw)})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if request.command == "show":
|
||||||
|
controller.remote_show()
|
||||||
|
elif request.command == "switch_tab":
|
||||||
|
controller.remote_switch_tab(request.target)
|
||||||
|
elif request.command == "play_playlist":
|
||||||
|
controller.remote_play_playlist(request.playlist)
|
||||||
|
else:
|
||||||
|
controller.remote_media_command(request.target, request.command, request.value)
|
||||||
|
reply({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_playlists(raw: Any) -> list[dict]:
|
||||||
|
"""Bereinigt die von der Seite gelieferte Liste (fremde Daten!): nur gueltige IDs,
|
||||||
|
Titel als gekuerzter Text, ohne Duplikate, "Titel, die ich mag" immer zuerst."""
|
||||||
|
result = [dict(LIKED_MUSIC)]
|
||||||
|
seen = {LIKED_MUSIC["id"]}
|
||||||
|
for item in raw if isinstance(raw, list) else []:
|
||||||
|
if len(result) >= MAX_PLAYLISTS:
|
||||||
|
break
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
playlist_id, title = item.get("id"), item.get("title")
|
||||||
|
if not isinstance(playlist_id, str) or not PLAYLIST_ID_RE.fullmatch(playlist_id):
|
||||||
|
continue
|
||||||
|
if playlist_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(playlist_id)
|
||||||
|
clean_title = " ".join(title.split())[:MAX_PLAYLIST_TITLE] if isinstance(title, str) else ""
|
||||||
|
result.append({"id": playlist_id, "title": clean_title or playlist_id})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target(target: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> str:
|
||||||
|
"""Welcher Tab einen Medienbefehl bekommt. "auto": der Tab, der gerade abspielt
|
||||||
|
(spielen beide, der sichtbare), sonst der sichtbare Browser-Tab, sonst der mit einem
|
||||||
|
Video, sonst YouTube."""
|
||||||
|
if target in TAB_NAMES:
|
||||||
|
return target
|
||||||
|
playing = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("playing")]
|
||||||
|
if len(playing) == 1:
|
||||||
|
return playing[0]
|
||||||
|
if visible_tab in TAB_NAMES:
|
||||||
|
return visible_tab
|
||||||
|
with_video = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("hasVideo")]
|
||||||
|
return with_video[0] if with_video else TAB_NAMES[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_state(version: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> dict:
|
||||||
|
"""Status-Antwort: pro Tab die oeffentlichen Probe-Felder plus der "auto"-Zieltab."""
|
||||||
|
public_tabs = {}
|
||||||
|
for name in TAB_NAMES:
|
||||||
|
info = tabs.get(name)
|
||||||
|
public_tabs[name] = {key: info.get(key) for key in _STATE_FIELDS} if info else None
|
||||||
|
return {
|
||||||
|
"protocol": PROTOCOL_VERSION,
|
||||||
|
"version": version,
|
||||||
|
"visibleTab": visible_tab,
|
||||||
|
"activeTab": resolve_target("auto", visible_tab, tabs),
|
||||||
|
"tabs": public_tabs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_response(request_id: Any, response: dict) -> bytes:
|
||||||
|
return (json.dumps({"id": request_id, **response}, ensure_ascii=False) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def process_line(line: str, controller: RemoteController, write: Callable[[bytes], None]) -> None:
|
||||||
|
"""Eine Anfragezeile -> genau eine kodierte Antwortzeile ueber `write`. Wirft nie."""
|
||||||
|
try:
|
||||||
|
request = parse_request(line)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
write(encode_response(None, {"ok": False, "error": str(exc)}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
handle_request(request, controller, lambda response: write(encode_response(request.id, response)))
|
||||||
|
except Exception as exc: # noqa: BLE001 - ein Steuerbefehl darf die App nie abstuerzen lassen
|
||||||
|
log_exception(f"remote_control: {request.command}", exc)
|
||||||
|
write(encode_response(request.id, {"ok": False, "error": "interner Fehler"}))
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteControlServer(QObject):
|
||||||
|
"""Lauscht auf der Named Pipe und beantwortet Anfragen im Qt-Hauptthread (die
|
||||||
|
Controller-Methoden fassen Widgets an und duerfen nur dort laufen)."""
|
||||||
|
|
||||||
|
def __init__(self, key: str, controller: RemoteController, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._key = key
|
||||||
|
self._controller = controller
|
||||||
|
self._server: QLocalServer | None = None
|
||||||
|
self._buffers: dict[QLocalSocket, bytearray] = {}
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
server = QLocalServer(self)
|
||||||
|
# Nur der angemeldete Benutzer darf verbinden (Windows: Pipe-ACL).
|
||||||
|
server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
|
||||||
|
if not server.listen(self._key):
|
||||||
|
log_exception("remote_control: listen", RuntimeError(server.errorString()))
|
||||||
|
return False
|
||||||
|
server.newConnection.connect(self._on_new_connection)
|
||||||
|
self._server = server
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_new_connection(self) -> None:
|
||||||
|
while self._server is not None and self._server.hasPendingConnections():
|
||||||
|
socket = self._server.nextPendingConnection()
|
||||||
|
if len(self._buffers) >= MAX_CLIENTS:
|
||||||
|
socket.disconnectFromServer()
|
||||||
|
socket.deleteLater()
|
||||||
|
continue
|
||||||
|
self._buffers[socket] = bytearray()
|
||||||
|
socket.readyRead.connect(lambda s=socket: self._on_ready_read(s))
|
||||||
|
socket.disconnected.connect(lambda s=socket: self._on_disconnected(s))
|
||||||
|
|
||||||
|
def _on_ready_read(self, socket: QLocalSocket) -> None:
|
||||||
|
buffer = self._buffers.get(socket)
|
||||||
|
if buffer is None:
|
||||||
|
return
|
||||||
|
buffer.extend(bytes(socket.readAll().data()))
|
||||||
|
while (newline := buffer.find(b"\n")) >= 0:
|
||||||
|
raw = bytes(buffer[:newline])
|
||||||
|
del buffer[: newline + 1]
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if line:
|
||||||
|
process_line(line, self._controller, lambda data, s=socket: self._write(s, data))
|
||||||
|
if len(buffer) > MAX_LINE_BYTES:
|
||||||
|
# Kein Zeilenende in Sicht: Client verhaelt sich falsch -> trennen.
|
||||||
|
buffer.clear()
|
||||||
|
socket.write(encode_response(None, {"ok": False, "error": "Nachricht zu lang"}))
|
||||||
|
socket.disconnectFromServer()
|
||||||
|
|
||||||
|
def _write(self, socket: QLocalSocket, data: bytes) -> None:
|
||||||
|
# Asynchrone Antworten (list_playlists) koennen nach dem Trennen eintreffen.
|
||||||
|
if socket in self._buffers:
|
||||||
|
socket.write(data)
|
||||||
|
|
||||||
|
def _on_disconnected(self, socket: QLocalSocket) -> None:
|
||||||
|
self._buffers.pop(socket, None)
|
||||||
|
socket.deleteLater()
|
||||||
+118
-3
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtMultimedia import QMediaDevices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
@@ -22,9 +24,32 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import __version__ as APP_VERSION
|
from . import __version__ as APP_VERSION
|
||||||
|
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
|
||||||
from .config import APP_NAME, save_config
|
from .config import APP_NAME, save_config
|
||||||
from .updater import GITHUB_REPO
|
from .updater import GITHUB_REPO
|
||||||
|
|
||||||
|
# Helle Schrift fuer Versionsnummer, Update-Status und GitHub-Link. palette(text) ist die
|
||||||
|
# Standard-Textfarbe (im dunklen Design weiss, im hellen dunkel) - das fruehere
|
||||||
|
# palette(mid) war ein dunkles Grau, das auf dem dunklen Hintergrund kaum lesbar war.
|
||||||
|
_LIGHT_TEXT_STYLE = "color: palette(text);"
|
||||||
|
|
||||||
|
# Update-Fortschrittsbalken: helle Beschriftung und gut sichtbarer Balken. Sobald ein
|
||||||
|
# Stylesheet gesetzt ist, zeichnet Qt den Balken nicht mehr mit dem nativen Stil - deshalb
|
||||||
|
# hier Rahmen, Hintergrund und Balken (chunk) vollstaendig angegeben.
|
||||||
|
_PROGRESS_BAR_STYLE = """
|
||||||
|
QProgressBar {
|
||||||
|
color: palette(text);
|
||||||
|
background-color: palette(base);
|
||||||
|
border: 1px solid palette(mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #2f81f7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class SettingsTab(QWidget):
|
class SettingsTab(QWidget):
|
||||||
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
"""Speichert Aenderungen sofort in config.json und meldet sie per Signal an
|
||||||
@@ -33,6 +58,9 @@ class SettingsTab(QWidget):
|
|||||||
settingsSaved = Signal(dict)
|
settingsSaved = Signal(dict)
|
||||||
checkUpdatesRequested = Signal()
|
checkUpdatesRequested = Signal()
|
||||||
installUpdateRequested = Signal()
|
installUpdateRequested = Signal()
|
||||||
|
# Neue Beschreibung, was gerade bearbeitet wird (fuer die Discord-Presence, siehe
|
||||||
|
# MainWindow._update_discord_presence).
|
||||||
|
activityChanged = Signal(str)
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any], parent=None):
|
def __init__(self, config: dict[str, Any], parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -48,7 +76,7 @@ class SettingsTab(QWidget):
|
|||||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
version_label = QLabel(f"Version {APP_VERSION}")
|
version_label = QLabel(f"Version {APP_VERSION}")
|
||||||
version_label.setStyleSheet("color: palette(mid);")
|
version_label.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
header.addWidget(version_label)
|
header.addWidget(version_label)
|
||||||
outer.addLayout(header)
|
outer.addLayout(header)
|
||||||
|
|
||||||
@@ -97,18 +125,44 @@ class SettingsTab(QWidget):
|
|||||||
update_form.addRow(update_actions)
|
update_form.addRow(update_actions)
|
||||||
|
|
||||||
self._update_status_label = QLabel("")
|
self._update_status_label = QLabel("")
|
||||||
self._update_status_label.setStyleSheet("color: palette(mid);")
|
self._update_status_label.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
self._update_status_label.setWordWrap(True)
|
self._update_status_label.setWordWrap(True)
|
||||||
update_form.addRow(self._update_status_label)
|
update_form.addRow(self._update_status_label)
|
||||||
|
|
||||||
self._update_progress = QProgressBar()
|
self._update_progress = QProgressBar()
|
||||||
self._update_progress.setRange(0, 100)
|
self._update_progress.setRange(0, 100)
|
||||||
self._update_progress.setTextVisible(True)
|
self._update_progress.setTextVisible(True)
|
||||||
|
self._update_progress.setStyleSheet(_PROGRESS_BAR_STYLE)
|
||||||
self._update_progress.setVisible(False)
|
self._update_progress.setVisible(False)
|
||||||
update_form.addRow(self._update_progress)
|
update_form.addRow(self._update_progress)
|
||||||
|
|
||||||
outer.addWidget(update_box)
|
outer.addWidget(update_box)
|
||||||
|
|
||||||
|
audio_cfg = config.get("audio", {})
|
||||||
|
audio_box = QGroupBox("Audioausgabe")
|
||||||
|
audio_form = QFormLayout(audio_box)
|
||||||
|
self._youtube_output = QComboBox()
|
||||||
|
self._music_output = QComboBox()
|
||||||
|
audio_form.addRow("YouTube:", self._youtube_output)
|
||||||
|
audio_form.addRow("YouTube Musik:", self._music_output)
|
||||||
|
audio_hint = QLabel(
|
||||||
|
"Legt den Ton von YouTube und YouTube Musik auf verschiedene Ausgabegeräte, z. B. "
|
||||||
|
"auf getrennte Sonar-Kanäle. Windows zeigt beide unter „Playtube“ - getrennt wird "
|
||||||
|
"über das Gerät. Damit die Seite das Gerät finden kann, erlaubt Playtube ihr dafür "
|
||||||
|
"den Zugriff auf die Audiogeräte-Namen (es wird nichts aufgenommen); die Freigabe "
|
||||||
|
"gilt nur, solange hier ein eigenes Gerät gewählt ist."
|
||||||
|
)
|
||||||
|
audio_hint.setWordWrap(True)
|
||||||
|
audio_hint.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
|
audio_form.addRow(audio_hint)
|
||||||
|
outer.addWidget(audio_box)
|
||||||
|
|
||||||
|
# QMediaDevices meldet, wenn Geraete ein-/ausgesteckt werden -> Listen aktuell halten.
|
||||||
|
self._media_devices = QMediaDevices(self)
|
||||||
|
self._fill_output_combo(self._youtube_output, str(audio_cfg.get("youtube_output", "") or ""))
|
||||||
|
self._fill_output_combo(self._music_output, str(audio_cfg.get("music_output", "") or ""))
|
||||||
|
self._media_devices.audioOutputsChanged.connect(self._reload_output_devices)
|
||||||
|
|
||||||
general_box = QGroupBox("Allgemein")
|
general_box = QGroupBox("Allgemein")
|
||||||
general_form = QFormLayout(general_box)
|
general_form = QFormLayout(general_box)
|
||||||
self._start_tab = QComboBox()
|
self._start_tab = QComboBox()
|
||||||
@@ -127,11 +181,68 @@ class SettingsTab(QWidget):
|
|||||||
outer.addLayout(button_row)
|
outer.addLayout(button_row)
|
||||||
|
|
||||||
info = QLabel(f"github.com/{GITHUB_REPO}")
|
info = QLabel(f"github.com/{GITHUB_REPO}")
|
||||||
info.setStyleSheet("color: palette(mid);")
|
info.setStyleSheet(_LIGHT_TEXT_STYLE)
|
||||||
outer.addWidget(info)
|
outer.addWidget(info)
|
||||||
|
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
|
|
||||||
|
# Beschreibung je Eingabefeld fuer die Discord-Presence ("was wird bearbeitet").
|
||||||
|
# Bewusst nur Feld-NAMEN, nie Inhalte (z.B. nicht die Client-ID selbst).
|
||||||
|
self._activity_by_widget: dict[QWidget, str] = {
|
||||||
|
self._discord_enabled: "Bearbeitet: Discord Rich Presence › Aktiviert",
|
||||||
|
self._client_id: "Bearbeitet: Discord Rich Presence › Client-ID",
|
||||||
|
self._discord_interval: "Bearbeitet: Discord Rich Presence › Update-Intervall",
|
||||||
|
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
|
||||||
|
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
|
||||||
|
self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall",
|
||||||
|
self._youtube_output: "Bearbeitet: Audioausgabe › YouTube",
|
||||||
|
self._music_output: "Bearbeitet: Audioausgabe › YouTube Musik",
|
||||||
|
self._check_updates_btn: "Sucht nach Updates",
|
||||||
|
self._install_update_btn: "Installiert ein Update",
|
||||||
|
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
|
||||||
|
save_btn: "Speichert die Einstellungen",
|
||||||
|
}
|
||||||
|
self._current_activity: str | None = None
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.focusChanged.connect(self._on_focus_changed)
|
||||||
|
|
||||||
|
def _fill_output_combo(self, combo: QComboBox, saved: str) -> None:
|
||||||
|
"""Fuellt eine Ausgabe-Auswahl: "Systemstandard" + alle vorhandenen Geraete. Ist das
|
||||||
|
gespeicherte Geraet gerade nicht angeschlossen, bleibt es (als "nicht verfuegbar")
|
||||||
|
ausgewaehlt, statt die Einstellung stillschweigend zu verlieren."""
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.clear()
|
||||||
|
combo.addItem(SYSTEM_DEFAULT_LABEL, "")
|
||||||
|
names = list_output_devices()
|
||||||
|
for name in names:
|
||||||
|
combo.addItem(name, name)
|
||||||
|
if saved and saved not in names:
|
||||||
|
combo.addItem(f"{saved} (nicht verfügbar)", saved)
|
||||||
|
combo.setCurrentIndex(max(0, combo.findData(saved)))
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _reload_output_devices(self) -> None:
|
||||||
|
for combo in (self._youtube_output, self._music_output):
|
||||||
|
self._fill_output_combo(combo, combo.currentData() or "")
|
||||||
|
|
||||||
|
def current_activity(self) -> str | None:
|
||||||
|
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
|
||||||
|
return self._current_activity
|
||||||
|
|
||||||
|
def _on_focus_changed(self, _old: QWidget | None, new: QWidget | None) -> None:
|
||||||
|
# Vom fokussierten Widget nach oben suchen: bei QSpinBox/QComboBox liegt der Fokus
|
||||||
|
# auf einem internen Kind-Widget (z.B. dem Textfeld des Spinners).
|
||||||
|
widget = new
|
||||||
|
while widget is not None and widget is not self:
|
||||||
|
activity = self._activity_by_widget.get(widget)
|
||||||
|
if activity:
|
||||||
|
if activity != self._current_activity:
|
||||||
|
self._current_activity = activity
|
||||||
|
self.activityChanged.emit(activity)
|
||||||
|
return
|
||||||
|
widget = widget.parentWidget()
|
||||||
|
|
||||||
def _on_save(self) -> None:
|
def _on_save(self) -> None:
|
||||||
self._config.setdefault("discord", {})
|
self._config.setdefault("discord", {})
|
||||||
self._config.setdefault("updates", {})
|
self._config.setdefault("updates", {})
|
||||||
@@ -144,6 +255,10 @@ class SettingsTab(QWidget):
|
|||||||
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
|
||||||
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
self._config["updates"]["check_interval_hours"] = self._check_interval.value()
|
||||||
|
|
||||||
|
self._config.setdefault("audio", {})
|
||||||
|
self._config["audio"]["youtube_output"] = self._youtube_output.currentData() or ""
|
||||||
|
self._config["audio"]["music_output"] = self._music_output.currentData() or ""
|
||||||
|
|
||||||
self._config["start_tab"] = self._start_tab.currentData()
|
self._config["start_tab"] = self._start_tab.currentData()
|
||||||
|
|
||||||
save_config(self._config)
|
save_config(self._config)
|
||||||
|
|||||||
+11
-2
@@ -466,7 +466,12 @@ rm -rf "{staging}"
|
|||||||
packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die
|
packaging/playtube.iss) und startet Playtube dank /RELAUNCH=1 danach neu. Die
|
||||||
laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss
|
laufende App beendet sich direkt nach diesem Aufruf (siehe MainWindow), muss
|
||||||
hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das
|
hier also nicht auf Setup warten. Setup wird abgekoppelt gestartet, damit es das
|
||||||
Beenden dieses Prozesses ueberlebt."""
|
Beenden dieses Prozesses ueberlebt.
|
||||||
|
|
||||||
|
/WAITPID=<eigene PID>: Setup wartet, bis sich diese App wirklich beendet hat,
|
||||||
|
bevor es Dateien tauscht. /LOG (ohne Pfad): Setup schreibt ein Protokoll nach
|
||||||
|
%TEMP% ("Setup Log ....txt") - so laesst sich ein fehlgeschlagenes Update
|
||||||
|
nachtraeglich nachvollziehen, obwohl die Installation selbst still laeuft."""
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[
|
[
|
||||||
str(setup_path),
|
str(setup_path),
|
||||||
@@ -475,6 +480,8 @@ rm -rf "{staging}"
|
|||||||
"/NORESTART",
|
"/NORESTART",
|
||||||
"/NOCANCEL",
|
"/NOCANCEL",
|
||||||
"/RELAUNCH=1",
|
"/RELAUNCH=1",
|
||||||
|
f"/WAITPID={os.getpid()}",
|
||||||
|
"/LOG",
|
||||||
],
|
],
|
||||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
@@ -497,7 +504,9 @@ $form.Refresh()
|
|||||||
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
|
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
|
||||||
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
|
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
|
||||||
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
|
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
|
||||||
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
# /R:3 /W:2 begrenzt die Wiederholungen bei gesperrten Dateien (Standard: praktisch endlos,
|
||||||
|
# das Update haengt dann sichtbar "fuer immer" im Fortschrittsfenster).
|
||||||
|
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
||||||
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
||||||
while (-not $roboProc.HasExited) {{
|
while (-not $roboProc.HasExited) {{
|
||||||
Start-Sleep -Milliseconds 200
|
Start-Sleep -Milliseconds 200
|
||||||
|
|||||||
@@ -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