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.
This commit is contained in:
@@ -97,6 +97,24 @@ Wichtig zu wissen:
|
|||||||
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
|
Gerätenamen). In der Konfiguration stehen die Namen unter `audio.youtube_output` und
|
||||||
`audio.music_output` (leer = Systemstandard).
|
`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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ from .audio_routing import SCRIPT_NAME as AUDIO_SCRIPT_NAME
|
|||||||
from .audio_routing import build_router_script
|
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,
|
||||||
@@ -180,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()
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"youtube_output": "",
|
"youtube_output": "",
|
||||||
"music_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/",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from .audio_routing import sync_audio_permissions
|
|||||||
from .browser import BrowserTab, get_shared_profile
|
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
|
||||||
|
|
||||||
@@ -292,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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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