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.
@@ -0,0 +1,9 @@
1
+ # pixi
2
+ .pixi/
3
+ # Python
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
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.
@@ -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).
@@ -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).
@@ -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])