pal-explorer-cli 0.4.12 → 0.4.13
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/README.md +149 -149
- package/bin/pal.js +63 -2
- package/extensions/@palexplorer/analytics/extension.json +20 -1
- package/extensions/@palexplorer/analytics/index.js +19 -9
- package/extensions/@palexplorer/audit/extension.json +14 -0
- package/extensions/@palexplorer/auth-email/extension.json +15 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
- package/extensions/@palexplorer/chat/extension.json +14 -0
- package/extensions/@palexplorer/discovery/extension.json +17 -0
- package/extensions/@palexplorer/discovery/index.js +1 -1
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/groups/extension.json +15 -0
- package/extensions/@palexplorer/share-links/extension.json +15 -0
- package/extensions/@palexplorer/sync/extension.json +16 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
- package/lib/capabilities.js +24 -24
- package/lib/commands/analytics.js +175 -175
- package/lib/commands/api-keys.js +131 -131
- package/lib/commands/audit.js +235 -235
- package/lib/commands/auth.js +137 -137
- package/lib/commands/backup.js +76 -76
- package/lib/commands/billing.js +148 -148
- package/lib/commands/chat.js +217 -217
- package/lib/commands/cloud-backup.js +231 -231
- package/lib/commands/comment.js +99 -99
- package/lib/commands/completion.js +203 -203
- package/lib/commands/compliance.js +218 -218
- package/lib/commands/config.js +136 -136
- package/lib/commands/connect.js +44 -44
- package/lib/commands/dept.js +294 -294
- package/lib/commands/device.js +146 -146
- package/lib/commands/download.js +240 -226
- package/lib/commands/explorer.js +178 -178
- package/lib/commands/extension.js +1060 -970
- package/lib/commands/favorite.js +90 -90
- package/lib/commands/federation.js +270 -270
- package/lib/commands/file.js +533 -533
- package/lib/commands/group.js +271 -271
- package/lib/commands/gui-share.js +29 -29
- package/lib/commands/init.js +61 -61
- package/lib/commands/invite.js +59 -59
- package/lib/commands/list.js +58 -58
- package/lib/commands/log.js +116 -116
- package/lib/commands/nearby.js +108 -108
- package/lib/commands/network.js +251 -251
- package/lib/commands/notify.js +198 -198
- package/lib/commands/org.js +273 -273
- package/lib/commands/pal.js +403 -180
- package/lib/commands/permissions.js +216 -216
- package/lib/commands/pin.js +97 -97
- package/lib/commands/protocol.js +357 -357
- package/lib/commands/rbac.js +147 -147
- package/lib/commands/recover.js +36 -36
- package/lib/commands/register.js +171 -171
- package/lib/commands/relay.js +131 -131
- package/lib/commands/remote.js +368 -368
- package/lib/commands/revoke.js +50 -50
- package/lib/commands/scanner.js +280 -280
- package/lib/commands/schedule.js +344 -344
- package/lib/commands/scim.js +203 -203
- package/lib/commands/search.js +181 -181
- package/lib/commands/serve.js +438 -438
- package/lib/commands/server.js +350 -350
- package/lib/commands/share-link.js +199 -199
- package/lib/commands/share.js +336 -323
- package/lib/commands/sso.js +200 -200
- package/lib/commands/status.js +145 -145
- package/lib/commands/stream.js +562 -562
- package/lib/commands/su.js +187 -187
- package/lib/commands/sync.js +979 -979
- package/lib/commands/transfers.js +152 -152
- package/lib/commands/uninstall.js +188 -188
- package/lib/commands/update.js +204 -204
- package/lib/commands/user.js +276 -276
- package/lib/commands/vfs.js +84 -84
- package/lib/commands/web-login.js +79 -79
- package/lib/commands/web.js +52 -52
- package/lib/commands/webhook.js +180 -180
- package/lib/commands/whoami.js +59 -59
- package/lib/commands/workspace.js +121 -121
- package/lib/core/billing.js +16 -5
- package/lib/core/dhtDiscovery.js +9 -2
- package/lib/core/discoveryClient.js +13 -7
- package/lib/core/extensions.js +142 -1
- package/lib/core/identity.js +33 -2
- package/lib/core/imageProcessor.js +109 -0
- package/lib/core/imageTorrent.js +167 -0
- package/lib/core/permissions.js +1 -1
- package/lib/core/pro.js +11 -4
- package/lib/core/serverList.js +4 -1
- package/lib/core/shares.js +12 -1
- package/lib/core/signalingServer.js +14 -2
- package/lib/core/su.js +1 -1
- package/lib/core/users.js +1 -1
- package/lib/protocol/messages.js +12 -3
- package/lib/utils/explorer.js +1 -1
- package/lib/utils/help.js +357 -357
- package/lib/utils/torrent.js +1 -0
- package/package.json +4 -3
package/lib/commands/stream.js
CHANGED
|
@@ -1,562 +1,562 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import http from 'http';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import crypto from 'crypto';
|
|
6
|
-
|
|
7
|
-
let streamToken = null;
|
|
8
|
-
|
|
9
|
-
let streamState = {
|
|
10
|
-
server: null,
|
|
11
|
-
port: null,
|
|
12
|
-
type: null,
|
|
13
|
-
filePath: null,
|
|
14
|
-
fileName: null,
|
|
15
|
-
magnet: null,
|
|
16
|
-
broadcast: null,
|
|
17
|
-
startedAt: null,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
let folderStreamState = {
|
|
21
|
-
server: null,
|
|
22
|
-
port: null,
|
|
23
|
-
folderPath: null,
|
|
24
|
-
files: [],
|
|
25
|
-
startedAt: null,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const MEDIA_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'mp4', 'mkv', 'webm', 'avi', 'mov']);
|
|
29
|
-
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a']);
|
|
30
|
-
|
|
31
|
-
const MIME_TYPES = {
|
|
32
|
-
'.mp4': 'video/mp4',
|
|
33
|
-
'.webm': 'video/webm',
|
|
34
|
-
'.mkv': 'video/x-matroska',
|
|
35
|
-
'.avi': 'video/x-msvideo',
|
|
36
|
-
'.mp3': 'audio/mpeg',
|
|
37
|
-
'.flac': 'audio/flac',
|
|
38
|
-
'.ogg': 'audio/ogg',
|
|
39
|
-
'.wav': 'audio/wav',
|
|
40
|
-
'.m4a': 'audio/mp4',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function getMime(fileName) {
|
|
44
|
-
const ext = path.extname(fileName).toLowerCase();
|
|
45
|
-
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function startLocalServer(filePath) {
|
|
49
|
-
return new Promise((resolve, reject) => {
|
|
50
|
-
streamToken = crypto.randomBytes(16).toString('hex');
|
|
51
|
-
const fileName = path.basename(filePath);
|
|
52
|
-
const server = http.createServer((req, res) => {
|
|
53
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
54
|
-
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
55
|
-
if (reqUrl.searchParams.get('token') !== streamToken) {
|
|
56
|
-
res.writeHead(401);
|
|
57
|
-
res.end('Unauthorized');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (reqUrl.pathname !== '/stream') {
|
|
61
|
-
res.writeHead(404);
|
|
62
|
-
res.end();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let fileSize;
|
|
67
|
-
try { fileSize = fs.statSync(filePath).size; } catch { res.writeHead(404); res.end(); return; }
|
|
68
|
-
|
|
69
|
-
const mime = getMime(fileName);
|
|
70
|
-
const range = req.headers.range;
|
|
71
|
-
if (range) {
|
|
72
|
-
const [startStr, endStr] = range.replace('bytes=', '').split('-');
|
|
73
|
-
const start = parseInt(startStr, 10);
|
|
74
|
-
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
|
75
|
-
res.writeHead(206, {
|
|
76
|
-
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
77
|
-
'Accept-Ranges': 'bytes',
|
|
78
|
-
'Content-Length': end - start + 1,
|
|
79
|
-
'Content-Type': mime,
|
|
80
|
-
});
|
|
81
|
-
fs.createReadStream(filePath, { start, end }).pipe(res);
|
|
82
|
-
} else {
|
|
83
|
-
res.writeHead(200, {
|
|
84
|
-
'Content-Length': fileSize,
|
|
85
|
-
'Content-Type': mime,
|
|
86
|
-
'Accept-Ranges': 'bytes',
|
|
87
|
-
});
|
|
88
|
-
fs.createReadStream(filePath).pipe(res);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
server.listen(0, '127.0.0.1', () => {
|
|
93
|
-
const port = server.address().port;
|
|
94
|
-
resolve({ server, port, token: streamToken });
|
|
95
|
-
});
|
|
96
|
-
server.on('error', reject);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function stopStream() {
|
|
101
|
-
if (streamState.server) {
|
|
102
|
-
streamState.server.close();
|
|
103
|
-
}
|
|
104
|
-
streamState = { server: null, port: null, type: null, filePath: null, fileName: null, magnet: null, broadcast: null, startedAt: null };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function scanFolder(dirPath, maxDepth = 5) {
|
|
108
|
-
const results = [];
|
|
109
|
-
function walk(dir, depth) {
|
|
110
|
-
if (depth > maxDepth || results.length >= 2000) return;
|
|
111
|
-
let entries;
|
|
112
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
113
|
-
for (const entry of entries) {
|
|
114
|
-
if (results.length >= 2000) return;
|
|
115
|
-
const fullPath = path.join(dir, entry.name);
|
|
116
|
-
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
117
|
-
walk(fullPath, depth + 1);
|
|
118
|
-
} else if (entry.isFile()) {
|
|
119
|
-
const ext = entry.name.split('.').pop().toLowerCase();
|
|
120
|
-
if (MEDIA_EXTS.has(ext)) {
|
|
121
|
-
let size = 0;
|
|
122
|
-
try { size = fs.statSync(fullPath).size; } catch {}
|
|
123
|
-
results.push({
|
|
124
|
-
name: entry.name,
|
|
125
|
-
path: fullPath,
|
|
126
|
-
size,
|
|
127
|
-
type: AUDIO_EXTS.has(ext) ? 'audio' : 'video',
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
walk(dirPath, 0);
|
|
134
|
-
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
|
-
return results;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function startFolderServer(folderPath, files) {
|
|
139
|
-
return new Promise((resolve, reject) => {
|
|
140
|
-
streamToken = crypto.randomBytes(16).toString('hex');
|
|
141
|
-
const server = http.createServer((req, res) => {
|
|
142
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
143
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
144
|
-
|
|
145
|
-
if (url.searchParams.get('token') !== streamToken) {
|
|
146
|
-
res.writeHead(401);
|
|
147
|
-
res.end('Unauthorized');
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (url.pathname === '/playlist') {
|
|
152
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
-
res.end(JSON.stringify(files.map(f => ({ name: f.name, path: f.path, size: f.size, type: f.type }))));
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (url.pathname === '/stream') {
|
|
158
|
-
const fileName = url.searchParams.get('file');
|
|
159
|
-
if (!fileName) { res.writeHead(400); res.end('Missing file param'); return; }
|
|
160
|
-
const file = files.find(f => f.name === fileName);
|
|
161
|
-
if (!file) { res.writeHead(404); res.end('File not found'); return; }
|
|
162
|
-
|
|
163
|
-
let fileSize;
|
|
164
|
-
try { fileSize = fs.statSync(file.path).size; } catch { res.writeHead(404); res.end(); return; }
|
|
165
|
-
|
|
166
|
-
const mime = getMime(file.name);
|
|
167
|
-
const range = req.headers.range;
|
|
168
|
-
if (range) {
|
|
169
|
-
const [startStr, endStr] = range.replace('bytes=', '').split('-');
|
|
170
|
-
const start = parseInt(startStr, 10);
|
|
171
|
-
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
|
172
|
-
res.writeHead(206, {
|
|
173
|
-
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
174
|
-
'Accept-Ranges': 'bytes',
|
|
175
|
-
'Content-Length': end - start + 1,
|
|
176
|
-
'Content-Type': mime,
|
|
177
|
-
});
|
|
178
|
-
fs.createReadStream(file.path, { start, end }).pipe(res);
|
|
179
|
-
} else {
|
|
180
|
-
res.writeHead(200, {
|
|
181
|
-
'Content-Length': fileSize,
|
|
182
|
-
'Content-Type': mime,
|
|
183
|
-
'Accept-Ranges': 'bytes',
|
|
184
|
-
});
|
|
185
|
-
fs.createReadStream(file.path).pipe(res);
|
|
186
|
-
}
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
res.writeHead(404);
|
|
191
|
-
res.end();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
server.listen(0, '127.0.0.1', () => {
|
|
195
|
-
resolve({ server, port: server.address().port, token: streamToken });
|
|
196
|
-
});
|
|
197
|
-
server.on('error', reject);
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function stopFolderStream() {
|
|
202
|
-
if (folderStreamState.server) {
|
|
203
|
-
folderStreamState.server.close();
|
|
204
|
-
}
|
|
205
|
-
folderStreamState = { server: null, port: null, folderPath: null, files: [], startedAt: null };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export default function streamCommand(program) {
|
|
209
|
-
const cmd = program
|
|
210
|
-
.command('stream')
|
|
211
|
-
.description('stream media files locally or from the network')
|
|
212
|
-
.addHelpText('after', `
|
|
213
|
-
Examples:
|
|
214
|
-
$
|
|
215
|
-
$
|
|
216
|
-
$
|
|
217
|
-
$
|
|
218
|
-
$
|
|
219
|
-
$
|
|
220
|
-
$
|
|
221
|
-
$
|
|
222
|
-
`)
|
|
223
|
-
.action(() => {
|
|
224
|
-
showStatus();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
cmd
|
|
228
|
-
.command('local <file>')
|
|
229
|
-
.description('stream a local file (starts HTTP server)')
|
|
230
|
-
.action(async (file) => {
|
|
231
|
-
const filePath = path.resolve(file);
|
|
232
|
-
if (!fs.existsSync(filePath)) {
|
|
233
|
-
console.log(chalk.red('File not found.'));
|
|
234
|
-
process.exitCode = 1;
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
stopStream();
|
|
239
|
-
try {
|
|
240
|
-
const { server, port } = await startLocalServer(filePath);
|
|
241
|
-
streamState = {
|
|
242
|
-
server,
|
|
243
|
-
port,
|
|
244
|
-
type: 'local',
|
|
245
|
-
filePath,
|
|
246
|
-
fileName: path.basename(filePath),
|
|
247
|
-
magnet: null,
|
|
248
|
-
broadcast: null,
|
|
249
|
-
startedAt: new Date().toISOString(),
|
|
250
|
-
};
|
|
251
|
-
console.log(chalk.green(`Streaming: ${streamState.fileName}`));
|
|
252
|
-
console.log(` Local: ${chalk.cyan(`http://127.0.0.1:${port}/stream?token=${streamToken}`)}`);
|
|
253
|
-
console.log(chalk.gray(` Token: ${streamToken}`));
|
|
254
|
-
console.log(chalk.gray('Press Ctrl+C to stop.'));
|
|
255
|
-
} catch (err) {
|
|
256
|
-
console.log(chalk.red(`Failed to start stream: ${err.message}`));
|
|
257
|
-
process.exitCode = 1;
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
cmd
|
|
262
|
-
.command('remote <magnet>')
|
|
263
|
-
.description('stream a remote file from magnet link')
|
|
264
|
-
.action(async (magnet) => {
|
|
265
|
-
stopStream();
|
|
266
|
-
try {
|
|
267
|
-
const WebTorrent = (await import('webtorrent')).default;
|
|
268
|
-
const client = new WebTorrent();
|
|
269
|
-
const torrent = await new Promise((resolve, reject) => {
|
|
270
|
-
const t = client.add(magnet);
|
|
271
|
-
t.on('ready', () => resolve(t));
|
|
272
|
-
t.on('error', reject);
|
|
273
|
-
setTimeout(() => reject(new Error('Torrent timeout')), 30000);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
const file = torrent.files[0];
|
|
277
|
-
console.log(chalk.green(`Streaming remote: ${file.name}`));
|
|
278
|
-
console.log(` Size: ${chalk.white((file.length / 1048576).toFixed(1))} MB`);
|
|
279
|
-
|
|
280
|
-
streamState = {
|
|
281
|
-
server: client,
|
|
282
|
-
port: null,
|
|
283
|
-
type: 'remote',
|
|
284
|
-
filePath: null,
|
|
285
|
-
fileName: file.name,
|
|
286
|
-
magnet,
|
|
287
|
-
broadcast: null,
|
|
288
|
-
startedAt: new Date().toISOString(),
|
|
289
|
-
};
|
|
290
|
-
} catch (err) {
|
|
291
|
-
console.log(chalk.red(`Failed to stream: ${err.message}`));
|
|
292
|
-
process.exitCode = 1;
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
cmd
|
|
297
|
-
.command('stop')
|
|
298
|
-
.description('stop the current stream')
|
|
299
|
-
.action(() => {
|
|
300
|
-
if (!streamState.type) {
|
|
301
|
-
console.log(chalk.gray('No active stream.'));
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
const name = streamState.fileName;
|
|
305
|
-
stopStream();
|
|
306
|
-
console.log(chalk.green(`Stream stopped: ${name}`));
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
cmd
|
|
310
|
-
.command('status')
|
|
311
|
-
.description('show current stream status')
|
|
312
|
-
.action(() => {
|
|
313
|
-
showStatus();
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
cmd
|
|
317
|
-
.command('broadcast <file>')
|
|
318
|
-
.description('start a broadcast session for a file')
|
|
319
|
-
.action(async (file) => {
|
|
320
|
-
const filePath = path.resolve(file);
|
|
321
|
-
if (!fs.existsSync(filePath)) {
|
|
322
|
-
console.log(chalk.red('File not found.'));
|
|
323
|
-
process.exitCode = 1;
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
stopStream();
|
|
328
|
-
try {
|
|
329
|
-
const { server, port } = await startLocalServer(filePath);
|
|
330
|
-
streamState = {
|
|
331
|
-
server,
|
|
332
|
-
port,
|
|
333
|
-
type: 'broadcast',
|
|
334
|
-
filePath,
|
|
335
|
-
fileName: path.basename(filePath),
|
|
336
|
-
magnet: null,
|
|
337
|
-
broadcast: { hostStartedAt: new Date().toISOString(), viewers: 0 },
|
|
338
|
-
startedAt: new Date().toISOString(),
|
|
339
|
-
};
|
|
340
|
-
console.log(chalk.green(`Broadcasting: ${streamState.fileName}`));
|
|
341
|
-
console.log(` URL: ${chalk.cyan(`http://127.0.0.1:${port}/stream?token=${streamToken}`)}`);
|
|
342
|
-
console.log(chalk.gray('Share the URL with your pals to let them watch.'));
|
|
343
|
-
} catch (err) {
|
|
344
|
-
console.log(chalk.red(`Failed to start broadcast: ${err.message}`));
|
|
345
|
-
process.exitCode = 1;
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
cmd
|
|
350
|
-
.command('join <magnet>')
|
|
351
|
-
.description('join a broadcast via magnet link')
|
|
352
|
-
.action(async (magnet) => {
|
|
353
|
-
stopStream();
|
|
354
|
-
try {
|
|
355
|
-
const WebTorrent = (await import('webtorrent')).default;
|
|
356
|
-
const client = new WebTorrent();
|
|
357
|
-
const torrent = await new Promise((resolve, reject) => {
|
|
358
|
-
const t = client.add(magnet);
|
|
359
|
-
t.on('ready', () => resolve(t));
|
|
360
|
-
t.on('error', reject);
|
|
361
|
-
setTimeout(() => reject(new Error('Torrent timeout')), 30000);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
const file = torrent.files[0];
|
|
365
|
-
console.log(chalk.green(`Joined broadcast: ${file.name}`));
|
|
366
|
-
console.log(` Peers: ${chalk.white(torrent.numPeers)}`);
|
|
367
|
-
|
|
368
|
-
streamState = {
|
|
369
|
-
server: client,
|
|
370
|
-
port: null,
|
|
371
|
-
type: 'joined-broadcast',
|
|
372
|
-
filePath: null,
|
|
373
|
-
fileName: file.name,
|
|
374
|
-
magnet,
|
|
375
|
-
broadcast: { hostHandle: null },
|
|
376
|
-
startedAt: new Date().toISOString(),
|
|
377
|
-
};
|
|
378
|
-
} catch (err) {
|
|
379
|
-
console.log(chalk.red(`Failed to join broadcast: ${err.message}`));
|
|
380
|
-
process.exitCode = 1;
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
cmd
|
|
385
|
-
.command('transport')
|
|
386
|
-
.description('show or configure stream transport settings')
|
|
387
|
-
.option('--set <type>', 'Set preferred transport: p2p, relay, tunnel')
|
|
388
|
-
.option('--stun-add <url>', 'Add custom STUN server')
|
|
389
|
-
.option('--stun-remove <url>', 'Remove custom STUN server')
|
|
390
|
-
.option('--turn-add <json>', 'Add custom TURN server (JSON: {"urls":"turn:host:3478","username":"u","credential":"p"})')
|
|
391
|
-
.option('--turn-remove <url>', 'Remove custom TURN server by URL')
|
|
392
|
-
.option('--tunnel <url>', 'Set custom tunnel URL (cloudflared, ngrok, etc.)')
|
|
393
|
-
.action(async (opts) => {
|
|
394
|
-
const { getTransportConfig, setTransportConfig } = await import('../core/streamTransport.js');
|
|
395
|
-
|
|
396
|
-
if (opts.set) {
|
|
397
|
-
const valid = ['p2p', 'relay', 'tunnel'];
|
|
398
|
-
if (!valid.includes(opts.set)) {
|
|
399
|
-
console.log(chalk.red(`Invalid transport. Options: ${valid.join(', ')}`));
|
|
400
|
-
process.exitCode = 1;
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
setTransportConfig({ preferred: opts.set });
|
|
404
|
-
console.log(chalk.green(`Transport set to: ${opts.set}`));
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (opts.stunAdd) {
|
|
409
|
-
const tc = getTransportConfig();
|
|
410
|
-
if (!tc.customStunServers.includes(opts.stunAdd)) {
|
|
411
|
-
tc.customStunServers.push(opts.stunAdd);
|
|
412
|
-
setTransportConfig({ customStunServers: tc.customStunServers });
|
|
413
|
-
}
|
|
414
|
-
console.log(chalk.green(`Added STUN: ${opts.stunAdd}`));
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (opts.stunRemove) {
|
|
419
|
-
const tc = getTransportConfig();
|
|
420
|
-
tc.customStunServers = tc.customStunServers.filter(s => s !== opts.stunRemove);
|
|
421
|
-
setTransportConfig({ customStunServers: tc.customStunServers });
|
|
422
|
-
console.log(chalk.green(`Removed STUN: ${opts.stunRemove}`));
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (opts.turnAdd) {
|
|
427
|
-
try {
|
|
428
|
-
const turn = JSON.parse(opts.turnAdd);
|
|
429
|
-
if (!turn.urls) throw new Error('Missing "urls"');
|
|
430
|
-
const tc = getTransportConfig();
|
|
431
|
-
tc.customTurnServers.push(turn);
|
|
432
|
-
setTransportConfig({ customTurnServers: tc.customTurnServers });
|
|
433
|
-
console.log(chalk.green(`Added TURN: ${turn.urls}`));
|
|
434
|
-
} catch (err) {
|
|
435
|
-
console.log(chalk.red(`Invalid JSON: ${err.message}`));
|
|
436
|
-
process.exitCode = 1;
|
|
437
|
-
}
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (opts.turnRemove) {
|
|
442
|
-
const tc = getTransportConfig();
|
|
443
|
-
tc.customTurnServers = tc.customTurnServers.filter(t => t.urls !== opts.turnRemove);
|
|
444
|
-
setTransportConfig({ customTurnServers: tc.customTurnServers });
|
|
445
|
-
console.log(chalk.green(`Removed TURN: ${opts.turnRemove}`));
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (opts.tunnel) {
|
|
450
|
-
setTransportConfig({ tunnelUrl: opts.tunnel });
|
|
451
|
-
console.log(chalk.green(`Tunnel URL set to: ${opts.tunnel}`));
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Show current config
|
|
456
|
-
const tc = getTransportConfig();
|
|
457
|
-
console.log('');
|
|
458
|
-
console.log(chalk.cyan('Stream Transport Config:'));
|
|
459
|
-
console.log(` Preferred: ${chalk.yellow(tc.preferred)}`);
|
|
460
|
-
console.log(` STUN servers (built-in): ${chalk.white(tc.stunServers.join(', ') || 'none')}`);
|
|
461
|
-
console.log(` STUN servers (custom): ${chalk.white(tc.customStunServers.join(', ') || 'none')}`);
|
|
462
|
-
console.log(` TURN servers (custom): ${chalk.white(tc.customTurnServers.map(t => t.urls).join(', ') || 'none')}`);
|
|
463
|
-
console.log(` TURN server (default): ${chalk.gray(tc.turnServer)}`);
|
|
464
|
-
console.log(` Tunnel URL: ${chalk.white(tc.tunnelUrl || 'not set')}`);
|
|
465
|
-
console.log('');
|
|
466
|
-
console.log(chalk.gray(' Transport options:'));
|
|
467
|
-
console.log(chalk.gray(' p2p — WebRTC peer-to-peer (default, best privacy)'));
|
|
468
|
-
console.log(chalk.gray(' relay — Through palexplorer relay server'));
|
|
469
|
-
console.log(chalk.gray(' tunnel — Through your own tunnel (cloudflared, ngrok, etc.)'));
|
|
470
|
-
});
|
|
471
|
-
cmd
|
|
472
|
-
.command('folder <path>')
|
|
473
|
-
.description('serve a folder as a streamable media library')
|
|
474
|
-
.action(async (folderPath) => {
|
|
475
|
-
const resolved = path.resolve(folderPath);
|
|
476
|
-
if (!fs.existsSync(resolved)) {
|
|
477
|
-
console.log(chalk.red('Folder not found.'));
|
|
478
|
-
process.exitCode = 1;
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
const stat = fs.statSync(resolved);
|
|
482
|
-
if (!stat.isDirectory()) {
|
|
483
|
-
console.log(chalk.red('Path is not a directory.'));
|
|
484
|
-
process.exitCode = 1;
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
console.log(chalk.gray('Scanning folder...'));
|
|
489
|
-
const files = scanFolder(resolved);
|
|
490
|
-
if (files.length === 0) {
|
|
491
|
-
console.log(chalk.red('No media files found.'));
|
|
492
|
-
process.exitCode = 1;
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
stopFolderStream();
|
|
497
|
-
try {
|
|
498
|
-
const { server, port } = await startFolderServer(resolved, files);
|
|
499
|
-
folderStreamState = { server, port, folderPath: resolved, files, startedAt: new Date().toISOString() };
|
|
500
|
-
|
|
501
|
-
const audio = files.filter(f => f.type === 'audio').length;
|
|
502
|
-
const video = files.filter(f => f.type === 'video').length;
|
|
503
|
-
console.log(chalk.green(`Serving ${files.length} files (${audio} audio, ${video} video)`));
|
|
504
|
-
console.log(` Playlist: ${chalk.cyan(`http://127.0.0.1:${port}/playlist?token=${streamToken}`)}`);
|
|
505
|
-
console.log(` Stream: ${chalk.cyan(`http://127.0.0.1:${port}/stream?file=<name>&token=${streamToken}`)}`);
|
|
506
|
-
console.log(chalk.gray(` Token: ${streamToken}`));
|
|
507
|
-
console.log(chalk.gray('Press Ctrl+C to stop.'));
|
|
508
|
-
} catch (err) {
|
|
509
|
-
console.log(chalk.red(`Failed to start folder stream: ${err.message}`));
|
|
510
|
-
process.exitCode = 1;
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
cmd
|
|
515
|
-
.command('playlist')
|
|
516
|
-
.description('show current folder stream playlist')
|
|
517
|
-
.action(() => {
|
|
518
|
-
if (!folderStreamState.server) {
|
|
519
|
-
console.log(chalk.gray('No folder stream active.'));
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
console.log('');
|
|
523
|
-
console.log(chalk.cyan(`Folder: ${folderStreamState.folderPath}`));
|
|
524
|
-
console.log(chalk.cyan(`URL: http://127.0.0.1:${folderStreamState.port}/playlist`));
|
|
525
|
-
console.log('');
|
|
526
|
-
folderStreamState.files.forEach((f, i) => {
|
|
527
|
-
const sizeStr = (f.size / 1048576).toFixed(1) + ' MB';
|
|
528
|
-
const icon = f.type === 'audio' ? '♪' : '▶';
|
|
529
|
-
console.log(` ${chalk.gray(String(i + 1).padStart(3))} ${icon} ${chalk.white(f.name)} ${chalk.gray(sizeStr)}`);
|
|
530
|
-
});
|
|
531
|
-
console.log('');
|
|
532
|
-
console.log(chalk.gray(`${folderStreamState.files.length} files | Started ${folderStreamState.startedAt}`));
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function showStatus() {
|
|
537
|
-
if (!streamState.type) {
|
|
538
|
-
console.log(chalk.gray('No active stream.'));
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
console.log('');
|
|
542
|
-
console.log(chalk.cyan('Stream Status:'));
|
|
543
|
-
console.log(` Type: ${chalk.yellow(streamState.type)}`);
|
|
544
|
-
console.log(` File: ${chalk.white(streamState.fileName)}`);
|
|
545
|
-
if (streamState.port) console.log(` URL: ${chalk.cyan(`http://127.0.0.1:${streamState.port}/stream`)}`);
|
|
546
|
-
if (streamState.magnet) console.log(` Magnet: ${chalk.gray(streamState.magnet.slice(0, 60))}...`);
|
|
547
|
-
console.log(` Started: ${chalk.gray(streamState.startedAt)}`);
|
|
548
|
-
if (streamState.broadcast) {
|
|
549
|
-
console.log(` Broadcast: ${chalk.green('Active')}`);
|
|
550
|
-
if (streamState.broadcast.viewers !== undefined) console.log(` Viewers: ${chalk.white(streamState.broadcast.viewers)}`);
|
|
551
|
-
}
|
|
552
|
-
if (folderStreamState.server) {
|
|
553
|
-
console.log('');
|
|
554
|
-
console.log(chalk.cyan('Folder Stream:'));
|
|
555
|
-
console.log(` Folder: ${chalk.white(folderStreamState.folderPath)}`);
|
|
556
|
-
console.log(` Files: ${chalk.white(folderStreamState.files.length)}`);
|
|
557
|
-
console.log(` Playlist: ${chalk.cyan(`http://127.0.0.1:${folderStreamState.port}/playlist`)}`);
|
|
558
|
-
console.log(` Started: ${chalk.gray(folderStreamState.startedAt)}`);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
export { streamState, stopStream, folderStreamState, stopFolderStream, scanFolder };
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
let streamToken = null;
|
|
8
|
+
|
|
9
|
+
let streamState = {
|
|
10
|
+
server: null,
|
|
11
|
+
port: null,
|
|
12
|
+
type: null,
|
|
13
|
+
filePath: null,
|
|
14
|
+
fileName: null,
|
|
15
|
+
magnet: null,
|
|
16
|
+
broadcast: null,
|
|
17
|
+
startedAt: null,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let folderStreamState = {
|
|
21
|
+
server: null,
|
|
22
|
+
port: null,
|
|
23
|
+
folderPath: null,
|
|
24
|
+
files: [],
|
|
25
|
+
startedAt: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MEDIA_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'mp4', 'mkv', 'webm', 'avi', 'mov']);
|
|
29
|
+
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a']);
|
|
30
|
+
|
|
31
|
+
const MIME_TYPES = {
|
|
32
|
+
'.mp4': 'video/mp4',
|
|
33
|
+
'.webm': 'video/webm',
|
|
34
|
+
'.mkv': 'video/x-matroska',
|
|
35
|
+
'.avi': 'video/x-msvideo',
|
|
36
|
+
'.mp3': 'audio/mpeg',
|
|
37
|
+
'.flac': 'audio/flac',
|
|
38
|
+
'.ogg': 'audio/ogg',
|
|
39
|
+
'.wav': 'audio/wav',
|
|
40
|
+
'.m4a': 'audio/mp4',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function getMime(fileName) {
|
|
44
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
45
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function startLocalServer(filePath) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
streamToken = crypto.randomBytes(16).toString('hex');
|
|
51
|
+
const fileName = path.basename(filePath);
|
|
52
|
+
const server = http.createServer((req, res) => {
|
|
53
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
54
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
55
|
+
if (reqUrl.searchParams.get('token') !== streamToken) {
|
|
56
|
+
res.writeHead(401);
|
|
57
|
+
res.end('Unauthorized');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (reqUrl.pathname !== '/stream') {
|
|
61
|
+
res.writeHead(404);
|
|
62
|
+
res.end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let fileSize;
|
|
67
|
+
try { fileSize = fs.statSync(filePath).size; } catch { res.writeHead(404); res.end(); return; }
|
|
68
|
+
|
|
69
|
+
const mime = getMime(fileName);
|
|
70
|
+
const range = req.headers.range;
|
|
71
|
+
if (range) {
|
|
72
|
+
const [startStr, endStr] = range.replace('bytes=', '').split('-');
|
|
73
|
+
const start = parseInt(startStr, 10);
|
|
74
|
+
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
|
75
|
+
res.writeHead(206, {
|
|
76
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
77
|
+
'Accept-Ranges': 'bytes',
|
|
78
|
+
'Content-Length': end - start + 1,
|
|
79
|
+
'Content-Type': mime,
|
|
80
|
+
});
|
|
81
|
+
fs.createReadStream(filePath, { start, end }).pipe(res);
|
|
82
|
+
} else {
|
|
83
|
+
res.writeHead(200, {
|
|
84
|
+
'Content-Length': fileSize,
|
|
85
|
+
'Content-Type': mime,
|
|
86
|
+
'Accept-Ranges': 'bytes',
|
|
87
|
+
});
|
|
88
|
+
fs.createReadStream(filePath).pipe(res);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
server.listen(0, '127.0.0.1', () => {
|
|
93
|
+
const port = server.address().port;
|
|
94
|
+
resolve({ server, port, token: streamToken });
|
|
95
|
+
});
|
|
96
|
+
server.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stopStream() {
|
|
101
|
+
if (streamState.server) {
|
|
102
|
+
streamState.server.close();
|
|
103
|
+
}
|
|
104
|
+
streamState = { server: null, port: null, type: null, filePath: null, fileName: null, magnet: null, broadcast: null, startedAt: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scanFolder(dirPath, maxDepth = 5) {
|
|
108
|
+
const results = [];
|
|
109
|
+
function walk(dir, depth) {
|
|
110
|
+
if (depth > maxDepth || results.length >= 2000) return;
|
|
111
|
+
let entries;
|
|
112
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (results.length >= 2000) return;
|
|
115
|
+
const fullPath = path.join(dir, entry.name);
|
|
116
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
117
|
+
walk(fullPath, depth + 1);
|
|
118
|
+
} else if (entry.isFile()) {
|
|
119
|
+
const ext = entry.name.split('.').pop().toLowerCase();
|
|
120
|
+
if (MEDIA_EXTS.has(ext)) {
|
|
121
|
+
let size = 0;
|
|
122
|
+
try { size = fs.statSync(fullPath).size; } catch {}
|
|
123
|
+
results.push({
|
|
124
|
+
name: entry.name,
|
|
125
|
+
path: fullPath,
|
|
126
|
+
size,
|
|
127
|
+
type: AUDIO_EXTS.has(ext) ? 'audio' : 'video',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
walk(dirPath, 0);
|
|
134
|
+
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function startFolderServer(folderPath, files) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
streamToken = crypto.randomBytes(16).toString('hex');
|
|
141
|
+
const server = http.createServer((req, res) => {
|
|
142
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
143
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
144
|
+
|
|
145
|
+
if (url.searchParams.get('token') !== streamToken) {
|
|
146
|
+
res.writeHead(401);
|
|
147
|
+
res.end('Unauthorized');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (url.pathname === '/playlist') {
|
|
152
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
+
res.end(JSON.stringify(files.map(f => ({ name: f.name, path: f.path, size: f.size, type: f.type }))));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (url.pathname === '/stream') {
|
|
158
|
+
const fileName = url.searchParams.get('file');
|
|
159
|
+
if (!fileName) { res.writeHead(400); res.end('Missing file param'); return; }
|
|
160
|
+
const file = files.find(f => f.name === fileName);
|
|
161
|
+
if (!file) { res.writeHead(404); res.end('File not found'); return; }
|
|
162
|
+
|
|
163
|
+
let fileSize;
|
|
164
|
+
try { fileSize = fs.statSync(file.path).size; } catch { res.writeHead(404); res.end(); return; }
|
|
165
|
+
|
|
166
|
+
const mime = getMime(file.name);
|
|
167
|
+
const range = req.headers.range;
|
|
168
|
+
if (range) {
|
|
169
|
+
const [startStr, endStr] = range.replace('bytes=', '').split('-');
|
|
170
|
+
const start = parseInt(startStr, 10);
|
|
171
|
+
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
|
172
|
+
res.writeHead(206, {
|
|
173
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
174
|
+
'Accept-Ranges': 'bytes',
|
|
175
|
+
'Content-Length': end - start + 1,
|
|
176
|
+
'Content-Type': mime,
|
|
177
|
+
});
|
|
178
|
+
fs.createReadStream(file.path, { start, end }).pipe(res);
|
|
179
|
+
} else {
|
|
180
|
+
res.writeHead(200, {
|
|
181
|
+
'Content-Length': fileSize,
|
|
182
|
+
'Content-Type': mime,
|
|
183
|
+
'Accept-Ranges': 'bytes',
|
|
184
|
+
});
|
|
185
|
+
fs.createReadStream(file.path).pipe(res);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
res.writeHead(404);
|
|
191
|
+
res.end();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
server.listen(0, '127.0.0.1', () => {
|
|
195
|
+
resolve({ server, port: server.address().port, token: streamToken });
|
|
196
|
+
});
|
|
197
|
+
server.on('error', reject);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stopFolderStream() {
|
|
202
|
+
if (folderStreamState.server) {
|
|
203
|
+
folderStreamState.server.close();
|
|
204
|
+
}
|
|
205
|
+
folderStreamState = { server: null, port: null, folderPath: null, files: [], startedAt: null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default function streamCommand(program) {
|
|
209
|
+
const cmd = program
|
|
210
|
+
.command('stream')
|
|
211
|
+
.description('stream media files locally or from the network')
|
|
212
|
+
.addHelpText('after', `
|
|
213
|
+
Examples:
|
|
214
|
+
$ pal stream local /path/to/video.mp4 Stream a local file via HTTP
|
|
215
|
+
$ pal stream remote magnet:?xt=... Stream a remote file from magnet
|
|
216
|
+
$ pal stream stop Stop current stream
|
|
217
|
+
$ pal stream status Show current stream info
|
|
218
|
+
$ pal stream broadcast /path/to/file Start a broadcast session
|
|
219
|
+
$ pal stream join magnet:?xt=... Join a broadcast
|
|
220
|
+
$ pal stream folder /path/to/music Serve a folder as a media library
|
|
221
|
+
$ pal stream playlist Show current folder playlist
|
|
222
|
+
`)
|
|
223
|
+
.action(() => {
|
|
224
|
+
showStatus();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
cmd
|
|
228
|
+
.command('local <file>')
|
|
229
|
+
.description('stream a local file (starts HTTP server)')
|
|
230
|
+
.action(async (file) => {
|
|
231
|
+
const filePath = path.resolve(file);
|
|
232
|
+
if (!fs.existsSync(filePath)) {
|
|
233
|
+
console.log(chalk.red('File not found.'));
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
stopStream();
|
|
239
|
+
try {
|
|
240
|
+
const { server, port } = await startLocalServer(filePath);
|
|
241
|
+
streamState = {
|
|
242
|
+
server,
|
|
243
|
+
port,
|
|
244
|
+
type: 'local',
|
|
245
|
+
filePath,
|
|
246
|
+
fileName: path.basename(filePath),
|
|
247
|
+
magnet: null,
|
|
248
|
+
broadcast: null,
|
|
249
|
+
startedAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
console.log(chalk.green(`Streaming: ${streamState.fileName}`));
|
|
252
|
+
console.log(` Local: ${chalk.cyan(`http://127.0.0.1:${port}/stream?token=${streamToken}`)}`);
|
|
253
|
+
console.log(chalk.gray(` Token: ${streamToken}`));
|
|
254
|
+
console.log(chalk.gray('Press Ctrl+C to stop.'));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.log(chalk.red(`Failed to start stream: ${err.message}`));
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
cmd
|
|
262
|
+
.command('remote <magnet>')
|
|
263
|
+
.description('stream a remote file from magnet link')
|
|
264
|
+
.action(async (magnet) => {
|
|
265
|
+
stopStream();
|
|
266
|
+
try {
|
|
267
|
+
const WebTorrent = (await import('webtorrent')).default;
|
|
268
|
+
const client = new WebTorrent();
|
|
269
|
+
const torrent = await new Promise((resolve, reject) => {
|
|
270
|
+
const t = client.add(magnet);
|
|
271
|
+
t.on('ready', () => resolve(t));
|
|
272
|
+
t.on('error', reject);
|
|
273
|
+
setTimeout(() => reject(new Error('Torrent timeout')), 30000);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const file = torrent.files[0];
|
|
277
|
+
console.log(chalk.green(`Streaming remote: ${file.name}`));
|
|
278
|
+
console.log(` Size: ${chalk.white((file.length / 1048576).toFixed(1))} MB`);
|
|
279
|
+
|
|
280
|
+
streamState = {
|
|
281
|
+
server: client,
|
|
282
|
+
port: null,
|
|
283
|
+
type: 'remote',
|
|
284
|
+
filePath: null,
|
|
285
|
+
fileName: file.name,
|
|
286
|
+
magnet,
|
|
287
|
+
broadcast: null,
|
|
288
|
+
startedAt: new Date().toISOString(),
|
|
289
|
+
};
|
|
290
|
+
} catch (err) {
|
|
291
|
+
console.log(chalk.red(`Failed to stream: ${err.message}`));
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
cmd
|
|
297
|
+
.command('stop')
|
|
298
|
+
.description('stop the current stream')
|
|
299
|
+
.action(() => {
|
|
300
|
+
if (!streamState.type) {
|
|
301
|
+
console.log(chalk.gray('No active stream.'));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const name = streamState.fileName;
|
|
305
|
+
stopStream();
|
|
306
|
+
console.log(chalk.green(`Stream stopped: ${name}`));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
cmd
|
|
310
|
+
.command('status')
|
|
311
|
+
.description('show current stream status')
|
|
312
|
+
.action(() => {
|
|
313
|
+
showStatus();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
cmd
|
|
317
|
+
.command('broadcast <file>')
|
|
318
|
+
.description('start a broadcast session for a file')
|
|
319
|
+
.action(async (file) => {
|
|
320
|
+
const filePath = path.resolve(file);
|
|
321
|
+
if (!fs.existsSync(filePath)) {
|
|
322
|
+
console.log(chalk.red('File not found.'));
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
stopStream();
|
|
328
|
+
try {
|
|
329
|
+
const { server, port } = await startLocalServer(filePath);
|
|
330
|
+
streamState = {
|
|
331
|
+
server,
|
|
332
|
+
port,
|
|
333
|
+
type: 'broadcast',
|
|
334
|
+
filePath,
|
|
335
|
+
fileName: path.basename(filePath),
|
|
336
|
+
magnet: null,
|
|
337
|
+
broadcast: { hostStartedAt: new Date().toISOString(), viewers: 0 },
|
|
338
|
+
startedAt: new Date().toISOString(),
|
|
339
|
+
};
|
|
340
|
+
console.log(chalk.green(`Broadcasting: ${streamState.fileName}`));
|
|
341
|
+
console.log(` URL: ${chalk.cyan(`http://127.0.0.1:${port}/stream?token=${streamToken}`)}`);
|
|
342
|
+
console.log(chalk.gray('Share the URL with your pals to let them watch.'));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.log(chalk.red(`Failed to start broadcast: ${err.message}`));
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
cmd
|
|
350
|
+
.command('join <magnet>')
|
|
351
|
+
.description('join a broadcast via magnet link')
|
|
352
|
+
.action(async (magnet) => {
|
|
353
|
+
stopStream();
|
|
354
|
+
try {
|
|
355
|
+
const WebTorrent = (await import('webtorrent')).default;
|
|
356
|
+
const client = new WebTorrent();
|
|
357
|
+
const torrent = await new Promise((resolve, reject) => {
|
|
358
|
+
const t = client.add(magnet);
|
|
359
|
+
t.on('ready', () => resolve(t));
|
|
360
|
+
t.on('error', reject);
|
|
361
|
+
setTimeout(() => reject(new Error('Torrent timeout')), 30000);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const file = torrent.files[0];
|
|
365
|
+
console.log(chalk.green(`Joined broadcast: ${file.name}`));
|
|
366
|
+
console.log(` Peers: ${chalk.white(torrent.numPeers)}`);
|
|
367
|
+
|
|
368
|
+
streamState = {
|
|
369
|
+
server: client,
|
|
370
|
+
port: null,
|
|
371
|
+
type: 'joined-broadcast',
|
|
372
|
+
filePath: null,
|
|
373
|
+
fileName: file.name,
|
|
374
|
+
magnet,
|
|
375
|
+
broadcast: { hostHandle: null },
|
|
376
|
+
startedAt: new Date().toISOString(),
|
|
377
|
+
};
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.log(chalk.red(`Failed to join broadcast: ${err.message}`));
|
|
380
|
+
process.exitCode = 1;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
cmd
|
|
385
|
+
.command('transport')
|
|
386
|
+
.description('show or configure stream transport settings')
|
|
387
|
+
.option('--set <type>', 'Set preferred transport: p2p, relay, tunnel')
|
|
388
|
+
.option('--stun-add <url>', 'Add custom STUN server')
|
|
389
|
+
.option('--stun-remove <url>', 'Remove custom STUN server')
|
|
390
|
+
.option('--turn-add <json>', 'Add custom TURN server (JSON: {"urls":"turn:host:3478","username":"u","credential":"p"})')
|
|
391
|
+
.option('--turn-remove <url>', 'Remove custom TURN server by URL')
|
|
392
|
+
.option('--tunnel <url>', 'Set custom tunnel URL (cloudflared, ngrok, etc.)')
|
|
393
|
+
.action(async (opts) => {
|
|
394
|
+
const { getTransportConfig, setTransportConfig } = await import('../core/streamTransport.js');
|
|
395
|
+
|
|
396
|
+
if (opts.set) {
|
|
397
|
+
const valid = ['p2p', 'relay', 'tunnel'];
|
|
398
|
+
if (!valid.includes(opts.set)) {
|
|
399
|
+
console.log(chalk.red(`Invalid transport. Options: ${valid.join(', ')}`));
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
setTransportConfig({ preferred: opts.set });
|
|
404
|
+
console.log(chalk.green(`Transport set to: ${opts.set}`));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (opts.stunAdd) {
|
|
409
|
+
const tc = getTransportConfig();
|
|
410
|
+
if (!tc.customStunServers.includes(opts.stunAdd)) {
|
|
411
|
+
tc.customStunServers.push(opts.stunAdd);
|
|
412
|
+
setTransportConfig({ customStunServers: tc.customStunServers });
|
|
413
|
+
}
|
|
414
|
+
console.log(chalk.green(`Added STUN: ${opts.stunAdd}`));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (opts.stunRemove) {
|
|
419
|
+
const tc = getTransportConfig();
|
|
420
|
+
tc.customStunServers = tc.customStunServers.filter(s => s !== opts.stunRemove);
|
|
421
|
+
setTransportConfig({ customStunServers: tc.customStunServers });
|
|
422
|
+
console.log(chalk.green(`Removed STUN: ${opts.stunRemove}`));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (opts.turnAdd) {
|
|
427
|
+
try {
|
|
428
|
+
const turn = JSON.parse(opts.turnAdd);
|
|
429
|
+
if (!turn.urls) throw new Error('Missing "urls"');
|
|
430
|
+
const tc = getTransportConfig();
|
|
431
|
+
tc.customTurnServers.push(turn);
|
|
432
|
+
setTransportConfig({ customTurnServers: tc.customTurnServers });
|
|
433
|
+
console.log(chalk.green(`Added TURN: ${turn.urls}`));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.log(chalk.red(`Invalid JSON: ${err.message}`));
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (opts.turnRemove) {
|
|
442
|
+
const tc = getTransportConfig();
|
|
443
|
+
tc.customTurnServers = tc.customTurnServers.filter(t => t.urls !== opts.turnRemove);
|
|
444
|
+
setTransportConfig({ customTurnServers: tc.customTurnServers });
|
|
445
|
+
console.log(chalk.green(`Removed TURN: ${opts.turnRemove}`));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (opts.tunnel) {
|
|
450
|
+
setTransportConfig({ tunnelUrl: opts.tunnel });
|
|
451
|
+
console.log(chalk.green(`Tunnel URL set to: ${opts.tunnel}`));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Show current config
|
|
456
|
+
const tc = getTransportConfig();
|
|
457
|
+
console.log('');
|
|
458
|
+
console.log(chalk.cyan('Stream Transport Config:'));
|
|
459
|
+
console.log(` Preferred: ${chalk.yellow(tc.preferred)}`);
|
|
460
|
+
console.log(` STUN servers (built-in): ${chalk.white(tc.stunServers.join(', ') || 'none')}`);
|
|
461
|
+
console.log(` STUN servers (custom): ${chalk.white(tc.customStunServers.join(', ') || 'none')}`);
|
|
462
|
+
console.log(` TURN servers (custom): ${chalk.white(tc.customTurnServers.map(t => t.urls).join(', ') || 'none')}`);
|
|
463
|
+
console.log(` TURN server (default): ${chalk.gray(tc.turnServer)}`);
|
|
464
|
+
console.log(` Tunnel URL: ${chalk.white(tc.tunnelUrl || 'not set')}`);
|
|
465
|
+
console.log('');
|
|
466
|
+
console.log(chalk.gray(' Transport options:'));
|
|
467
|
+
console.log(chalk.gray(' p2p — WebRTC peer-to-peer (default, best privacy)'));
|
|
468
|
+
console.log(chalk.gray(' relay — Through palexplorer relay server'));
|
|
469
|
+
console.log(chalk.gray(' tunnel — Through your own tunnel (cloudflared, ngrok, etc.)'));
|
|
470
|
+
});
|
|
471
|
+
cmd
|
|
472
|
+
.command('folder <path>')
|
|
473
|
+
.description('serve a folder as a streamable media library')
|
|
474
|
+
.action(async (folderPath) => {
|
|
475
|
+
const resolved = path.resolve(folderPath);
|
|
476
|
+
if (!fs.existsSync(resolved)) {
|
|
477
|
+
console.log(chalk.red('Folder not found.'));
|
|
478
|
+
process.exitCode = 1;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const stat = fs.statSync(resolved);
|
|
482
|
+
if (!stat.isDirectory()) {
|
|
483
|
+
console.log(chalk.red('Path is not a directory.'));
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(chalk.gray('Scanning folder...'));
|
|
489
|
+
const files = scanFolder(resolved);
|
|
490
|
+
if (files.length === 0) {
|
|
491
|
+
console.log(chalk.red('No media files found.'));
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
stopFolderStream();
|
|
497
|
+
try {
|
|
498
|
+
const { server, port } = await startFolderServer(resolved, files);
|
|
499
|
+
folderStreamState = { server, port, folderPath: resolved, files, startedAt: new Date().toISOString() };
|
|
500
|
+
|
|
501
|
+
const audio = files.filter(f => f.type === 'audio').length;
|
|
502
|
+
const video = files.filter(f => f.type === 'video').length;
|
|
503
|
+
console.log(chalk.green(`Serving ${files.length} files (${audio} audio, ${video} video)`));
|
|
504
|
+
console.log(` Playlist: ${chalk.cyan(`http://127.0.0.1:${port}/playlist?token=${streamToken}`)}`);
|
|
505
|
+
console.log(` Stream: ${chalk.cyan(`http://127.0.0.1:${port}/stream?file=<name>&token=${streamToken}`)}`);
|
|
506
|
+
console.log(chalk.gray(` Token: ${streamToken}`));
|
|
507
|
+
console.log(chalk.gray('Press Ctrl+C to stop.'));
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.log(chalk.red(`Failed to start folder stream: ${err.message}`));
|
|
510
|
+
process.exitCode = 1;
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
cmd
|
|
515
|
+
.command('playlist')
|
|
516
|
+
.description('show current folder stream playlist')
|
|
517
|
+
.action(() => {
|
|
518
|
+
if (!folderStreamState.server) {
|
|
519
|
+
console.log(chalk.gray('No folder stream active.'));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
console.log('');
|
|
523
|
+
console.log(chalk.cyan(`Folder: ${folderStreamState.folderPath}`));
|
|
524
|
+
console.log(chalk.cyan(`URL: http://127.0.0.1:${folderStreamState.port}/playlist`));
|
|
525
|
+
console.log('');
|
|
526
|
+
folderStreamState.files.forEach((f, i) => {
|
|
527
|
+
const sizeStr = (f.size / 1048576).toFixed(1) + ' MB';
|
|
528
|
+
const icon = f.type === 'audio' ? '♪' : '▶';
|
|
529
|
+
console.log(` ${chalk.gray(String(i + 1).padStart(3))} ${icon} ${chalk.white(f.name)} ${chalk.gray(sizeStr)}`);
|
|
530
|
+
});
|
|
531
|
+
console.log('');
|
|
532
|
+
console.log(chalk.gray(`${folderStreamState.files.length} files | Started ${folderStreamState.startedAt}`));
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function showStatus() {
|
|
537
|
+
if (!streamState.type) {
|
|
538
|
+
console.log(chalk.gray('No active stream.'));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
console.log('');
|
|
542
|
+
console.log(chalk.cyan('Stream Status:'));
|
|
543
|
+
console.log(` Type: ${chalk.yellow(streamState.type)}`);
|
|
544
|
+
console.log(` File: ${chalk.white(streamState.fileName)}`);
|
|
545
|
+
if (streamState.port) console.log(` URL: ${chalk.cyan(`http://127.0.0.1:${streamState.port}/stream`)}`);
|
|
546
|
+
if (streamState.magnet) console.log(` Magnet: ${chalk.gray(streamState.magnet.slice(0, 60))}...`);
|
|
547
|
+
console.log(` Started: ${chalk.gray(streamState.startedAt)}`);
|
|
548
|
+
if (streamState.broadcast) {
|
|
549
|
+
console.log(` Broadcast: ${chalk.green('Active')}`);
|
|
550
|
+
if (streamState.broadcast.viewers !== undefined) console.log(` Viewers: ${chalk.white(streamState.broadcast.viewers)}`);
|
|
551
|
+
}
|
|
552
|
+
if (folderStreamState.server) {
|
|
553
|
+
console.log('');
|
|
554
|
+
console.log(chalk.cyan('Folder Stream:'));
|
|
555
|
+
console.log(` Folder: ${chalk.white(folderStreamState.folderPath)}`);
|
|
556
|
+
console.log(` Files: ${chalk.white(folderStreamState.files.length)}`);
|
|
557
|
+
console.log(` Playlist: ${chalk.cyan(`http://127.0.0.1:${folderStreamState.port}/playlist`)}`);
|
|
558
|
+
console.log(` Started: ${chalk.gray(folderStreamState.startedAt)}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export { streamState, stopStream, folderStreamState, stopFolderStream, scanFolder };
|