@nickname4th/pura-cli 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 +224 -0
- package/dist/client/assets/index-0nO8CWi5.css +1 -0
- package/dist/client/assets/index-BI4BBLE6.js +12 -0
- package/dist/client/index.html +13 -0
- package/package.json +69 -0
- package/server/dist/adb.js +183 -0
- package/server/dist/agent.js +179 -0
- package/server/dist/cli.js +286 -0
- package/server/dist/config.js +17 -0
- package/server/dist/device-id.js +13 -0
- package/server/dist/hub.js +287 -0
- package/server/dist/index.js +67 -0
- package/server/dist/network.js +20 -0
- package/server/dist/presence.js +92 -0
- package/server/dist/registry.js +61 -0
- package/server/dist/screenshots.js +59 -0
- package/server/dist/sessions.js +229 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
export function getLanAddress() {
|
|
3
|
+
for (const interfaces of Object.values(os.networkInterfaces())) {
|
|
4
|
+
for (const item of interfaces ?? []) {
|
|
5
|
+
if (item.family === "IPv4" && !item.internal) {
|
|
6
|
+
return item.address;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return "127.0.0.1";
|
|
11
|
+
}
|
|
12
|
+
export function normalizeHttpUrl(input) {
|
|
13
|
+
const value = input.trim();
|
|
14
|
+
if (!value)
|
|
15
|
+
throw new Error("Missing URL");
|
|
16
|
+
return /^https?:\/\//i.test(value) ? value.replace(/\/$/, "") : `http://${value.replace(/\/$/, "")}`;
|
|
17
|
+
}
|
|
18
|
+
export function httpToWs(input) {
|
|
19
|
+
return input.replace(/^http:/i, "ws:").replace(/^https:/i, "wss:");
|
|
20
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
const rooms = new Map();
|
|
3
|
+
export function attachPresenceClient(roomId, socket) {
|
|
4
|
+
const client = {
|
|
5
|
+
id: "",
|
|
6
|
+
name: "Viewer",
|
|
7
|
+
color: "#d6ff59",
|
|
8
|
+
socket
|
|
9
|
+
};
|
|
10
|
+
const room = rooms.get(roomId) ?? new Set();
|
|
11
|
+
room.add(client);
|
|
12
|
+
rooms.set(roomId, room);
|
|
13
|
+
socket.on("message", (data) => {
|
|
14
|
+
const message = parseCursorMessage(data);
|
|
15
|
+
if (!message)
|
|
16
|
+
return;
|
|
17
|
+
client.id = message.clientId ?? client.id;
|
|
18
|
+
client.name = (message.name ?? client.name).slice(0, 32);
|
|
19
|
+
client.color = normalizeColor(message.color ?? client.color);
|
|
20
|
+
if (message.type === "leave") {
|
|
21
|
+
broadcast(room, client, { type: "leave", clientId: client.id });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (message.type === "clear") {
|
|
25
|
+
broadcast(room, client, { type: "clear", clientId: client.id });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (message.type === "annotation") {
|
|
29
|
+
broadcast(room, client, {
|
|
30
|
+
type: "annotation",
|
|
31
|
+
clientId: client.id,
|
|
32
|
+
name: client.name,
|
|
33
|
+
color: client.color,
|
|
34
|
+
annotation: message.annotation && typeof message.annotation === "object"
|
|
35
|
+
? {
|
|
36
|
+
...message.annotation,
|
|
37
|
+
name: client.name,
|
|
38
|
+
color: client.color
|
|
39
|
+
}
|
|
40
|
+
: message.annotation,
|
|
41
|
+
seenAt: Date.now()
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (message.type === "cursor") {
|
|
46
|
+
const xRatio = clamp(Number(message.xRatio), 0, 1);
|
|
47
|
+
const yRatio = clamp(Number(message.yRatio), 0, 1);
|
|
48
|
+
broadcast(room, client, {
|
|
49
|
+
type: "cursor",
|
|
50
|
+
clientId: client.id,
|
|
51
|
+
name: client.name,
|
|
52
|
+
color: client.color,
|
|
53
|
+
xRatio,
|
|
54
|
+
yRatio,
|
|
55
|
+
seenAt: Date.now()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
socket.on("close", () => {
|
|
60
|
+
room.delete(client);
|
|
61
|
+
broadcast(room, client, { type: "leave", clientId: client.id });
|
|
62
|
+
if (room.size === 0)
|
|
63
|
+
rooms.delete(roomId);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function broadcast(room, sender, payload) {
|
|
67
|
+
const text = JSON.stringify(payload);
|
|
68
|
+
for (const client of room) {
|
|
69
|
+
if (client !== sender && client.socket.readyState === WebSocket.OPEN) {
|
|
70
|
+
client.socket.send(text);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function parseCursorMessage(data) {
|
|
75
|
+
try {
|
|
76
|
+
const message = JSON.parse(data.toString());
|
|
77
|
+
if (!["cursor", "leave", "annotation", "clear"].includes(message.type))
|
|
78
|
+
return null;
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function normalizeColor(value) {
|
|
86
|
+
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#d6ff59";
|
|
87
|
+
}
|
|
88
|
+
function clamp(value, min, max) {
|
|
89
|
+
if (!Number.isFinite(value))
|
|
90
|
+
return min;
|
|
91
|
+
return Math.max(min, Math.min(max, value));
|
|
92
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const dataDir = path.resolve(process.env.DATA_DIR ?? "data");
|
|
4
|
+
const registryPath = path.join(dataDir, "devices.json");
|
|
5
|
+
export function getPublication(serial) {
|
|
6
|
+
return readRegistry().publications[serial];
|
|
7
|
+
}
|
|
8
|
+
export function getPublications() {
|
|
9
|
+
return readRegistry().publications;
|
|
10
|
+
}
|
|
11
|
+
export function publishDevice(serial, input) {
|
|
12
|
+
const registry = readRegistry();
|
|
13
|
+
const current = registry.publications[serial];
|
|
14
|
+
const label = clean(input.label) || current?.label || serial;
|
|
15
|
+
const publication = {
|
|
16
|
+
serial,
|
|
17
|
+
label,
|
|
18
|
+
owner: clean(input.owner) || undefined,
|
|
19
|
+
note: clean(input.note) || undefined,
|
|
20
|
+
published: true,
|
|
21
|
+
updatedAt: new Date().toISOString()
|
|
22
|
+
};
|
|
23
|
+
registry.publications[serial] = publication;
|
|
24
|
+
writeRegistry(registry);
|
|
25
|
+
return publication;
|
|
26
|
+
}
|
|
27
|
+
export function unpublishDevice(serial) {
|
|
28
|
+
const registry = readRegistry();
|
|
29
|
+
const current = registry.publications[serial];
|
|
30
|
+
if (!current)
|
|
31
|
+
return undefined;
|
|
32
|
+
const publication = {
|
|
33
|
+
...current,
|
|
34
|
+
published: false,
|
|
35
|
+
updatedAt: new Date().toISOString()
|
|
36
|
+
};
|
|
37
|
+
registry.publications[serial] = publication;
|
|
38
|
+
writeRegistry(registry);
|
|
39
|
+
return publication;
|
|
40
|
+
}
|
|
41
|
+
function readRegistry() {
|
|
42
|
+
try {
|
|
43
|
+
const text = fs.readFileSync(registryPath, "utf8");
|
|
44
|
+
const parsed = JSON.parse(text);
|
|
45
|
+
return {
|
|
46
|
+
publications: parsed.publications ?? {}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { publications: {} };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeRegistry(registry) {
|
|
54
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
|
56
|
+
}
|
|
57
|
+
function clean(value) {
|
|
58
|
+
if (typeof value !== "string")
|
|
59
|
+
return "";
|
|
60
|
+
return value.trim().slice(0, 120);
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
const dataDir = path.resolve(process.env.DATA_DIR ?? "data");
|
|
5
|
+
const screenshotsDir = path.join(dataDir, "screenshots");
|
|
6
|
+
const screenshotsIndexPath = path.join(screenshotsDir, "index.json");
|
|
7
|
+
export function installScreenshotRoutes(app) {
|
|
8
|
+
app.get("/api/screenshots/:fileName", (req, res) => {
|
|
9
|
+
const safeName = path.basename(req.params.fileName);
|
|
10
|
+
const filePath = path.join(screenshotsDir, safeName);
|
|
11
|
+
if (!safeName.endsWith(".png") || !fs.existsSync(filePath)) {
|
|
12
|
+
res.status(404).json({ error: "Screenshot not found" });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (req.query.download === "1") {
|
|
16
|
+
res.download(filePath, safeName);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
res.setHeader("Content-Type", "image/png");
|
|
20
|
+
res.setHeader("Cache-Control", "private, max-age=86400");
|
|
21
|
+
fs.createReadStream(filePath).pipe(res);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function listDeviceScreenshots(deviceSerial) {
|
|
25
|
+
return readIndex().screenshots.filter((screenshot) => screenshot.deviceSerial === deviceSerial).slice(0, 24);
|
|
26
|
+
}
|
|
27
|
+
export async function saveScreenshot(image, deviceSerial) {
|
|
28
|
+
await fs.promises.mkdir(screenshotsDir, { recursive: true });
|
|
29
|
+
const createdAt = new Date().toISOString();
|
|
30
|
+
const safeLabel = deviceSerial.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 48) || "device";
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
const fileName = `${createdAt.replace(/[:.]/g, "-")}-${safeLabel}-${id.slice(0, 8)}.png`;
|
|
33
|
+
const filePath = path.join(screenshotsDir, fileName);
|
|
34
|
+
await fs.promises.writeFile(filePath, image);
|
|
35
|
+
const screenshot = {
|
|
36
|
+
id,
|
|
37
|
+
deviceSerial,
|
|
38
|
+
fileName,
|
|
39
|
+
url: `/api/screenshots/${encodeURIComponent(fileName)}`,
|
|
40
|
+
downloadUrl: `/api/screenshots/${encodeURIComponent(fileName)}?download=1`,
|
|
41
|
+
createdAt,
|
|
42
|
+
sizeBytes: image.length
|
|
43
|
+
};
|
|
44
|
+
const index = readIndex();
|
|
45
|
+
index.screenshots = [screenshot, ...index.screenshots.filter((item) => item.id !== id)].slice(0, 500);
|
|
46
|
+
await fs.promises.writeFile(screenshotsIndexPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
47
|
+
return screenshot;
|
|
48
|
+
}
|
|
49
|
+
function readIndex() {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(fs.readFileSync(screenshotsIndexPath, "utf8"));
|
|
52
|
+
return {
|
|
53
|
+
screenshots: Array.isArray(parsed.screenshots) ? parsed.screenshots : []
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { screenshots: [] };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { adbCommand } from "./adb.js";
|
|
5
|
+
const sessions = new Map();
|
|
6
|
+
const sessionsBySerial = new Map();
|
|
7
|
+
const STREAM_SIZE = process.env.STREAM_SIZE;
|
|
8
|
+
const STREAM_BITRATE = process.env.STREAM_BITRATE ?? "8000000";
|
|
9
|
+
const STREAM_TIME_LIMIT_SECONDS = process.env.STREAM_TIME_LIMIT_SECONDS ?? "180";
|
|
10
|
+
export function getOrCreateSession(serial, options) {
|
|
11
|
+
const existingId = sessionsBySerial.get(serial);
|
|
12
|
+
if (existingId) {
|
|
13
|
+
const existing = sessions.get(existingId);
|
|
14
|
+
if (existing && !options?.restart)
|
|
15
|
+
return toPublicSession(existing);
|
|
16
|
+
if (existing)
|
|
17
|
+
cleanupSession(existing);
|
|
18
|
+
}
|
|
19
|
+
const session = {
|
|
20
|
+
id: randomUUID(),
|
|
21
|
+
serial,
|
|
22
|
+
clients: new Set(),
|
|
23
|
+
h264Config: [],
|
|
24
|
+
h264Replay: []
|
|
25
|
+
};
|
|
26
|
+
sessions.set(session.id, session);
|
|
27
|
+
sessionsBySerial.set(serial, session.id);
|
|
28
|
+
startStream(session);
|
|
29
|
+
return toPublicSession(session);
|
|
30
|
+
}
|
|
31
|
+
export function getSession(id) {
|
|
32
|
+
return sessions.get(id);
|
|
33
|
+
}
|
|
34
|
+
export function deleteSession(id) {
|
|
35
|
+
const session = sessions.get(id);
|
|
36
|
+
if (!session)
|
|
37
|
+
return false;
|
|
38
|
+
cleanupSession(session);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
export function attachClient(id, socket) {
|
|
42
|
+
const session = sessions.get(id);
|
|
43
|
+
if (!session) {
|
|
44
|
+
socket.close(1008, "session not found");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
session.clients.add(socket);
|
|
48
|
+
if (session.stopTimer) {
|
|
49
|
+
clearTimeout(session.stopTimer);
|
|
50
|
+
session.stopTimer = undefined;
|
|
51
|
+
}
|
|
52
|
+
if (!session.process) {
|
|
53
|
+
startStream(session);
|
|
54
|
+
}
|
|
55
|
+
sendReplay(session, socket);
|
|
56
|
+
socket.on("close", () => {
|
|
57
|
+
session.clients.delete(socket);
|
|
58
|
+
scheduleStopIfIdle(session);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function listSessions() {
|
|
62
|
+
return [...sessions.values()].map(toPublicSession);
|
|
63
|
+
}
|
|
64
|
+
function startStream(session) {
|
|
65
|
+
if (session.process || session.restartTimer)
|
|
66
|
+
return;
|
|
67
|
+
const args = [
|
|
68
|
+
"-s",
|
|
69
|
+
session.serial,
|
|
70
|
+
"exec-out",
|
|
71
|
+
"screenrecord",
|
|
72
|
+
"--output-format=h264",
|
|
73
|
+
"--bit-rate",
|
|
74
|
+
STREAM_BITRATE,
|
|
75
|
+
"--time-limit",
|
|
76
|
+
STREAM_TIME_LIMIT_SECONDS,
|
|
77
|
+
"-"
|
|
78
|
+
];
|
|
79
|
+
if (STREAM_SIZE) {
|
|
80
|
+
args.splice(6, 0, "--size", STREAM_SIZE);
|
|
81
|
+
}
|
|
82
|
+
const adb = adbCommand(args);
|
|
83
|
+
const child = spawn(adb.command, adb.args, {
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
85
|
+
});
|
|
86
|
+
session.process = child;
|
|
87
|
+
session.startedAt = Date.now();
|
|
88
|
+
session.lastError = undefined;
|
|
89
|
+
child.stdout.on("data", (chunk) => {
|
|
90
|
+
recordReplay(session, chunk);
|
|
91
|
+
for (const client of session.clients) {
|
|
92
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
93
|
+
client.send(chunk, { binary: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
child.stderr.on("data", (chunk) => {
|
|
98
|
+
const text = chunk.toString("utf8").trim();
|
|
99
|
+
if (text)
|
|
100
|
+
session.lastError = text.slice(-800);
|
|
101
|
+
});
|
|
102
|
+
child.on("error", (error) => {
|
|
103
|
+
session.lastError = error.message;
|
|
104
|
+
});
|
|
105
|
+
child.on("close", () => {
|
|
106
|
+
session.process = undefined;
|
|
107
|
+
if (session.clients.size > 0) {
|
|
108
|
+
session.restartTimer = setTimeout(() => {
|
|
109
|
+
session.restartTimer = undefined;
|
|
110
|
+
startStream(session);
|
|
111
|
+
}, 600);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
scheduleStopIfIdle(session);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function scheduleStopIfIdle(session) {
|
|
119
|
+
if (session.clients.size > 0 || session.stopTimer)
|
|
120
|
+
return;
|
|
121
|
+
session.stopTimer = setTimeout(() => {
|
|
122
|
+
if (session.clients.size === 0)
|
|
123
|
+
cleanupSession(session);
|
|
124
|
+
}, 5000);
|
|
125
|
+
}
|
|
126
|
+
function cleanupSession(session) {
|
|
127
|
+
if (session.restartTimer)
|
|
128
|
+
clearTimeout(session.restartTimer);
|
|
129
|
+
if (session.stopTimer)
|
|
130
|
+
clearTimeout(session.stopTimer);
|
|
131
|
+
if (session.process && !session.process.killed) {
|
|
132
|
+
session.process.kill("SIGTERM");
|
|
133
|
+
}
|
|
134
|
+
for (const client of session.clients) {
|
|
135
|
+
client.close(1001, "session ended");
|
|
136
|
+
}
|
|
137
|
+
sessions.delete(session.id);
|
|
138
|
+
sessionsBySerial.delete(session.serial);
|
|
139
|
+
}
|
|
140
|
+
function sendReplay(session, socket) {
|
|
141
|
+
for (const chunk of session.h264Replay) {
|
|
142
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
143
|
+
socket.send(chunk, { binary: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function recordReplay(session, chunk) {
|
|
148
|
+
const data = session.h264Pending ? Buffer.concat([session.h264Pending, chunk]) : chunk;
|
|
149
|
+
const starts = findStartCodes(data);
|
|
150
|
+
if (starts.length < 2) {
|
|
151
|
+
session.h264Pending = starts.length === 1 ? data.subarray(starts[0]) : data.subarray(Math.max(0, data.length - 4));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (let index = 0; index < starts.length - 1; index += 1) {
|
|
155
|
+
const start = starts[index];
|
|
156
|
+
const nextStart = starts[index + 1];
|
|
157
|
+
if (nextStart <= start)
|
|
158
|
+
continue;
|
|
159
|
+
recordNalUnit(session, data.subarray(start, nextStart));
|
|
160
|
+
}
|
|
161
|
+
session.h264Pending = data.subarray(starts[starts.length - 1]);
|
|
162
|
+
}
|
|
163
|
+
function recordNalUnit(session, nal) {
|
|
164
|
+
const nalType = getNalType(nal);
|
|
165
|
+
if (!nalType)
|
|
166
|
+
return;
|
|
167
|
+
if (nalType === 7 || nalType === 8) {
|
|
168
|
+
const existingIndex = session.h264Config.findIndex((item) => getNalType(item) === nalType);
|
|
169
|
+
if (existingIndex >= 0) {
|
|
170
|
+
session.h264Config[existingIndex] = Buffer.from(nal);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
session.h264Config.push(Buffer.from(nal));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (nalType === 5) {
|
|
177
|
+
session.h264Replay = [...session.h264Config.map((item) => Buffer.from(item)), Buffer.from(nal)];
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (session.h264Replay.length > 0) {
|
|
181
|
+
session.h264Replay.push(Buffer.from(nal));
|
|
182
|
+
trimReplay(session);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function trimReplay(session) {
|
|
186
|
+
const maxBytes = 4 * 1024 * 1024;
|
|
187
|
+
let total = session.h264Replay.reduce((sum, item) => sum + item.length, 0);
|
|
188
|
+
while (session.h264Replay.length > session.h264Config.length + 1 && total > maxBytes) {
|
|
189
|
+
const removed = session.h264Replay.splice(session.h264Config.length, 1)[0];
|
|
190
|
+
total -= removed.length;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function getNalType(nal) {
|
|
194
|
+
const startCodeLength = nal[2] === 1 ? 3 : nal[3] === 1 ? 4 : 0;
|
|
195
|
+
if (!startCodeLength || nal.length <= startCodeLength)
|
|
196
|
+
return undefined;
|
|
197
|
+
return nal[startCodeLength] & 0x1f;
|
|
198
|
+
}
|
|
199
|
+
function findStartCodes(data) {
|
|
200
|
+
const starts = [];
|
|
201
|
+
for (let index = 0; index < data.length - 3; index += 1) {
|
|
202
|
+
if (data[index] !== 0 || data[index + 1] !== 0)
|
|
203
|
+
continue;
|
|
204
|
+
if (data[index + 2] === 1) {
|
|
205
|
+
starts.push(index);
|
|
206
|
+
index += 2;
|
|
207
|
+
}
|
|
208
|
+
else if (data[index + 2] === 0 && data[index + 3] === 1) {
|
|
209
|
+
starts.push(index);
|
|
210
|
+
index += 3;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return starts;
|
|
214
|
+
}
|
|
215
|
+
function toPublicSession(session) {
|
|
216
|
+
return {
|
|
217
|
+
id: session.id,
|
|
218
|
+
serial: session.serial,
|
|
219
|
+
viewerCount: session.clients.size,
|
|
220
|
+
startedAt: session.startedAt,
|
|
221
|
+
lastError: session.lastError,
|
|
222
|
+
stream: {
|
|
223
|
+
codec: "h264",
|
|
224
|
+
container: "annexb",
|
|
225
|
+
size: STREAM_SIZE ?? "native",
|
|
226
|
+
bitrate: STREAM_BITRATE
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|