auralis-sdk 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -75,12 +75,12 @@ var LoadError = class extends AuralisError {
75
75
  };
76
76
 
77
77
  // src/types.ts
78
- var BIG_TAG = "__BIG__";
78
+ var BIG_TAG = "||BIGINT||";
79
79
  function snowflakeStringify(obj) {
80
80
  return JSON.stringify(
81
81
  obj,
82
82
  (_k, v) => typeof v === "bigint" ? `${BIG_TAG}${v}${BIG_TAG}` : v
83
- ).replace(new RegExp(`"${BIG_TAG}(\\d+)${BIG_TAG}"`, "g"), "$1");
83
+ ).replace(/"\|\|BIGINT\|\|(\d+)\|\|BIGINT\|\|"/g, "$1");
84
84
  }
85
85
  var LoopMode = /* @__PURE__ */ ((LoopMode2) => {
86
86
  LoopMode2["Off"] = "off";
@@ -189,6 +189,9 @@ var Player = class {
189
189
  maxPositionMs = 0;
190
190
  /** Alternate-upload attempts spent on the current dead track. */
191
191
  recoverAttempts = 0;
192
+ /** Wall-clock time (ms) when the last PLAYER_UPDATE position was received.
193
+ * Used by the {@link positionMs} interpolating getter. */
194
+ _positionTimestamp = 0;
192
195
  // --- voice plumbing ---------------------------------------------------
193
196
  handleVoiceServerUpdate(data) {
194
197
  this.voiceServer = { token: data.token, endpoint: data.endpoint };
@@ -214,6 +217,7 @@ var Player = class {
214
217
  * spotted, and clears the recovery counter once a track is genuinely playing. */
215
218
  applyState(state) {
216
219
  this.state = state;
220
+ this._positionTimestamp = Date.now();
217
221
  if (state.positionMs > this.maxPositionMs) this.maxPositionMs = state.positionMs;
218
222
  if (this.maxPositionMs > this.deadStreamThresholdMs) this.recoverAttempts = 0;
219
223
  }
@@ -362,6 +366,7 @@ var Player = class {
362
366
  const prev = this.history.pop();
363
367
  if (!prev) return;
364
368
  if (this.current !== null) this.queue.unshift(this.current);
369
+ this.current = null;
365
370
  this.queue.unshift(prev);
366
371
  await this.advance(true);
367
372
  }
@@ -369,17 +374,139 @@ var Player = class {
369
374
  async setBassBoost(gainDb) {
370
375
  await this.setFilters(gainDb === 0 ? null : `bass=g=${gainDb}`);
371
376
  }
372
- /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal. */
377
+ /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal.
378
+ * Uses `atempo` for tempo and `asetrate` for pitch shift — both evaluated
379
+ * in JS so the filter string contains literal numbers, not expressions. */
373
380
  async setSpeed(rate) {
374
381
  const r = Math.max(0.5, Math.min(2, rate));
375
- await this.setFilters(r === 1 ? null : `atempo=${r},asetrate=48000*${r}`);
382
+ await this.setFilters(r === 1 ? null : `atempo=${r},asetrate=${48e3 * r}`);
376
383
  }
377
384
  nowPlaying() {
378
385
  return this.current;
379
386
  }
387
+ /** Enqueue a track or query to play immediately after the current one.
388
+ * Unlike `insert(0, ...)`, this is an explicit "play next" intent — it
389
+ * always inserts at position 0 regardless of queue state and starts
390
+ * playback if the player is idle. */
391
+ async playNext(query, requester) {
392
+ return this.insert(0, query, requester);
393
+ }
394
+ /** Remove all duplicate tracks from the queue (keeps first occurrence).
395
+ * Uniqueness is determined by `encoded` token. Returns the number removed. */
396
+ dedupe() {
397
+ const seen = /* @__PURE__ */ new Set();
398
+ const before = this.queue.length;
399
+ this.queue = this.queue.filter((t) => {
400
+ if (seen.has(t.encoded)) return false;
401
+ seen.add(t.encoded);
402
+ return true;
403
+ });
404
+ return before - this.queue.length;
405
+ }
406
+ /** Find all tracks in the queue whose title or author contains `query`
407
+ * (case-insensitive). Returns `[index, track]` pairs so callers can remove
408
+ * or jump to a result by index. */
409
+ find(query) {
410
+ const q = query.toLowerCase();
411
+ return this.queue.reduce((acc, t, i) => {
412
+ if (t.title.toLowerCase().includes(q) || t.author.toLowerCase().includes(q)) {
413
+ acc.push([i, t]);
414
+ }
415
+ return acc;
416
+ }, []);
417
+ }
418
+ /** Total duration of all queued tracks in milliseconds. Streams count as 0.
419
+ * Does not include the currently playing track. */
420
+ get totalDurationMs() {
421
+ return this.queue.reduce((sum, t) => sum + (t.isStream ? 0 : t.lengthMs), 0);
422
+ }
423
+ /** Interpolated playback position in milliseconds. Between `PLAYER_UPDATE`
424
+ * frames this advances using wall-clock time so progress bars stay smooth.
425
+ * Returns 0 if nothing is playing or the player is paused. */
426
+ get positionMs() {
427
+ if (!this.state.playing || this.state.paused) return this.state.positionMs;
428
+ const elapsed = Date.now() - this._positionTimestamp;
429
+ return Math.min(
430
+ this.state.positionMs + elapsed,
431
+ this.current?.lengthMs ?? this.state.positionMs + elapsed
432
+ );
433
+ }
434
+ /** Serialize current queue, history, loop mode and volume into a plain object
435
+ * that can be JSON-stringified and stored. Restore with {@link Player.restore}. */
436
+ export() {
437
+ return {
438
+ version: 1,
439
+ guildId: String(this.guildId),
440
+ queue: this.queue.map((t) => ({ ...t })),
441
+ history: this.history.map((t) => ({ ...t })),
442
+ current: this.current ? { ...this.current } : null,
443
+ loop: this.loop,
444
+ volume: this.volume,
445
+ paused: this.paused
446
+ };
447
+ }
448
+ /** Restore queue/history/loop/volume from a previously exported snapshot.
449
+ * Does NOT auto-start playback — call `startIfIdle()` or let the next
450
+ * voice event trigger it. */
451
+ restore(snapshot) {
452
+ this.queue = snapshot.queue.map((t) => ({ ...t }));
453
+ this.history = snapshot.history.map((t) => ({ ...t }));
454
+ this.current = snapshot.current ? { ...snapshot.current } : null;
455
+ this.loop = snapshot.loop;
456
+ this.volume = snapshot.volume;
457
+ this.paused = snapshot.paused;
458
+ }
459
+ /** Apply a named EQ preset. `"flat"` clears all filters.
460
+ *
461
+ * | preset | effect |
462
+ * |-------------|---------------------------------------------|
463
+ * | flat | no filters (passthrough) |
464
+ * | bassboost | heavy bass boost (+8 dB) |
465
+ * | soft | gentle bass lift (+3 dB) |
466
+ * | nightcore | speed+pitch up (1.3×) |
467
+ * | vaporwave | speed+pitch down (0.8×) |
468
+ * | earrape | extreme distortion / clipping (use wisely) |
469
+ * | treble | high-frequency boost (+6 dB) |
470
+ * | karaoke | basic vocal removal |
471
+ */
472
+ async setEqualizer(preset) {
473
+ const graphs = {
474
+ flat: null,
475
+ bassboost: "bass=g=8",
476
+ soft: "bass=g=3",
477
+ nightcore: `atempo=1.3,asetrate=${Math.round(48e3 * 1.3)}`,
478
+ vaporwave: `atempo=0.8,asetrate=${Math.round(48e3 * 0.8)}`,
479
+ earrape: "acrusher=level_in=8:level_out=18:bits=8:mode=log:aa=1",
480
+ treble: "treble=g=6",
481
+ karaoke: "pan=stereo|c0=c0-c1|c1=c1-c0"
482
+ };
483
+ await this.setFilters(graphs[preset]);
484
+ }
485
+ /** Reverse the current queue order in-place. */
486
+ reverse() {
487
+ this.queue.reverse();
488
+ }
489
+ /** Remove all tracks from the queue that were requested by `requesterId`. */
490
+ removeBy(requesterId) {
491
+ const id = String(requesterId);
492
+ const before = this.queue.length;
493
+ this.queue = this.queue.filter((t) => String(t.extras.requester) !== id);
494
+ return before - this.queue.length;
495
+ }
496
+ /** Peek at the next N tracks without mutating state. Defaults to 5. */
497
+ peek(n = 5) {
498
+ return this.queue.slice(0, n);
499
+ }
500
+ /** Returns true if nothing is queued and nothing is playing. */
501
+ get idle() {
502
+ return this.current === null && this.queue.length === 0;
503
+ }
380
504
  async disconnect() {
381
505
  this.queue = [];
382
506
  this.current = null;
507
+ this.history = [];
508
+ this.paused = false;
509
+ this.volume = 100;
383
510
  if (this.created) await this.host.rest.destroyPlayer(this.guildId);
384
511
  this.created = false;
385
512
  this.voiceServer = null;
@@ -410,10 +537,13 @@ var Player = class {
410
537
  if (this.current !== null) this.pushHistory(this.current);
411
538
  return this.queue.shift();
412
539
  }
413
- if (this.loop === "queue" /* Queue */ && this.history.length > 0) {
414
- this.queue = [...this.history, ...this.current ? [this.current] : []];
415
- this.history = [];
416
- return this.queue.shift() ?? null;
540
+ if (this.loop === "queue" /* Queue */) {
541
+ const cycle = [...this.history, ...this.current ? [this.current] : []];
542
+ if (cycle.length > 0) {
543
+ this.queue = cycle;
544
+ this.history = [];
545
+ return this.queue.shift() ?? null;
546
+ }
417
547
  }
418
548
  if (this.autoplay !== null) return this.autoplay(this);
419
549
  return null;
@@ -425,14 +555,27 @@ var Player = class {
425
555
  return this.serialize(() => this.advanceInner(forced));
426
556
  }
427
557
  async advanceInner(forced = false) {
428
- const next = await this.nextTrack(forced);
429
- if (next === null) {
430
- this.current = null;
431
- if (this.created) await this.host.rest.stop(this.guildId);
432
- this.host.emit("queueEnd", this);
433
- return;
558
+ for (let attempts = this.queue.length + 1; attempts >= 0; attempts--) {
559
+ const next = await this.nextTrack(forced);
560
+ if (next === null) {
561
+ this.current = null;
562
+ if (this.created) await this.host.rest.stop(this.guildId);
563
+ this.host.emit("queueEnd", this);
564
+ return;
565
+ }
566
+ try {
567
+ await this.playTrack(next);
568
+ return;
569
+ } catch (e) {
570
+ if (e instanceof NotConnected) {
571
+ this.queue.unshift(next);
572
+ throw e;
573
+ }
574
+ this.current = null;
575
+ forced = true;
576
+ this.host.emit("trackException", this, { track: next, error: String(e) });
577
+ }
434
578
  }
435
- await this.playTrack(next);
436
579
  }
437
580
  async playTrack(track) {
438
581
  if (!this.connected) throw new NotConnected(`guild ${this.guildId} has no voice connection`);
@@ -678,6 +821,38 @@ var Client = class extends import_node_events.EventEmitter {
678
821
  this.closed = false;
679
822
  this.openSocket();
680
823
  }
824
+ /** Resolves once the client fires `ready` or `resumed`. If it is already
825
+ * connected it resolves on the next tick. Rejects after `timeoutMs` (default
826
+ * 30 s) so callers don't hang forever on a bad URL/token. */
827
+ waitForReady(timeoutMs = 3e4) {
828
+ return new Promise((resolve, reject) => {
829
+ if (this.hasConnected) {
830
+ resolve();
831
+ return;
832
+ }
833
+ const onReady = () => {
834
+ clearTimeout(timer);
835
+ resolve();
836
+ };
837
+ const onResumed = () => {
838
+ clearTimeout(timer);
839
+ resolve();
840
+ };
841
+ const timer = setTimeout(() => {
842
+ this.off("ready", onReady);
843
+ this.off("resumed", onResumed);
844
+ reject(new Error("[auralis] waitForReady timed out"));
845
+ }, timeoutMs);
846
+ this.once("ready", onReady);
847
+ this.once("resumed", onResumed);
848
+ });
849
+ }
850
+ /** Gracefully disconnect every active player then close the WebSocket.
851
+ * Prefer this over `close()` when you want clean backend teardown. */
852
+ async destroy() {
853
+ await Promise.allSettled(this.players().map((p) => p.disconnect()));
854
+ await this.close();
855
+ }
681
856
  async close() {
682
857
  this.closed = true;
683
858
  if (this.heartbeatTimer) {
@@ -705,6 +880,8 @@ var Client = class extends import_node_events.EventEmitter {
705
880
  if (!p) {
706
881
  p = new Player(this, id);
707
882
  this.playersMap.set(id, p);
883
+ this.subscribeGuild(id);
884
+ this.emit("playerCreated", p);
708
885
  }
709
886
  return p;
710
887
  }
@@ -713,16 +890,22 @@ var Client = class extends import_node_events.EventEmitter {
713
890
  }
714
891
  dropPlayer(guildId) {
715
892
  this.playersMap.delete(guildId);
893
+ this.emit("playerDestroyed", guildId);
716
894
  }
717
895
  // --- metadata convenience --------------------------------------------
718
896
  loadTracks(identifier) {
719
897
  return this.rest.loadTracks(identifier);
720
898
  }
721
- /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
899
+ /** `ytsearch:`-prefix a bare query and return the hits. Throws `LoadError` on
900
+ * a server-side error so callers aren't silently handed an empty array. */
722
901
  async search(query) {
723
902
  const first = query.split(" ", 1)[0];
724
903
  const ident = first.includes(":") ? query : `ytsearch:${query}`;
725
- return (await this.loadTracks(ident)).tracks;
904
+ const result = await this.loadTracks(ident);
905
+ if (result.loadType === "error" /* Error */) {
906
+ throw new LoadError(result.error ?? `search failed for: ${query}`);
907
+ }
908
+ return result.tracks;
726
909
  }
727
910
  // --- websocket --------------------------------------------------------
728
911
  get wsUrl() {
@@ -781,6 +964,14 @@ var Client = class extends import_node_events.EventEmitter {
781
964
  });
782
965
  }
783
966
  }
967
+ subscribeGuild(guildId) {
968
+ if (this.ws?.readyState === import_ws.default.OPEN) {
969
+ this.send({
970
+ op: "SUBSCRIBE",
971
+ d: { guilds: [String(guildId)] }
972
+ });
973
+ }
974
+ }
784
975
  send(payload) {
785
976
  if (this.ws?.readyState === import_ws.default.OPEN) this.ws.send(JSON.stringify(payload));
786
977
  }
@@ -816,6 +1007,9 @@ var Client = class extends import_node_events.EventEmitter {
816
1007
  }
817
1008
  if (op === "STATS") {
818
1009
  this.lastStats = d;
1010
+ const eventName2 = EVENT_OPS[op];
1011
+ if (eventName2) this.emit(eventName2, null, d);
1012
+ return;
819
1013
  }
820
1014
  const guildId = d.guild_id != null ? BigInt(d.guild_id) : null;
821
1015
  const player = guildId !== null ? this.playersMap.get(guildId) : void 0;