ytlounge 0.1.0__tar.gz
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.
- ytlounge-0.1.0/.gitignore +9 -0
- ytlounge-0.1.0/LICENSE +21 -0
- ytlounge-0.1.0/PKG-INFO +116 -0
- ytlounge-0.1.0/README.md +92 -0
- ytlounge-0.1.0/pixi.toml +28 -0
- ytlounge-0.1.0/pyproject.toml +46 -0
- ytlounge-0.1.0/src/ytlounge/__init__.py +31 -0
- ytlounge-0.1.0/src/ytlounge/chunked.py +74 -0
- ytlounge-0.1.0/src/ytlounge/client.py +274 -0
- ytlounge-0.1.0/src/ytlounge/models.py +49 -0
- ytlounge-0.1.0/src/ytlounge/video.py +102 -0
- ytlounge-0.1.0/tests/__init__.py +0 -0
- ytlounge-0.1.0/tests/fixtures/bind_command.txt +2 -0
- ytlounge-0.1.0/tests/fixtures/bind_session.txt +8 -0
- ytlounge-0.1.0/tests/fixtures/youtube_urls.txt +205 -0
- ytlounge-0.1.0/tests/test_chunked.py +54 -0
- ytlounge-0.1.0/tests/test_client.py +268 -0
- ytlounge-0.1.0/tests/test_device.py +43 -0
- ytlounge-0.1.0/tests/test_video.py +106 -0
ytlounge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 desvaters
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ytlounge-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ytlounge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the YouTube Lounge API: pair with a TV screen, play videos, manage its queue
|
|
5
|
+
Project-URL: Homepage, https://github.com/desvaters/ytlounge
|
|
6
|
+
Project-URL: Issues, https://github.com/desvaters/ytlounge/issues
|
|
7
|
+
Author-email: desvaters <pypi@desvate.rs>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cast,lounge,queue,remote,tv,youtube
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ytlounge
|
|
26
|
+
|
|
27
|
+
Client for the YouTube Lounge API: the protocol behind the "Play on TV"
|
|
28
|
+
button. Pair with a screen once, then play videos on it and manage its
|
|
29
|
+
queue from Python. Synchronous, one dependency (httpx), no device code.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ytlounge import Lounge
|
|
33
|
+
|
|
34
|
+
with Lounge(name="my-remote") as lounge:
|
|
35
|
+
screen = lounge.pair("123 456 789") # code from Settings › Link with TV code
|
|
36
|
+
lounge.play(screen, ["dQw4w9WgXcQ"]) # play now
|
|
37
|
+
lounge.add(screen, ["jNQXAC9IVRw"]) # append to the queue
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Screen` is a small frozen dataclass; keep `screen_id` and you can always
|
|
41
|
+
get a fresh token with `lounge.refresh(screen)`. Tokens live about two
|
|
42
|
+
weeks, `Screen.is_expired()` tells you when.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ytlounge
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.11 or newer.
|
|
51
|
+
|
|
52
|
+
## What it does and does not do
|
|
53
|
+
|
|
54
|
+
- Pair with a TV code, refresh a token from a screen id, open a session,
|
|
55
|
+
play a list of videos (with a start index and start time), append videos
|
|
56
|
+
to the queue.
|
|
57
|
+
- `ytlounge.video.parse_video` turns an id or any of the usual YouTube
|
|
58
|
+
URL forms into a `Video` with an optional start time.
|
|
59
|
+
- It does **not** find TVs, launch apps or store anything. That is the job
|
|
60
|
+
of a tool built on top, such as [yttv](https://github.com/desvaters/yttv),
|
|
61
|
+
which adds Cast, Apple TV and DIAL backends, a cache and a command line.
|
|
62
|
+
|
|
63
|
+
The Lounge API is not documented by Google and can change at any time.
|
|
64
|
+
Everything below was verified on the wire in September 2026 and is kept as
|
|
65
|
+
assertions in the tests; when something breaks, that is where to look.
|
|
66
|
+
|
|
67
|
+
## The protocol, as verified
|
|
68
|
+
|
|
69
|
+
| Field | Lives | Comes from |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `screen_id` | for good, survives a cold start of the TV | one pairing with a TV code |
|
|
72
|
+
| `lounge_token` | about 13 days | `get_lounge_token_batch` from the screen id |
|
|
73
|
+
| `SID`, `gsessionid` | one session | a bind at the start of every play/add |
|
|
74
|
+
|
|
75
|
+
- **Pair:** `POST /api/lounge/pairing/get_screen` with `pairing_code=`.
|
|
76
|
+
The `expiration` here is a string, on the token endpoint a number.
|
|
77
|
+
- **Refresh:** `POST /api/lounge/pairing/get_lounge_token_batch` with
|
|
78
|
+
`screen_ids=`. A refresh does not revoke earlier tokens.
|
|
79
|
+
- **Session:** `POST /api/lounge/bc/bind?CVER=1&RID=1&VER=8&app=youtube-desktop&device=REMOTE_CONTROL&id=remote&loungeIdToken=…&name=…`
|
|
80
|
+
with an empty body. The endpoint insists on a `Content-Length` header
|
|
81
|
+
even then (httpx always sends one). The reply is framed: a decimal
|
|
82
|
+
length on its own line, then that many characters of a JSON array of
|
|
83
|
+
numbered messages; `["c", SID, …]` and `["S", gsessionid]` are in the
|
|
84
|
+
first frame, followed by the screen's status and current queue.
|
|
85
|
+
- **Play:** `POST bind?CVER=1&RID=2&SID=…&VER=8&gsessionid=…&loungeIdToken=…`,
|
|
86
|
+
form body `count=1&req0__sc=setPlaylist&req0_currentIndex=0&req0_currentTime=0&req0_videoId=…&req0_videoIds=a,b,c`.
|
|
87
|
+
- **Add:** same query, `req0__sc=addVideo&req0_videoId=…`, one request per
|
|
88
|
+
video with a random 2–5 s pause before each. The pause is not cosmetic:
|
|
89
|
+
without it consecutive adds race on the screen and the queue comes out
|
|
90
|
+
incomplete or reordered.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pixi run test # fixture tests
|
|
96
|
+
YTLOUNGE_SCREEN_ID=… pixi run test-device # against the real API, no TV changes
|
|
97
|
+
pixi run check # build and validate the artifacts
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Origins
|
|
101
|
+
|
|
102
|
+
The Lounge protocol was reverse-engineered independently by several
|
|
103
|
+
people. This client contains none of their code, but learned the protocol
|
|
104
|
+
from Marco Lucidi's [ytcast](https://github.com/MarcoLucidi01/ytcast),
|
|
105
|
+
whose requests were the reference for verifying this implementation on
|
|
106
|
+
the wire, and through it from the sources ytcast credits:
|
|
107
|
+
|
|
108
|
+
- https://0x41.cf/automation/2021/03/02/google-assistant-youtube-smart-tvs.html
|
|
109
|
+
- https://github.com/thedroidgeek/youtube-cast-automation-api
|
|
110
|
+
- https://github.com/mutantmonkey/youtube-remote
|
|
111
|
+
- https://bugs.xdavidhu.me/google/2021/04/05/i-built-a-tv-that-plays-all-of-your-private-youtube-videos
|
|
112
|
+
- https://github.com/aykevl/plaincast
|
|
113
|
+
- https://github.com/ur1katz/casttube
|
|
114
|
+
|
|
115
|
+
The YouTube URL forms in the tests come from
|
|
116
|
+
[this gist](https://gist.github.com/rodrigoborgesdeoliveira/987683cfbfcc8d800192da1e73adc486).
|
ytlounge-0.1.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ytlounge
|
|
2
|
+
|
|
3
|
+
Client for the YouTube Lounge API: the protocol behind the "Play on TV"
|
|
4
|
+
button. Pair with a screen once, then play videos on it and manage its
|
|
5
|
+
queue from Python. Synchronous, one dependency (httpx), no device code.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from ytlounge import Lounge
|
|
9
|
+
|
|
10
|
+
with Lounge(name="my-remote") as lounge:
|
|
11
|
+
screen = lounge.pair("123 456 789") # code from Settings › Link with TV code
|
|
12
|
+
lounge.play(screen, ["dQw4w9WgXcQ"]) # play now
|
|
13
|
+
lounge.add(screen, ["jNQXAC9IVRw"]) # append to the queue
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`Screen` is a small frozen dataclass; keep `screen_id` and you can always
|
|
17
|
+
get a fresh token with `lounge.refresh(screen)`. Tokens live about two
|
|
18
|
+
weeks, `Screen.is_expired()` tells you when.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install ytlounge
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.11 or newer.
|
|
27
|
+
|
|
28
|
+
## What it does and does not do
|
|
29
|
+
|
|
30
|
+
- Pair with a TV code, refresh a token from a screen id, open a session,
|
|
31
|
+
play a list of videos (with a start index and start time), append videos
|
|
32
|
+
to the queue.
|
|
33
|
+
- `ytlounge.video.parse_video` turns an id or any of the usual YouTube
|
|
34
|
+
URL forms into a `Video` with an optional start time.
|
|
35
|
+
- It does **not** find TVs, launch apps or store anything. That is the job
|
|
36
|
+
of a tool built on top, such as [yttv](https://github.com/desvaters/yttv),
|
|
37
|
+
which adds Cast, Apple TV and DIAL backends, a cache and a command line.
|
|
38
|
+
|
|
39
|
+
The Lounge API is not documented by Google and can change at any time.
|
|
40
|
+
Everything below was verified on the wire in September 2026 and is kept as
|
|
41
|
+
assertions in the tests; when something breaks, that is where to look.
|
|
42
|
+
|
|
43
|
+
## The protocol, as verified
|
|
44
|
+
|
|
45
|
+
| Field | Lives | Comes from |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `screen_id` | for good, survives a cold start of the TV | one pairing with a TV code |
|
|
48
|
+
| `lounge_token` | about 13 days | `get_lounge_token_batch` from the screen id |
|
|
49
|
+
| `SID`, `gsessionid` | one session | a bind at the start of every play/add |
|
|
50
|
+
|
|
51
|
+
- **Pair:** `POST /api/lounge/pairing/get_screen` with `pairing_code=`.
|
|
52
|
+
The `expiration` here is a string, on the token endpoint a number.
|
|
53
|
+
- **Refresh:** `POST /api/lounge/pairing/get_lounge_token_batch` with
|
|
54
|
+
`screen_ids=`. A refresh does not revoke earlier tokens.
|
|
55
|
+
- **Session:** `POST /api/lounge/bc/bind?CVER=1&RID=1&VER=8&app=youtube-desktop&device=REMOTE_CONTROL&id=remote&loungeIdToken=…&name=…`
|
|
56
|
+
with an empty body. The endpoint insists on a `Content-Length` header
|
|
57
|
+
even then (httpx always sends one). The reply is framed: a decimal
|
|
58
|
+
length on its own line, then that many characters of a JSON array of
|
|
59
|
+
numbered messages; `["c", SID, …]` and `["S", gsessionid]` are in the
|
|
60
|
+
first frame, followed by the screen's status and current queue.
|
|
61
|
+
- **Play:** `POST bind?CVER=1&RID=2&SID=…&VER=8&gsessionid=…&loungeIdToken=…`,
|
|
62
|
+
form body `count=1&req0__sc=setPlaylist&req0_currentIndex=0&req0_currentTime=0&req0_videoId=…&req0_videoIds=a,b,c`.
|
|
63
|
+
- **Add:** same query, `req0__sc=addVideo&req0_videoId=…`, one request per
|
|
64
|
+
video with a random 2–5 s pause before each. The pause is not cosmetic:
|
|
65
|
+
without it consecutive adds race on the screen and the queue comes out
|
|
66
|
+
incomplete or reordered.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pixi run test # fixture tests
|
|
72
|
+
YTLOUNGE_SCREEN_ID=… pixi run test-device # against the real API, no TV changes
|
|
73
|
+
pixi run check # build and validate the artifacts
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Origins
|
|
77
|
+
|
|
78
|
+
The Lounge protocol was reverse-engineered independently by several
|
|
79
|
+
people. This client contains none of their code, but learned the protocol
|
|
80
|
+
from Marco Lucidi's [ytcast](https://github.com/MarcoLucidi01/ytcast),
|
|
81
|
+
whose requests were the reference for verifying this implementation on
|
|
82
|
+
the wire, and through it from the sources ytcast credits:
|
|
83
|
+
|
|
84
|
+
- https://0x41.cf/automation/2021/03/02/google-assistant-youtube-smart-tvs.html
|
|
85
|
+
- https://github.com/thedroidgeek/youtube-cast-automation-api
|
|
86
|
+
- https://github.com/mutantmonkey/youtube-remote
|
|
87
|
+
- https://bugs.xdavidhu.me/google/2021/04/05/i-built-a-tv-that-plays-all-of-your-private-youtube-videos
|
|
88
|
+
- https://github.com/aykevl/plaincast
|
|
89
|
+
- https://github.com/ur1katz/casttube
|
|
90
|
+
|
|
91
|
+
The YouTube URL forms in the tests come from
|
|
92
|
+
[this gist](https://gist.github.com/rodrigoborgesdeoliveira/987683cfbfcc8d800192da1e73adc486).
|
ytlounge-0.1.0/pixi.toml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
name = "ytlounge"
|
|
3
|
+
channels = ["conda-forge"]
|
|
4
|
+
platforms = ["linux-64"]
|
|
5
|
+
|
|
6
|
+
[dependencies]
|
|
7
|
+
# Unpinned on purpose: the client must work on the newest Python; nothing
|
|
8
|
+
# here caps the version. Today that resolves to 3.14.
|
|
9
|
+
python = ">=3.11"
|
|
10
|
+
# The only runtime dependency. Without it: ModuleNotFoundError on import.
|
|
11
|
+
httpx = ">=0.27"
|
|
12
|
+
# Test runner. Without it `pixi run test` fails with "pytest: command not found".
|
|
13
|
+
pytest = ">=8"
|
|
14
|
+
# Builds wheel and sdist for `pixi run build`. Without it: "No module named build".
|
|
15
|
+
python-build = ">=1.2"
|
|
16
|
+
# Validates the built metadata the way PyPI will, and does the upload.
|
|
17
|
+
twine = ">=6"
|
|
18
|
+
|
|
19
|
+
[pypi-dependencies]
|
|
20
|
+
# The project itself, editable, so changes under src/ are visible at once.
|
|
21
|
+
ytlounge = { path = ".", editable = true }
|
|
22
|
+
|
|
23
|
+
[tasks]
|
|
24
|
+
# Fixture tests only. The device test needs YTLOUNGE_SCREEN_ID and network.
|
|
25
|
+
test = "pytest -m 'not device'"
|
|
26
|
+
test-device = "pytest -m device"
|
|
27
|
+
build = "python -m build"
|
|
28
|
+
check = { cmd = "twine check dist/*", depends-on = ["build"] }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ytlounge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client for the YouTube Lounge API: pair with a TV screen, play videos, manage its queue"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "desvaters", email = "pypi@desvate.rs" }]
|
|
14
|
+
keywords = ["youtube", "lounge", "tv", "remote", "cast", "queue"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Multimedia :: Video",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
]
|
|
27
|
+
# The only runtime dependency. httpx always sends Content-Length, which the
|
|
28
|
+
# Lounge bind endpoint requires even for an empty body.
|
|
29
|
+
dependencies = ["httpx>=0.27"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/desvaters/ytlounge"
|
|
33
|
+
Issues = "https://github.com/desvaters/ytlounge/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/ytlounge"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
exclude = ["/.pixi", "/pixi.lock", "/.claude", "/NOTES.md", "/HANDOFF.md"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = "-ra"
|
|
44
|
+
markers = [
|
|
45
|
+
"device: talks to the real Lounge API and needs a paired screen (opt-in)",
|
|
46
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ytlounge: a client for the YouTube Lounge API.
|
|
2
|
+
|
|
3
|
+
The Lounge API is the unofficial protocol that a phone or browser uses to
|
|
4
|
+
drive the YouTube app on a TV: pair with a screen, play a video, append to the
|
|
5
|
+
queue. Every way of reaching a TV (Cast, Apple TV, DIAL) ends up here.
|
|
6
|
+
|
|
7
|
+
Scope of this package: a ``screen_id`` goes in, play and queue come out.
|
|
8
|
+
It knows nothing about device discovery, app launching, caches or file
|
|
9
|
+
paths; tools such as yttv build those on top.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import Lounge, Session
|
|
13
|
+
from .models import (
|
|
14
|
+
CommandError,
|
|
15
|
+
LoungeError,
|
|
16
|
+
PairingError,
|
|
17
|
+
Screen,
|
|
18
|
+
SessionError,
|
|
19
|
+
TokenError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CommandError",
|
|
24
|
+
"Lounge",
|
|
25
|
+
"LoungeError",
|
|
26
|
+
"PairingError",
|
|
27
|
+
"Screen",
|
|
28
|
+
"Session",
|
|
29
|
+
"SessionError",
|
|
30
|
+
"TokenError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Parser for the framed responses of ``/api/lounge/bc/bind``.
|
|
2
|
+
|
|
3
|
+
The body is a sequence of frames. Each frame is a decimal length on its own
|
|
4
|
+
line followed by that many characters of JSON: an array of numbered messages
|
|
5
|
+
``[index, [name, payload...]]``. The session handshake answers with the
|
|
6
|
+
session id as ``["c", sid, ...]`` and the gsession id as ``["S", gsessionid]``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
Message = list[Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_frames(body: str) -> list[Message]:
|
|
18
|
+
"""Return every message from every frame in ``body``, in order.
|
|
19
|
+
|
|
20
|
+
Raises ``ValueError`` on a malformed frame.
|
|
21
|
+
"""
|
|
22
|
+
messages: list[Message] = []
|
|
23
|
+
pos = 0
|
|
24
|
+
while pos < len(body):
|
|
25
|
+
newline = body.find("\n", pos)
|
|
26
|
+
if newline == -1:
|
|
27
|
+
raise ValueError(f"missing length line at offset {pos}")
|
|
28
|
+
length_text = body[pos:newline].strip()
|
|
29
|
+
if not length_text:
|
|
30
|
+
pos = newline + 1
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
length = int(length_text)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ValueError(f"bad frame length {length_text!r}") from exc
|
|
36
|
+
start = newline + 1
|
|
37
|
+
payload = body[start : start + length]
|
|
38
|
+
try:
|
|
39
|
+
frame = json.loads(payload)
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise ValueError(f"bad frame JSON at offset {start}: {exc}") from exc
|
|
42
|
+
if not isinstance(frame, list):
|
|
43
|
+
raise ValueError("frame is not a JSON array")
|
|
44
|
+
messages.extend(frame)
|
|
45
|
+
pos = start + length
|
|
46
|
+
return messages
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_event(messages: list[Message], name: str) -> Message | None:
|
|
50
|
+
"""Return the first ``[name, payload...]`` event with the given name."""
|
|
51
|
+
for message in messages:
|
|
52
|
+
if (
|
|
53
|
+
isinstance(message, list)
|
|
54
|
+
and len(message) >= 2
|
|
55
|
+
and isinstance(message[1], list)
|
|
56
|
+
and message[1]
|
|
57
|
+
and message[1][0] == name
|
|
58
|
+
):
|
|
59
|
+
return message[1]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def session_ids(messages: list[Message]) -> tuple[str, str]:
|
|
64
|
+
"""Extract ``(sid, gsessionid)`` from a bind handshake.
|
|
65
|
+
|
|
66
|
+
Raises ``ValueError`` when either is missing.
|
|
67
|
+
"""
|
|
68
|
+
c_event = find_event(messages, "c")
|
|
69
|
+
s_event = find_event(messages, "S")
|
|
70
|
+
if c_event is None or len(c_event) < 2 or not c_event[1]:
|
|
71
|
+
raise ValueError("no session id ('c' event) in bind response")
|
|
72
|
+
if s_event is None or len(s_event) < 2 or not s_event[1]:
|
|
73
|
+
raise ValueError("no gsessionid ('S' event) in bind response")
|
|
74
|
+
return str(c_event[1]), str(s_event[1])
|