mopidy-dial 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mopidy_dial/__init__.py +38 -0
- mopidy_dial/ext.conf +3 -0
- mopidy_dial/frontend.py +396 -0
- mopidy_dial/lounge_commander.py +96 -0
- mopidy_dial/lounge_handler.py +855 -0
- mopidy_dial/lounge_session.py +320 -0
- mopidy_dial/lounge_subscriber.py +181 -0
- mopidy_dial/web.py +391 -0
- mopidy_dial-0.0.1.dist-info/METADATA +176 -0
- mopidy_dial-0.0.1.dist-info/RECORD +14 -0
- mopidy_dial-0.0.1.dist-info/WHEEL +5 -0
- mopidy_dial-0.0.1.dist-info/entry_points.txt +2 -0
- mopidy_dial-0.0.1.dist-info/licenses/LICENSE +202 -0
- mopidy_dial-0.0.1.dist-info/top_level.txt +1 -0
mopidy_dial/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pathlib
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from mopidy import config, ext
|
|
6
|
+
|
|
7
|
+
__version__ = version("mopidy-dial")
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Extension(ext.Extension):
|
|
13
|
+
dist_name = "mopidy-dial"
|
|
14
|
+
ext_name = "dial"
|
|
15
|
+
version = __version__
|
|
16
|
+
|
|
17
|
+
def get_default_config(self):
|
|
18
|
+
return config.read(pathlib.Path(__file__).parent / "ext.conf")
|
|
19
|
+
|
|
20
|
+
def get_config_schema(self):
|
|
21
|
+
schema = super().get_config_schema()
|
|
22
|
+
schema["friendly_name"] = config.String()
|
|
23
|
+
return schema
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def setup(self, registry):
|
|
27
|
+
from .frontend import DialFrontend
|
|
28
|
+
from .web import DialHandler
|
|
29
|
+
|
|
30
|
+
registry.add("frontend", DialFrontend)
|
|
31
|
+
|
|
32
|
+
# Mopidy expects a list of (regexp, handler_class) tuples
|
|
33
|
+
registry.add("http:app", {
|
|
34
|
+
"name": self.ext_name, # "dial"
|
|
35
|
+
"factory": lambda config, core: [
|
|
36
|
+
(r"/(.*)", DialHandler, {"config": config, "core": core}),
|
|
37
|
+
],
|
|
38
|
+
})
|
mopidy_dial/ext.conf
ADDED
mopidy_dial/frontend.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""
|
|
2
|
+
frontend.py
|
|
3
|
+
-----------
|
|
4
|
+
Mopidy frontend actor. Responsible for:
|
|
5
|
+
1. Running the SSDP listener/notifier (makes device visible on LAN)
|
|
6
|
+
2. Creating and managing the Lounge session
|
|
7
|
+
3. Wiring the DIAL HTTP handler (web.py) to the Lounge session
|
|
8
|
+
4. Forwarding Mopidy CoreListener events to the active LoungeHandler
|
|
9
|
+
so that Mopidy-side changes (play/pause/seek/volume) are reported
|
|
10
|
+
back to the YouTube app in real time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import struct
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import pykka
|
|
20
|
+
|
|
21
|
+
from mopidy import core as mopidy_core
|
|
22
|
+
from mopidy_dial import logger
|
|
23
|
+
|
|
24
|
+
from .lounge_session import LoungeSession
|
|
25
|
+
from .lounge_handler import LoungeHandler
|
|
26
|
+
from .lounge_subscriber import LoungeSubscriber
|
|
27
|
+
from .web import DialHandler
|
|
28
|
+
|
|
29
|
+
SSDP_ADDR = "239.255.255.250"
|
|
30
|
+
SSDP_PORT = 1900
|
|
31
|
+
SSDP_ST = "urn:dial-multiscreen-org:service:dial:1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _local_ip() -> str:
|
|
35
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
36
|
+
try:
|
|
37
|
+
s.connect(("8.8.8.8", 80))
|
|
38
|
+
return s.getsockname()[0]
|
|
39
|
+
finally:
|
|
40
|
+
s.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DialFrontend(pykka.ThreadingActor, mopidy_core.CoreListener):
|
|
44
|
+
|
|
45
|
+
def __init__(self, config, core):
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.core = core
|
|
48
|
+
self.config = config
|
|
49
|
+
self.local_ip = _local_ip()
|
|
50
|
+
self.port = config["http"]["port"]
|
|
51
|
+
self.friendly_name = config.get("dial", {}).get("friendly_name", "Mopidy")
|
|
52
|
+
self._running = False
|
|
53
|
+
self._stop_event = threading.Event()
|
|
54
|
+
|
|
55
|
+
# One Lounge session (and handler) per sender type, keyed by theme:
|
|
56
|
+
# "" for the YouTube app, "m" for YouTube Music. CoreListener events
|
|
57
|
+
# are forwarded to whichever handlers have a remote connected.
|
|
58
|
+
self._sessions: dict[str, LoungeSession] = {}
|
|
59
|
+
self._handlers: dict[str, LoungeHandler] = {}
|
|
60
|
+
self._handler_lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Pykka lifecycle
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def on_start(self):
|
|
67
|
+
self._running = True
|
|
68
|
+
self._session_ready = threading.Event()
|
|
69
|
+
DialHandler.on_launch = self._on_dial_launch
|
|
70
|
+
DialHandler.on_pairing_code = self._on_dial_pairing_code
|
|
71
|
+
|
|
72
|
+
# SSDP listener starts immediately (must respond to M-SEARCH)
|
|
73
|
+
threading.Thread(target=self._run_ssdp, daemon=True).start()
|
|
74
|
+
# NOTIFY thread waits until session is ready before announcing
|
|
75
|
+
threading.Thread(target=self._run_notify, daemon=True).start()
|
|
76
|
+
threading.Thread(target=self._start_lounge_session, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
def on_stop(self):
|
|
79
|
+
self._running = False
|
|
80
|
+
self._stop_event.set() # wake any threads sleeping on this event
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# CoreListener — Mopidy → YouTube direction
|
|
84
|
+
# Each event is forwarded to the active LoungeHandler.
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _forward(self, event: str, *args):
|
|
88
|
+
with self._handler_lock:
|
|
89
|
+
handlers = [h for h in self._handlers.values() if h.connected]
|
|
90
|
+
if not handlers:
|
|
91
|
+
handlers = list(self._handlers.values())
|
|
92
|
+
for h in handlers:
|
|
93
|
+
try:
|
|
94
|
+
getattr(h, event)(*args)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"Error forwarding {event} to handler: {e}", exc_info=True)
|
|
97
|
+
|
|
98
|
+
def track_playback_started(self, tl_track):
|
|
99
|
+
self._forward("on_track_playback_started", tl_track)
|
|
100
|
+
|
|
101
|
+
def track_playback_paused(self, tl_track, time_position):
|
|
102
|
+
self._forward("on_track_playback_paused", tl_track, time_position)
|
|
103
|
+
|
|
104
|
+
def track_playback_resumed(self, tl_track, time_position):
|
|
105
|
+
self._forward("on_track_playback_resumed", tl_track, time_position)
|
|
106
|
+
|
|
107
|
+
def track_playback_ended(self, tl_track, time_position):
|
|
108
|
+
self._forward("on_track_playback_ended", tl_track, time_position)
|
|
109
|
+
|
|
110
|
+
def seeked(self, time_position):
|
|
111
|
+
self._forward("on_seeked", time_position)
|
|
112
|
+
|
|
113
|
+
def volume_changed(self, volume):
|
|
114
|
+
self._forward("on_volume_changed", volume)
|
|
115
|
+
|
|
116
|
+
def mute_changed(self, mute):
|
|
117
|
+
self._forward("on_mute_changed", mute)
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# DIAL launch callback (called from web.py when app POSTs)
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _on_dial_launch(self, lounge_token: str, screen_id: str):
|
|
124
|
+
"""Called when YouTube app POSTs with a loungeToken directly."""
|
|
125
|
+
logger.info(f"DIAL launch (token flow) — screenId={screen_id!r}")
|
|
126
|
+
threading.Thread(
|
|
127
|
+
target=self._run_session,
|
|
128
|
+
args=(screen_id, lounge_token),
|
|
129
|
+
daemon=True,
|
|
130
|
+
).start()
|
|
131
|
+
|
|
132
|
+
def _on_dial_pairing_code(self, pairing_code: str, theme: str = ""):
|
|
133
|
+
"""
|
|
134
|
+
Called when a sender POSTs a pairingCode to launch the app over DIAL.
|
|
135
|
+
|
|
136
|
+
The code is generated by the sender. A real TV registers it with
|
|
137
|
+
YouTube against its own screen, and the sender then looks the screen
|
|
138
|
+
up by that code and connects to it. YouTube Music (theme "m") only
|
|
139
|
+
connects to a screen created with that theme, so register the code on
|
|
140
|
+
the matching session.
|
|
141
|
+
"""
|
|
142
|
+
session = self._sessions.get(theme if theme == "m" else "")
|
|
143
|
+
logger.info(f"DIAL launch — theme={theme!r} pairingCode={pairing_code!r}")
|
|
144
|
+
if session is None:
|
|
145
|
+
logger.warning(f"No Lounge session for theme {theme!r} yet")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
def register():
|
|
149
|
+
try:
|
|
150
|
+
session.register_pairing_code(pairing_code)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Failed to register pairing code: {e}",
|
|
153
|
+
exc_info=True)
|
|
154
|
+
|
|
155
|
+
threading.Thread(target=register, daemon=True).start()
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
# Lounge session management
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _start_lounge_session(self):
|
|
162
|
+
"""
|
|
163
|
+
Create a persistent lounge session with automatic pairing code refresh.
|
|
164
|
+
Credentials are only published to DialHandler (and SSDP only fires)
|
|
165
|
+
after the session is fully bound — preventing the race condition where
|
|
166
|
+
the Music app polls and receives empty credentials.
|
|
167
|
+
"""
|
|
168
|
+
while self._running:
|
|
169
|
+
try:
|
|
170
|
+
# Persist screen_id so YouTube Music can reconnect after restarts
|
|
171
|
+
persist_path = os.path.join(
|
|
172
|
+
os.path.expanduser("~"), ".config", "mopidy", "dial_screen_id"
|
|
173
|
+
)
|
|
174
|
+
session = LoungeSession.create(self.friendly_name,
|
|
175
|
+
persist_path=persist_path)
|
|
176
|
+
|
|
177
|
+
# Get + register pairing code BEFORE publishing credentials
|
|
178
|
+
self._sessions[""] = session
|
|
179
|
+
self._refresh_pairing_code(session)
|
|
180
|
+
|
|
181
|
+
# Bind the lounge session first
|
|
182
|
+
session.bind()
|
|
183
|
+
logger.info(f"Lounge session bound — SID={session.sid}")
|
|
184
|
+
|
|
185
|
+
# NOW publish credentials — 503 guard will clear on next poll
|
|
186
|
+
DialHandler.lounge_token = session.lounge_token
|
|
187
|
+
DialHandler.screen_id = session.screen_id
|
|
188
|
+
DialHandler.device_id = session.device_id
|
|
189
|
+
|
|
190
|
+
# Signal SSDP notify thread that it can start broadcasting
|
|
191
|
+
self._session_ready.set()
|
|
192
|
+
|
|
193
|
+
# Start pairing code refresh thread
|
|
194
|
+
threading.Thread(
|
|
195
|
+
target=self._pairing_code_refresher,
|
|
196
|
+
args=(session,),
|
|
197
|
+
daemon=True,
|
|
198
|
+
).start()
|
|
199
|
+
|
|
200
|
+
handler = LoungeHandler(session, self.core)
|
|
201
|
+
with self._handler_lock:
|
|
202
|
+
self._handlers[""] = handler
|
|
203
|
+
threading.Thread(target=self._start_music_session,
|
|
204
|
+
daemon=True).start()
|
|
205
|
+
self._run_subscriber(session, handler)
|
|
206
|
+
return
|
|
207
|
+
except Exception as e:
|
|
208
|
+
if not self._running or self._stop_event.is_set():
|
|
209
|
+
return
|
|
210
|
+
logger.error(f"Failed to start Lounge session: {e} — retrying in 10s",
|
|
211
|
+
exc_info=True)
|
|
212
|
+
time.sleep(10)
|
|
213
|
+
|
|
214
|
+
def _start_music_session(self):
|
|
215
|
+
"""A second screen with theme "m", which YouTube Music connects to."""
|
|
216
|
+
while self._running:
|
|
217
|
+
try:
|
|
218
|
+
persist_path = os.path.join(
|
|
219
|
+
os.path.expanduser("~"), ".config", "mopidy",
|
|
220
|
+
"dial_screen_id_music"
|
|
221
|
+
)
|
|
222
|
+
session = LoungeSession.create(
|
|
223
|
+
self.friendly_name, persist_path=persist_path, theme="m")
|
|
224
|
+
session.bind()
|
|
225
|
+
logger.info(f"Lounge session (YouTube Music) bound — "
|
|
226
|
+
f"SID={session.sid}")
|
|
227
|
+
self._sessions["m"] = session
|
|
228
|
+
handler = LoungeHandler(session, self.core)
|
|
229
|
+
with self._handler_lock:
|
|
230
|
+
self._handlers["m"] = handler
|
|
231
|
+
self._run_subscriber(session, handler)
|
|
232
|
+
return
|
|
233
|
+
except Exception as e:
|
|
234
|
+
if not self._running or self._stop_event.is_set():
|
|
235
|
+
return
|
|
236
|
+
logger.error(f"Failed to start YouTube Music session: {e} — retrying in 10s",
|
|
237
|
+
exc_info=True)
|
|
238
|
+
time.sleep(10)
|
|
239
|
+
|
|
240
|
+
def _run_subscriber(self, session: LoungeSession, handler: LoungeHandler):
|
|
241
|
+
"""Listen for events, rebinding when the SID goes stale."""
|
|
242
|
+
while self._running:
|
|
243
|
+
try:
|
|
244
|
+
result = LoungeSubscriber(
|
|
245
|
+
session, handler, self._stop_event).run_forever()
|
|
246
|
+
logger.info(f"Lounge subscriber (theme={session.theme!r}) "
|
|
247
|
+
f"exited: {result}")
|
|
248
|
+
if result == "stopped" or not self._running or self._stop_event.is_set():
|
|
249
|
+
return
|
|
250
|
+
if result != "rebind":
|
|
251
|
+
logger.warning(
|
|
252
|
+
f"Lounge subscriber (theme={session.theme!r}) "
|
|
253
|
+
f"unexpected exit ({result!r}) — rebinding in 5s"
|
|
254
|
+
)
|
|
255
|
+
time.sleep(5)
|
|
256
|
+
logger.info(f"Rebinding session (theme={session.theme!r})...")
|
|
257
|
+
session.bind()
|
|
258
|
+
except Exception as e:
|
|
259
|
+
if not self._running or self._stop_event.is_set():
|
|
260
|
+
return
|
|
261
|
+
logger.error(
|
|
262
|
+
f"Subscriber/rebind error (theme={session.theme!r}): {e} — retrying in 5s",
|
|
263
|
+
exc_info=True,
|
|
264
|
+
)
|
|
265
|
+
time.sleep(5)
|
|
266
|
+
|
|
267
|
+
def _refresh_pairing_code(self, session: LoungeSession):
|
|
268
|
+
try:
|
|
269
|
+
code = session.get_pairing_code()
|
|
270
|
+
DialHandler.pairing_code = code
|
|
271
|
+
logger.info(f"YouTube pairing code: {code} "
|
|
272
|
+
f"(enter in YouTube → Settings → Watch on TV)")
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning(f"Failed to refresh pairing code: {e}")
|
|
275
|
+
|
|
276
|
+
def _pairing_code_refresher(self, session: LoungeSession):
|
|
277
|
+
"""Refresh the pairing code every 4 minutes while running."""
|
|
278
|
+
while self._running:
|
|
279
|
+
# Sleep in short intervals so we respond to shutdown quickly
|
|
280
|
+
for _ in range(240): # 240 × 1s = 4 minutes
|
|
281
|
+
if not self._running:
|
|
282
|
+
return
|
|
283
|
+
time.sleep(1)
|
|
284
|
+
if self._running:
|
|
285
|
+
self._refresh_pairing_code(session)
|
|
286
|
+
|
|
287
|
+
def _run_session(self, screen_id: str, lounge_token: str,
|
|
288
|
+
session: LoungeSession = None):
|
|
289
|
+
handler = None
|
|
290
|
+
try:
|
|
291
|
+
if session is None:
|
|
292
|
+
session = LoungeSession(screen_id, lounge_token,
|
|
293
|
+
self.friendly_name)
|
|
294
|
+
session.bind()
|
|
295
|
+
logger.info(f"Lounge session bound — SID={session.sid}")
|
|
296
|
+
|
|
297
|
+
handler = LoungeHandler(session, self.core)
|
|
298
|
+
with self._handler_lock:
|
|
299
|
+
self._handlers["token"] = handler
|
|
300
|
+
self._run_subscriber(session, handler)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.error(f"Lounge session error: {e}", exc_info=True)
|
|
303
|
+
finally:
|
|
304
|
+
with self._handler_lock:
|
|
305
|
+
self._handlers.pop("token", None)
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
# SSDP
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _ssdp_response(self) -> bytes:
|
|
312
|
+
location = (f"http://{self.local_ip}:{self.port}"
|
|
313
|
+
f"/dial/ssdp/device-desc.xml")
|
|
314
|
+
return (
|
|
315
|
+
"HTTP/1.1 200 OK\r\n"
|
|
316
|
+
f"LOCATION: {location}\r\n"
|
|
317
|
+
"CACHE-CONTROL: max-age=1800\r\n"
|
|
318
|
+
"CONFIGID.UPNP.ORG: 7337\r\n"
|
|
319
|
+
"BOOTID.UPNP.ORG: 7337\r\n"
|
|
320
|
+
f"USN: uuid:{DialHandler.device_uuid}::{SSDP_ST}\r\n"
|
|
321
|
+
f"ST: {SSDP_ST}\r\n"
|
|
322
|
+
"\r\n"
|
|
323
|
+
).encode()
|
|
324
|
+
|
|
325
|
+
def _run_ssdp(self):
|
|
326
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
|
|
327
|
+
socket.IPPROTO_UDP)
|
|
328
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
329
|
+
try:
|
|
330
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
331
|
+
except AttributeError:
|
|
332
|
+
pass
|
|
333
|
+
sock.bind(("", SSDP_PORT))
|
|
334
|
+
mreq = struct.pack("4sL", socket.inet_aton(SSDP_ADDR),
|
|
335
|
+
socket.INADDR_ANY)
|
|
336
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
337
|
+
sock.settimeout(1.0)
|
|
338
|
+
logger.info(f"SSDP listening on {SSDP_ADDR}:{SSDP_PORT}")
|
|
339
|
+
|
|
340
|
+
while self._running:
|
|
341
|
+
try:
|
|
342
|
+
data, addr = sock.recvfrom(4096)
|
|
343
|
+
msg = data.decode("utf-8", errors="replace")
|
|
344
|
+
if "M-SEARCH" in msg and (
|
|
345
|
+
SSDP_ST in msg or "ssdp:all" in msg
|
|
346
|
+
):
|
|
347
|
+
logger.debug(f"SSDP M-SEARCH from {addr[0]}")
|
|
348
|
+
time.sleep(0.1)
|
|
349
|
+
sock.sendto(self._ssdp_response(), addr)
|
|
350
|
+
except socket.timeout:
|
|
351
|
+
continue
|
|
352
|
+
except Exception as e:
|
|
353
|
+
if self._running:
|
|
354
|
+
logger.error(f"SSDP error: {e}")
|
|
355
|
+
sock.close()
|
|
356
|
+
|
|
357
|
+
def _run_notify(self):
|
|
358
|
+
location = (f"http://{self.local_ip}:{self.port}"
|
|
359
|
+
f"/dial/ssdp/device-desc.xml")
|
|
360
|
+
|
|
361
|
+
def packet(nts="ssdp:alive"):
|
|
362
|
+
return (
|
|
363
|
+
"NOTIFY * HTTP/1.1\r\n"
|
|
364
|
+
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
|
365
|
+
"CACHE-CONTROL: max-age=1800\r\n"
|
|
366
|
+
f"LOCATION: {location}\r\n"
|
|
367
|
+
f"NT: {SSDP_ST}\r\n"
|
|
368
|
+
f"NTS: {nts}\r\n"
|
|
369
|
+
f"USN: uuid:{DialHandler.device_uuid}::{SSDP_ST}\r\n"
|
|
370
|
+
"\r\n"
|
|
371
|
+
).encode()
|
|
372
|
+
|
|
373
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
|
|
374
|
+
socket.IPPROTO_UDP)
|
|
375
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
376
|
+
|
|
377
|
+
# Wait until the lounge session is fully bound before announcing.
|
|
378
|
+
# This prevents apps from discovering us, polling /apps/YouTube,
|
|
379
|
+
# and reading empty credentials due to a startup race condition.
|
|
380
|
+
logger.debug("SSDP notify waiting for session to be ready...")
|
|
381
|
+
self._session_ready.wait(timeout=30)
|
|
382
|
+
if not self._running:
|
|
383
|
+
return
|
|
384
|
+
logger.info("SSDP notify: session ready, starting announcements")
|
|
385
|
+
|
|
386
|
+
for _ in range(3):
|
|
387
|
+
sock.sendto(packet(), (SSDP_ADDR, SSDP_PORT))
|
|
388
|
+
time.sleep(0.2)
|
|
389
|
+
|
|
390
|
+
while self._running:
|
|
391
|
+
time.sleep(30)
|
|
392
|
+
if self._running:
|
|
393
|
+
sock.sendto(packet(), (SSDP_ADDR, SSDP_PORT))
|
|
394
|
+
|
|
395
|
+
sock.sendto(packet("ssdp:byebye"), (SSDP_ADDR, SSDP_PORT))
|
|
396
|
+
sock.close()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lounge_commander.py
|
|
3
|
+
-------------------
|
|
4
|
+
Sends commands from the screen back to the YouTube Lounge API.
|
|
5
|
+
All methods take a LoungeSession and use its current state (SID,
|
|
6
|
+
gsessionid, AID, RID) to build the correct request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from mopidy_dial import logger
|
|
12
|
+
from .lounge_session import LoungeSession, API_BASE
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoungeCommander:
|
|
16
|
+
def __init__(self, session: LoungeSession):
|
|
17
|
+
self.session = session
|
|
18
|
+
|
|
19
|
+
def send(self, sc: str, params: dict = {}) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
Send a single screen command (_sc) with optional parameters.
|
|
22
|
+
Returns True on success, False on failure.
|
|
23
|
+
"""
|
|
24
|
+
s = self.session
|
|
25
|
+
with s.send_lock:
|
|
26
|
+
return self._send_locked(sc, params)
|
|
27
|
+
|
|
28
|
+
def _send_locked(self, sc: str, params: dict) -> bool:
|
|
29
|
+
s = self.session
|
|
30
|
+
rid = s.rid
|
|
31
|
+
s.rid += 1 # RIDs are consumed even if the request fails
|
|
32
|
+
ofs = s.ofs
|
|
33
|
+
s.ofs += 1
|
|
34
|
+
query = urllib.parse.urlencode({
|
|
35
|
+
**s.common_query_params,
|
|
36
|
+
"RID": rid,
|
|
37
|
+
"AID": s.aid,
|
|
38
|
+
"CVER": "1",
|
|
39
|
+
})
|
|
40
|
+
url = f"{API_BASE}/bc/bind?{query}"
|
|
41
|
+
|
|
42
|
+
body = {"count": "1", "ofs": str(ofs), "req0__sc": sc}
|
|
43
|
+
for k, v in params.items():
|
|
44
|
+
body[f"req0_{k}"] = str(v)
|
|
45
|
+
|
|
46
|
+
payload = urllib.parse.urlencode(body).encode()
|
|
47
|
+
headers = {
|
|
48
|
+
**s.common_headers,
|
|
49
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
req = urllib.request.Request(url, data=payload, headers=headers)
|
|
54
|
+
resp = urllib.request.urlopen(req, timeout=15)
|
|
55
|
+
reply = resp.read()
|
|
56
|
+
logger.debug(f"Sent '{sc}' OK (RID={rid} ofs={ofs}) {params} -> {resp.status} {reply[:120]!r}")
|
|
57
|
+
return True
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warning(f"Failed to send '{sc}' (RID={rid}): {e}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# --- Convenience wrappers ---
|
|
63
|
+
|
|
64
|
+
def now_playing(self, video_id="", list_id="", state="-1",
|
|
65
|
+
current_time="0", duration="0", cpn=""):
|
|
66
|
+
return self.send("nowPlaying", {
|
|
67
|
+
"videoId": video_id,
|
|
68
|
+
"listId": list_id,
|
|
69
|
+
"state": state,
|
|
70
|
+
"currentTime": current_time,
|
|
71
|
+
"duration": duration,
|
|
72
|
+
"cpn": cpn,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
def state_change(self, state: str, current_time="0", duration="0",
|
|
76
|
+
video_id="", cpn=""):
|
|
77
|
+
params = {"state": state, "currentTime": current_time, "duration": duration}
|
|
78
|
+
if video_id:
|
|
79
|
+
params["videoId"] = video_id
|
|
80
|
+
if cpn:
|
|
81
|
+
params["cpn"] = cpn
|
|
82
|
+
return self.send("onStateChange", params)
|
|
83
|
+
|
|
84
|
+
def volume_changed(self, volume: int, muted: bool = False):
|
|
85
|
+
return self.send("onVolumeChanged", {
|
|
86
|
+
"volume": volume,
|
|
87
|
+
"muted": str(muted).lower(),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
def discovery_device_id(self, device_id: str):
|
|
91
|
+
return self.send("onDiscoveryDeviceIdRetrieved", {
|
|
92
|
+
"discoveryDeviceId": device_id,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
def noop(self):
|
|
96
|
+
return self.send("noop")
|