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.
@@ -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
@@ -0,0 +1,3 @@
1
+ [dial]
2
+ enabled = true
3
+ friendly_name = Mopidy
@@ -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")