@iobroker/youtube-lounge 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/build/chunks.d.ts +14 -0
- package/build/chunks.js +49 -0
- package/build/chunks.js.map +1 -0
- package/build/dial.d.ts +42 -0
- package/build/dial.js +119 -0
- package/build/dial.js.map +1 -0
- package/build/esm/chunks.d.ts +14 -0
- package/build/esm/chunks.js +44 -0
- package/build/esm/chunks.js.map +1 -0
- package/build/esm/dial.d.ts +42 -0
- package/build/esm/dial.js +110 -0
- package/build/esm/dial.js.map +1 -0
- package/build/esm/index.d.ts +4 -0
- package/build/esm/index.js +5 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/lounge.d.ts +99 -0
- package/build/esm/lounge.js +330 -0
- package/build/esm/lounge.js.map +1 -0
- package/build/esm/package.json +4 -0
- package/build/esm/target.d.ts +25 -0
- package/build/esm/target.js +59 -0
- package/build/esm/target.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.js +18 -0
- package/build/index.js.map +1 -0
- package/build/lounge.d.ts +99 -0
- package/build/lounge.js +334 -0
- package/build/lounge.js.map +1 -0
- package/build/target.d.ts +25 -0
- package/build/target.js +63 -0
- package/build/target.js.map +1 -0
- package/package.json +60 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Haev <dogafox@gmail.com>
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @iobroker/youtube-lounge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Control the YouTube app of a TV over the YouTube Lounge API - the way "Link with TV" of the YouTube app on a phone
|
|
4
|
+
works: play a video, a playlist or the uploads of a channel, pause, skip, seek, and follow what the TV plays.
|
|
5
|
+
|
|
6
|
+
It works with every device whose YouTube app offers "Link with TV": smart TVs (Samsung, LG, Sony, Philips, ...),
|
|
7
|
+
Android TV / Google TV, Fire TV, game consoles.
|
|
8
|
+
|
|
9
|
+
**Note:** The Lounge API is not documented by Google; the protocol follows
|
|
10
|
+
[pyytlounge](https://github.com/FabioGNR/pyytlounge) and may change. The requests go to Google's servers
|
|
11
|
+
(`https://www.youtube.com/api/lounge`), so an internet connection is needed. Only finding the TV and reading its
|
|
12
|
+
screen ID (DIAL) stay in the local network.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const {
|
|
18
|
+
YouTubeLounge,
|
|
19
|
+
discoverDialDevices,
|
|
20
|
+
getYouTubeScreenId,
|
|
21
|
+
parseYouTubeTarget,
|
|
22
|
+
} = require('@iobroker/youtube-lounge');
|
|
23
|
+
|
|
24
|
+
const lounge = new YouTubeLounge({ name: 'ioBroker' });
|
|
25
|
+
|
|
26
|
+
// Pair: either with the screen ID the TV reports over DIAL ...
|
|
27
|
+
const [tv] = await discoverDialDevices();
|
|
28
|
+
const screenId = await getYouTubeScreenId(tv.appUrl); // many TVs report it only while the app runs
|
|
29
|
+
await lounge.pairWithScreenId(screenId);
|
|
30
|
+
// ... or with the code of the YouTube app on the TV: Settings > Link with TV code
|
|
31
|
+
await lounge.pairWithCode('123 456 789 012');
|
|
32
|
+
|
|
33
|
+
// lounge.screen can be stored and given to lounge.useScreen() later, so no pairing is needed again
|
|
34
|
+
|
|
35
|
+
await lounge.play(parseYouTubeTarget('https://www.youtube.com/watch?v=jNQXAC9IVRw'));
|
|
36
|
+
await lounge.play(parseYouTubeTarget('UCbmF2AHnpQsogOLW-MxBiJg')); // the uploads of a channel
|
|
37
|
+
await lounge.play(parseYouTubeTarget('pause'));
|
|
38
|
+
await lounge.seekTo(90);
|
|
39
|
+
|
|
40
|
+
lounge.on('nowPlaying', playing => console.log(playing)); // { videoId, listId, currentTime, duration, state } or null
|
|
41
|
+
lounge.on('volume', ({ volume, muted }) => console.log(volume, muted));
|
|
42
|
+
lounge.subscribe();
|
|
43
|
+
// ...
|
|
44
|
+
lounge.close();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`parseYouTubeTarget` takes a video ID, a playlist ID (`PL…`), a channel ID (`UC…`), a youtube.com / youtu.be URL, or
|
|
48
|
+
`play`, `pause`, `next`, `previous`. Handles (`@name`) cannot be resolved without the YouTube Data API.
|
|
49
|
+
|
|
50
|
+
An expired lounge token is renewed with the screen ID, an ended session is opened again, both on their own.
|
|
51
|
+
|
|
52
|
+
<!--
|
|
53
|
+
Placeholder for the next version (at the beginning of the line):
|
|
54
|
+
### **WORK IN PROGRESS**
|
|
55
|
+
-->
|
|
56
|
+
|
|
57
|
+
## Changelog
|
|
58
|
+
|
|
59
|
+
### **WORK IN PROGRESS**
|
|
60
|
+
|
|
61
|
+
- (@GermanBluefox) Initial release, taken from the adapter `samsungtv`
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT License
|
|
66
|
+
|
|
67
|
+
Copyright (c) 2026 Denis Haev <dogafox@gmail.com>
|
|
68
|
+
|
|
69
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
70
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
71
|
+
in the Software without restriction, including without limitation the rights
|
|
72
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
73
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
74
|
+
furnished to do so, subject to the following conditions:
|
|
75
|
+
|
|
76
|
+
The above copyright notice and this permission notice shall be included in all
|
|
77
|
+
copies or substantial portions of the Software.
|
|
78
|
+
|
|
79
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
80
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
81
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
82
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
83
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
84
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
85
|
+
SOFTWARE.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** An event of the lounge channel: `[id, [type, ...args]]` */
|
|
2
|
+
export type LoungeEvent = [number, [string, ...unknown[]]];
|
|
3
|
+
/**
|
|
4
|
+
* Splits the chunked answer of bc/bind (`<length>\n<json>\n<length>\n<json>…`) into events. The length counts
|
|
5
|
+
* bytes, including the new line after the JSON. Data may arrive in pieces, as on the long poll; a chunk is
|
|
6
|
+
* reported once it is complete.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ChunkParser {
|
|
9
|
+
private buffer;
|
|
10
|
+
/** Add received data; returns the events of all chunks completed by it */
|
|
11
|
+
push(data: Buffer | Uint8Array | string): LoungeEvent[];
|
|
12
|
+
}
|
|
13
|
+
/** All events of a complete answer */
|
|
14
|
+
export declare function parseBindChunks(data: Buffer | string): LoungeEvent[];
|
package/build/chunks.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChunkParser = void 0;
|
|
4
|
+
exports.parseBindChunks = parseBindChunks;
|
|
5
|
+
/**
|
|
6
|
+
* Splits the chunked answer of bc/bind (`<length>\n<json>\n<length>\n<json>…`) into events. The length counts
|
|
7
|
+
* bytes, including the new line after the JSON. Data may arrive in pieces, as on the long poll; a chunk is
|
|
8
|
+
* reported once it is complete.
|
|
9
|
+
*/
|
|
10
|
+
class ChunkParser {
|
|
11
|
+
buffer = Buffer.alloc(0);
|
|
12
|
+
/** Add received data; returns the events of all chunks completed by it */
|
|
13
|
+
push(data) {
|
|
14
|
+
this.buffer = Buffer.concat([this.buffer, typeof data === 'string' ? Buffer.from(data, 'utf8') : data]);
|
|
15
|
+
const events = [];
|
|
16
|
+
for (;;) {
|
|
17
|
+
const newLine = this.buffer.indexOf(0x0a);
|
|
18
|
+
if (newLine < 0) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
const header = this.buffer.subarray(0, newLine).toString('ascii').trim();
|
|
22
|
+
if (!/^\d+$/.test(header)) {
|
|
23
|
+
// not a length: skip the line
|
|
24
|
+
this.buffer = this.buffer.subarray(newLine + 1);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const length = Number(header);
|
|
28
|
+
const start = newLine + 1;
|
|
29
|
+
if (this.buffer.length < start + length) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const chunk = this.buffer.subarray(start, start + length).toString('utf8');
|
|
33
|
+
this.buffer = this.buffer.subarray(start + length);
|
|
34
|
+
try {
|
|
35
|
+
events.push(...JSON.parse(chunk));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// not JSON: ignored
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return events;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.ChunkParser = ChunkParser;
|
|
45
|
+
/** All events of a complete answer */
|
|
46
|
+
function parseBindChunks(data) {
|
|
47
|
+
return new ChunkParser().push(data);
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=chunks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunks.js","sourceRoot":"","sources":["../src/chunks.ts"],"names":[],"mappings":";;;AA4CA,0CAEC;AA3CD;;;;GAIG;AACH,MAAa,WAAW;IACZ,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEjC,0EAA0E;IAC1E,IAAI,CAAC,IAAkC;QACnC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACxG,MAAM,MAAM,GAAkB,EAAE,CAAC;QACjC,SAAS,CAAC;YACN,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC1C,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;gBACd,MAAM;YACV,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;YACzE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,8BAA8B;gBAC9B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC;gBAChD,SAAS;YACb,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;YAC9B,MAAM,KAAK,GAAG,OAAO,GAAG,CAAC,CAAC;YAC1B,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,KAAK,GAAG,MAAM,EAAE,CAAC;gBACtC,MAAM;YACV,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YAC3E,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC;YACnD,IAAI,CAAC;gBACD,MAAM,CAAC,IAAI,CAAC,GAAI,IAAI,CAAC,KAAK,CAAC,KAAK,CAAmB,CAAC,CAAC;YACzD,CAAC;YAAC,MAAM,CAAC;gBACL,oBAAoB;YACxB,CAAC;QACL,CAAC;QACD,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ;AAjCD,kCAiCC;AAED,sCAAsC;AACtC,SAAgB,eAAe,CAAC,IAAqB;IACjD,OAAO,IAAI,WAAW,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC"}
|
package/build/dial.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A device with a DIAL server, e.g. a TV with the YouTube app */
|
|
2
|
+
export interface DialDevice {
|
|
3
|
+
/** Address of the device */
|
|
4
|
+
ip: string;
|
|
5
|
+
/** Name from the device description */
|
|
6
|
+
name: string;
|
|
7
|
+
manufacturer: string;
|
|
8
|
+
model: string;
|
|
9
|
+
/** UDN of the device description, a stable ID */
|
|
10
|
+
udn: string;
|
|
11
|
+
/** Base URL of the DIAL apps (`Application-URL`), e.g. `http://192.168.1.147:8080/ws/app/` */
|
|
12
|
+
appUrl: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Read the device description of a DIAL device.
|
|
16
|
+
*
|
|
17
|
+
* @param location the `LOCATION` of its SSDP answer
|
|
18
|
+
* @returns the device, `null` if it has no DIAL `Application-URL`
|
|
19
|
+
*/
|
|
20
|
+
export declare function readDialDevice(location: string): Promise<DialDevice | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Find DIAL devices with SSDP. The search goes out on every IPv4 interface: a host with several of them (docker,
|
|
23
|
+
* VPN) sends a single multicast out of one only.
|
|
24
|
+
*
|
|
25
|
+
* @param timeout how long to wait for answers, in ms
|
|
26
|
+
*/
|
|
27
|
+
export declare function discoverDialDevices(timeout?: number): Promise<DialDevice[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Read the screen ID of the YouTube app from its DIAL description. Many TVs report it only while the app runs or
|
|
30
|
+
* has run since they were switched on.
|
|
31
|
+
*
|
|
32
|
+
* @param appUrl the `appUrl` of the device
|
|
33
|
+
* @returns the screen ID, `null` if the app reports none
|
|
34
|
+
*/
|
|
35
|
+
export declare function getYouTubeScreenId(appUrl: string): Promise<string | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Start the YouTube app over DIAL. Some TVs refuse it (The Frame 2022: 403); then start it in another way.
|
|
38
|
+
*
|
|
39
|
+
* @param appUrl the `appUrl` of the device
|
|
40
|
+
* @returns whether the TV accepted it
|
|
41
|
+
*/
|
|
42
|
+
export declare function launchYouTubeOverDial(appUrl: string): Promise<boolean>;
|
package/build/dial.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readDialDevice = readDialDevice;
|
|
7
|
+
exports.discoverDialDevices = discoverDialDevices;
|
|
8
|
+
exports.getYouTubeScreenId = getYouTubeScreenId;
|
|
9
|
+
exports.launchYouTubeOverDial = launchYouTubeOverDial;
|
|
10
|
+
const node_dgram_1 = __importDefault(require("node:dgram"));
|
|
11
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
12
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
13
|
+
const SSDP_ADDRESS = '239.255.255.250';
|
|
14
|
+
const SSDP_PORT = 1900;
|
|
15
|
+
const DIAL_SERVICE = 'urn:dial-multiscreen-org:service:dial:1';
|
|
16
|
+
function request(url, method = 'GET', timeout = 5000) {
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
const req = node_http_1.default.request(url, { method, timeout, agent: false }, res => {
|
|
19
|
+
let body = '';
|
|
20
|
+
res.setEncoding('utf8');
|
|
21
|
+
res.on('data', (chunk) => (body += chunk));
|
|
22
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body }));
|
|
23
|
+
});
|
|
24
|
+
req.on('timeout', () => req.destroy());
|
|
25
|
+
req.on('error', () => resolve({ status: 0, headers: {}, body: '' }));
|
|
26
|
+
req.end();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function xmlValue(xml, tag) {
|
|
30
|
+
return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim() ?? '';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read the device description of a DIAL device.
|
|
34
|
+
*
|
|
35
|
+
* @param location the `LOCATION` of its SSDP answer
|
|
36
|
+
* @returns the device, `null` if it has no DIAL `Application-URL`
|
|
37
|
+
*/
|
|
38
|
+
async function readDialDevice(location) {
|
|
39
|
+
const { status, headers, body } = await request(location);
|
|
40
|
+
const appUrl = headers['application-url'];
|
|
41
|
+
if (status !== 200 || typeof appUrl !== 'string' || !appUrl) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ip: new URL(location).hostname,
|
|
46
|
+
name: xmlValue(body, 'friendlyName'),
|
|
47
|
+
manufacturer: xmlValue(body, 'manufacturer'),
|
|
48
|
+
model: xmlValue(body, 'modelName'),
|
|
49
|
+
udn: xmlValue(body, 'UDN'),
|
|
50
|
+
appUrl: appUrl.endsWith('/') ? appUrl : `${appUrl}/`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Find DIAL devices with SSDP. The search goes out on every IPv4 interface: a host with several of them (docker,
|
|
55
|
+
* VPN) sends a single multicast out of one only.
|
|
56
|
+
*
|
|
57
|
+
* @param timeout how long to wait for answers, in ms
|
|
58
|
+
*/
|
|
59
|
+
async function discoverDialDevices(timeout = 4000) {
|
|
60
|
+
const message = Buffer.from(`M-SEARCH * HTTP/1.1\r\nHOST: ${SSDP_ADDRESS}:${SSDP_PORT}\r\nMAN: "ssdp:discover"\r\nMX: 2\r\nST: ${DIAL_SERVICE}\r\n\r\n`);
|
|
61
|
+
const addresses = Object.values(node_os_1.default.networkInterfaces())
|
|
62
|
+
.flat()
|
|
63
|
+
.filter(entry => entry && entry.family === 'IPv4' && !entry.internal)
|
|
64
|
+
.map(entry => entry.address);
|
|
65
|
+
const locations = new Set();
|
|
66
|
+
await Promise.all((addresses.length ? addresses : ['0.0.0.0']).map(address => new Promise(resolve => {
|
|
67
|
+
const socket = node_dgram_1.default.createSocket({ type: 'udp4', reuseAddr: true });
|
|
68
|
+
socket.on('message', answer => {
|
|
69
|
+
const location = /^location:\s*(\S+)/im.exec(answer.toString('utf8'))?.[1];
|
|
70
|
+
if (location) {
|
|
71
|
+
locations.add(location);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
socket.on('error', () => resolve());
|
|
75
|
+
socket.bind(0, address, () => {
|
|
76
|
+
try {
|
|
77
|
+
socket.setMulticastInterface(address);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// the default interface then
|
|
81
|
+
}
|
|
82
|
+
socket.send(message, SSDP_PORT, SSDP_ADDRESS);
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
socket.close();
|
|
85
|
+
resolve();
|
|
86
|
+
}, timeout);
|
|
87
|
+
});
|
|
88
|
+
})));
|
|
89
|
+
const devices = await Promise.all([...locations].map(location => readDialDevice(location)));
|
|
90
|
+
const byUdn = new Map();
|
|
91
|
+
for (const device of devices) {
|
|
92
|
+
if (device) {
|
|
93
|
+
byUdn.set(device.udn || device.ip, device);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...byUdn.values()];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read the screen ID of the YouTube app from its DIAL description. Many TVs report it only while the app runs or
|
|
100
|
+
* has run since they were switched on.
|
|
101
|
+
*
|
|
102
|
+
* @param appUrl the `appUrl` of the device
|
|
103
|
+
* @returns the screen ID, `null` if the app reports none
|
|
104
|
+
*/
|
|
105
|
+
async function getYouTubeScreenId(appUrl) {
|
|
106
|
+
const { status, body } = await request(`${appUrl}YouTube`);
|
|
107
|
+
return status === 200 ? xmlValue(body, 'screenId') || null : null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Start the YouTube app over DIAL. Some TVs refuse it (The Frame 2022: 403); then start it in another way.
|
|
111
|
+
*
|
|
112
|
+
* @param appUrl the `appUrl` of the device
|
|
113
|
+
* @returns whether the TV accepted it
|
|
114
|
+
*/
|
|
115
|
+
async function launchYouTubeOverDial(appUrl) {
|
|
116
|
+
const { status } = await request(`${appUrl}YouTube`, 'POST');
|
|
117
|
+
return status >= 200 && status < 300;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=dial.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dial.js","sourceRoot":"","sources":["../src/dial.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** An event of the lounge channel: `[id, [type, ...args]]` */
|
|
2
|
+
export type LoungeEvent = [number, [string, ...unknown[]]];
|
|
3
|
+
/**
|
|
4
|
+
* Splits the chunked answer of bc/bind (`<length>\n<json>\n<length>\n<json>…`) into events. The length counts
|
|
5
|
+
* bytes, including the new line after the JSON. Data may arrive in pieces, as on the long poll; a chunk is
|
|
6
|
+
* reported once it is complete.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ChunkParser {
|
|
9
|
+
private buffer;
|
|
10
|
+
/** Add received data; returns the events of all chunks completed by it */
|
|
11
|
+
push(data: Buffer | Uint8Array | string): LoungeEvent[];
|
|
12
|
+
}
|
|
13
|
+
/** All events of a complete answer */
|
|
14
|
+
export declare function parseBindChunks(data: Buffer | string): LoungeEvent[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits the chunked answer of bc/bind (`<length>\n<json>\n<length>\n<json>…`) into events. The length counts
|
|
3
|
+
* bytes, including the new line after the JSON. Data may arrive in pieces, as on the long poll; a chunk is
|
|
4
|
+
* reported once it is complete.
|
|
5
|
+
*/
|
|
6
|
+
export class ChunkParser {
|
|
7
|
+
buffer = Buffer.alloc(0);
|
|
8
|
+
/** Add received data; returns the events of all chunks completed by it */
|
|
9
|
+
push(data) {
|
|
10
|
+
this.buffer = Buffer.concat([this.buffer, typeof data === 'string' ? Buffer.from(data, 'utf8') : data]);
|
|
11
|
+
const events = [];
|
|
12
|
+
for (;;) {
|
|
13
|
+
const newLine = this.buffer.indexOf(0x0a);
|
|
14
|
+
if (newLine < 0) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
const header = this.buffer.subarray(0, newLine).toString('ascii').trim();
|
|
18
|
+
if (!/^\d+$/.test(header)) {
|
|
19
|
+
// not a length: skip the line
|
|
20
|
+
this.buffer = this.buffer.subarray(newLine + 1);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const length = Number(header);
|
|
24
|
+
const start = newLine + 1;
|
|
25
|
+
if (this.buffer.length < start + length) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const chunk = this.buffer.subarray(start, start + length).toString('utf8');
|
|
29
|
+
this.buffer = this.buffer.subarray(start + length);
|
|
30
|
+
try {
|
|
31
|
+
events.push(...JSON.parse(chunk));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// not JSON: ignored
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return events;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** All events of a complete answer */
|
|
41
|
+
export function parseBindChunks(data) {
|
|
42
|
+
return new ChunkParser().push(data);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=chunks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunks.js","sourceRoot":"","sources":["../../src/chunks.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,MAAM,OAAO,WAAW;IACZ,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEjC,0EAA0E;IAC1E,IAAI,CAAC,IAAkC;QACnC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACxG,MAAM,MAAM,GAAkB,EAAE,CAAC;QACjC,SAAS,CAAC;YACN,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC1C,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;gBACd,MAAM;YACV,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;YACzE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,8BAA8B;gBAC9B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC;gBAChD,SAAS;YACb,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;YAC9B,MAAM,KAAK,GAAG,OAAO,GAAG,CAAC,CAAC;YAC1B,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,KAAK,GAAG,MAAM,EAAE,CAAC;gBACtC,MAAM;YACV,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YAC3E,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC;YACnD,IAAI,CAAC;gBACD,MAAM,CAAC,IAAI,CAAC,GAAI,IAAI,CAAC,KAAK,CAAC,KAAK,CAAmB,CAAC,CAAC;YACzD,CAAC;YAAC,MAAM,CAAC;gBACL,oBAAoB;YACxB,CAAC;QACL,CAAC;QACD,OAAO,MAAM,CAAC;IAClB,CAAC;CACJ;AAED,sCAAsC;AACtC,MAAM,UAAU,eAAe,CAAC,IAAqB;IACjD,OAAO,IAAI,WAAW,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A device with a DIAL server, e.g. a TV with the YouTube app */
|
|
2
|
+
export interface DialDevice {
|
|
3
|
+
/** Address of the device */
|
|
4
|
+
ip: string;
|
|
5
|
+
/** Name from the device description */
|
|
6
|
+
name: string;
|
|
7
|
+
manufacturer: string;
|
|
8
|
+
model: string;
|
|
9
|
+
/** UDN of the device description, a stable ID */
|
|
10
|
+
udn: string;
|
|
11
|
+
/** Base URL of the DIAL apps (`Application-URL`), e.g. `http://192.168.1.147:8080/ws/app/` */
|
|
12
|
+
appUrl: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Read the device description of a DIAL device.
|
|
16
|
+
*
|
|
17
|
+
* @param location the `LOCATION` of its SSDP answer
|
|
18
|
+
* @returns the device, `null` if it has no DIAL `Application-URL`
|
|
19
|
+
*/
|
|
20
|
+
export declare function readDialDevice(location: string): Promise<DialDevice | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Find DIAL devices with SSDP. The search goes out on every IPv4 interface: a host with several of them (docker,
|
|
23
|
+
* VPN) sends a single multicast out of one only.
|
|
24
|
+
*
|
|
25
|
+
* @param timeout how long to wait for answers, in ms
|
|
26
|
+
*/
|
|
27
|
+
export declare function discoverDialDevices(timeout?: number): Promise<DialDevice[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Read the screen ID of the YouTube app from its DIAL description. Many TVs report it only while the app runs or
|
|
30
|
+
* has run since they were switched on.
|
|
31
|
+
*
|
|
32
|
+
* @param appUrl the `appUrl` of the device
|
|
33
|
+
* @returns the screen ID, `null` if the app reports none
|
|
34
|
+
*/
|
|
35
|
+
export declare function getYouTubeScreenId(appUrl: string): Promise<string | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Start the YouTube app over DIAL. Some TVs refuse it (The Frame 2022: 403); then start it in another way.
|
|
38
|
+
*
|
|
39
|
+
* @param appUrl the `appUrl` of the device
|
|
40
|
+
* @returns whether the TV accepted it
|
|
41
|
+
*/
|
|
42
|
+
export declare function launchYouTubeOverDial(appUrl: string): Promise<boolean>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import dgram from 'node:dgram';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
const SSDP_ADDRESS = '239.255.255.250';
|
|
5
|
+
const SSDP_PORT = 1900;
|
|
6
|
+
const DIAL_SERVICE = 'urn:dial-multiscreen-org:service:dial:1';
|
|
7
|
+
function request(url, method = 'GET', timeout = 5000) {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
const req = http.request(url, { method, timeout, agent: false }, res => {
|
|
10
|
+
let body = '';
|
|
11
|
+
res.setEncoding('utf8');
|
|
12
|
+
res.on('data', (chunk) => (body += chunk));
|
|
13
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body }));
|
|
14
|
+
});
|
|
15
|
+
req.on('timeout', () => req.destroy());
|
|
16
|
+
req.on('error', () => resolve({ status: 0, headers: {}, body: '' }));
|
|
17
|
+
req.end();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function xmlValue(xml, tag) {
|
|
21
|
+
return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1]?.trim() ?? '';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read the device description of a DIAL device.
|
|
25
|
+
*
|
|
26
|
+
* @param location the `LOCATION` of its SSDP answer
|
|
27
|
+
* @returns the device, `null` if it has no DIAL `Application-URL`
|
|
28
|
+
*/
|
|
29
|
+
export async function readDialDevice(location) {
|
|
30
|
+
const { status, headers, body } = await request(location);
|
|
31
|
+
const appUrl = headers['application-url'];
|
|
32
|
+
if (status !== 200 || typeof appUrl !== 'string' || !appUrl) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
ip: new URL(location).hostname,
|
|
37
|
+
name: xmlValue(body, 'friendlyName'),
|
|
38
|
+
manufacturer: xmlValue(body, 'manufacturer'),
|
|
39
|
+
model: xmlValue(body, 'modelName'),
|
|
40
|
+
udn: xmlValue(body, 'UDN'),
|
|
41
|
+
appUrl: appUrl.endsWith('/') ? appUrl : `${appUrl}/`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Find DIAL devices with SSDP. The search goes out on every IPv4 interface: a host with several of them (docker,
|
|
46
|
+
* VPN) sends a single multicast out of one only.
|
|
47
|
+
*
|
|
48
|
+
* @param timeout how long to wait for answers, in ms
|
|
49
|
+
*/
|
|
50
|
+
export async function discoverDialDevices(timeout = 4000) {
|
|
51
|
+
const message = Buffer.from(`M-SEARCH * HTTP/1.1\r\nHOST: ${SSDP_ADDRESS}:${SSDP_PORT}\r\nMAN: "ssdp:discover"\r\nMX: 2\r\nST: ${DIAL_SERVICE}\r\n\r\n`);
|
|
52
|
+
const addresses = Object.values(os.networkInterfaces())
|
|
53
|
+
.flat()
|
|
54
|
+
.filter(entry => entry && entry.family === 'IPv4' && !entry.internal)
|
|
55
|
+
.map(entry => entry.address);
|
|
56
|
+
const locations = new Set();
|
|
57
|
+
await Promise.all((addresses.length ? addresses : ['0.0.0.0']).map(address => new Promise(resolve => {
|
|
58
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
59
|
+
socket.on('message', answer => {
|
|
60
|
+
const location = /^location:\s*(\S+)/im.exec(answer.toString('utf8'))?.[1];
|
|
61
|
+
if (location) {
|
|
62
|
+
locations.add(location);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
socket.on('error', () => resolve());
|
|
66
|
+
socket.bind(0, address, () => {
|
|
67
|
+
try {
|
|
68
|
+
socket.setMulticastInterface(address);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// the default interface then
|
|
72
|
+
}
|
|
73
|
+
socket.send(message, SSDP_PORT, SSDP_ADDRESS);
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
socket.close();
|
|
76
|
+
resolve();
|
|
77
|
+
}, timeout);
|
|
78
|
+
});
|
|
79
|
+
})));
|
|
80
|
+
const devices = await Promise.all([...locations].map(location => readDialDevice(location)));
|
|
81
|
+
const byUdn = new Map();
|
|
82
|
+
for (const device of devices) {
|
|
83
|
+
if (device) {
|
|
84
|
+
byUdn.set(device.udn || device.ip, device);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...byUdn.values()];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read the screen ID of the YouTube app from its DIAL description. Many TVs report it only while the app runs or
|
|
91
|
+
* has run since they were switched on.
|
|
92
|
+
*
|
|
93
|
+
* @param appUrl the `appUrl` of the device
|
|
94
|
+
* @returns the screen ID, `null` if the app reports none
|
|
95
|
+
*/
|
|
96
|
+
export async function getYouTubeScreenId(appUrl) {
|
|
97
|
+
const { status, body } = await request(`${appUrl}YouTube`);
|
|
98
|
+
return status === 200 ? xmlValue(body, 'screenId') || null : null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Start the YouTube app over DIAL. Some TVs refuse it (The Frame 2022: 403); then start it in another way.
|
|
102
|
+
*
|
|
103
|
+
* @param appUrl the `appUrl` of the device
|
|
104
|
+
* @returns whether the TV accepted it
|
|
105
|
+
*/
|
|
106
|
+
export async function launchYouTubeOverDial(appUrl) {
|
|
107
|
+
const { status } = await request(`${appUrl}YouTube`, 'POST');
|
|
108
|
+
return status >= 200 && status < 300;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=dial.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dial.js","sourceRoot":"","sources":["../../src/dial.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ChunkParser, parseBindChunks, type LoungeEvent } from './chunks.js';
|
|
2
|
+
export { discoverDialDevices, getYouTubeScreenId, launchYouTubeOverDial, readDialDevice, type DialDevice, } from './dial.js';
|
|
3
|
+
export { PlayerState, YouTubeLounge, type LoungeOptions, type LoungeScreen, type NowPlaying } from './lounge.js';
|
|
4
|
+
export { parseYouTubeTarget, uploadsPlaylistId, type PlayerCommand, type YouTubeTarget } from './target.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ChunkParser, parseBindChunks } from './chunks.js';
|
|
2
|
+
export { discoverDialDevices, getYouTubeScreenId, launchYouTubeOverDial, readDialDevice, } from './dial.js';
|
|
3
|
+
export { PlayerState, YouTubeLounge } from './lounge.js';
|
|
4
|
+
export { parseYouTubeTarget, uploadsPlaylistId } from './target.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,eAAe,EAAoB,MAAM,aAAa,CAAC;AAC7E,OAAO,EACH,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EACrB,cAAc,GAEjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,WAAW,EAAE,aAAa,EAA0D,MAAM,aAAa,CAAC;AACjH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAA0C,MAAM,aAAa,CAAC"}
|