kiku-agent 1.0.0 → 1.0.4

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 (40) hide show
  1. package/README.md +293 -122
  2. package/package.json +60 -61
  3. package/server/agent/agentCore.js +892 -782
  4. package/server/agent/appPaths.js +21 -0
  5. package/server/agent/audioPlayer.js +739 -673
  6. package/server/agent/cli.js +1414 -1111
  7. package/server/agent/commandRegistry.js +74 -58
  8. package/server/agent/commands.js +1694 -788
  9. package/server/agent/config.js +264 -90
  10. package/server/agent/contextCompactor.js +79 -0
  11. package/server/agent/doctor.js +69 -0
  12. package/server/agent/eventBus.js +107 -103
  13. package/server/agent/gpuMetrics.js +189 -0
  14. package/server/agent/llmClient.js +369 -200
  15. package/server/agent/mcpClient.js +364 -0
  16. package/server/agent/memory.js +670 -566
  17. package/server/agent/modelManager.js +77 -0
  18. package/server/agent/research.js +202 -0
  19. package/server/agent/session.js +244 -239
  20. package/server/agent/subagent.js +116 -0
  21. package/server/agent/theme.js +125 -124
  22. package/server/agent/toolRouter.js +201 -0
  23. package/server/agent/tools.js +1679 -1528
  24. package/server/agent/ui.js +760 -469
  25. package/server/agent/youtubeAuth.js +144 -65
  26. package/server/mcp/claude-desktop-config.example.json +1 -1
  27. package/server/mcp/data/discography.js +36 -36
  28. package/server/mcp/dataAdapter.js +102 -0
  29. package/server/mcp/examples/claude-code.mcp.example.json +19 -0
  30. package/server/mcp/examples/claude-desktop.example.json +11 -0
  31. package/server/mcp/examples/cline.example.json +13 -0
  32. package/server/mcp/examples/cursor.example.json +14 -0
  33. package/server/mcp/examples/gemini-cli.example.json +17 -0
  34. package/server/mcp/examples/opencode.example.json +21 -0
  35. package/server/mcp/examples/vscode.example.json +19 -0
  36. package/server/mcp/examples/windsurf.example.json +14 -0
  37. package/server/mcp/handler.js +85 -159
  38. package/server/mcp/security.js +140 -138
  39. package/server/mcp/server.js +129 -100
  40. package/server/mcp/stdio.js +4 -2
@@ -1,673 +1,739 @@
1
- import ytdl from '@distube/ytdl-core';
2
- import yts from 'yt-search';
3
- import ffmpeg from 'fluent-ffmpeg';
4
- import ffmpegPath from 'ffmpeg-static';
5
- import Speaker from 'speaker';
6
- import { spawn } from 'child_process';
7
- import { existsSync, unlinkSync, readdirSync, statSync } from 'node:fs';
8
- import { join, dirname, basename } from 'node:path';
9
- import { ensureYouTubeCookies, getCookieStatus } from './youtubeAuth.js';
10
-
11
- ffmpeg.setFfmpegPath(ffmpegPath);
12
-
13
- const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
14
-
15
- const YTDLP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
16
-
17
- function ytDlpSpawn(args) {
18
- return spawn(YTDLP_CMD, args, { windowsHide: true });
19
- }
20
-
21
- function ytDlpArgs(query, opts = {}) {
22
- const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
23
- const args = ['-f', 'bestaudio', '--no-warnings', '--ignore-errors'];
24
- if (opts.client) args.push('--extractor-args', `youtube:player_client=${opts.client}`);
25
- if (opts.cookies) {
26
- if (opts.cookies.startsWith('browser:')) args.push('--cookies-from-browser', opts.cookies.slice('browser:'.length));
27
- else args.push('--cookies', opts.cookies);
28
- }
29
- args.push(arg);
30
- return args;
31
- }
32
-
33
- const YTDLP_STRATEGIES = [
34
- {},
35
- { client: 'tv' },
36
- { client: 'ios' },
37
- { client: 'web' },
38
- { cookies: 'browser:chrome' },
39
- { cookies: 'browser:edge' },
40
- { cookies: 'browser:firefox' },
41
- { cookies: 'browser:brave' },
42
- { client: 'tv', cookies: 'browser:chrome' },
43
- { client: 'ios', cookies: 'browser:chrome' }
44
- ];
45
-
46
- function buildStrategies(cookieResult) {
47
- const strategies = [];
48
-
49
- const status = getCookieStatus();
50
- const cookiesFile = process.env.KIKU_YT_COOKIES || status.envCookies;
51
- if (cookiesFile) {
52
- strategies.push({ cookies: cookiesFile });
53
- }
54
-
55
- const detectedBrowser = cookieResult?.browser || process.env.KIKU_YT_BROWSER;
56
- if (detectedBrowser) {
57
- strategies.push({ cookies: `browser:${detectedBrowser}` });
58
- }
59
-
60
- if (process.env.KIKU_YTDLP_CLIENT) {
61
- strategies.push({ client: process.env.KIKU_YTDLP_CLIENT });
62
- }
63
-
64
- for (const s of YTDLP_STRATEGIES) {
65
- if (!strategies.some(existing => existing.client === s.client && existing.cookies === s.cookies)) {
66
- strategies.push(s);
67
- }
68
- }
69
-
70
- return strategies;
71
- }
72
-
73
- function ytDlpTry(query, opts) {
74
- return new Promise((resolve, reject) => {
75
- const proc = ytDlpSpawn(ytDlpArgs(query, opts));
76
- let gotData = false;
77
- let errBuf = '';
78
- proc.stdout.once('data', () => { gotData = true; resolve(proc); });
79
- proc.stderr.on('data', d => { errBuf += d.toString(); });
80
- proc.on('error', e => { if (!gotData) { try { proc.kill('SIGKILL'); } catch {} reject(e); } });
81
- proc.on('close', code => {
82
- if (!gotData) { try { proc.kill('SIGKILL'); } catch {} reject(new Error(errBuf.trim() || `yt-dlp exited with code ${code}`)); }
83
- });
84
- });
85
- }
86
-
87
- function findDownloadedFile(dir, baseName) {
88
- try {
89
- const entries = readdirSync(dir);
90
- for (const entry of entries) {
91
- if (entry === baseName) {
92
- const full = join(dir, entry);
93
- try { if (statSync(full).isFile()) return full; } catch {}
94
- }
95
- if (entry.startsWith(baseName + '.')) {
96
- const full = join(dir, entry);
97
- try { if (statSync(full).isFile()) return full; } catch {}
98
- }
99
- }
100
- } catch {}
101
- return null;
102
- }
103
-
104
- function normalizeSearchQuery(query) {
105
- if (!query || typeof query !== 'string') return query;
106
- if (/^https?:\/\//.test(query)) return query;
107
- if (/jujingyi|จวีจิ้งอี|鞠婧祎|ju jingyi/i.test(query)) return query;
108
- return `Ju Jingyi ${query}`;
109
- }
110
-
111
- async function ytDlpDownload(query, opts, outputPath) {
112
- const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
113
- const args = [
114
- '-f', 'bestaudio',
115
- '--no-warnings',
116
- '--ignore-errors',
117
- '-x', '--audio-format', 'mp3',
118
- '-o', outputPath + '.%(ext)s',
119
- '--no-playlist'
120
- ];
121
- if (opts.client) args.push('--extractor-args', `youtube:player_client=${opts.client}`);
122
- if (opts.cookies) {
123
- if (opts.cookies.startsWith('browser:')) args.push('--cookies-from-browser', opts.cookies.slice('browser:'.length));
124
- else args.push('--cookies', opts.cookies);
125
- }
126
- args.push(arg);
127
-
128
- const dir = dirname(outputPath);
129
- const baseName = basename(outputPath);
130
-
131
- return new Promise((resolve, reject) => {
132
- const p = ytDlpSpawn(args);
133
- let err = '';
134
- p.stderr.on('data', d => { err += d.toString(); });
135
- p.on('error', reject);
136
- p.on('close', code => {
137
- const found = findDownloadedFile(dir, baseName);
138
- if (found) {
139
- resolve(found);
140
- } else if (code === 0) {
141
- resolve(outputPath);
142
- } else {
143
- reject(new Error(err.trim() || `yt-dlp exited ${code}`));
144
- }
145
- });
146
- });
147
- }
148
-
149
- async function ytDlpDownloadUrl(url, outputPath) {
150
- return new Promise((resolve, reject) => {
151
- ffmpeg(url)
152
- .inputOptions([
153
- '-reconnect', '1',
154
- '-reconnect_streamed', '1',
155
- '-reconnect_delay_max', '5',
156
- '-user_agent', YTDLP_UA
157
- ])
158
- .format('mp3')
159
- .on('error', reject)
160
- .on('end', () => resolve(outputPath + '.mp3'))
161
- .save(outputPath + '.mp3');
162
- });
163
- }
164
-
165
- async function ytDlpGetTitle(query, strategies) {
166
- if (!strategies) strategies = buildStrategies();
167
- for (const s of strategies) {
168
- try {
169
- const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
170
- const args = ['--print', '%(title)s', '--no-warnings'];
171
- if (s.client) args.push('--extractor-args', `youtube:player_client=${s.client}`);
172
- if (s.cookies) {
173
- if (s.cookies.startsWith('browser:')) args.push('--cookies-from-browser', s.cookies.slice('browser:'.length));
174
- else args.push('--cookies', s.cookies);
175
- }
176
- args.push(arg);
177
- const title = await new Promise((resolve, reject) => {
178
- const p = ytDlpSpawn(args);
179
- let out = '';
180
- let err = '';
181
- p.stdout.on('data', d => { out += d.toString(); });
182
- p.stderr.on('data', d => { err += d.toString(); });
183
- p.on('error', e => reject(e));
184
- p.on('close', c => {
185
- const t = out.trim();
186
- if (c === 0 && t) resolve(t);
187
- else reject(new Error(err.trim() || `yt-dlp exited ${c}`));
188
- });
189
- });
190
- if (title) return title;
191
- } catch {}
192
- }
193
- return null;
194
- }
195
-
196
- async function ytDlpGetStream(query, strategies) {
197
- if (!strategies) strategies = buildStrategies();
198
- let lastErr;
199
- for (const s of strategies) {
200
- try {
201
- const proc = await ytDlpTry(query, s);
202
- return { proc, opts: s };
203
- } catch (e) {
204
- lastErr = e;
205
- }
206
- }
207
- throw lastErr || new Error('yt-dlp could not extract an audio stream');
208
- }
209
-
210
- async function resolveViaYtdlCore(query) {
211
- let videoUrl;
212
- if (/^https?:\/\//.test(query)) {
213
- videoUrl = query;
214
- } else {
215
- const searchResult = await yts(query);
216
- const searchResults = searchResult.videos || [];
217
- if (!searchResults.length) throw new Error(`No video found for: ${query}`);
218
- videoUrl = searchResults[0].url;
219
- }
220
- const info = await ytdl.getInfo(videoUrl);
221
- const format = ytdl.chooseFormat(info.formats, { filter: 'audioonly', quality: 'highestaudio' });
222
- const title = info.videoDetails?.title || query;
223
- if (!format || !format.url) throw new Error('No playable audio format found');
224
- return { title, url: format.url };
225
- }
226
-
227
- class AudioPlayer {
228
- constructor() {
229
- this.ytdlStream = null;
230
- this.ffmpegProcess = null;
231
- this.speaker = null;
232
- this.currentTrack = null;
233
- this.isPlaying = false;
234
- this.isPaused = false;
235
- this.isMuted = false;
236
- this.queue = [];
237
- this.currentIndex = -1;
238
- this._advancing = false;
239
- this._playPromise = null;
240
- this._ytDlpProc = null;
241
- this._intentionalStop = false;
242
- }
243
-
244
- _cleanup() {
245
- this._intentionalStop = true;
246
- if (this.speaker) {
247
- try { this.speaker.close(); } catch {}
248
- this.speaker = null;
249
- }
250
- if (this._ytDlpProc) {
251
- try { this._ytDlpProc.kill('SIGKILL'); } catch {}
252
- this._ytDlpProc = null;
253
- }
254
- if (this.ffmpegProcess) {
255
- try { this.ffmpegProcess.kill('SIGKILL'); } catch {}
256
- this.ffmpegProcess = null;
257
- }
258
- if (this.ytdlStream) {
259
- try { this.ytdlStream.destroy(); } catch {}
260
- this.ytdlStream = null;
261
- }
262
- }
263
-
264
- _cleanupTempFile() {
265
- if (!this._tempFile) return;
266
- try {
267
- const dir = dirname(this._tempFile);
268
- const baseName = basename(this._tempFile);
269
- const entries = readdirSync(dir);
270
- for (const entry of entries) {
271
- if (entry === baseName || entry.startsWith(baseName + '.')) {
272
- try { unlinkSync(join(dir, entry)); } catch {}
273
- }
274
- }
275
- } catch {}
276
- this._tempFile = null;
277
- }
278
-
279
- _createStream(query) {
280
- this._intentionalStop = false;
281
- query = normalizeSearchQuery(query);
282
- return new Promise(async (resolve, reject) => {
283
- try {
284
- const cookieResult = await ensureYouTubeCookies({ silent: true });
285
- if (!cookieResult.ok) {
286
- console.error(' [WARNING] YouTube cookies unavailable (' + (cookieResult.reason || 'unknown') + ')');
287
- console.error(' Playback may fail due to YouTube restrictions.');
288
- console.error(' Run /login to sign in, or set KIKU_YT_COOKIES in .env\n');
289
- } else {
290
- console.log(` [OK] YouTube session: ${cookieResult.source} (${cookieResult.browser || cookieResult.cookiesPath || 'file'})`);
291
- }
292
-
293
- const strategies = buildStrategies(cookieResult);
294
-
295
- let tempFile = null;
296
- let title = null;
297
- let ytProc = null;
298
-
299
- try {
300
- title = await ytDlpGetTitle(query, strategies).catch(() => null);
301
-
302
- const { mkdtemp } = await import('node:fs/promises');
303
- const { tmpdir } = await import('node:os');
304
- const tmpDir = await mkdtemp(join(tmpdir(), 'kiku-audio-'));
305
- tempFile = join(tmpDir, 'audio');
306
-
307
- let downloaded = false;
308
- for (const s of strategies) {
309
- try {
310
- const downloadedPath = await ytDlpDownload(query, s, tempFile);
311
- tempFile = downloadedPath;
312
- downloaded = true;
313
- console.log(' [OK] Downloaded audio via yt-dlp to', tempFile);
314
- break;
315
- } catch (e) {
316
- console.log(' Strategy failed:', e.message.slice(0, 80));
317
- }
318
- }
319
-
320
- if (!downloaded) {
321
- console.log(' [WARNING] yt-dlp download failed, trying ytdl-core...');
322
- try {
323
- const src = await resolveViaYtdlCore(query);
324
- title = src.title;
325
- const downloadedPath = await ytDlpDownloadUrl(src.url, join(tmpDir, 'audio'));
326
- tempFile = downloadedPath;
327
- console.log(' [OK] Downloaded via ytdl-core to', tempFile);
328
- } catch (ytdlErr) {
329
- console.error(' ytdl-core also failed:', ytdlErr.message);
330
- tempFile = null;
331
- }
332
- }
333
- } catch (err) {
334
- console.error(' All download methods failed:', err.message);
335
- }
336
-
337
- if (!tempFile) {
338
- this._cleanup();
339
- this.isPlaying = false;
340
- this.isPaused = false;
341
- this._advancing = false;
342
- return reject(new Error('No playable audio source found'));
343
- }
344
-
345
- this.currentTrack = title || query;
346
- this._ytDlpProc = ytProc;
347
- this._tempFile = tempFile;
348
-
349
- const speaker = new Speaker({
350
- channels: 2,
351
- bitDepth: 16,
352
- sampleRate: 44100
353
- });
354
-
355
- this.speaker = speaker;
356
-
357
- const volumeFilter = this.isMuted ? 'volume=0' : 'volume=1';
358
-
359
- const ff = ffmpeg(tempFile)
360
- .format('s16le')
361
- .audioFrequency(44100)
362
- .audioChannels(2)
363
- .audioFilter(volumeFilter)
364
- .on('error', (err) => {
365
- if (this._intentionalStop) return;
366
- console.error('Stream error:', err.message);
367
- this._cleanup();
368
- this._cleanupTempFile();
369
- this.isPlaying = false;
370
- this.isPaused = false;
371
- this._advancing = false;
372
- this._removeCurrentFromQueue();
373
- reject(new Error(err.message));
374
- })
375
- .on('end', () => {
376
- if (this._intentionalStop) return;
377
- this._cleanup();
378
- this._cleanupTempFile();
379
- this.isPlaying = false;
380
- this.isPaused = false;
381
- this._advancing = false;
382
- this._autoNext();
383
- })
384
- .pipe(speaker, { end: true });
385
-
386
- this.ffmpegProcess = ff;
387
- this.isPlaying = true;
388
- this.isPaused = false;
389
- resolve();
390
- } catch (err) {
391
- this._cleanup();
392
- this.isPlaying = false;
393
- this.isPaused = false;
394
- this._advancing = false;
395
- this._removeCurrentFromQueue();
396
- reject(err);
397
- }
398
- });
399
- }
400
-
401
- _removeCurrentFromQueue() {
402
- if (this.currentIndex < 0 || this.currentIndex >= this.queue.length) return;
403
- this.queue.splice(this.currentIndex, 1);
404
- if (this.queue.length === 0) {
405
- this.currentIndex = -1;
406
- this.currentTrack = null;
407
- } else if (this.currentIndex >= this.queue.length) {
408
- this.currentIndex = this.queue.length - 1;
409
- }
410
- }
411
-
412
- async play(query) {
413
- this._cleanup();
414
- this.queue = [query];
415
- this.currentIndex = 0;
416
- this.currentTrack = query;
417
- this._playPromise = this._createStream(query);
418
- await this._playPromise;
419
- return `Now playing: ${query}`;
420
- }
421
-
422
- async playFromQueue(index) {
423
- if (index < 0 || index >= this.queue.length) return 'Invalid queue index';
424
- this._cleanup();
425
- this.currentIndex = index;
426
- this.currentTrack = this.queue[index];
427
- this._playPromise = this._createStream(this.currentTrack);
428
- await this._playPromise;
429
- return `Playing from queue [${index + 1}/${this.queue.length}]: ${this.currentTrack}`;
430
- }
431
-
432
- async addToQueue(query) {
433
- this.queue.push(query);
434
- if (!this.isPlaying) {
435
- this.currentIndex = this.queue.length - 1;
436
- return await this.play(query);
437
- }
438
- return `Added "${query}" to queue (position ${this.queue.length})`;
439
- }
440
-
441
- next() {
442
- if (this.currentIndex < this.queue.length - 1) {
443
- this._cleanup();
444
- this.currentIndex++;
445
- this.currentTrack = this.queue[this.currentIndex];
446
- this._playPromise = this._createStream(this.currentTrack);
447
- return `Playing next track [${this.currentIndex + 1}/${this.queue.length}]: ${this.currentTrack}`;
448
- }
449
- return 'No next track in queue';
450
- }
451
-
452
- prev() {
453
- if (this.currentIndex > 0) {
454
- this._cleanup();
455
- this.currentIndex--;
456
- this.currentTrack = this.queue[this.currentIndex];
457
- this._playPromise = this._createStream(this.currentTrack);
458
- return `Playing previous track [${this.currentIndex + 1}/${this.queue.length}]: ${this.currentTrack}`;
459
- }
460
- return 'No previous track in queue';
461
- }
462
-
463
- removeFromQueue(index) {
464
- if (index < 0 || index >= this.queue.length) return 'Invalid queue index';
465
- const removed = this.queue[index];
466
- this.queue.splice(index, 1);
467
- if (this.currentIndex === index) {
468
- this._cleanup();
469
- this.isPlaying = false;
470
- this.isPaused = false;
471
- this.currentTrack = null;
472
- return `Removed current track: ${removed} (stopped playback)`;
473
- } else if (this.currentIndex > index) {
474
- this.currentIndex--;
475
- }
476
- return `Removed "${removed}" from queue`;
477
- }
478
-
479
- stop() {
480
- this._cleanup();
481
- this._cleanupTempFile();
482
- this.isPlaying = false;
483
- this.isPaused = false;
484
- this.currentTrack = null;
485
- this.queue = [];
486
- this.currentIndex = -1;
487
- this._playPromise = null;
488
- return 'Stopped playback and cleared queue';
489
- }
490
-
491
- clearQueue() {
492
- if (this.isPlaying) {
493
- this._cleanup();
494
- this._cleanupTempFile();
495
- this.isPlaying = false;
496
- this.isPaused = false;
497
- this.currentTrack = null;
498
- }
499
- const count = this.queue.length;
500
- this.queue = [];
501
- this.currentIndex = -1;
502
- return `Cleared ${count} songs from queue`;
503
- }
504
-
505
- pause() {
506
- if (!this.isPlaying) return 'No song is currently playing';
507
- this.isPaused = true;
508
- this._cleanup();
509
- return `Paused: ${this.currentTrack}`;
510
- }
511
-
512
- resume() {
513
- if (!this.isPaused || !this.currentTrack) return 'No track is paused';
514
- this.isPaused = false;
515
- if (this._tempFile) {
516
- this._replayFromTemp();
517
- } else {
518
- this._playPromise = this._createStream(this.currentTrack);
519
- }
520
- return `Resumed: ${this.currentTrack}`;
521
- }
522
-
523
- mute() {
524
- if (!this.isPlaying) return 'No song is currently playing';
525
- this.isMuted = true;
526
- if (this._tempFile) {
527
- this._replayFromTemp();
528
- }
529
- return `Muted: ${this.currentTrack}`;
530
- }
531
-
532
- unmute() {
533
- if (!this.isPlaying) return 'No song is currently playing';
534
- this.isMuted = false;
535
- if (this._tempFile) {
536
- this._replayFromTemp();
537
- }
538
- return `Unmuted: ${this.currentTrack}`;
539
- }
540
-
541
- _replayFromTemp() {
542
- this._intentionalStop = true;
543
-
544
- if (this.speaker) {
545
- try { this.speaker.close(); } catch {}
546
- this.speaker = null;
547
- }
548
- if (this.ffmpegProcess) {
549
- try { this.ffmpegProcess.kill('SIGKILL'); } catch {}
550
- this.ffmpegProcess = null;
551
- }
552
-
553
- const speaker = new Speaker({
554
- channels: 2,
555
- bitDepth: 16,
556
- sampleRate: 44100
557
- });
558
- this.speaker = speaker;
559
-
560
- const volumeFilter = this.isMuted ? 'volume=0' : 'volume=1';
561
-
562
- this._intentionalStop = false;
563
- const ff = ffmpeg(this._tempFile)
564
- .format('s16le')
565
- .audioFrequency(44100)
566
- .audioChannels(2)
567
- .audioFilter(volumeFilter)
568
- .on('error', (err) => {
569
- if (this._intentionalStop) return;
570
- console.error('Replay stream error:', err.message);
571
- this._cleanup();
572
- this._cleanupTempFile();
573
- this.isPlaying = false;
574
- this.isPaused = false;
575
- this._advancing = false;
576
- this._removeCurrentFromQueue();
577
- })
578
- .on('end', () => {
579
- if (this._intentionalStop) return;
580
- this._cleanup();
581
- this._cleanupTempFile();
582
- this.isPlaying = false;
583
- this.isPaused = false;
584
- this._advancing = false;
585
- this._autoNext();
586
- })
587
- .pipe(speaker, { end: true });
588
-
589
- this.ffmpegProcess = ff;
590
- }
591
-
592
- async searchYouTube(query) {
593
- try {
594
- const result = await yts(query);
595
- const results = result.videos || [];
596
- return results.map(item => {
597
- let duration = '--:--';
598
- if (item.duration) {
599
- if (typeof item.duration === 'number') {
600
- duration = `${Math.floor(item.duration / 60)}:${String(item.duration % 60).padStart(2, '0')}`;
601
- } else if (typeof item.duration === 'object' && typeof item.duration.seconds === 'number') {
602
- const s = item.duration.seconds;
603
- duration = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
604
- } else if (typeof item.duration === 'string') {
605
- duration = item.duration;
606
- }
607
- }
608
- return {
609
- title: item.title,
610
- url: item.url,
611
- duration,
612
- channel: item.author?.name || item.channel?.name || 'Unknown'
613
- };
614
- });
615
- } catch (err) {
616
- console.error('YouTube search error:', err.message);
617
- return [];
618
- }
619
- }
620
-
621
- getQueue() {
622
- return this.queue.map((track, i) => ({
623
- index: i,
624
- title: track,
625
- isCurrent: i === this.currentIndex
626
- }));
627
- }
628
-
629
- getStatus() {
630
- if (this.isPlaying && this.currentTrack) {
631
- const shortName = this.currentTrack.length > 20
632
- ? this.currentTrack.slice(0, 20) + '...'
633
- : this.currentTrack;
634
- const qInfo = this.queue.length > 1 ? ` [${this.queue.length} in queue]` : '';
635
- if (this.isMuted) {
636
- return `[Muted: ${shortName}${qInfo}]`;
637
- }
638
- if (this.isPaused) {
639
- return `[Paused: ${shortName}${qInfo}]`;
640
- }
641
- return `[Playing: ${shortName}${qInfo}]`;
642
- }
643
- if (this.queue.length > 0) {
644
- return `[Queue: ${this.queue.length} songs]`;
645
- }
646
- return '';
647
- }
648
-
649
- getFullState() {
650
- return {
651
- isPlaying: this.isPlaying,
652
- isPaused: this.isPaused,
653
- currentTrack: this.currentTrack,
654
- queue: this.queue,
655
- currentIndex: this.currentIndex,
656
- queueLength: this.queue.length
657
- };
658
- }
659
-
660
- _autoNext() {
661
- if (this._advancing) return;
662
- if (this.queue.length > 0 && this.currentIndex < this.queue.length - 1) {
663
- this._advancing = true;
664
- this.currentIndex++;
665
- this.currentTrack = this.queue[this.currentIndex];
666
- this._createStream(this.currentTrack);
667
- } else {
668
- this.currentTrack = null;
669
- }
670
- }
671
- }
672
-
673
- export const audioPlayer = new AudioPlayer();
1
+ import { spawn } from 'child_process';
2
+ import { existsSync, unlinkSync, readdirSync, statSync } from 'node:fs';
3
+ import { join, dirname, basename } from 'node:path';
4
+ import { ensureYouTubeCookies, getCookieStatus } from './youtubeAuth.js';
5
+
6
+ let _ffmpeg = null;
7
+ async function loadFfmpeg() {
8
+ if (_ffmpeg) return _ffmpeg;
9
+ try {
10
+ const [{ default: ffmpeg }, { default: ffmpegPath }] = await Promise.all([
11
+ import('fluent-ffmpeg'),
12
+ import('ffmpeg-static'),
13
+ ]);
14
+ if (ffmpegPath) ffmpeg.setFfmpegPath(ffmpegPath);
15
+ _ffmpeg = ffmpeg;
16
+ return _ffmpeg;
17
+ } catch {
18
+ throw new Error('Local audio output is not bundled with the global core CLI because its native dependencies can break installation. You can still use search and media-link features.');
19
+ }
20
+ }
21
+
22
+ let _SpeakerCtor = null;
23
+ async function loadSpeaker() {
24
+ if (_SpeakerCtor) return _SpeakerCtor;
25
+ try {
26
+ const mod = await import('speaker');
27
+ _SpeakerCtor = mod.default;
28
+ } catch {
29
+ throw new Error('Local audio output is unavailable in the global core CLI; the native speaker add-on is intentionally not installed.');
30
+ }
31
+ return _SpeakerCtor;
32
+ }
33
+
34
+ const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
35
+
36
+ function ytDlpSpawn(args) {
37
+ return spawn(YTDLP_CMD, args, { windowsHide: true });
38
+ }
39
+
40
+ function ytDlpArgs(query, opts = {}) {
41
+ const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
42
+ const args = ['-f', 'bestaudio', '--no-warnings', '--ignore-errors'];
43
+ if (opts.client) args.push('--extractor-args', `youtube:player_client=${opts.client}`);
44
+ if (opts.cookies) {
45
+ if (opts.cookies.startsWith('browser:')) args.push('--cookies-from-browser', opts.cookies.slice('browser:'.length));
46
+ else args.push('--cookies', opts.cookies);
47
+ }
48
+ args.push(arg);
49
+ return args;
50
+ }
51
+
52
+ const YTDLP_STRATEGIES = [
53
+ {},
54
+ { client: 'tv' },
55
+ { client: 'ios' },
56
+ { client: 'web' },
57
+ { cookies: 'browser:chrome' },
58
+ { cookies: 'browser:edge' },
59
+ { cookies: 'browser:firefox' },
60
+ { cookies: 'browser:brave' },
61
+ { client: 'tv', cookies: 'browser:chrome' },
62
+ { client: 'ios', cookies: 'browser:chrome' }
63
+ ];
64
+
65
+ function buildStrategies(cookieResult) {
66
+ const strategies = [];
67
+
68
+ const status = getCookieStatus();
69
+ const cookiesFile = process.env.KIKU_YT_COOKIES || status.envCookies;
70
+ if (cookiesFile) {
71
+ strategies.push({ cookies: cookiesFile });
72
+ }
73
+
74
+ const detectedBrowser = cookieResult?.browser || process.env.KIKU_YT_BROWSER;
75
+ if (detectedBrowser) {
76
+ strategies.push({ cookies: `browser:${detectedBrowser}` });
77
+ }
78
+
79
+ if (process.env.KIKU_YTDLP_CLIENT) {
80
+ strategies.push({ client: process.env.KIKU_YTDLP_CLIENT });
81
+ }
82
+
83
+ for (const s of YTDLP_STRATEGIES) {
84
+ if (!strategies.some(existing => existing.client === s.client && existing.cookies === s.cookies)) {
85
+ strategies.push(s);
86
+ }
87
+ }
88
+
89
+ return strategies;
90
+ }
91
+
92
+ function ytDlpTry(query, opts) {
93
+ return new Promise((resolve, reject) => {
94
+ const proc = ytDlpSpawn(ytDlpArgs(query, opts));
95
+ let gotData = false;
96
+ let errBuf = '';
97
+ proc.stdout.once('data', () => { gotData = true; resolve(proc); });
98
+ proc.stderr.on('data', d => { errBuf += d.toString(); });
99
+ proc.on('error', e => { if (!gotData) { try { proc.kill('SIGKILL'); } catch {} reject(e); } });
100
+ proc.on('close', code => {
101
+ if (!gotData) { try { proc.kill('SIGKILL'); } catch {} reject(new Error(errBuf.trim() || `yt-dlp exited with code ${code}`)); }
102
+ });
103
+ });
104
+ }
105
+
106
+ function findDownloadedFile(dir, baseName) {
107
+ try {
108
+ const entries = readdirSync(dir);
109
+ for (const entry of entries) {
110
+ if (entry === baseName) {
111
+ const full = join(dir, entry);
112
+ try { if (statSync(full).isFile()) return full; } catch {}
113
+ }
114
+ if (entry.startsWith(baseName + '.')) {
115
+ const full = join(dir, entry);
116
+ try { if (statSync(full).isFile()) return full; } catch {}
117
+ }
118
+ }
119
+ } catch {}
120
+ return null;
121
+ }
122
+
123
+ function normalizeSearchQuery(query) {
124
+ if (!query || typeof query !== 'string') return query;
125
+ if (/^https?:\/\//.test(query)) return query;
126
+ if (/jujingyi|จวีจิ้งอี|鞠婧祎|ju jingyi/i.test(query)) return query;
127
+ return `Ju Jingyi ${query}`;
128
+ }
129
+
130
+ async function ytDlpDownload(query, opts, outputPath) {
131
+ const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
132
+ const args = [
133
+ '-f', 'bestaudio',
134
+ '--no-warnings',
135
+ '--ignore-errors',
136
+ '-x', '--audio-format', 'mp3',
137
+ '-o', outputPath + '.%(ext)s',
138
+ '--no-playlist'
139
+ ];
140
+ if (opts.client) args.push('--extractor-args', `youtube:player_client=${opts.client}`);
141
+ if (opts.cookies) {
142
+ if (opts.cookies.startsWith('browser:')) args.push('--cookies-from-browser', opts.cookies.slice('browser:'.length));
143
+ else args.push('--cookies', opts.cookies);
144
+ }
145
+ args.push(arg);
146
+
147
+ const dir = dirname(outputPath);
148
+ const baseName = basename(outputPath);
149
+
150
+ return new Promise((resolve, reject) => {
151
+ const p = ytDlpSpawn(args);
152
+ let err = '';
153
+ p.stderr.on('data', d => { err += d.toString(); });
154
+ p.on('error', reject);
155
+ p.on('close', code => {
156
+ const found = findDownloadedFile(dir, baseName);
157
+ if (found) {
158
+ resolve(found);
159
+ } else if (code === 0) {
160
+ resolve(outputPath);
161
+ } else {
162
+ reject(new Error(err.trim() || `yt-dlp exited ${code}`));
163
+ }
164
+ });
165
+ });
166
+ }
167
+
168
+ async function ytDlpGetTitle(query, strategies) {
169
+ if (!strategies) strategies = buildStrategies();
170
+ for (const s of strategies) {
171
+ try {
172
+ const arg = /^https?:\/\//.test(query) ? query : `ytsearch1:${query}`;
173
+ const args = ['--print', '%(title)s', '--no-warnings'];
174
+ if (s.client) args.push('--extractor-args', `youtube:player_client=${s.client}`);
175
+ if (s.cookies) {
176
+ if (s.cookies.startsWith('browser:')) args.push('--cookies-from-browser', s.cookies.slice('browser:'.length));
177
+ else args.push('--cookies', s.cookies);
178
+ }
179
+ args.push(arg);
180
+ const title = await new Promise((resolve, reject) => {
181
+ const p = ytDlpSpawn(args);
182
+ let out = '';
183
+ let err = '';
184
+ p.stdout.on('data', d => { out += d.toString(); });
185
+ p.stderr.on('data', d => { err += d.toString(); });
186
+ p.on('error', e => reject(e));
187
+ p.on('close', c => {
188
+ const t = out.trim();
189
+ if (c === 0 && t) resolve(t);
190
+ else reject(new Error(err.trim() || `yt-dlp exited ${c}`));
191
+ });
192
+ });
193
+ if (title) return title;
194
+ } catch {}
195
+ }
196
+ return null;
197
+ }
198
+
199
+ async function ytDlpGetStream(query, strategies) {
200
+ if (!strategies) strategies = buildStrategies();
201
+ let lastErr;
202
+ for (const s of strategies) {
203
+ try {
204
+ const proc = await ytDlpTry(query, s);
205
+ return { proc, opts: s };
206
+ } catch (e) {
207
+ lastErr = e;
208
+ }
209
+ }
210
+ throw lastErr || new Error('yt-dlp could not extract an audio stream');
211
+ }
212
+
213
+ async function ytDlpJsonSearch(query, limit = 10) {
214
+ return new Promise((resolve, reject) => {
215
+ const proc = ytDlpSpawn(['--flat-playlist', '--dump-single-json', '--no-warnings', `ytsearch${limit}:${query}`]);
216
+ let out = '';
217
+ const timer = setTimeout(() => {
218
+ try { proc.kill('SIGKILL'); } catch {}
219
+ reject(new Error('YouTube search timed out'));
220
+ }, 30000);
221
+ proc.stdout.on('data', (d) => { out += d.toString(); });
222
+ proc.on('error', (e) => {
223
+ clearTimeout(timer);
224
+ reject(e);
225
+ });
226
+ proc.on('close', () => {
227
+ clearTimeout(timer);
228
+ try {
229
+ resolve(JSON.parse(out));
230
+ } catch {
231
+ reject(new Error('Could not parse YouTube search results'));
232
+ }
233
+ });
234
+ });
235
+ }
236
+
237
+ function searchEntryUrl(entry) {
238
+ if (!entry) return null;
239
+ return entry.url || (entry.id ? `https://www.youtube.com/watch?v=${entry.id}` : null);
240
+ }
241
+
242
+ function formatSeconds(secs) {
243
+ const s = Math.floor(Math.max(0, Number(secs) || 0));
244
+ const mins = Math.floor(s / 60);
245
+ const rem = s % 60;
246
+ return `${mins}:${String(rem).padStart(2, '0')}`;
247
+ }
248
+
249
+ class AudioPlayer {
250
+ constructor() {
251
+ this.ytdlStream = null;
252
+ this.ffmpegProcess = null;
253
+ this.speaker = null;
254
+ this.currentTrack = null;
255
+ this.isPlaying = false;
256
+ this.isPaused = false;
257
+ this.isMuted = false;
258
+ this.queue = [];
259
+ this.currentIndex = -1;
260
+ this._advancing = false;
261
+ this._playPromise = null;
262
+ this._ytDlpProc = null;
263
+ this._intentionalStop = false;
264
+ this._currentOffsetSeconds = 0;
265
+ this._playbackStartedAt = 0;
266
+ }
267
+
268
+ _cleanup() {
269
+ this._intentionalStop = true;
270
+ if (this.speaker) {
271
+ try { this.speaker.close(); } catch {}
272
+ this.speaker = null;
273
+ }
274
+ if (this._ytDlpProc) {
275
+ try { this._ytDlpProc.kill('SIGKILL'); } catch {}
276
+ this._ytDlpProc = null;
277
+ }
278
+ if (this.ffmpegProcess) {
279
+ try { this.ffmpegProcess.kill('SIGKILL'); } catch {}
280
+ this.ffmpegProcess = null;
281
+ }
282
+ if (this.ytdlStream) {
283
+ try { this.ytdlStream.destroy(); } catch {}
284
+ this.ytdlStream = null;
285
+ }
286
+ }
287
+
288
+ _cleanupTempFile() {
289
+ this._currentOffsetSeconds = 0;
290
+ this._playbackStartedAt = 0;
291
+ if (!this._tempFile) return;
292
+ try {
293
+ const dir = dirname(this._tempFile);
294
+ const baseName = basename(this._tempFile);
295
+ const entries = readdirSync(dir);
296
+ for (const entry of entries) {
297
+ if (entry === baseName || entry.startsWith(baseName + '.')) {
298
+ try { unlinkSync(join(dir, entry)); } catch {}
299
+ }
300
+ }
301
+ } catch {}
302
+ this._tempFile = null;
303
+ }
304
+
305
+ _createStream(query) {
306
+ this._intentionalStop = false;
307
+ query = normalizeSearchQuery(query);
308
+ return new Promise(async (resolve, reject) => {
309
+ try {
310
+ const cookieResult = await ensureYouTubeCookies({ silent: true });
311
+ if (!cookieResult.ok) {
312
+ console.error(' [WARNING] YouTube cookies unavailable (' + (cookieResult.reason || 'unknown') + ')');
313
+ console.error(' Playback may fail due to YouTube restrictions.');
314
+ console.error(' Run /login to sign in, or set KIKU_YT_COOKIES in .env\n');
315
+ } else {
316
+ console.log(` [OK] YouTube session: ${cookieResult.source} (${cookieResult.browser || cookieResult.cookiesPath || 'file'})`);
317
+ }
318
+
319
+ const strategies = buildStrategies(cookieResult);
320
+
321
+ let tempFile = null;
322
+ let title = null;
323
+ let ytProc = null;
324
+
325
+ try {
326
+ title = await ytDlpGetTitle(query, strategies).catch(() => null);
327
+
328
+ const { mkdtemp } = await import('node:fs/promises');
329
+ const { tmpdir } = await import('node:os');
330
+ const tmpDir = await mkdtemp(join(tmpdir(), 'kiku-audio-'));
331
+ tempFile = join(tmpDir, 'audio');
332
+
333
+ let downloaded = false;
334
+ for (const s of strategies) {
335
+ try {
336
+ const downloadedPath = await ytDlpDownload(query, s, tempFile);
337
+ tempFile = downloadedPath;
338
+ downloaded = true;
339
+ console.log(' [OK] Downloaded audio via yt-dlp to', tempFile);
340
+ break;
341
+ } catch (e) {
342
+ console.log(' Strategy failed:', e.message.slice(0, 80));
343
+ }
344
+ }
345
+
346
+ if (!downloaded) {
347
+ console.error(' [WARNING] yt-dlp could not download a playable audio source.');
348
+ tempFile = null;
349
+ }
350
+ } catch (err) {
351
+ console.error(' All download methods failed:', err.message);
352
+ }
353
+
354
+ if (!tempFile) {
355
+ this._cleanup();
356
+ this.isPlaying = false;
357
+ this.isPaused = false;
358
+ this._advancing = false;
359
+ return reject(new Error('No playable audio source found'));
360
+ }
361
+
362
+ this.currentTrack = title || query;
363
+ this._ytDlpProc = ytProc;
364
+ this._tempFile = tempFile;
365
+ this._currentOffsetSeconds = 0;
366
+ this._playbackStartedAt = Date.now();
367
+
368
+ const SpeakerCtor = await loadSpeaker();
369
+ const speaker = new SpeakerCtor({
370
+ channels: 2,
371
+ bitDepth: 16,
372
+ sampleRate: 44100
373
+ });
374
+
375
+ this.speaker = speaker;
376
+
377
+ const volumeFilter = this.isMuted ? 'volume=0' : 'volume=1';
378
+
379
+ const ffmpeg = await loadFfmpeg();
380
+ let ff = ffmpeg(tempFile);
381
+ if (this._currentOffsetSeconds > 0) {
382
+ ff = ff.seekInput(this._currentOffsetSeconds);
383
+ }
384
+ ff = ff
385
+ .format('s16le')
386
+ .audioFrequency(44100)
387
+ .audioChannels(2)
388
+ .audioFilter(volumeFilter)
389
+ .on('error', (err) => {
390
+ if (this._intentionalStop) return;
391
+ console.error('Stream error:', err.message);
392
+ this._cleanup();
393
+ this._cleanupTempFile();
394
+ this.isPlaying = false;
395
+ this.isPaused = false;
396
+ this._advancing = false;
397
+ this._removeCurrentFromQueue();
398
+ reject(new Error(err.message));
399
+ })
400
+ .on('end', () => {
401
+ if (this._intentionalStop) return;
402
+ this._cleanup();
403
+ this._cleanupTempFile();
404
+ this.isPlaying = false;
405
+ this.isPaused = false;
406
+ this._advancing = false;
407
+ this._autoNext();
408
+ })
409
+ .pipe(speaker, { end: true });
410
+
411
+ this.ffmpegProcess = ff;
412
+ this.isPlaying = true;
413
+ this.isPaused = false;
414
+ resolve();
415
+ } catch (err) {
416
+ this._cleanup();
417
+ this.isPlaying = false;
418
+ this.isPaused = false;
419
+ this._advancing = false;
420
+ this._removeCurrentFromQueue();
421
+ reject(err);
422
+ }
423
+ });
424
+ }
425
+
426
+ _removeCurrentFromQueue() {
427
+ if (this.currentIndex < 0 || this.currentIndex >= this.queue.length) return;
428
+ this.queue.splice(this.currentIndex, 1);
429
+ if (this.queue.length === 0) {
430
+ this.currentIndex = -1;
431
+ this.currentTrack = null;
432
+ } else if (this.currentIndex >= this.queue.length) {
433
+ this.currentIndex = this.queue.length - 1;
434
+ }
435
+ }
436
+
437
+ async play(query) {
438
+ this._cleanup();
439
+ this.queue = [query];
440
+ this.currentIndex = 0;
441
+ this.currentTrack = query;
442
+ this._playPromise = this._createStream(query);
443
+ await this._playPromise;
444
+ return `Now playing: ${query}`;
445
+ }
446
+
447
+ async playFromQueue(index) {
448
+ if (index < 0 || index >= this.queue.length) return 'Invalid queue index';
449
+ this._cleanup();
450
+ this.currentIndex = index;
451
+ this.currentTrack = this.queue[index];
452
+ this._playPromise = this._createStream(this.currentTrack);
453
+ await this._playPromise;
454
+ return `Playing from queue [${index + 1}/${this.queue.length}]: ${this.currentTrack}`;
455
+ }
456
+
457
+ async addToQueue(query) {
458
+ this.queue.push(query);
459
+ if (!this.isPlaying) {
460
+ this.currentIndex = this.queue.length - 1;
461
+ return await this.play(query);
462
+ }
463
+ return `Added "${query}" to queue (position ${this.queue.length})`;
464
+ }
465
+
466
+ next() {
467
+ if (this.currentIndex < this.queue.length - 1) {
468
+ this._cleanup();
469
+ this.currentIndex++;
470
+ this.currentTrack = this.queue[this.currentIndex];
471
+ this._playPromise = this._createStream(this.currentTrack);
472
+ return `Playing next track [${this.currentIndex + 1}/${this.queue.length}]: ${this.currentTrack}`;
473
+ }
474
+ return 'No next track in queue';
475
+ }
476
+
477
+ prev() {
478
+ if (this.currentIndex > 0) {
479
+ this._cleanup();
480
+ this.currentIndex--;
481
+ this.currentTrack = this.queue[this.currentIndex];
482
+ this._playPromise = this._createStream(this.currentTrack);
483
+ return `Playing previous track [${this.currentIndex + 1}/${this.queue.length}]: ${this.currentTrack}`;
484
+ }
485
+ return 'No previous track in queue';
486
+ }
487
+
488
+ removeFromQueue(index) {
489
+ if (index < 0 || index >= this.queue.length) return 'Invalid queue index';
490
+ const removed = this.queue[index];
491
+ this.queue.splice(index, 1);
492
+ if (this.currentIndex === index) {
493
+ this._cleanup();
494
+ this.isPlaying = false;
495
+ this.isPaused = false;
496
+ this.currentTrack = null;
497
+ return `Removed current track: ${removed} (stopped playback)`;
498
+ } else if (this.currentIndex > index) {
499
+ this.currentIndex--;
500
+ }
501
+ return `Removed "${removed}" from queue`;
502
+ }
503
+
504
+ stop() {
505
+ this._currentOffsetSeconds = 0;
506
+ this._playbackStartedAt = 0;
507
+ this._cleanup();
508
+ this._cleanupTempFile();
509
+ this.isPlaying = false;
510
+ this.isPaused = false;
511
+ this.currentTrack = null;
512
+ this.queue = [];
513
+ this.currentIndex = -1;
514
+ this._playPromise = null;
515
+ return 'Stopped playback and cleared queue';
516
+ }
517
+
518
+ clearQueue() {
519
+ if (this.isPlaying) {
520
+ this._cleanup();
521
+ this._cleanupTempFile();
522
+ this.isPlaying = false;
523
+ this.isPaused = false;
524
+ this.currentTrack = null;
525
+ }
526
+ const count = this.queue.length;
527
+ this.queue = [];
528
+ this.currentIndex = -1;
529
+ this._currentOffsetSeconds = 0;
530
+ this._playbackStartedAt = 0;
531
+ return `Cleared ${count} songs from queue`;
532
+ }
533
+
534
+ pause() {
535
+ if (!this.isPlaying) return 'No song is currently playing';
536
+ if (this.isPaused) return 'Song is already paused';
537
+ if (this._playbackStartedAt > 0) {
538
+ const elapsed = (Date.now() - this._playbackStartedAt) / 1000;
539
+ this._currentOffsetSeconds += Math.max(0, elapsed);
540
+ this._playbackStartedAt = 0;
541
+ }
542
+ this.isPaused = true;
543
+ this._cleanup();
544
+ const pos = formatSeconds(this._currentOffsetSeconds);
545
+ return `Paused: ${this.currentTrack} (${pos})`;
546
+ }
547
+
548
+ resume() {
549
+ if (!this.isPaused || !this.currentTrack) return 'No track is paused';
550
+ this.isPaused = false;
551
+ this.isPlaying = true;
552
+ this._playbackStartedAt = Date.now();
553
+ if (this._tempFile) {
554
+ this._replayFromTemp();
555
+ } else {
556
+ this._playPromise = this._createStream(this.currentTrack);
557
+ }
558
+ const pos = formatSeconds(this._currentOffsetSeconds);
559
+ return `Resumed: ${this.currentTrack} (from ${pos})`;
560
+ }
561
+
562
+ mute() {
563
+ if (!this.isPlaying) return 'No song is currently playing';
564
+ this.isMuted = true;
565
+ if (this._playbackStartedAt > 0 && !this.isPaused) {
566
+ this._currentOffsetSeconds += (Date.now() - this._playbackStartedAt) / 1000;
567
+ this._playbackStartedAt = Date.now();
568
+ }
569
+ if (this._tempFile && !this.isPaused) {
570
+ this._replayFromTemp();
571
+ }
572
+ return `Muted: ${this.currentTrack}`;
573
+ }
574
+
575
+ unmute() {
576
+ if (!this.isPlaying) return 'No song is currently playing';
577
+ this.isMuted = false;
578
+ if (this._playbackStartedAt > 0 && !this.isPaused) {
579
+ this._currentOffsetSeconds += (Date.now() - this._playbackStartedAt) / 1000;
580
+ this._playbackStartedAt = Date.now();
581
+ }
582
+ if (this._tempFile && !this.isPaused) {
583
+ this._replayFromTemp();
584
+ }
585
+ return `Unmuted: ${this.currentTrack}`;
586
+ }
587
+
588
+ _replayFromTemp() {
589
+ this._startTempPlayback().catch((err) => {
590
+ console.error('Replay error:', err.message);
591
+ this._cleanup();
592
+ this._cleanupTempFile();
593
+ this.isPlaying = false;
594
+ this.isPaused = false;
595
+ this._advancing = false;
596
+ });
597
+ }
598
+
599
+ async _startTempPlayback() {
600
+ this._intentionalStop = true;
601
+
602
+ if (this.speaker) {
603
+ try { this.speaker.close(); } catch {}
604
+ this.speaker = null;
605
+ }
606
+ if (this.ffmpegProcess) {
607
+ try { this.ffmpegProcess.kill('SIGKILL'); } catch {}
608
+ this.ffmpegProcess = null;
609
+ }
610
+
611
+ const SpeakerCtor = await loadSpeaker();
612
+ const speaker = new SpeakerCtor({
613
+ channels: 2,
614
+ bitDepth: 16,
615
+ sampleRate: 44100
616
+ });
617
+ this.speaker = speaker;
618
+
619
+ const volumeFilter = this.isMuted ? 'volume=0' : 'volume=1';
620
+
621
+ this._intentionalStop = false;
622
+ const ffmpeg = await loadFfmpeg();
623
+ let ff = ffmpeg(this._tempFile);
624
+ if (this._currentOffsetSeconds > 0) {
625
+ ff = ff.seekInput(this._currentOffsetSeconds);
626
+ }
627
+ ff = ff
628
+ .format('s16le')
629
+ .audioFrequency(44100)
630
+ .audioChannels(2)
631
+ .audioFilter(volumeFilter)
632
+ .on('error', (err) => {
633
+ if (this._intentionalStop) return;
634
+ console.error('Replay stream error:', err.message);
635
+ this._cleanup();
636
+ this._cleanupTempFile();
637
+ this.isPlaying = false;
638
+ this.isPaused = false;
639
+ this._advancing = false;
640
+ this._removeCurrentFromQueue();
641
+ })
642
+ .on('end', () => {
643
+ if (this._intentionalStop) return;
644
+ this._cleanup();
645
+ this._cleanupTempFile();
646
+ this.isPlaying = false;
647
+ this.isPaused = false;
648
+ this._advancing = false;
649
+ this._autoNext();
650
+ })
651
+ .pipe(speaker, { end: true });
652
+
653
+ this.ffmpegProcess = ff;
654
+ }
655
+
656
+ async searchYouTube(query) {
657
+ try {
658
+ const data = await ytDlpJsonSearch(query, 10);
659
+ const results = data.entries || [];
660
+ return results
661
+ .filter(Boolean)
662
+ .map(item => {
663
+ let duration = '--:--';
664
+ if (typeof item.duration === 'number') {
665
+ duration = `${Math.floor(item.duration / 60)}:${String(Math.round(item.duration % 60)).padStart(2, '0')}`;
666
+ }
667
+ const url = searchEntryUrl(item);
668
+ return {
669
+ title: item.title,
670
+ url,
671
+ duration,
672
+ channel: item.channel || item.uploader || 'Unknown'
673
+ };
674
+ })
675
+ .filter(item => item.url);
676
+ } catch (err) {
677
+ console.error('YouTube search error:', err.message);
678
+ return [];
679
+ }
680
+ }
681
+
682
+ getQueue() {
683
+ return this.queue.map((track, i) => ({
684
+ index: i,
685
+ title: track,
686
+ isCurrent: i === this.currentIndex
687
+ }));
688
+ }
689
+
690
+ getStatus() {
691
+ if (this.isPlaying && this.currentTrack) {
692
+ const shortName = this.currentTrack.length > 20
693
+ ? this.currentTrack.slice(0, 20) + '...'
694
+ : this.currentTrack;
695
+ const qInfo = this.queue.length > 1 ? ` [${this.queue.length} in queue]` : '';
696
+ let currentPos = this._currentOffsetSeconds;
697
+ if (!this.isPaused && this._playbackStartedAt > 0) {
698
+ currentPos += (Date.now() - this._playbackStartedAt) / 1000;
699
+ }
700
+ const timeStr = currentPos > 0 ? ` (${formatSeconds(currentPos)})` : '';
701
+ if (this.isMuted) {
702
+ return `[Muted: ${shortName}${timeStr}${qInfo}]`;
703
+ }
704
+ if (this.isPaused) {
705
+ return `[Paused: ${shortName}${timeStr}${qInfo}]`;
706
+ }
707
+ return `[Playing: ${shortName}${timeStr}${qInfo}]`;
708
+ }
709
+ if (this.queue.length > 0) {
710
+ return `[Queue: ${this.queue.length} songs]`;
711
+ }
712
+ return '';
713
+ }
714
+
715
+ getFullState() {
716
+ return {
717
+ isPlaying: this.isPlaying,
718
+ isPaused: this.isPaused,
719
+ currentTrack: this.currentTrack,
720
+ queue: this.queue,
721
+ currentIndex: this.currentIndex,
722
+ queueLength: this.queue.length
723
+ };
724
+ }
725
+
726
+ _autoNext() {
727
+ if (this._advancing) return;
728
+ if (this.queue.length > 0 && this.currentIndex < this.queue.length - 1) {
729
+ this._advancing = true;
730
+ this.currentIndex++;
731
+ this.currentTrack = this.queue[this.currentIndex];
732
+ this._createStream(this.currentTrack);
733
+ } else {
734
+ this.currentTrack = null;
735
+ }
736
+ }
737
+ }
738
+
739
+ export const audioPlayer = new AudioPlayer();