kiku-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +122 -0
  2. package/package.json +85 -0
  3. package/server/agent/agentCore.js +782 -0
  4. package/server/agent/agentState.js +140 -0
  5. package/server/agent/audioPlayer.js +673 -0
  6. package/server/agent/cli.js +1111 -0
  7. package/server/agent/commandRegistry.js +58 -0
  8. package/server/agent/commands.js +788 -0
  9. package/server/agent/config.js +90 -0
  10. package/server/agent/errors.js +9 -0
  11. package/server/agent/eventBus.js +103 -0
  12. package/server/agent/llmClient.js +200 -0
  13. package/server/agent/memory.js +566 -0
  14. package/server/agent/sanitizer.js +314 -0
  15. package/server/agent/session.js +239 -0
  16. package/server/agent/theme.js +124 -0
  17. package/server/agent/tools.js +1528 -0
  18. package/server/agent/ui.js +469 -0
  19. package/server/agent/webSearch.js +719 -0
  20. package/server/agent/youtubeAuth.js +299 -0
  21. package/server/mcp/claude-desktop-config.example.json +9 -0
  22. package/server/mcp/data/awards.js +14 -0
  23. package/server/mcp/data/brands.js +9 -0
  24. package/server/mcp/data/discography.js +36 -0
  25. package/server/mcp/data/gallery.js +25 -0
  26. package/server/mcp/data/index.js +7 -0
  27. package/server/mcp/data/profile.js +44 -0
  28. package/server/mcp/data/timeline.js +16 -0
  29. package/server/mcp/data/works.js +104 -0
  30. package/server/mcp/handler.js +159 -0
  31. package/server/mcp/prompts.js +143 -0
  32. package/server/mcp/resources.js +54 -0
  33. package/server/mcp/security.js +138 -0
  34. package/server/mcp/server.js +100 -0
  35. package/server/mcp/stdio.js +33 -0
  36. package/server/mcp/tools.js +126 -0
  37. package/src/data/awardsData.js +146 -0
  38. package/src/data/brandsData.js +249 -0
  39. package/src/data/discographyData.js +874 -0
  40. package/src/data/galleryData.js +666 -0
  41. package/src/data/musicVideoData.js +406 -0
  42. package/src/data/profileData.js +156 -0
  43. package/src/data/socialUpdatesData.js +381 -0
  44. package/src/data/timelineData.js +172 -0
  45. package/src/data/worksData.js +516 -0
@@ -0,0 +1,673 @@
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();