5 Commits
Author SHA1 Message Date
Fojadrachi 6a682fc49c chore: Release v4.5.1
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-25 23:44:05 +02:00
Fojadrachi 33043f1319 feat: lokale Fernsteuerung per Named Pipe fuer das Stream Dock Plugin
Playback, Lautstaerke, Tab-Wechsel sowie list_playlists/play_playlist ueber
\.\pipe\Playtube.Remote.<Name>; Tests inklusive.
2026-09-25 23:43:17 +02:00
Fojadrachi c30efec612 Release v4.5.0 2026-09-19 21:14:37 +02:00
Fojadrachi 3f3f86f597 Audioausgabe pro Tab: YouTube und YouTube Musik auf verschiedene Geraete legen
In den Einstellungen (Gruppe "Audioausgabe", Felder "YouTube" und "YouTube Musik") laesst
sich getrennt waehlen, ueber welches Ausgabegeraet der Ton jedes Tabs laeuft - z.B. auf
getrennte Sonar-Kanaele. Die Auswahl gilt sofort, auch fuer bereits geoeffnete Seiten.

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

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

- discord_rpc: build_settings_payload + DiscordRPCWorker.submit_settings_activity
- settings_tab: Signal activityChanged, verfolgt den Fokus (auch bei Spinner/Combobox,
  deren Fokus auf einem internen Kind-Widget liegt)
- mainwindow: _update_discord_presence waehlt zwischen Einstellungen, Wiedergabe und
  Leerlauf; Einstellungs-Status folgt der Option "Status anzeigen, wenn gerade nichts
  laeuft" (show_idle_presence); bei Tab-Wechsel und nach dem Speichern aktualisiert
- README: Hinweis zur neuen Anzeige
2026-09-19 20:30:33 +02:00
13 changed files with 1273 additions and 14 deletions
+53 -1
View File
@@ -8,7 +8,14 @@ Erweiterung) mit:
- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei - **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei
Google anmelden reicht für beide Dienste. Google anmelden reicht für 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 läuft. einen Link-Button in deinem Discord-Profil, sobald etwas läuft. Ist der
**Einstellungen-Tab** offen, steht dort stattdessen „In den Einstellungen“ und welches
Feld du gerade bearbeitest (z.B. „Discord Rich Presence › Client-ID“) - nur der
Feldname, nie der Inhalt. Das folgt der Option „Status anzeigen, wenn gerade nichts
läuft“ (aus = auch kein Einstellungs-Status). Discord übernimmt Änderungen höchstens
alle 15 Sekunden, die Anzeige hinkt dem Klicken also etwas hinterher.
- **Audioausgabe pro Tab** – YouTube und YouTube Musik lassen sich in den Einstellungen
auf verschiedene Ausgabegeräte legen (z.B. getrennte Sonar-Kanäle), siehe unten.
- **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick - **System-Tray** – Fenster schliessen minimiert nur (Musik läuft weiter), Rechtsklick
aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menü. 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
@@ -63,6 +70,51 @@ sie ändern oder eine eigene Anwendung nutzen willst:
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 läuft) anpassen. Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts läuft) anpassen.
## Audioausgabe pro Tab (YouTube / YouTube Musik)
Im **Einstellungen-Tab** legst du unter „Audioausgabe“ getrennt fest, über welches
Ausgabegerät der Ton von **YouTube** und von **YouTube Musik** läuft (Standard:
Systemstandard). So kannst du z.B. YouTube auf „Sonar - Media“ und YouTube Musik auf
„Sonar - Aux“ legen und beide getrennt regeln. Nach „Speichern“ gilt die Auswahl sofort,
auch für bereits geöffnete Seiten (kein Neuladen nötig).
Wichtig zu wissen:
- **Windows zeigt weiterhin einen Eintrag „Playtube“.** Chromium (QtWebEngine) spielt den
Ton beider Tabs über *einen* gemeinsamen Audio-Prozess ab, deshalb kann Windows (auch
„App-Lautstärke und Geräteeinstellungen“) die Tabs nicht einzeln benennen oder routen.
Getrennt wird stattdessen über das **Gerät** - in Sonar & Co. also über den gewählten
Kanal/das gewählte Gerät.
- **Freigabe der Gerätenamen:** Chromium blendet Gerätenamen aus, solange die Seite keine
Mikrofon-Berechtigung hat. Damit die Seite das gewählte Gerät findet, erteilt Playtube
YouTube/YouTube Musik diese Freigabe - nur, solange mindestens ein Tab ein eigenes Gerät
nutzt, nur für diese beiden Seiten und nur für die laufende Sitzung (sie wird nicht
gespeichert und bei jedem Start neu erteilt). Es wird nichts aufgenommen.
- Ist das gewählte Gerät gerade nicht angeschlossen, bleibt die Auswahl (als „nicht
verfügbar“) gespeichert und der Ton läuft solange über den Systemstandard.
- Technik: [playtube/audio_routing.py](playtube/audio_routing.py) legt per
`HTMLMediaElement.setSinkId` die Medienelemente der Seite auf das Gerät (Suche über den
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
`audio.music_output` (leer = Systemstandard).
## Fernsteuerung (Stream Dock)
Playtube lässt sich lokal fernsteuern, z.B. über das Stream-Dock-Plugin
`com.fojadrachi.playtube.sdPlugin` (Ajazz/Mirabox AKP153E & Co.). Dafür lauscht Playtube
auf einer Named Pipe (`\\.\pipe\Playtube.Remote.Playtube`, im Entwicklungsmodus
`...PlaytubeDev`). Es gibt keinen Netzwerk-Port, verbinden darf nur der angemeldete
Benutzer.
- Protokoll: eine JSON-Nachricht pro Zeile, z.B. `{"id":1,"cmd":"play_pause","target":"auto"}`,
Antwort `{"id":1,"ok":true}`. Details in [playtube/remote_control.py](playtube/remote_control.py).
- Befehle: `status`, `show`, `switch_tab`, `play_pause`, `play`, `pause`, `next`,
`previous`, `seek`, `volume_change`, `set_volume`, `mute_toggle`, `like`, `dislike`,
`shuffle`, `repeat`, `list_playlists`, `play_playlist`. Es gibt eine feste Whitelist;
beliebiges JavaScript oder URLs lassen sich nicht senden.
- `target: "auto"` steuert den Tab, der gerade abspielt, sonst den sichtbaren.
- Abschalten: in `config.json` `"remote_control": {"enabled": false}`.
- Tests: `.venv\Scripts\python -m unittest discover -s tests -v`
## App als eigenständige Playtube.exe packen ## App als eigenständige Playtube.exe packen
Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut Für die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut
+7
View File
@@ -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:
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube"
__version__ = "2.4.0" __version__ = "4.5.1"
+151
View File
@@ -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 ""))
+57
View File
@@ -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()
+9
View File
@@ -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/",
+40
View File
@@ -112,6 +112,24 @@ def build_idle_payload(session_start: int) -> dict[str, Any]:
} }
SETTINGS_DETAILS = "In den Einstellungen"
SETTINGS_DEFAULT_STATE = "Schaut sich die Einstellungen an"
def build_settings_payload(session_start: int, activity: str | None) -> dict[str, Any]:
"""Presence, solange der Einstellungen-Tab offen ist. `activity` beschreibt, was
gerade bearbeitet wird (z.B. "Bearbeitet: Discord Rich Presence › Client-ID") - es
wird bewusst nur der NAME des Feldes uebergeben, nie sein Inhalt (sonst wuerde
z.B. die eingetragene Client-ID oeffentlich im Discord-Profil stehen)."""
return {
"details": SETTINGS_DETAILS,
"state": _truncate(activity, fallback=SETTINGS_DEFAULT_STATE),
"large_image": ASSET_DEFAULT,
"large_text": APP_NAME,
"start": session_start,
}
class DiscordRPCWorker(QThread): class DiscordRPCWorker(QThread):
"""Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates """Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates
debounced (max. 1 Update pro `interval` Sekunden) versendet.""" debounced (max. 1 Update pro `interval` Sekunden) versendet."""
@@ -143,6 +161,11 @@ class DiscordRPCWorker(QThread):
"""Neuester bekannter Zustand (None = nichts spielt / idle).""" """Neuester bekannter Zustand (None = nichts spielt / idle)."""
self._replace_queue(info if info is not None else _IDLE_SENTINEL) self._replace_queue(info if info is not None else _IDLE_SENTINEL)
def submit_settings_activity(self, activity: str | None) -> None:
"""Nutzer ist im Einstellungen-Tab; `activity` = was gerade bearbeitet wird
(None = nur "schaut sich die Einstellungen an")."""
self._replace_queue(_SettingsActivity(activity))
def _replace_queue(self, item) -> None: def _replace_queue(self, item) -> None:
try: try:
self._queue.get_nowait() self._queue.get_nowait()
@@ -254,6 +277,14 @@ class DiscordRPCWorker(QThread):
else: else:
payload = None payload = None
self._presence.clear() self._presence.clear()
elif isinstance(item, _SettingsActivity):
# Wie beim Leerlauf: der naechste echte Track bekommt einen frischen
# Zeit-Anker.
self._track_key = None
self._track_start_ts = None
payload = build_settings_payload(self._session_start, item.activity)
self._presence.update(**payload)
log_line("[send-settings] state=%r" % (payload["state"],))
else: else:
start_ts, end_ts = self._track_timestamps(item) start_ts, end_ts = self._track_timestamps(item)
payload = build_presence_payload(item, self._session_start, start_ts, end_ts) payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
@@ -287,6 +318,15 @@ class DiscordRPCWorker(QThread):
pass pass
class _SettingsActivity:
"""Queue-Eintrag: Nutzer ist in den Einstellungen und bearbeitet `activity`."""
__slots__ = ("activity",)
def __init__(self, activity: str | None) -> None:
self.activity = activity
class _Sentinel: class _Sentinel:
__slots__ = () __slots__ = ()
+104 -9
View File
@@ -20,9 +20,12 @@ 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
@@ -54,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)
@@ -180,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", {})
@@ -211,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()
@@ -236,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()
+156
View File
@@ -0,0 +1,156 @@
"""JavaScript fuer die Fernsteuerung (siehe remote_control.py): baut pro Befehl ein kleines
Skript, das im YouTube- bzw. YouTube-Music-Tab ausgefuehrt wird.
Es werden ausschliesslich feste Skripte aus einer Whitelist erzeugt; der einzige
variable Teil ist ein bereits validierter Zahlenwert (Sekunden/Lautstaerke), der als
Zahl eingesetzt wird - fremder Text landet nie im Skript."""
from __future__ import annotations
import math
# Gemeinsame Helfer: der YouTube-Player (#movie_player) bietet auf youtube.com UND
# music.youtube.com eine API fuer Lautstaerke/Stummschaltung/Titelwechsel; das
# <video>-Element dient als Fallback, falls die API (noch) nicht verfuegbar ist.
_PRELUDE = r"""
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
var video = document.querySelector('video');
var player = document.getElementById('movie_player');
function has(fn) { return player && typeof player[fn] === 'function'; }
function click(selectors) {
for (var i = 0; i < selectors.length; i++) {
var el = document.querySelector(selectors[i]);
if (el) { el.click(); return true; }
}
return false;
}
function clamp(v) { return Math.max(0, Math.min(100, Math.round(v))); }
function getVolume() {
if (has('getVolume')) { return player.getVolume(); }
return video ? video.volume * 100 : 0;
}
function unmute() {
if (has('unMute')) { player.unMute(); } else if (video) { video.muted = false; }
}
function mute() {
if (has('mute')) { player.mute(); } else if (video) { video.muted = true; }
}
function isMuted() {
if (has('isMuted')) { return player.isMuted(); }
return video ? video.muted : false;
}
function setVolume(v) {
v = clamp(v);
if (has('setVolume')) { player.setVolume(v); }
else if (video) { video.volume = v / 100; }
if (v > 0) { unmute(); }
}
"""
_NEXT_SELECTORS = (
"['ytmusic-player-bar .next-button', 'ytmusic-player-bar #next-button button', "
"'tp-yt-paper-icon-button.next-button', '.ytp-next-button']"
)
_PREV_SELECTORS = (
"['ytmusic-player-bar .previous-button', 'ytmusic-player-bar #previous-button button', "
"'tp-yt-paper-icon-button.previous-button', '.ytp-prev-button']"
)
_LIKE_SELECTORS = (
"['ytmusic-player-bar ytmusic-like-button-renderer #button-shape-like button', "
"'like-button-view-model button', '#segmented-like-button button']"
)
_DISLIKE_SELECTORS = (
"['ytmusic-player-bar ytmusic-like-button-renderer #button-shape-dislike button', "
"'dislike-button-view-model button', '#segmented-dislike-button button']"
)
# Befehl -> Skriptkoerper. "{value}" wird durch den validierten Zahlenwert ersetzt.
_BODIES: dict[str, str] = {
"play_pause": "if (video) { video.paused ? video.play() : video.pause(); }",
"play": "if (video && video.paused) { video.play(); }",
"pause": "if (video && !video.paused) { video.pause(); }",
# YouTube (ohne Playlist) blendet den "Naechster"-Knopf teils aus -> Player-API.
"next": f"if (!click({_NEXT_SELECTORS}) && has('nextVideo')) {{ player.nextVideo(); }}",
# Ohne Vorgaenger springt "Zurueck" wie bei ueblichen Playern an den Anfang.
"previous": (
f"if (!click({_PREV_SELECTORS})) {{"
" if (video) { video.currentTime = 0; } }"
),
"seek": (
"if (video && isFinite(video.currentTime)) {"
" var end = isFinite(video.duration) ? video.duration : video.currentTime + {value};"
" video.currentTime = Math.max(0, Math.min(end, video.currentTime + {value})); }"
),
"volume_change": "setVolume(getVolume() + {value});",
"set_volume": "setVolume({value});",
"mute_toggle": "if (isMuted()) { unmute(); } else { mute(); }",
"like": f"click({_LIKE_SELECTORS});",
"dislike": f"click({_DISLIKE_SELECTORS});",
"shuffle": (
"if (isMusic) { click(['ytmusic-player-bar .shuffle', "
"'ytmusic-player-bar #shuffle-button', 'ytmusic-player-bar .shuffle-button']); }"
),
# YouTube Music: Wiederholen-Modus durchschalten; YouTube: Video in Schleife an/aus.
"repeat": (
"if (isMusic) { click(['ytmusic-player-bar .repeat', "
"'ytmusic-player-bar #repeat-button', 'ytmusic-player-bar .repeat-button']); }"
" else if (video) { video.loop = !video.loop; }"
),
}
MEDIA_COMMANDS = frozenset(_BODIES)
# Liest die Playlists aus der Seitenleiste von YouTube Music. Die Eintraege sind keine
# normalen Links: Ziel steht in den Polymer-Daten des Elements
# (data.navigationEndpoint.browseEndpoint.browseId = "VL<Playlist-ID>"); Links
# ("browse/VL<id>", "playlist?list=<id>") dienen nur als Fallback. Liefert einen
# JSON-String (Objekte kommen ueber die runJavaScript()-Bruecke nicht zuverlaessig an,
# siehe media_probe.py); die Daten gelten als fremd und werden in
# remote_control.sanitize_playlists bereinigt.
LIST_PLAYLISTS_JS = r"""
(function() {
var out = [];
function idFromBrowseId(browseId) {
return (typeof browseId === 'string' && browseId.indexOf('VL') === 0) ? browseId.slice(2) : null;
}
function idFromHref(href) {
var m = /[?&]list=([A-Za-z0-9_-]+)/.exec(href || '') || /browse\/VL([A-Za-z0-9_-]+)/.exec(href || '');
return m ? m[1] : null;
}
function textOf(formatted) {
if (!formatted) { return ''; }
if (typeof formatted.simpleText === 'string') { return formatted.simpleText; }
return (formatted.runs || []).map(function(r) { return r.text || ''; }).join('');
}
var entries = document.querySelectorAll('ytmusic-guide-entry-renderer');
for (var i = 0; i < entries.length; i++) {
var el = entries[i];
var data = el.data || (el.__data && el.__data.data) || null;
var endpoint = data && data.navigationEndpoint;
var id = idFromBrowseId(endpoint && endpoint.browseEndpoint && endpoint.browseEndpoint.browseId);
if (!id) {
var link = el.querySelector('[href]');
id = idFromHref(link ? link.getAttribute('href') : '');
}
if (!id) { continue; }
var titleEl = el.querySelector('.title');
var title = textOf(data && data.formattedTitle) || (titleEl ? titleEl.textContent : '');
out.push({ id: id, title: (title || '').trim() });
}
return JSON.stringify(out);
})();
"""
MUSIC_PLAYLIST_URL = "https://music.youtube.com/watch?list={playlist_id}"
def build_control_js(command: str, value: float = 0) -> str:
"""Liefert das Skript fuer `command`. Unbekannte Befehle oder nicht-endliche Werte
-> ValueError (die Wertebereiche prueft vorher remote_control.parse_request)."""
body = _BODIES.get(command)
if body is None:
raise ValueError(f"unbekannter Befehl: {command}")
number = float(value)
if not math.isfinite(number):
raise ValueError("value muss eine endliche Zahl sein")
# repr() einer endlichen float ist immer ein gueltiges JS-Zahlenliteral (z.B. -10.0).
return "(function() {" + _PRELUDE + body.replace("{value}", repr(number)) + "\n})();"
+29 -1
View File
@@ -105,6 +105,30 @@ MEDIA_PROBE_JS = r"""
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null); thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
} }
// Zusatzstatus fuer die Fernsteuerung (Stream-Dock-Plugin, siehe remote_control.py).
var player = document.getElementById('movie_player');
var volume = (player && typeof player.getVolume === 'function')
? player.getVolume() : (video ? Math.round(video.volume * 100) : null);
var muted = (player && typeof player.isMuted === 'function')
? player.isMuted() : (video ? video.muted : null);
function likeState() {
if (isMusic) {
var r = document.querySelector('ytmusic-player-bar ytmusic-like-button-renderer');
var s = r ? r.getAttribute('like-status') : null;
return s ? s.toLowerCase() : null; // "like" | "dislike" | "indifferent"
}
var like = document.querySelector('like-button-view-model button, #segmented-like-button button');
var dislike = document.querySelector('dislike-button-view-model button, #segmented-dislike-button button');
if (like && like.getAttribute('aria-pressed') === 'true') { return 'like'; }
if (dislike && dislike.getAttribute('aria-pressed') === 'true') { return 'dislike'; }
return like ? 'indifferent' : null;
}
function repeatMode() {
if (!isMusic) { return video && video.loop ? 'ONE' : 'NONE'; }
var bar = document.querySelector('ytmusic-player-bar');
return bar ? (bar.getAttribute('repeat-mode') || bar.getAttribute('repeat-mode_') || null) : null;
}
var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo(); var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo();
var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0); var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0);
var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0); var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0);
@@ -123,7 +147,11 @@ MEDIA_PROBE_JS = r"""
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: currentTime, currentTime: currentTime,
duration: duration, duration: duration,
hasVideo: !!video hasVideo: !!video,
volume: volume,
muted: muted,
likeStatus: likeState(),
repeatMode: repeatMode()
}); });
})(); })();
""" """
+300
View File
@@ -0,0 +1,300 @@
"""Lokale Fernsteuerung fuer externe Tools - z.B. das Stream-Dock-Plugin
(com.fojadrachi.playtube.sdPlugin).
Transport: ein QLocalServer (Named Pipe unter Windows, z.B. \\\\.\\pipe\\Playtube.Remote.Playtube)
- nur lokal erreichbar, kein offener Netzwerk-Port. Getrennt vom Einzelinstanz-Pipe
(single_instance.py), damit ein Steuerbefehl nie das Fenster nach vorne holt und aeltere
Playtube-Versionen nicht faelschlich reagieren.
Protokoll (eine JSON-Nachricht pro Zeile, UTF-8, "\\n"-getrennt, Verbindung bleibt offen):
-> {"id": 1, "cmd": "play_pause", "target": "auto"}
<- {"id": 1, "ok": true}
-> {"id": 2, "cmd": "status"}
<- {"id": 2, "ok": true, "state": {...}}
<- {"id": 3, "ok": false, "error": "..."}
Playlists (nur YouTube Music):
-> {"id": 4, "cmd": "list_playlists"}
<- {"id": 4, "ok": true, "playlists": [{"id": "LM", "title": "Titel, die ich mag"}, ...]}
-> {"id": 5, "cmd": "play_playlist", "playlist": "PL..."}
`cmd` stammt aus einer festen Whitelist (APP_COMMANDS + media_control.MEDIA_COMMANDS),
`target` ist "auto" | "youtube" | "music", `value` eine Zahl mit befehlsabhaengigem
Wertebereich. Beliebiges JavaScript oder URLs lassen sich bewusst NICHT senden.
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from typing import Any, Callable, Protocol
from PySide6.QtCore import QObject
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from .debug_log import log_exception
from .media_control import MEDIA_COMMANDS
PROTOCOL_VERSION = 1
PIPE_PREFIX = "Playtube.Remote."
MAX_LINE_BYTES = 4096
MAX_CLIENTS = 8
TAB_NAMES = ("youtube", "music")
TARGETS = frozenset({"auto", *TAB_NAMES})
APP_COMMANDS = frozenset({"status", "show", "switch_tab", "list_playlists", "play_playlist"})
# Playlist-IDs (z.B. "PL...", "OLAK5uy_...", "LM") - landen in einer URL, daher streng.
PLAYLIST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{2,64}$")
LIKED_MUSIC = {"id": "LM", "title": "Titel, die ich mag"}
MAX_PLAYLISTS = 100
MAX_PLAYLIST_TITLE = 100
# Befehl -> (min, max) fuer `value`; Befehle ohne Eintrag ignorieren `value`.
_VALUE_RANGES: dict[str, tuple[float, float]] = {
"seek": (-3600, 3600),
"volume_change": (-100, 100),
"set_volume": (0, 100),
}
# Nur diese Felder des Media-Probes (media_probe.py) werden nach aussen gegeben.
_STATE_FIELDS = (
"isMusic", "title", "subtitle", "thumbnail", "playing", "currentTime", "duration",
"hasVideo", "volume", "muted", "likeStatus", "repeatMode",
)
class ProtocolError(ValueError):
"""Ungueltige Anfrage - wird dem Client als {"ok": false, "error": ...} gemeldet."""
@dataclass(frozen=True)
class Request:
id: Any
command: str
target: str = "auto"
value: float = 0
playlist: str | None = None
class RemoteController(Protocol):
"""Was der Server von der App braucht (implementiert von MainWindow)."""
def remote_state(self) -> dict: ...
def remote_show(self) -> None: ...
def remote_switch_tab(self, target: str) -> None: ...
def remote_media_command(self, target: str, command: str, value: float) -> None: ...
def remote_list_playlists(self, callback: Callable[[Any], None]) -> None: ...
def remote_play_playlist(self, playlist_id: str) -> None: ...
def parse_request(line: str) -> Request:
"""Parst und validiert eine Anfragezeile. Wirft ProtocolError bei jedem Fehler."""
try:
data = json.loads(line)
except json.JSONDecodeError as exc:
raise ProtocolError("ungueltiges JSON") from exc
if not isinstance(data, dict):
raise ProtocolError("Anfrage muss ein JSON-Objekt sein")
request_id = data.get("id")
if isinstance(request_id, bool) or (
request_id is not None and not isinstance(request_id, (int, str))
):
raise ProtocolError("id muss Zahl oder Text sein")
command = data.get("cmd")
if not isinstance(command, str) or (
command not in APP_COMMANDS and command not in MEDIA_COMMANDS
):
raise ProtocolError(f"unbekannter Befehl: {command!r}")
target = data.get("target", "auto")
if not isinstance(target, str) or target not in TARGETS:
raise ProtocolError(f"unbekanntes Ziel: {target!r}")
playlist = None
if command == "play_playlist":
playlist = data.get("playlist")
if not isinstance(playlist, str) or not PLAYLIST_ID_RE.fullmatch(playlist):
raise ProtocolError("play_playlist braucht eine gueltige Playlist-ID")
return Request(
id=request_id,
command=command,
target=target,
value=_parse_value(command, data),
playlist=playlist,
)
def _parse_value(command: str, data: dict) -> float:
value_range = _VALUE_RANGES.get(command)
if value_range is None:
return 0
raw = data.get("value")
# bool ist in Python ein int - true/false sind hier aber kein sinnvoller Wert.
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw):
raise ProtocolError(f"{command} braucht einen Zahlenwert")
low, high = value_range
if not low <= raw <= high:
raise ProtocolError(f"{command}: Wert muss zwischen {low:g} und {high:g} liegen")
return float(raw)
Reply = Callable[[dict], None]
def handle_request(request: Request, controller: RemoteController, reply: Reply) -> None:
"""Fuehrt eine gueltige Anfrage aus und meldet die Antwort (ohne id) ueber `reply` -
sofort, oder bei list_playlists sobald die Seite geantwortet hat."""
if request.command == "status":
reply({"ok": True, "state": controller.remote_state()})
return
if request.command == "list_playlists":
controller.remote_list_playlists(
lambda raw: reply({"ok": True, "playlists": sanitize_playlists(raw)})
)
return
if request.command == "show":
controller.remote_show()
elif request.command == "switch_tab":
controller.remote_switch_tab(request.target)
elif request.command == "play_playlist":
controller.remote_play_playlist(request.playlist)
else:
controller.remote_media_command(request.target, request.command, request.value)
reply({"ok": True})
def sanitize_playlists(raw: Any) -> list[dict]:
"""Bereinigt die von der Seite gelieferte Liste (fremde Daten!): nur gueltige IDs,
Titel als gekuerzter Text, ohne Duplikate, "Titel, die ich mag" immer zuerst."""
result = [dict(LIKED_MUSIC)]
seen = {LIKED_MUSIC["id"]}
for item in raw if isinstance(raw, list) else []:
if len(result) >= MAX_PLAYLISTS:
break
if not isinstance(item, dict):
continue
playlist_id, title = item.get("id"), item.get("title")
if not isinstance(playlist_id, str) or not PLAYLIST_ID_RE.fullmatch(playlist_id):
continue
if playlist_id in seen:
continue
seen.add(playlist_id)
clean_title = " ".join(title.split())[:MAX_PLAYLIST_TITLE] if isinstance(title, str) else ""
result.append({"id": playlist_id, "title": clean_title or playlist_id})
return result
def resolve_target(target: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> str:
"""Welcher Tab einen Medienbefehl bekommt. "auto": der Tab, der gerade abspielt
(spielen beide, der sichtbare), sonst der sichtbare Browser-Tab, sonst der mit einem
Video, sonst YouTube."""
if target in TAB_NAMES:
return target
playing = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("playing")]
if len(playing) == 1:
return playing[0]
if visible_tab in TAB_NAMES:
return visible_tab
with_video = [name for name in TAB_NAMES if (tabs.get(name) or {}).get("hasVideo")]
return with_video[0] if with_video else TAB_NAMES[0]
def build_state(version: str, visible_tab: str | None, tabs: dict[str, dict | None]) -> dict:
"""Status-Antwort: pro Tab die oeffentlichen Probe-Felder plus der "auto"-Zieltab."""
public_tabs = {}
for name in TAB_NAMES:
info = tabs.get(name)
public_tabs[name] = {key: info.get(key) for key in _STATE_FIELDS} if info else None
return {
"protocol": PROTOCOL_VERSION,
"version": version,
"visibleTab": visible_tab,
"activeTab": resolve_target("auto", visible_tab, tabs),
"tabs": public_tabs,
}
def encode_response(request_id: Any, response: dict) -> bytes:
return (json.dumps({"id": request_id, **response}, ensure_ascii=False) + "\n").encode("utf-8")
def process_line(line: str, controller: RemoteController, write: Callable[[bytes], None]) -> None:
"""Eine Anfragezeile -> genau eine kodierte Antwortzeile ueber `write`. Wirft nie."""
try:
request = parse_request(line)
except ProtocolError as exc:
write(encode_response(None, {"ok": False, "error": str(exc)}))
return
try:
handle_request(request, controller, lambda response: write(encode_response(request.id, response)))
except Exception as exc: # noqa: BLE001 - ein Steuerbefehl darf die App nie abstuerzen lassen
log_exception(f"remote_control: {request.command}", exc)
write(encode_response(request.id, {"ok": False, "error": "interner Fehler"}))
class RemoteControlServer(QObject):
"""Lauscht auf der Named Pipe und beantwortet Anfragen im Qt-Hauptthread (die
Controller-Methoden fassen Widgets an und duerfen nur dort laufen)."""
def __init__(self, key: str, controller: RemoteController, parent: QObject | None = None) -> None:
super().__init__(parent)
self._key = key
self._controller = controller
self._server: QLocalServer | None = None
self._buffers: dict[QLocalSocket, bytearray] = {}
def start(self) -> bool:
server = QLocalServer(self)
# Nur der angemeldete Benutzer darf verbinden (Windows: Pipe-ACL).
server.setSocketOptions(QLocalServer.SocketOption.UserAccessOption)
if not server.listen(self._key):
log_exception("remote_control: listen", RuntimeError(server.errorString()))
return False
server.newConnection.connect(self._on_new_connection)
self._server = server
return True
def _on_new_connection(self) -> None:
while self._server is not None and self._server.hasPendingConnections():
socket = self._server.nextPendingConnection()
if len(self._buffers) >= MAX_CLIENTS:
socket.disconnectFromServer()
socket.deleteLater()
continue
self._buffers[socket] = bytearray()
socket.readyRead.connect(lambda s=socket: self._on_ready_read(s))
socket.disconnected.connect(lambda s=socket: self._on_disconnected(s))
def _on_ready_read(self, socket: QLocalSocket) -> None:
buffer = self._buffers.get(socket)
if buffer is None:
return
buffer.extend(bytes(socket.readAll().data()))
while (newline := buffer.find(b"\n")) >= 0:
raw = bytes(buffer[:newline])
del buffer[: newline + 1]
line = raw.decode("utf-8", errors="replace").strip()
if line:
process_line(line, self._controller, lambda data, s=socket: self._write(s, data))
if len(buffer) > MAX_LINE_BYTES:
# Kein Zeilenende in Sicht: Client verhaelt sich falsch -> trennen.
buffer.clear()
socket.write(encode_response(None, {"ok": False, "error": "Nachricht zu lang"}))
socket.disconnectFromServer()
def _write(self, socket: QLocalSocket, data: bytes) -> None:
# Asynchrone Antworten (list_playlists) koennen nach dem Trennen eintreffen.
if socket in self._buffers:
socket.write(data)
def _on_disconnected(self, socket: QLocalSocket) -> None:
self._buffers.pop(socket, None)
socket.deleteLater()
+92
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtMultimedia import QMediaDevices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QFormLayout, QFormLayout,
@@ -22,6 +24,7 @@ from PySide6.QtWidgets import (
) )
from . import __version__ as APP_VERSION from . import __version__ as APP_VERSION
from .audio_routing import SYSTEM_DEFAULT_LABEL, list_output_devices
from .config import APP_NAME, save_config from .config import APP_NAME, save_config
from .updater import GITHUB_REPO from .updater import GITHUB_REPO
@@ -55,6 +58,9 @@ class SettingsTab(QWidget):
settingsSaved = Signal(dict) settingsSaved = Signal(dict)
checkUpdatesRequested = Signal() checkUpdatesRequested = Signal()
installUpdateRequested = Signal() installUpdateRequested = Signal()
# Neue Beschreibung, was gerade bearbeitet wird (fuer die Discord-Presence, siehe
# MainWindow._update_discord_presence).
activityChanged = Signal(str)
def __init__(self, config: dict[str, Any], parent=None): def __init__(self, config: dict[str, Any], parent=None):
super().__init__(parent) super().__init__(parent)
@@ -132,6 +138,31 @@ class SettingsTab(QWidget):
outer.addWidget(update_box) outer.addWidget(update_box)
audio_cfg = config.get("audio", {})
audio_box = QGroupBox("Audioausgabe")
audio_form = QFormLayout(audio_box)
self._youtube_output = QComboBox()
self._music_output = QComboBox()
audio_form.addRow("YouTube:", self._youtube_output)
audio_form.addRow("YouTube Musik:", self._music_output)
audio_hint = QLabel(
"Legt den Ton von YouTube und YouTube Musik auf verschiedene Ausgabegeräte, z. B. "
"auf getrennte Sonar-Kanäle. Windows zeigt beide unter „Playtube“ - getrennt wird "
"über das Gerät. Damit die Seite das Gerät finden kann, erlaubt Playtube ihr dafür "
"den Zugriff auf die Audiogeräte-Namen (es wird nichts aufgenommen); die Freigabe "
"gilt nur, solange hier ein eigenes Gerät gewählt ist."
)
audio_hint.setWordWrap(True)
audio_hint.setStyleSheet(_LIGHT_TEXT_STYLE)
audio_form.addRow(audio_hint)
outer.addWidget(audio_box)
# QMediaDevices meldet, wenn Geraete ein-/ausgesteckt werden -> Listen aktuell halten.
self._media_devices = QMediaDevices(self)
self._fill_output_combo(self._youtube_output, str(audio_cfg.get("youtube_output", "") or ""))
self._fill_output_combo(self._music_output, str(audio_cfg.get("music_output", "") or ""))
self._media_devices.audioOutputsChanged.connect(self._reload_output_devices)
general_box = QGroupBox("Allgemein") general_box = QGroupBox("Allgemein")
general_form = QFormLayout(general_box) general_form = QFormLayout(general_box)
self._start_tab = QComboBox() self._start_tab = QComboBox()
@@ -155,6 +186,63 @@ class SettingsTab(QWidget):
outer.addStretch(1) outer.addStretch(1)
# Beschreibung je Eingabefeld fuer die Discord-Presence ("was wird bearbeitet").
# Bewusst nur Feld-NAMEN, nie Inhalte (z.B. nicht die Client-ID selbst).
self._activity_by_widget: dict[QWidget, str] = {
self._discord_enabled: "Bearbeitet: Discord Rich Presence › Aktiviert",
self._client_id: "Bearbeitet: Discord Rich Presence › Client-ID",
self._discord_interval: "Bearbeitet: Discord Rich Presence › Update-Intervall",
self._show_idle: "Bearbeitet: Discord Rich Presence › Status im Leerlauf",
self._updates_enabled: "Bearbeitet: Automatische Updates › Aktiviert",
self._check_interval: "Bearbeitet: Automatische Updates › Prüfintervall",
self._youtube_output: "Bearbeitet: Audioausgabe › YouTube",
self._music_output: "Bearbeitet: Audioausgabe › YouTube Musik",
self._check_updates_btn: "Sucht nach Updates",
self._install_update_btn: "Installiert ein Update",
self._start_tab: "Bearbeitet: Allgemein › Beim Start öffnen",
save_btn: "Speichert die Einstellungen",
}
self._current_activity: str | None = None
app = QApplication.instance()
if app is not None:
app.focusChanged.connect(self._on_focus_changed)
def _fill_output_combo(self, combo: QComboBox, saved: str) -> None:
"""Fuellt eine Ausgabe-Auswahl: "Systemstandard" + alle vorhandenen Geraete. Ist das
gespeicherte Geraet gerade nicht angeschlossen, bleibt es (als "nicht verfuegbar")
ausgewaehlt, statt die Einstellung stillschweigend zu verlieren."""
combo.blockSignals(True)
combo.clear()
combo.addItem(SYSTEM_DEFAULT_LABEL, "")
names = list_output_devices()
for name in names:
combo.addItem(name, name)
if saved and saved not in names:
combo.addItem(f"{saved} (nicht verfügbar)", saved)
combo.setCurrentIndex(max(0, combo.findData(saved)))
combo.blockSignals(False)
def _reload_output_devices(self) -> None:
for combo in (self._youtube_output, self._music_output):
self._fill_output_combo(combo, combo.currentData() or "")
def current_activity(self) -> str | None:
"""Zuletzt bearbeitetes Feld (None, solange noch kein Feld angeklickt wurde)."""
return self._current_activity
def _on_focus_changed(self, _old: QWidget | None, new: QWidget | None) -> None:
# Vom fokussierten Widget nach oben suchen: bei QSpinBox/QComboBox liegt der Fokus
# auf einem internen Kind-Widget (z.B. dem Textfeld des Spinners).
widget = new
while widget is not None and widget is not self:
activity = self._activity_by_widget.get(widget)
if activity:
if activity != self._current_activity:
self._current_activity = activity
self.activityChanged.emit(activity)
return
widget = widget.parentWidget()
def _on_save(self) -> None: def _on_save(self) -> None:
self._config.setdefault("discord", {}) self._config.setdefault("discord", {})
self._config.setdefault("updates", {}) self._config.setdefault("updates", {})
@@ -167,6 +255,10 @@ class SettingsTab(QWidget):
self._config["updates"]["enabled"] = self._updates_enabled.isChecked() self._config["updates"]["enabled"] = self._updates_enabled.isChecked()
self._config["updates"]["check_interval_hours"] = self._check_interval.value() self._config["updates"]["check_interval_hours"] = self._check_interval.value()
self._config.setdefault("audio", {})
self._config["audio"]["youtube_output"] = self._youtube_output.currentData() or ""
self._config["audio"]["music_output"] = self._music_output.currentData() or ""
self._config["start_tab"] = self._start_tab.currentData() self._config["start_tab"] = self._start_tab.currentData()
save_config(self._config) save_config(self._config)
+272
View File
@@ -0,0 +1,272 @@
"""Tests fuer die lokale Fernsteuerung (playtube/remote_control.py, media_control.py).
Start: .venv\\Scripts\\python -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import sys
import threading
import unittest
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from playtube.media_control import MEDIA_COMMANDS, build_control_js # noqa: E402
from playtube.remote_control import ( # noqa: E402
ProtocolError,
Request,
build_state,
handle_request,
parse_request,
process_line,
resolve_target,
sanitize_playlists,
)
class FakeController:
def __init__(self) -> None:
self.calls: list[tuple] = []
def remote_state(self) -> dict:
return {"protocol": 1}
def remote_show(self) -> None:
self.calls.append(("show",))
def remote_switch_tab(self, target: str) -> None:
self.calls.append(("switch_tab", target))
def remote_media_command(self, target: str, command: str, value: float) -> None:
self.calls.append(("media", target, command, value))
def remote_list_playlists(self, callback) -> None:
self.pending_playlist_callback = callback
def remote_play_playlist(self, playlist_id: str) -> None:
self.calls.append(("play_playlist", playlist_id))
def run(request: Request, controller: FakeController) -> list[dict]:
replies: list[dict] = []
handle_request(request, controller, replies.append)
return replies
def run_line(line: str, controller: FakeController) -> list[dict]:
written: list[bytes] = []
process_line(line, controller, written.append)
return [json.loads(chunk) for chunk in written]
class ParseRequestTests(unittest.TestCase):
def test_parses_media_command_with_default_target(self):
request = parse_request('{"id": 7, "cmd": "play_pause"}')
self.assertEqual(request, Request(id=7, command="play_pause", target="auto", value=0))
def test_parses_ranged_value(self):
request = parse_request('{"cmd": "seek", "target": "music", "value": -10}')
self.assertEqual((request.target, request.value), ("music", -10.0))
def test_ignores_value_for_commands_without_range(self):
self.assertEqual(parse_request('{"cmd": "next", "value": "evil()"}').value, 0)
def test_rejects_invalid_input(self):
bad_lines = [
"not json",
"[1, 2]",
'{"cmd": "eval"}',
'{"cmd": ["play_pause"]}',
'{"cmd": "next", "target": "https://example.com"}',
'{"cmd": "next", "id": {"x": 1}}',
'{"cmd": "next", "id": true}',
'{"cmd": "set_volume"}',
'{"cmd": "set_volume", "value": "50"}',
'{"cmd": "set_volume", "value": true}',
'{"cmd": "set_volume", "value": 101}',
'{"cmd": "seek", "value": 99999}',
'{"cmd": "volume_change", "value": NaN}',
'{"cmd": "play_playlist"}',
'{"cmd": "play_playlist", "playlist": "x"}',
'{"cmd": "play_playlist", "playlist": "PL1&autoplay=0"}',
'{"cmd": "play_playlist", "playlist": "../evil"}',
'{"cmd": "play_playlist", "playlist": 123}',
]
for line in bad_lines:
with self.subTest(line=line), self.assertRaises(ProtocolError):
parse_request(line)
class HandleRequestTests(unittest.TestCase):
def test_status_returns_state(self):
replies = run(Request(id=1, command="status"), FakeController())
self.assertEqual(replies, [{"ok": True, "state": {"protocol": 1}}])
def test_dispatches_app_and_media_commands(self):
controller = FakeController()
run(Request(id=1, command="show"), controller)
run(Request(id=2, command="switch_tab", target="music"), controller)
run(Request(id=3, command="volume_change", target="youtube", value=5), controller)
run(parse_request('{"cmd": "play_playlist", "playlist": "PLabc_-1"}'), controller)
self.assertEqual(
controller.calls,
[
("show",),
("switch_tab", "music"),
("media", "youtube", "volume_change", 5),
("play_playlist", "PLabc_-1"),
],
)
def test_list_playlists_replies_when_page_answers(self):
controller = FakeController()
written: list[bytes] = []
process_line('{"id": 9, "cmd": "list_playlists"}', controller, written.append)
self.assertEqual(written, []) # Seite hat noch nicht geantwortet
controller.pending_playlist_callback([{"id": "PL1", "title": "Mix"}])
self.assertEqual(
[json.loads(chunk) for chunk in written],
[{"id": 9, "ok": True, "playlists": [
{"id": "LM", "title": "Titel, die ich mag"},
{"id": "PL1", "title": "Mix"},
]}],
)
def test_process_line_reports_protocol_errors_without_raising(self):
[reply] = run_line('{"cmd": "nope"}', FakeController())
self.assertFalse(reply["ok"])
self.assertIsNone(reply["id"])
def test_process_line_hides_internal_errors(self):
class Broken(FakeController):
def remote_show(self) -> None:
raise RuntimeError("secret detail")
[reply] = run_line('{"id": 4, "cmd": "show"}', Broken())
self.assertEqual(reply, {"id": 4, "ok": False, "error": "interner Fehler"})
class SanitizePlaylistsTests(unittest.TestCase):
def test_keeps_valid_entries_and_puts_liked_music_first(self):
raw = [
{"id": "PL1", "title": " Mein Mix "},
{"id": "LM", "title": "Liked music"},
{"id": "PL1", "title": "Duplikat"},
{"id": "bad id!", "title": "x"},
{"id": "PL2", "title": None},
"kein Objekt",
]
self.assertEqual(
sanitize_playlists(raw),
[
{"id": "LM", "title": "Titel, die ich mag"},
{"id": "PL1", "title": "Mein Mix"},
{"id": "PL2", "title": "PL2"},
],
)
def test_handles_garbage_and_limits_size(self):
self.assertEqual(len(sanitize_playlists(None)), 1)
many = [{"id": f"PL{i:03d}", "title": "t" * 500} for i in range(500)]
result = sanitize_playlists(many)
self.assertEqual(len(result), 100)
self.assertTrue(all(len(item["title"]) <= 100 for item in result))
class ResolveTargetTests(unittest.TestCase):
def test_explicit_target_wins(self):
self.assertEqual(resolve_target("music", "youtube", {}), "music")
def test_auto_prefers_the_playing_tab(self):
tabs = {"youtube": {"playing": False}, "music": {"playing": True}}
self.assertEqual(resolve_target("auto", "youtube", tabs), "music")
def test_auto_uses_visible_tab_when_both_or_none_play(self):
both = {"youtube": {"playing": True}, "music": {"playing": True}}
self.assertEqual(resolve_target("auto", "music", both), "music")
self.assertEqual(resolve_target("auto", "youtube", {}), "youtube")
def test_auto_from_settings_falls_back_to_tab_with_video(self):
tabs = {"youtube": {"hasVideo": False}, "music": {"hasVideo": True}}
self.assertEqual(resolve_target("auto", "settings", tabs), "music")
self.assertEqual(resolve_target("auto", "settings", {}), "youtube")
class BuildStateTests(unittest.TestCase):
def test_exposes_only_public_fields(self):
tabs = {"youtube": {"title": "T", "playing": True, "url": "https://x"}, "music": None}
state = build_state("9.9.9", "youtube", tabs)
self.assertEqual(state["activeTab"], "youtube")
self.assertIsNone(state["tabs"]["music"])
self.assertEqual(state["tabs"]["youtube"]["title"], "T")
self.assertNotIn("url", state["tabs"]["youtube"])
class BuildControlJsTests(unittest.TestCase):
def test_every_command_builds_a_wrapped_script(self):
for command in MEDIA_COMMANDS:
with self.subTest(command=command):
script = build_control_js(command, 5)
self.assertTrue(script.startswith("(function() {"))
self.assertNotIn("{value}", script)
def test_value_is_inserted_as_number(self):
self.assertIn("setVolume(42.0)", build_control_js("set_volume", 42))
def test_rejects_unknown_command_and_non_finite_value(self):
with self.assertRaises(ValueError):
build_control_js("alert", 0)
with self.assertRaises(ValueError):
build_control_js("seek", float("inf"))
@unittest.skipUnless(sys.platform == "win32", "Named-Pipe-Test nur unter Windows")
class NamedPipeIntegrationTests(unittest.TestCase):
"""Echte Named Pipe: Client in einem Thread, Server im Qt-Event-Loop."""
def test_round_trip_over_named_pipe(self):
from PySide6.QtCore import QCoreApplication, QTimer
from playtube.remote_control import RemoteControlServer
app = QCoreApplication.instance() or QCoreApplication([])
key = f"Playtube.Remote.Test.{uuid.uuid4().hex}"
controller = FakeController()
server = RemoteControlServer(key, controller)
self.assertTrue(server.start())
replies: list[dict] = []
errors: list[BaseException] = []
def client() -> None:
try:
with open(rf"\\.\pipe\{key}", "r+b", buffering=0) as pipe:
pipe.write(b'{"id": 1, "cmd": "next"}\n{"id": 2, "cmd": "status"}\n')
data = b""
while data.count(b"\n") < 2:
chunk = pipe.read(4096)
if not chunk:
break
data += chunk
replies.extend(json.loads(line) for line in data.splitlines())
except BaseException as exc: # noqa: BLE001 - im Hauptthread auswerten
errors.append(exc)
finally:
QTimer.singleShot(0, app.quit)
thread = threading.Thread(target=client, daemon=True)
QTimer.singleShot(0, thread.start)
QTimer.singleShot(5000, app.quit) # Sicherheitsnetz gegen Haengen
app.exec()
thread.join(2)
self.assertEqual(errors, [])
self.assertEqual(replies, [{"id": 1, "ok": True}, {"id": 2, "ok": True, "state": {"protocol": 1}}])
self.assertEqual(controller.calls, [("media", "auto", "next", 0)])
if __name__ == "__main__":
unittest.main()