auralis-sdk 0.4.2 → 0.5.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.
package/dist/index.d.cts CHANGED
@@ -118,6 +118,20 @@ interface PlayerHost {
118
118
  dropPlayer(guildId: bigint): void;
119
119
  }
120
120
  type AutoplayHook = (player: Player) => Promise<Track | null>;
121
+ /** Named EQ presets accepted by {@link Player.setEqualizer}. */
122
+ type EqPreset = "flat" | "bassboost" | "soft" | "nightcore" | "vaporwave" | "earrape" | "treble" | "karaoke";
123
+ /** Portable snapshot of a player's queue state, produced by {@link Player.export}
124
+ * and consumed by {@link Player.restore}. Safe to JSON.stringify / store. */
125
+ interface SerializedPlayer {
126
+ version: 1;
127
+ guildId: string;
128
+ queue: Track[];
129
+ history: Track[];
130
+ current: Track | null;
131
+ loop: LoopMode;
132
+ volume: number;
133
+ paused: boolean;
134
+ }
121
135
  declare class Player {
122
136
  private host;
123
137
  queue: Track[];
@@ -155,6 +169,9 @@ declare class Player {
155
169
  private maxPositionMs;
156
170
  /** Alternate-upload attempts spent on the current dead track. */
157
171
  private recoverAttempts;
172
+ /** Wall-clock time (ms) when the last PLAYER_UPDATE position was received.
173
+ * Used by the {@link positionMs} interpolating getter. */
174
+ private _positionTimestamp;
158
175
  constructor(host: PlayerHost, guildId: Snowflake);
159
176
  handleVoiceServerUpdate(data: VoiceServerUpdate): void;
160
177
  handleVoiceStateUpdate(data: VoiceStateUpdate): void;
@@ -180,8 +197,8 @@ declare class Player {
180
197
  * concurrent triggers — the idle check runs inside the serialized section, so
181
198
  * exactly one caller wins and the rest see `current !== null` and no-op. */
182
199
  private startIfIdle;
183
- play(query: string | Track, requester?: number): Promise<Track[]>;
184
- add(query: string | Track, requester?: number): Promise<Track[]>;
200
+ play(query: string | Track, requester?: Snowflake): Promise<Track[]>;
201
+ add(query: string | Track, requester?: Snowflake): Promise<Track[]>;
185
202
  skip(): Promise<void>;
186
203
  stop(): Promise<void>;
187
204
  pause(): Promise<void>;
@@ -198,18 +215,68 @@ declare class Player {
198
215
  /** Reorder the queue: move the track at `from` to `to`. No-op on bad indices. */
199
216
  move(from: number, to: number): void;
200
217
  /** Resolve `query` and insert it at `index` (clamped) instead of appending. */
201
- insert(index: number, query: string | Track, requester?: number): Promise<Track[]>;
218
+ insert(index: number, query: string | Track, requester?: Snowflake): Promise<Track[]>;
202
219
  /** Batch-enqueue several queries/tracks in order. Failed resolves are skipped. */
203
- addMany(queries: (string | Track)[], requester?: number): Promise<Track[]>;
220
+ addMany(queries: (string | Track)[], requester?: Snowflake): Promise<Track[]>;
204
221
  /** Skip forward to queue position `index`, discarding the ones in between. */
205
222
  jump(index: number): Promise<void>;
206
223
  /** Replay the previous track (from history), pushing the current one back on. */
207
224
  previous(): Promise<void>;
208
225
  /** Convenience EQ: bass boost in dB (0 clears). Built on {@link setFilters}. */
209
226
  setBassBoost(gainDb: number): Promise<void>;
210
- /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal. */
227
+ /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal.
228
+ * Uses `atempo` for tempo and `asetrate` for pitch shift — both evaluated
229
+ * in JS so the filter string contains literal numbers, not expressions. */
211
230
  setSpeed(rate: number): Promise<void>;
212
231
  nowPlaying(): Track | null;
232
+ /** Enqueue a track or query to play immediately after the current one.
233
+ * Unlike `insert(0, ...)`, this is an explicit "play next" intent — it
234
+ * always inserts at position 0 regardless of queue state and starts
235
+ * playback if the player is idle. */
236
+ playNext(query: string | Track, requester?: Snowflake): Promise<Track[]>;
237
+ /** Remove all duplicate tracks from the queue (keeps first occurrence).
238
+ * Uniqueness is determined by `encoded` token. Returns the number removed. */
239
+ dedupe(): number;
240
+ /** Find all tracks in the queue whose title or author contains `query`
241
+ * (case-insensitive). Returns `[index, track]` pairs so callers can remove
242
+ * or jump to a result by index. */
243
+ find(query: string): [number, Track][];
244
+ /** Total duration of all queued tracks in milliseconds. Streams count as 0.
245
+ * Does not include the currently playing track. */
246
+ get totalDurationMs(): number;
247
+ /** Interpolated playback position in milliseconds. Between `PLAYER_UPDATE`
248
+ * frames this advances using wall-clock time so progress bars stay smooth.
249
+ * Returns 0 if nothing is playing or the player is paused. */
250
+ get positionMs(): number;
251
+ /** Serialize current queue, history, loop mode and volume into a plain object
252
+ * that can be JSON-stringified and stored. Restore with {@link Player.restore}. */
253
+ export(): SerializedPlayer;
254
+ /** Restore queue/history/loop/volume from a previously exported snapshot.
255
+ * Does NOT auto-start playback — call `startIfIdle()` or let the next
256
+ * voice event trigger it. */
257
+ restore(snapshot: SerializedPlayer): void;
258
+ /** Apply a named EQ preset. `"flat"` clears all filters.
259
+ *
260
+ * | preset | effect |
261
+ * |-------------|---------------------------------------------|
262
+ * | flat | no filters (passthrough) |
263
+ * | bassboost | heavy bass boost (+8 dB) |
264
+ * | soft | gentle bass lift (+3 dB) |
265
+ * | nightcore | speed+pitch up (1.3×) |
266
+ * | vaporwave | speed+pitch down (0.8×) |
267
+ * | earrape | extreme distortion / clipping (use wisely) |
268
+ * | treble | high-frequency boost (+6 dB) |
269
+ * | karaoke | basic vocal removal |
270
+ */
271
+ setEqualizer(preset: EqPreset): Promise<void>;
272
+ /** Reverse the current queue order in-place. */
273
+ reverse(): void;
274
+ /** Remove all tracks from the queue that were requested by `requesterId`. */
275
+ removeBy(requesterId: Snowflake): number;
276
+ /** Peek at the next N tracks without mutating state. Defaults to 5. */
277
+ peek(n?: number): Track[];
278
+ /** Returns true if nothing is queued and nothing is playing. */
279
+ get idle(): boolean;
213
280
  disconnect(): Promise<void>;
214
281
  /** Append to history, evicting the oldest past `historyLimit` (leak guard). */
215
282
  private pushHistory;
@@ -268,6 +335,10 @@ interface ClientEvents {
268
335
  failed: Track;
269
336
  alternate: Track;
270
337
  }];
338
+ /** Emitted when a new Player is created via {@link Client.getPlayer}. */
339
+ playerCreated: [player: Player];
340
+ /** Emitted when a player is fully torn down via {@link Player.disconnect}. */
341
+ playerDestroyed: [guildId: bigint];
271
342
  }
272
343
  interface Client {
273
344
  on<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
@@ -295,6 +366,13 @@ declare class Client extends EventEmitter implements PlayerHost {
295
366
  lastStats: Record<string, unknown> | null;
296
367
  constructor(baseUrl: string, token: string, opts?: ClientOptions);
297
368
  start(): Promise<void>;
369
+ /** Resolves once the client fires `ready` or `resumed`. If it is already
370
+ * connected it resolves on the next tick. Rejects after `timeoutMs` (default
371
+ * 30 s) so callers don't hang forever on a bad URL/token. */
372
+ waitForReady(timeoutMs?: number): Promise<void>;
373
+ /** Gracefully disconnect every active player then close the WebSocket.
374
+ * Prefer this over `close()` when you want clean backend teardown. */
375
+ destroy(): Promise<void>;
298
376
  close(): Promise<void>;
299
377
  /** Emit "error" without the Node EventEmitter footgun: an unlistened "error"
300
378
  * throws and would crash the host process, so fall back to a console warning. */
@@ -303,7 +381,8 @@ declare class Client extends EventEmitter implements PlayerHost {
303
381
  players(): Player[];
304
382
  dropPlayer(guildId: bigint): void;
305
383
  loadTracks(identifier: string): Promise<LoadResult>;
306
- /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
384
+ /** `ytsearch:`-prefix a bare query and return the hits. Throws `LoadError` on
385
+ * a server-side error so callers aren't silently handed an empty array. */
307
386
  search(query: string): Promise<Track[]>;
308
387
  private get wsUrl();
309
388
  private openSocket;
@@ -311,6 +390,7 @@ declare class Client extends EventEmitter implements PlayerHost {
311
390
  * position/volume/paused are accurate again. Per-player failures are ignored. */
312
391
  private resyncPlayers;
313
392
  private subscribeAll;
393
+ private subscribeGuild;
314
394
  private send;
315
395
  private handleFrame;
316
396
  }
@@ -338,4 +418,4 @@ declare class NotConnected extends AuralisError {
338
418
  declare class LoadError extends AuralisError {
339
419
  }
340
420
 
341
- export { APIError, AuralisError, AuthError, type AutoplayHook, Client, type ClientEvents, type ClientOptions, EndReason, LoadError, type LoadResult, LoadType, LoopMode, NotConnected, Player, type PlayerHost, type PlayerState, REST, RateLimited, type Snowflake, type Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
421
+ export { APIError, AuralisError, AuthError, type AutoplayHook, Client, type ClientEvents, type ClientOptions, EndReason, type EqPreset, LoadError, type LoadResult, LoadType, LoopMode, NotConnected, Player, type PlayerHost, type PlayerState, REST, RateLimited, type SerializedPlayer, type Snowflake, type Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
package/dist/index.d.ts CHANGED
@@ -118,6 +118,20 @@ interface PlayerHost {
118
118
  dropPlayer(guildId: bigint): void;
119
119
  }
120
120
  type AutoplayHook = (player: Player) => Promise<Track | null>;
121
+ /** Named EQ presets accepted by {@link Player.setEqualizer}. */
122
+ type EqPreset = "flat" | "bassboost" | "soft" | "nightcore" | "vaporwave" | "earrape" | "treble" | "karaoke";
123
+ /** Portable snapshot of a player's queue state, produced by {@link Player.export}
124
+ * and consumed by {@link Player.restore}. Safe to JSON.stringify / store. */
125
+ interface SerializedPlayer {
126
+ version: 1;
127
+ guildId: string;
128
+ queue: Track[];
129
+ history: Track[];
130
+ current: Track | null;
131
+ loop: LoopMode;
132
+ volume: number;
133
+ paused: boolean;
134
+ }
121
135
  declare class Player {
122
136
  private host;
123
137
  queue: Track[];
@@ -155,6 +169,9 @@ declare class Player {
155
169
  private maxPositionMs;
156
170
  /** Alternate-upload attempts spent on the current dead track. */
157
171
  private recoverAttempts;
172
+ /** Wall-clock time (ms) when the last PLAYER_UPDATE position was received.
173
+ * Used by the {@link positionMs} interpolating getter. */
174
+ private _positionTimestamp;
158
175
  constructor(host: PlayerHost, guildId: Snowflake);
159
176
  handleVoiceServerUpdate(data: VoiceServerUpdate): void;
160
177
  handleVoiceStateUpdate(data: VoiceStateUpdate): void;
@@ -180,8 +197,8 @@ declare class Player {
180
197
  * concurrent triggers — the idle check runs inside the serialized section, so
181
198
  * exactly one caller wins and the rest see `current !== null` and no-op. */
182
199
  private startIfIdle;
183
- play(query: string | Track, requester?: number): Promise<Track[]>;
184
- add(query: string | Track, requester?: number): Promise<Track[]>;
200
+ play(query: string | Track, requester?: Snowflake): Promise<Track[]>;
201
+ add(query: string | Track, requester?: Snowflake): Promise<Track[]>;
185
202
  skip(): Promise<void>;
186
203
  stop(): Promise<void>;
187
204
  pause(): Promise<void>;
@@ -198,18 +215,68 @@ declare class Player {
198
215
  /** Reorder the queue: move the track at `from` to `to`. No-op on bad indices. */
199
216
  move(from: number, to: number): void;
200
217
  /** Resolve `query` and insert it at `index` (clamped) instead of appending. */
201
- insert(index: number, query: string | Track, requester?: number): Promise<Track[]>;
218
+ insert(index: number, query: string | Track, requester?: Snowflake): Promise<Track[]>;
202
219
  /** Batch-enqueue several queries/tracks in order. Failed resolves are skipped. */
203
- addMany(queries: (string | Track)[], requester?: number): Promise<Track[]>;
220
+ addMany(queries: (string | Track)[], requester?: Snowflake): Promise<Track[]>;
204
221
  /** Skip forward to queue position `index`, discarding the ones in between. */
205
222
  jump(index: number): Promise<void>;
206
223
  /** Replay the previous track (from history), pushing the current one back on. */
207
224
  previous(): Promise<void>;
208
225
  /** Convenience EQ: bass boost in dB (0 clears). Built on {@link setFilters}. */
209
226
  setBassBoost(gainDb: number): Promise<void>;
210
- /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal. */
227
+ /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal.
228
+ * Uses `atempo` for tempo and `asetrate` for pitch shift — both evaluated
229
+ * in JS so the filter string contains literal numbers, not expressions. */
211
230
  setSpeed(rate: number): Promise<void>;
212
231
  nowPlaying(): Track | null;
232
+ /** Enqueue a track or query to play immediately after the current one.
233
+ * Unlike `insert(0, ...)`, this is an explicit "play next" intent — it
234
+ * always inserts at position 0 regardless of queue state and starts
235
+ * playback if the player is idle. */
236
+ playNext(query: string | Track, requester?: Snowflake): Promise<Track[]>;
237
+ /** Remove all duplicate tracks from the queue (keeps first occurrence).
238
+ * Uniqueness is determined by `encoded` token. Returns the number removed. */
239
+ dedupe(): number;
240
+ /** Find all tracks in the queue whose title or author contains `query`
241
+ * (case-insensitive). Returns `[index, track]` pairs so callers can remove
242
+ * or jump to a result by index. */
243
+ find(query: string): [number, Track][];
244
+ /** Total duration of all queued tracks in milliseconds. Streams count as 0.
245
+ * Does not include the currently playing track. */
246
+ get totalDurationMs(): number;
247
+ /** Interpolated playback position in milliseconds. Between `PLAYER_UPDATE`
248
+ * frames this advances using wall-clock time so progress bars stay smooth.
249
+ * Returns 0 if nothing is playing or the player is paused. */
250
+ get positionMs(): number;
251
+ /** Serialize current queue, history, loop mode and volume into a plain object
252
+ * that can be JSON-stringified and stored. Restore with {@link Player.restore}. */
253
+ export(): SerializedPlayer;
254
+ /** Restore queue/history/loop/volume from a previously exported snapshot.
255
+ * Does NOT auto-start playback — call `startIfIdle()` or let the next
256
+ * voice event trigger it. */
257
+ restore(snapshot: SerializedPlayer): void;
258
+ /** Apply a named EQ preset. `"flat"` clears all filters.
259
+ *
260
+ * | preset | effect |
261
+ * |-------------|---------------------------------------------|
262
+ * | flat | no filters (passthrough) |
263
+ * | bassboost | heavy bass boost (+8 dB) |
264
+ * | soft | gentle bass lift (+3 dB) |
265
+ * | nightcore | speed+pitch up (1.3×) |
266
+ * | vaporwave | speed+pitch down (0.8×) |
267
+ * | earrape | extreme distortion / clipping (use wisely) |
268
+ * | treble | high-frequency boost (+6 dB) |
269
+ * | karaoke | basic vocal removal |
270
+ */
271
+ setEqualizer(preset: EqPreset): Promise<void>;
272
+ /** Reverse the current queue order in-place. */
273
+ reverse(): void;
274
+ /** Remove all tracks from the queue that were requested by `requesterId`. */
275
+ removeBy(requesterId: Snowflake): number;
276
+ /** Peek at the next N tracks without mutating state. Defaults to 5. */
277
+ peek(n?: number): Track[];
278
+ /** Returns true if nothing is queued and nothing is playing. */
279
+ get idle(): boolean;
213
280
  disconnect(): Promise<void>;
214
281
  /** Append to history, evicting the oldest past `historyLimit` (leak guard). */
215
282
  private pushHistory;
@@ -268,6 +335,10 @@ interface ClientEvents {
268
335
  failed: Track;
269
336
  alternate: Track;
270
337
  }];
338
+ /** Emitted when a new Player is created via {@link Client.getPlayer}. */
339
+ playerCreated: [player: Player];
340
+ /** Emitted when a player is fully torn down via {@link Player.disconnect}. */
341
+ playerDestroyed: [guildId: bigint];
271
342
  }
272
343
  interface Client {
273
344
  on<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
@@ -295,6 +366,13 @@ declare class Client extends EventEmitter implements PlayerHost {
295
366
  lastStats: Record<string, unknown> | null;
296
367
  constructor(baseUrl: string, token: string, opts?: ClientOptions);
297
368
  start(): Promise<void>;
369
+ /** Resolves once the client fires `ready` or `resumed`. If it is already
370
+ * connected it resolves on the next tick. Rejects after `timeoutMs` (default
371
+ * 30 s) so callers don't hang forever on a bad URL/token. */
372
+ waitForReady(timeoutMs?: number): Promise<void>;
373
+ /** Gracefully disconnect every active player then close the WebSocket.
374
+ * Prefer this over `close()` when you want clean backend teardown. */
375
+ destroy(): Promise<void>;
298
376
  close(): Promise<void>;
299
377
  /** Emit "error" without the Node EventEmitter footgun: an unlistened "error"
300
378
  * throws and would crash the host process, so fall back to a console warning. */
@@ -303,7 +381,8 @@ declare class Client extends EventEmitter implements PlayerHost {
303
381
  players(): Player[];
304
382
  dropPlayer(guildId: bigint): void;
305
383
  loadTracks(identifier: string): Promise<LoadResult>;
306
- /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
384
+ /** `ytsearch:`-prefix a bare query and return the hits. Throws `LoadError` on
385
+ * a server-side error so callers aren't silently handed an empty array. */
307
386
  search(query: string): Promise<Track[]>;
308
387
  private get wsUrl();
309
388
  private openSocket;
@@ -311,6 +390,7 @@ declare class Client extends EventEmitter implements PlayerHost {
311
390
  * position/volume/paused are accurate again. Per-player failures are ignored. */
312
391
  private resyncPlayers;
313
392
  private subscribeAll;
393
+ private subscribeGuild;
314
394
  private send;
315
395
  private handleFrame;
316
396
  }
@@ -338,4 +418,4 @@ declare class NotConnected extends AuralisError {
338
418
  declare class LoadError extends AuralisError {
339
419
  }
340
420
 
341
- export { APIError, AuralisError, AuthError, type AutoplayHook, Client, type ClientEvents, type ClientOptions, EndReason, LoadError, type LoadResult, LoadType, LoopMode, NotConnected, Player, type PlayerHost, type PlayerState, REST, RateLimited, type Snowflake, type Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
421
+ export { APIError, AuralisError, AuthError, type AutoplayHook, Client, type ClientEvents, type ClientOptions, EndReason, type EqPreset, LoadError, type LoadResult, LoadType, LoopMode, NotConnected, Player, type PlayerHost, type PlayerState, REST, RateLimited, type SerializedPlayer, type Snowflake, type Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
package/dist/index.js CHANGED
@@ -27,12 +27,12 @@ var LoadError = class extends AuralisError {
27
27
  };
28
28
 
29
29
  // src/types.ts
30
- var BIG_TAG = "__BIG__";
30
+ var BIG_TAG = "||BIGINT||";
31
31
  function snowflakeStringify(obj) {
32
32
  return JSON.stringify(
33
33
  obj,
34
34
  (_k, v) => typeof v === "bigint" ? `${BIG_TAG}${v}${BIG_TAG}` : v
35
- ).replace(new RegExp(`"${BIG_TAG}(\\d+)${BIG_TAG}"`, "g"), "$1");
35
+ ).replace(/"\|\|BIGINT\|\|(\d+)\|\|BIGINT\|\|"/g, "$1");
36
36
  }
37
37
  var LoopMode = /* @__PURE__ */ ((LoopMode2) => {
38
38
  LoopMode2["Off"] = "off";
@@ -141,6 +141,9 @@ var Player = class {
141
141
  maxPositionMs = 0;
142
142
  /** Alternate-upload attempts spent on the current dead track. */
143
143
  recoverAttempts = 0;
144
+ /** Wall-clock time (ms) when the last PLAYER_UPDATE position was received.
145
+ * Used by the {@link positionMs} interpolating getter. */
146
+ _positionTimestamp = 0;
144
147
  // --- voice plumbing ---------------------------------------------------
145
148
  handleVoiceServerUpdate(data) {
146
149
  this.voiceServer = { token: data.token, endpoint: data.endpoint };
@@ -166,6 +169,7 @@ var Player = class {
166
169
  * spotted, and clears the recovery counter once a track is genuinely playing. */
167
170
  applyState(state) {
168
171
  this.state = state;
172
+ this._positionTimestamp = Date.now();
169
173
  if (state.positionMs > this.maxPositionMs) this.maxPositionMs = state.positionMs;
170
174
  if (this.maxPositionMs > this.deadStreamThresholdMs) this.recoverAttempts = 0;
171
175
  }
@@ -314,6 +318,7 @@ var Player = class {
314
318
  const prev = this.history.pop();
315
319
  if (!prev) return;
316
320
  if (this.current !== null) this.queue.unshift(this.current);
321
+ this.current = null;
317
322
  this.queue.unshift(prev);
318
323
  await this.advance(true);
319
324
  }
@@ -321,17 +326,139 @@ var Player = class {
321
326
  async setBassBoost(gainDb) {
322
327
  await this.setFilters(gainDb === 0 ? null : `bass=g=${gainDb}`);
323
328
  }
324
- /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal. */
329
+ /** Convenience speed/pitch (nightcore/vaporwave): `rate` 1.0 = normal.
330
+ * Uses `atempo` for tempo and `asetrate` for pitch shift — both evaluated
331
+ * in JS so the filter string contains literal numbers, not expressions. */
325
332
  async setSpeed(rate) {
326
333
  const r = Math.max(0.5, Math.min(2, rate));
327
- await this.setFilters(r === 1 ? null : `atempo=${r},asetrate=48000*${r}`);
334
+ await this.setFilters(r === 1 ? null : `atempo=${r},asetrate=${48e3 * r}`);
328
335
  }
329
336
  nowPlaying() {
330
337
  return this.current;
331
338
  }
339
+ /** Enqueue a track or query to play immediately after the current one.
340
+ * Unlike `insert(0, ...)`, this is an explicit "play next" intent — it
341
+ * always inserts at position 0 regardless of queue state and starts
342
+ * playback if the player is idle. */
343
+ async playNext(query, requester) {
344
+ return this.insert(0, query, requester);
345
+ }
346
+ /** Remove all duplicate tracks from the queue (keeps first occurrence).
347
+ * Uniqueness is determined by `encoded` token. Returns the number removed. */
348
+ dedupe() {
349
+ const seen = /* @__PURE__ */ new Set();
350
+ const before = this.queue.length;
351
+ this.queue = this.queue.filter((t) => {
352
+ if (seen.has(t.encoded)) return false;
353
+ seen.add(t.encoded);
354
+ return true;
355
+ });
356
+ return before - this.queue.length;
357
+ }
358
+ /** Find all tracks in the queue whose title or author contains `query`
359
+ * (case-insensitive). Returns `[index, track]` pairs so callers can remove
360
+ * or jump to a result by index. */
361
+ find(query) {
362
+ const q = query.toLowerCase();
363
+ return this.queue.reduce((acc, t, i) => {
364
+ if (t.title.toLowerCase().includes(q) || t.author.toLowerCase().includes(q)) {
365
+ acc.push([i, t]);
366
+ }
367
+ return acc;
368
+ }, []);
369
+ }
370
+ /** Total duration of all queued tracks in milliseconds. Streams count as 0.
371
+ * Does not include the currently playing track. */
372
+ get totalDurationMs() {
373
+ return this.queue.reduce((sum, t) => sum + (t.isStream ? 0 : t.lengthMs), 0);
374
+ }
375
+ /** Interpolated playback position in milliseconds. Between `PLAYER_UPDATE`
376
+ * frames this advances using wall-clock time so progress bars stay smooth.
377
+ * Returns 0 if nothing is playing or the player is paused. */
378
+ get positionMs() {
379
+ if (!this.state.playing || this.state.paused) return this.state.positionMs;
380
+ const elapsed = Date.now() - this._positionTimestamp;
381
+ return Math.min(
382
+ this.state.positionMs + elapsed,
383
+ this.current?.lengthMs ?? this.state.positionMs + elapsed
384
+ );
385
+ }
386
+ /** Serialize current queue, history, loop mode and volume into a plain object
387
+ * that can be JSON-stringified and stored. Restore with {@link Player.restore}. */
388
+ export() {
389
+ return {
390
+ version: 1,
391
+ guildId: String(this.guildId),
392
+ queue: this.queue.map((t) => ({ ...t })),
393
+ history: this.history.map((t) => ({ ...t })),
394
+ current: this.current ? { ...this.current } : null,
395
+ loop: this.loop,
396
+ volume: this.volume,
397
+ paused: this.paused
398
+ };
399
+ }
400
+ /** Restore queue/history/loop/volume from a previously exported snapshot.
401
+ * Does NOT auto-start playback — call `startIfIdle()` or let the next
402
+ * voice event trigger it. */
403
+ restore(snapshot) {
404
+ this.queue = snapshot.queue.map((t) => ({ ...t }));
405
+ this.history = snapshot.history.map((t) => ({ ...t }));
406
+ this.current = snapshot.current ? { ...snapshot.current } : null;
407
+ this.loop = snapshot.loop;
408
+ this.volume = snapshot.volume;
409
+ this.paused = snapshot.paused;
410
+ }
411
+ /** Apply a named EQ preset. `"flat"` clears all filters.
412
+ *
413
+ * | preset | effect |
414
+ * |-------------|---------------------------------------------|
415
+ * | flat | no filters (passthrough) |
416
+ * | bassboost | heavy bass boost (+8 dB) |
417
+ * | soft | gentle bass lift (+3 dB) |
418
+ * | nightcore | speed+pitch up (1.3×) |
419
+ * | vaporwave | speed+pitch down (0.8×) |
420
+ * | earrape | extreme distortion / clipping (use wisely) |
421
+ * | treble | high-frequency boost (+6 dB) |
422
+ * | karaoke | basic vocal removal |
423
+ */
424
+ async setEqualizer(preset) {
425
+ const graphs = {
426
+ flat: null,
427
+ bassboost: "bass=g=8",
428
+ soft: "bass=g=3",
429
+ nightcore: `atempo=1.3,asetrate=${Math.round(48e3 * 1.3)}`,
430
+ vaporwave: `atempo=0.8,asetrate=${Math.round(48e3 * 0.8)}`,
431
+ earrape: "acrusher=level_in=8:level_out=18:bits=8:mode=log:aa=1",
432
+ treble: "treble=g=6",
433
+ karaoke: "pan=stereo|c0=c0-c1|c1=c1-c0"
434
+ };
435
+ await this.setFilters(graphs[preset]);
436
+ }
437
+ /** Reverse the current queue order in-place. */
438
+ reverse() {
439
+ this.queue.reverse();
440
+ }
441
+ /** Remove all tracks from the queue that were requested by `requesterId`. */
442
+ removeBy(requesterId) {
443
+ const id = String(requesterId);
444
+ const before = this.queue.length;
445
+ this.queue = this.queue.filter((t) => String(t.extras.requester) !== id);
446
+ return before - this.queue.length;
447
+ }
448
+ /** Peek at the next N tracks without mutating state. Defaults to 5. */
449
+ peek(n = 5) {
450
+ return this.queue.slice(0, n);
451
+ }
452
+ /** Returns true if nothing is queued and nothing is playing. */
453
+ get idle() {
454
+ return this.current === null && this.queue.length === 0;
455
+ }
332
456
  async disconnect() {
333
457
  this.queue = [];
334
458
  this.current = null;
459
+ this.history = [];
460
+ this.paused = false;
461
+ this.volume = 100;
335
462
  if (this.created) await this.host.rest.destroyPlayer(this.guildId);
336
463
  this.created = false;
337
464
  this.voiceServer = null;
@@ -469,7 +596,7 @@ var Player = class {
469
596
  };
470
597
 
471
598
  // src/rest.ts
472
- var DEFAULT_TIMEOUT_MS = 8e3;
599
+ var DEFAULT_TIMEOUT_MS = 16e3;
473
600
  var RETRYABLE_STATUS = /* @__PURE__ */ new Set([502, 503]);
474
601
  var BACKOFF_MS = [200, 500];
475
602
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -630,6 +757,38 @@ var Client = class extends EventEmitter {
630
757
  this.closed = false;
631
758
  this.openSocket();
632
759
  }
760
+ /** Resolves once the client fires `ready` or `resumed`. If it is already
761
+ * connected it resolves on the next tick. Rejects after `timeoutMs` (default
762
+ * 30 s) so callers don't hang forever on a bad URL/token. */
763
+ waitForReady(timeoutMs = 3e4) {
764
+ return new Promise((resolve, reject) => {
765
+ if (this.hasConnected) {
766
+ resolve();
767
+ return;
768
+ }
769
+ const onReady = () => {
770
+ clearTimeout(timer);
771
+ resolve();
772
+ };
773
+ const onResumed = () => {
774
+ clearTimeout(timer);
775
+ resolve();
776
+ };
777
+ const timer = setTimeout(() => {
778
+ this.off("ready", onReady);
779
+ this.off("resumed", onResumed);
780
+ reject(new Error("[auralis] waitForReady timed out"));
781
+ }, timeoutMs);
782
+ this.once("ready", onReady);
783
+ this.once("resumed", onResumed);
784
+ });
785
+ }
786
+ /** Gracefully disconnect every active player then close the WebSocket.
787
+ * Prefer this over `close()` when you want clean backend teardown. */
788
+ async destroy() {
789
+ await Promise.allSettled(this.players().map((p) => p.disconnect()));
790
+ await this.close();
791
+ }
633
792
  async close() {
634
793
  this.closed = true;
635
794
  if (this.heartbeatTimer) {
@@ -657,6 +816,8 @@ var Client = class extends EventEmitter {
657
816
  if (!p) {
658
817
  p = new Player(this, id);
659
818
  this.playersMap.set(id, p);
819
+ this.subscribeGuild(id);
820
+ this.emit("playerCreated", p);
660
821
  }
661
822
  return p;
662
823
  }
@@ -665,16 +826,22 @@ var Client = class extends EventEmitter {
665
826
  }
666
827
  dropPlayer(guildId) {
667
828
  this.playersMap.delete(guildId);
829
+ this.emit("playerDestroyed", guildId);
668
830
  }
669
831
  // --- metadata convenience --------------------------------------------
670
832
  loadTracks(identifier) {
671
833
  return this.rest.loadTracks(identifier);
672
834
  }
673
- /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
835
+ /** `ytsearch:`-prefix a bare query and return the hits. Throws `LoadError` on
836
+ * a server-side error so callers aren't silently handed an empty array. */
674
837
  async search(query) {
675
838
  const first = query.split(" ", 1)[0];
676
839
  const ident = first.includes(":") ? query : `ytsearch:${query}`;
677
- return (await this.loadTracks(ident)).tracks;
840
+ const result = await this.loadTracks(ident);
841
+ if (result.loadType === "error" /* Error */) {
842
+ throw new LoadError(result.error ?? `search failed for: ${query}`);
843
+ }
844
+ return result.tracks;
678
845
  }
679
846
  // --- websocket --------------------------------------------------------
680
847
  get wsUrl() {
@@ -733,6 +900,14 @@ var Client = class extends EventEmitter {
733
900
  });
734
901
  }
735
902
  }
903
+ subscribeGuild(guildId) {
904
+ if (this.ws?.readyState === WebSocket.OPEN) {
905
+ this.send({
906
+ op: "SUBSCRIBE",
907
+ d: { guilds: [String(guildId)] }
908
+ });
909
+ }
910
+ }
736
911
  send(payload) {
737
912
  if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(payload));
738
913
  }
@@ -768,6 +943,9 @@ var Client = class extends EventEmitter {
768
943
  }
769
944
  if (op === "STATS") {
770
945
  this.lastStats = d;
946
+ const eventName2 = EVENT_OPS[op];
947
+ if (eventName2) this.emit(eventName2, null, d);
948
+ return;
771
949
  }
772
950
  const guildId = d.guild_id != null ? BigInt(d.guild_id) : null;
773
951
  const player = guildId !== null ? this.playersMap.get(guildId) : void 0;