Skip to content

Commit 5410737

Browse files
authored
Merge pull request #438 from dmunozv04/improve-pairing
Improve pairing
2 parents 2442a04 + 5e37a1b commit 5410737

7 files changed

Lines changed: 694 additions & 96 deletions

File tree

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ textual==8.2.3
88
textual-slider==0.2.0
99
xmltodict==1.0.4
1010
rich_click==1.9.7
11+
yarl==1.23.0
12+
zeroconf==0.148.0
13+
pychromecast==14.0.7

src/iSponsorBlockTV/api_helpers.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import html
22
from hashlib import sha256
3+
from asyncio import FIRST_COMPLETED, Task, create_task, wait
4+
from typing import AsyncIterator, Collection, TypeVar
35

46
from aiohttp import ClientSession
57
from cache import AsyncLRU
8+
from pyytlounge.wrapper import api_base
69

7-
from . import constants, dial_client
10+
from . import chromecast_client, constants, dial_client
811
from .conditional_ttl_cache import AsyncConditionalTTL
912

1013

14+
_T = TypeVar("_T")
15+
16+
1117
def list_to_tuple(function):
1218
def wrapper(*args):
1319
args = [tuple(x) if isinstance(x, list) else x for x in args]
@@ -18,6 +24,33 @@ def wrapper(*args):
1824
return wrapper
1925

2026

27+
async def _await_next(iterator: AsyncIterator[_T]) -> _T:
28+
return await iterator.__anext__()
29+
30+
31+
def _as_task(iterator: AsyncIterator[_T]) -> Task[_T]:
32+
return create_task(_await_next(iterator))
33+
34+
35+
async def merge_iterators(iterators: Collection[AsyncIterator[_T]]) -> AsyncIterator[_T]:
36+
pending_tasks = {_as_task(iterator): iterator for iterator in iterators}
37+
try:
38+
while pending_tasks:
39+
done, _ = await wait(pending_tasks, return_when=FIRST_COMPLETED)
40+
for task in done:
41+
iterator = pending_tasks.pop(task)
42+
try:
43+
yield task.result()
44+
except StopAsyncIteration:
45+
continue
46+
except Exception:
47+
continue
48+
pending_tasks[_as_task(iterator)] = iterator
49+
finally:
50+
for task in pending_tasks:
51+
task.cancel()
52+
53+
2154
# Class that handles all the api calls and their cache
2255
class ApiHelper:
2356
def __init__(self, config, web_session: ClientSession) -> None:
@@ -29,6 +62,37 @@ def __init__(self, config, web_session: ClientSession) -> None:
2962
self.num_devices = len(config.devices)
3063
self.minimum_skip_length = config.minimum_skip_length
3164

65+
@staticmethod
66+
def _normalize_pairing_code(pairing_code):
67+
return str(pairing_code).replace("-", "").replace(" ", "")
68+
69+
async def pair_with_code(self, pairing_code):
70+
normalized_code = self._normalize_pairing_code(pairing_code)
71+
pair_url = f"{api_base}/pairing/get_screen"
72+
pair_data = {"pairing_code": normalized_code}
73+
74+
async with self.web_session.post(url=pair_url, data=pair_data) as response:
75+
if response.status != 200:
76+
return None
77+
try:
78+
pair_response = await response.json()
79+
except BaseException:
80+
return None
81+
82+
screen = pair_response.get("screen")
83+
if not screen:
84+
return None
85+
86+
screen_id = screen.get("screenId")
87+
if not screen_id:
88+
return None
89+
90+
return {
91+
"screen_id": screen_id,
92+
"name": screen.get("name"),
93+
"lounge_token": screen.get("loungeToken"),
94+
}
95+
3296
# Not used anymore, maybe it can stay here a little longer
3397
@AsyncLRU(maxsize=10)
3498
async def get_vid_id(self, title, artist, api_key, web_session):
@@ -204,8 +268,21 @@ async def mark_viewed_segments(self, uuids):
204268
params = {"UUID": i}
205269
await self.web_session.post(url, params=params)
206270

207-
async def discover_youtube_devices_dial(self):
208-
"""Discovers YouTube devices using DIAL"""
209-
dial_screens = await dial_client.discover(self.web_session)
210-
# print(dial_screens)
211-
return dial_screens
271+
async def discover_youtube_devices_dial(self, active=True):
272+
"""Discovers YouTube devices using DIAL and Chromecast discovery.
273+
274+
Yields devices as they are discovered instead of waiting for the full
275+
discovery cycle to finish.
276+
"""
277+
seen_screen_ids = set()
278+
async for device in merge_iterators(
279+
[
280+
dial_client.discover(self.web_session, self, active=active),
281+
chromecast_client.discover(active=active),
282+
]
283+
):
284+
screen_id = device.get("screen_id")
285+
if not screen_id or screen_id in seen_screen_ids:
286+
continue
287+
seen_screen_ids.add(screen_id)
288+
yield device
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Chromecast discovery using Cast protocol via pychromecast."""
2+
3+
# skipcq: PYL-R0401
4+
import asyncio
5+
from typing import Any
6+
import logging
7+
8+
import pychromecast
9+
from pychromecast.controllers.youtube import YouTubeController
10+
from pychromecast import Chromecast
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def _build_device_from_cast(cast: Chromecast) -> dict[str, Any] | None:
16+
try:
17+
cast.wait(timeout=5)
18+
19+
yt_controller = YouTubeController()
20+
cast.register_handler(yt_controller)
21+
try:
22+
yt_controller.update_screen_id()
23+
except Exception:
24+
logger.exception("YouTubeController.update_screen_id() failed")
25+
26+
# Give the controller a moment to process status messages.
27+
cast.wait(timeout=2)
28+
screen_id = getattr(yt_controller, "_screen_id", None)
29+
if not screen_id:
30+
logger.debug("No YouTube MDX screen_id for cast=%s", getattr(cast, "name", cast))
31+
return None
32+
33+
try:
34+
cast_name = cast.cast_info.friendly_name
35+
except Exception:
36+
try:
37+
cast_name = cast.name
38+
except Exception:
39+
cast_name = "Chromecast"
40+
41+
return {
42+
"screen_id": screen_id,
43+
"name": cast_name,
44+
"offset": 0,
45+
}
46+
except Exception:
47+
logger.exception("Error while building device from cast")
48+
return None
49+
finally:
50+
try:
51+
cast.disconnect(timeout=1)
52+
except Exception:
53+
pass
54+
55+
56+
async def discover(active=True):
57+
"""Discover Chromecast devices and yield YouTube MDX-capable screens.
58+
59+
The signature mirrors the newer DIAL discovery generator so callers can
60+
consume both sources with the same `async for` pattern.
61+
"""
62+
loop = asyncio.get_running_loop()
63+
discovered_casts: asyncio.Queue = asyncio.Queue()
64+
65+
def on_cast_discovered(cast) -> None:
66+
print(f"Discovered cast: {cast}")
67+
loop.call_soon_threadsafe(discovered_casts.put_nowait, cast)
68+
69+
browser = pychromecast.get_chromecasts(
70+
timeout=5,
71+
blocking=False,
72+
callback=on_cast_discovered,
73+
)
74+
75+
pending_tasks: set[asyncio.Task] = set()
76+
seen_screen_ids: set[str] = set()
77+
discovery_deadline = loop.time() + (5 if active else 15)
78+
79+
try:
80+
while True:
81+
if not active and pending_tasks:
82+
pass
83+
elif (
84+
loop.time() >= discovery_deadline and discovered_casts.empty() and not pending_tasks
85+
):
86+
break
87+
88+
try:
89+
cast = await asyncio.wait_for(discovered_casts.get(), timeout=0.5)
90+
pending_tasks.add(
91+
asyncio.create_task(asyncio.to_thread(_build_device_from_cast, cast))
92+
)
93+
discovery_deadline = max(discovery_deadline, loop.time() + 1)
94+
except asyncio.TimeoutError:
95+
pass
96+
97+
done_tasks = {task for task in pending_tasks if task.done()}
98+
for task in done_tasks:
99+
pending_tasks.discard(task)
100+
device = None
101+
try:
102+
device = task.result()
103+
except Exception:
104+
device = None
105+
if not device:
106+
continue
107+
screen_id = device.get("screen_id")
108+
if not screen_id or screen_id in seen_screen_ids:
109+
continue
110+
seen_screen_ids.add(screen_id)
111+
yield device
112+
finally:
113+
for task in pending_tasks:
114+
task.cancel()
115+
await asyncio.to_thread(browser.stop_discovery)
116+
117+
118+
if __name__ == "__main__":
119+
import json
120+
121+
logging.basicConfig(
122+
level=logging.DEBUG,
123+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
124+
)
125+
logger.debug("Starting chromecast discovery test...")
126+
127+
async def main():
128+
async for device in discover(None):
129+
print(json.dumps(device, indent=2))
130+
131+
asyncio.run(main())

src/iSponsorBlockTV/config_setup.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import aiohttp
44

5-
from . import api_helpers, ytlounge
5+
from . import api_helpers
66

77
# Constants for user input prompts
88
USE_PROXY_PROMPT = "Do you want to use system-wide proxy? (y/N)"
@@ -50,22 +50,18 @@ async def create_web_session(use_proxy):
5050
return aiohttp.ClientSession(trust_env=use_proxy)
5151

5252

53-
async def pair_device(web_session: aiohttp.ClientSession):
53+
async def pair_device(config, web_session: aiohttp.ClientSession, api_helper):
5454
try:
55-
lounge_controller = ytlounge.YtLoungeApi()
56-
await lounge_controller.change_web_session(web_session)
55+
api_helper = api_helpers.ApiHelper(config, web_session)
5756
pairing_code = input(PAIRING_CODE_PROMPT)
58-
pairing_code = int(
59-
pairing_code.replace("-", "").replace(" ", "")
60-
) # remove dashes and spaces
6157
print("Pairing...")
62-
paired = await lounge_controller.pair(pairing_code)
63-
if not paired:
64-
print("Failed to pair device")
58+
paired_device = await api_helper.pair_with_code(pairing_code)
59+
if not paired_device:
60+
print("Failed to pair device. Try restarting the YouTube app")
6561
return
6662
device = {
67-
"screen_id": lounge_controller.auth.screen_id,
68-
"name": lounge_controller.screen_name,
63+
"screen_id": paired_device["screen_id"],
64+
"name": paired_device["name"],
6965
}
7066
print(f"Paired device: {device['name']}")
7167
return device
@@ -96,9 +92,10 @@ def main(config, debug: bool) -> None:
9692
del config["atvs"]
9793

9894
devices = config.devices
95+
api_helper = api_helpers.ApiHelper(config, web_session)
9996
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))
10097
while choice == "y":
101-
device = loop.run_until_complete(pair_device(web_session))
98+
device = loop.run_until_complete(pair_device(config, web_session, api_helper))
10299
if device:
103100
devices.append(device)
104101
choice = get_yn_input(ADD_MORE_DEVICES_PROMPT.format(num_devices=len(devices)))

0 commit comments

Comments
 (0)