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.
Files changed (99) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +63 -2
  3. package/extensions/@palexplorer/analytics/extension.json +20 -1
  4. package/extensions/@palexplorer/analytics/index.js +19 -9
  5. package/extensions/@palexplorer/audit/extension.json +14 -0
  6. package/extensions/@palexplorer/auth-email/extension.json +15 -0
  7. package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
  8. package/extensions/@palexplorer/chat/extension.json +14 -0
  9. package/extensions/@palexplorer/discovery/extension.json +17 -0
  10. package/extensions/@palexplorer/discovery/index.js +1 -1
  11. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  12. package/extensions/@palexplorer/groups/extension.json +15 -0
  13. package/extensions/@palexplorer/share-links/extension.json +15 -0
  14. package/extensions/@palexplorer/sync/extension.json +16 -0
  15. package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
  16. package/lib/capabilities.js +24 -24
  17. package/lib/commands/analytics.js +175 -175
  18. package/lib/commands/api-keys.js +131 -131
  19. package/lib/commands/audit.js +235 -235
  20. package/lib/commands/auth.js +137 -137
  21. package/lib/commands/backup.js +76 -76
  22. package/lib/commands/billing.js +148 -148
  23. package/lib/commands/chat.js +217 -217
  24. package/lib/commands/cloud-backup.js +231 -231
  25. package/lib/commands/comment.js +99 -99
  26. package/lib/commands/completion.js +203 -203
  27. package/lib/commands/compliance.js +218 -218
  28. package/lib/commands/config.js +136 -136
  29. package/lib/commands/connect.js +44 -44
  30. package/lib/commands/dept.js +294 -294
  31. package/lib/commands/device.js +146 -146
  32. package/lib/commands/download.js +240 -226
  33. package/lib/commands/explorer.js +178 -178
  34. package/lib/commands/extension.js +1060 -970
  35. package/lib/commands/favorite.js +90 -90
  36. package/lib/commands/federation.js +270 -270
  37. package/lib/commands/file.js +533 -533
  38. package/lib/commands/group.js +271 -271
  39. package/lib/commands/gui-share.js +29 -29
  40. package/lib/commands/init.js +61 -61
  41. package/lib/commands/invite.js +59 -59
  42. package/lib/commands/list.js +58 -58
  43. package/lib/commands/log.js +116 -116
  44. package/lib/commands/nearby.js +108 -108
  45. package/lib/commands/network.js +251 -251
  46. package/lib/commands/notify.js +198 -198
  47. package/lib/commands/org.js +273 -273
  48. package/lib/commands/pal.js +403 -180
  49. package/lib/commands/permissions.js +216 -216
  50. package/lib/commands/pin.js +97 -97
  51. package/lib/commands/protocol.js +357 -357
  52. package/lib/commands/rbac.js +147 -147
  53. package/lib/commands/recover.js +36 -36
  54. package/lib/commands/register.js +171 -171
  55. package/lib/commands/relay.js +131 -131
  56. package/lib/commands/remote.js +368 -368
  57. package/lib/commands/revoke.js +50 -50
  58. package/lib/commands/scanner.js +280 -280
  59. package/lib/commands/schedule.js +344 -344
  60. package/lib/commands/scim.js +203 -203
  61. package/lib/commands/search.js +181 -181
  62. package/lib/commands/serve.js +438 -438
  63. package/lib/commands/server.js +350 -350
  64. package/lib/commands/share-link.js +199 -199
  65. package/lib/commands/share.js +336 -323
  66. package/lib/commands/sso.js +200 -200
  67. package/lib/commands/status.js +145 -145
  68. package/lib/commands/stream.js +562 -562
  69. package/lib/commands/su.js +187 -187
  70. package/lib/commands/sync.js +979 -979
  71. package/lib/commands/transfers.js +152 -152
  72. package/lib/commands/uninstall.js +188 -188
  73. package/lib/commands/update.js +204 -204
  74. package/lib/commands/user.js +276 -276
  75. package/lib/commands/vfs.js +84 -84
  76. package/lib/commands/web-login.js +79 -79
  77. package/lib/commands/web.js +52 -52
  78. package/lib/commands/webhook.js +180 -180
  79. package/lib/commands/whoami.js +59 -59
  80. package/lib/commands/workspace.js +121 -121
  81. package/lib/core/billing.js +16 -5
  82. package/lib/core/dhtDiscovery.js +9 -2
  83. package/lib/core/discoveryClient.js +13 -7
  84. package/lib/core/extensions.js +142 -1
  85. package/lib/core/identity.js +33 -2
  86. package/lib/core/imageProcessor.js +109 -0
  87. package/lib/core/imageTorrent.js +167 -0
  88. package/lib/core/permissions.js +1 -1
  89. package/lib/core/pro.js +11 -4
  90. package/lib/core/serverList.js +4 -1
  91. package/lib/core/shares.js +12 -1
  92. package/lib/core/signalingServer.js +14 -2
  93. package/lib/core/su.js +1 -1
  94. package/lib/core/users.js +1 -1
  95. package/lib/protocol/messages.js +12 -3
  96. package/lib/utils/explorer.js +1 -1
  97. package/lib/utils/help.js +357 -357
  98. package/lib/utils/torrent.js +1 -0
  99. package/package.json +4 -3
@@ -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
- $ pe stream local /path/to/video.mp4 Stream a local file via HTTP
215
- $ pe stream remote magnet:?xt=... Stream a remote file from magnet
216
- $ pe stream stop Stop current stream
217
- $ pe stream status Show current stream info
218
- $ pe stream broadcast /path/to/file Start a broadcast session
219
- $ pe stream join magnet:?xt=... Join a broadcast
220
- $ pe stream folder /path/to/music Serve a folder as a media library
221
- $ pe 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 };
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 };