auralis-sdk 0.2.0 → 0.4.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
@@ -1,6 +1,10 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
3
  /** Wire vocabulary and data models — mirror the control-plane JSON exactly. */
4
+ /** A Discord snowflake. Accepted as string/number/bigint at the API boundary and
5
+ * normalized to `bigint` internally: a 64-bit id cannot survive a JS `number`
6
+ * (over 2^53 the low digits corrupt), and the control plane parses ids as `u64`. */
7
+ type Snowflake = string | number | bigint;
4
8
  /** How the queue advances when a track finishes. */
5
9
  declare enum LoopMode {
6
10
  Off = "off",
@@ -61,8 +65,8 @@ interface VoiceServerUpdate {
61
65
  }
62
66
  interface VoiceStateUpdate {
63
67
  session_id: string;
64
- channel_id: string | number | null;
65
- user_id: string | number;
68
+ channel_id: Snowflake | null;
69
+ user_id: Snowflake;
66
70
  }
67
71
 
68
72
  /** Thin wrapper over the control-plane HTTP API using the global `fetch`
@@ -71,24 +75,27 @@ interface VoiceStateUpdate {
71
75
  declare class REST {
72
76
  private base;
73
77
  private headers;
78
+ /** Per-request timeout (ms). Exposed so a slow-network deployment can raise it. */
79
+ timeoutMs: number;
74
80
  constructor(baseUrl: string, token: string);
81
+ private fetchWithTimeout;
75
82
  private request;
76
83
  loadTracks(identifier: string): Promise<LoadResult>;
77
- prewarm(encoded: string[], guildId?: number): Promise<any>;
78
- createPlayer(guildId: number, voice: Record<string, unknown>, opts?: {
84
+ prewarm(encoded: string[], guildId?: bigint): Promise<any>;
85
+ createPlayer(guildId: bigint, voice: Record<string, unknown>, opts?: {
79
86
  track?: string;
80
87
  volume?: number;
81
88
  paused?: boolean;
82
89
  audioGraph?: string;
83
90
  }): Promise<any>;
84
- destroyPlayer(guildId: number): Promise<any>;
85
- getState(guildId: number): Promise<PlayerState>;
86
- play(guildId: number, track: string, audioGraph?: string): Promise<any>;
87
- stop(guildId: number): Promise<any>;
88
- setPaused(guildId: number, paused: boolean): Promise<any>;
89
- setVolume(guildId: number, volume: number): Promise<any>;
90
- seek(guildId: number, positionMs: number): Promise<any>;
91
- setFilters(guildId: number, audioGraph: string | null): Promise<any>;
91
+ destroyPlayer(guildId: bigint): Promise<any>;
92
+ getState(guildId: bigint): Promise<PlayerState>;
93
+ play(guildId: bigint, track: string, audioGraph?: string): Promise<any>;
94
+ stop(guildId: bigint): Promise<any>;
95
+ setPaused(guildId: bigint, paused: boolean): Promise<any>;
96
+ setVolume(guildId: bigint, volume: number): Promise<any>;
97
+ seek(guildId: bigint, positionMs: number): Promise<any>;
98
+ setFilters(guildId: bigint, audioGraph: string | null): Promise<any>;
92
99
  }
93
100
 
94
101
  /** Per-guild player: the client-side queue brain.
@@ -108,12 +115,11 @@ declare class REST {
108
115
  interface PlayerHost {
109
116
  rest: REST;
110
117
  emit(event: string, ...args: unknown[]): void;
111
- dropPlayer(guildId: number): void;
118
+ dropPlayer(guildId: bigint): void;
112
119
  }
113
120
  type AutoplayHook = (player: Player) => Promise<Track | null>;
114
121
  declare class Player {
115
122
  private host;
116
- guildId: number;
117
123
  queue: Track[];
118
124
  history: Track[];
119
125
  current: Track | null;
@@ -126,13 +132,42 @@ declare class Player {
126
132
  state: PlayerState;
127
133
  /** Optional async hook used when the queue and loop are exhausted. */
128
134
  autoplay: AutoplayHook | null;
135
+ /** When a non-stream track "finishes" without ever streaming (dead source:
136
+ * geo/age-restricted, throttled, removed), search for and play an alternate
137
+ * upload instead of silently draining the queue. */
138
+ recoverDeadStreams: boolean;
139
+ /** A track whose furthest observed position never passed this many ms is
140
+ * treated as never-streamed (used by {@link recoverDeadStreams}). */
141
+ deadStreamThresholdMs: number;
142
+ /** Give up recovering a single dead track after this many alternate attempts. */
143
+ deadStreamRecoveryLimit: number;
144
+ /** Auto-skip a track the node reports as `TRACK_STUCK` instead of stalling. */
145
+ advanceOnStuck: boolean;
146
+ /** On a remote `VOICE_CLOSED`, reset so the next voice material re-creates the
147
+ * player and resumes the current track (the bot must re-send its op-4 join). */
148
+ rejoinOnVoiceClosed: boolean;
149
+ /** The guild this player drives. Always a `bigint` so 64-bit ids stay exact. */
150
+ readonly guildId: bigint;
129
151
  private created;
130
152
  private voiceServer;
131
153
  private voiceState;
132
- constructor(host: PlayerHost, guildId: number);
154
+ /** Furthest position seen for the current track — reset on every track start. */
155
+ private maxPositionMs;
156
+ /** Alternate-upload attempts spent on the current dead track. */
157
+ private recoverAttempts;
158
+ constructor(host: PlayerHost, guildId: Snowflake);
133
159
  handleVoiceServerUpdate(data: VoiceServerUpdate): void;
134
160
  handleVoiceStateUpdate(data: VoiceStateUpdate): void;
135
161
  get connected(): boolean;
162
+ /** Called by the client on `PLAYER_UPDATE` (and reconnect resync). Tracks the
163
+ * furthest position reached so a "finished but never streamed" track can be
164
+ * spotted, and clears the recovery counter once a track is genuinely playing. */
165
+ applyState(state: PlayerState): void;
166
+ /** Discord terminated the voice session (remote `VOICE_CLOSED`). The node has
167
+ * dropped our player, so reset local voice/created state and requeue the
168
+ * current track; once the bot re-sends its op-4 join, fresh voice material
169
+ * re-creates the player and resumes. Bot-initiated closes don't call this. */
170
+ handleVoiceClosed(): void;
136
171
  private voicePayload;
137
172
  private maybeStart;
138
173
  play(query: string | Track, requester?: number): Promise<Track[]>;
@@ -173,8 +208,19 @@ declare class Player {
173
208
  private advance;
174
209
  private playTrack;
175
210
  private setPausedInternal;
176
- /** Called by the client's WS loop when a TRACK_END for this guild arrives. */
211
+ /** Called by the client's WS loop when a TRACK_END for this guild arrives.
212
+ * `advances` is true for FINISHED / LOAD_FAILED. Before draining the queue we
213
+ * check for a dead source and try to recover it with an alternate upload. */
177
214
  onTrackEnd(advances: boolean): Promise<void>;
215
+ /** Called by the client on `TRACK_STUCK`: skip the stalled track. */
216
+ onTrackStuck(): Promise<void>;
217
+ /** A non-stream track that "finished" without its position ever advancing
218
+ * almost certainly never streamed. Genuinely short tracks (length at most a
219
+ * few × the threshold) are excluded so we don't loop on real short songs. */
220
+ private isDeadStream;
221
+ /** Search for an alternate upload of the current (dead) track and play it.
222
+ * Returns true when an alternate started; false to let the caller advance. */
223
+ private tryRecover;
178
224
  }
179
225
 
180
226
  /** The top-level client: owns the REST wrapper, the WebSocket event loop, the
@@ -190,14 +236,23 @@ interface ClientEvents {
190
236
  resumed: [];
191
237
  disconnect: [];
192
238
  error: [err: unknown];
193
- trackStart: [player: Player | number, data: any];
194
- trackEnd: [player: Player | number, data: any];
195
- trackException: [player: Player | number, data: any];
196
- trackStuck: [player: Player | number, data: any];
197
- playerUpdate: [player: Player | number, data: any];
198
- voiceClosed: [player: Player | number, data: any];
199
- stats: [player: Player | number | null, data: any];
239
+ trackStart: [player: Player | bigint, data: any];
240
+ trackEnd: [player: Player | bigint, data: any];
241
+ trackException: [player: Player | bigint, data: any];
242
+ trackStuck: [player: Player | bigint, data: any];
243
+ playerUpdate: [player: Player | bigint, data: any];
244
+ voiceClosed: [player: Player | bigint, data: any];
245
+ stats: [player: Player | bigint | null, data: any];
200
246
  queueEnd: [player: Player];
247
+ /** A dead source could not be recovered — the bot should tell the user. */
248
+ trackStreamDead: [player: Player, data: {
249
+ failed: Track;
250
+ }];
251
+ /** A dead source was auto-recovered by playing an alternate upload. */
252
+ trackRecovered: [player: Player, data: {
253
+ failed: Track;
254
+ alternate: Track;
255
+ }];
201
256
  }
202
257
  interface Client {
203
258
  on<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
@@ -229,9 +284,9 @@ declare class Client extends EventEmitter implements PlayerHost {
229
284
  /** Emit "error" without the Node EventEmitter footgun: an unlistened "error"
230
285
  * throws and would crash the host process, so fall back to a console warning. */
231
286
  private safeError;
232
- getPlayer(guildId: number): Player;
287
+ getPlayer(guildId: Snowflake): Player;
233
288
  players(): Player[];
234
- dropPlayer(guildId: number): void;
289
+ dropPlayer(guildId: bigint): void;
235
290
  loadTracks(identifier: string): Promise<LoadResult>;
236
291
  /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
237
292
  search(query: string): Promise<Track[]>;
@@ -268,4 +323,4 @@ declare class NotConnected extends AuralisError {
268
323
  declare class LoadError extends AuralisError {
269
324
  }
270
325
 
271
- 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 Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
326
+ 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 };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
3
  /** Wire vocabulary and data models — mirror the control-plane JSON exactly. */
4
+ /** A Discord snowflake. Accepted as string/number/bigint at the API boundary and
5
+ * normalized to `bigint` internally: a 64-bit id cannot survive a JS `number`
6
+ * (over 2^53 the low digits corrupt), and the control plane parses ids as `u64`. */
7
+ type Snowflake = string | number | bigint;
4
8
  /** How the queue advances when a track finishes. */
5
9
  declare enum LoopMode {
6
10
  Off = "off",
@@ -61,8 +65,8 @@ interface VoiceServerUpdate {
61
65
  }
62
66
  interface VoiceStateUpdate {
63
67
  session_id: string;
64
- channel_id: string | number | null;
65
- user_id: string | number;
68
+ channel_id: Snowflake | null;
69
+ user_id: Snowflake;
66
70
  }
67
71
 
68
72
  /** Thin wrapper over the control-plane HTTP API using the global `fetch`
@@ -71,24 +75,27 @@ interface VoiceStateUpdate {
71
75
  declare class REST {
72
76
  private base;
73
77
  private headers;
78
+ /** Per-request timeout (ms). Exposed so a slow-network deployment can raise it. */
79
+ timeoutMs: number;
74
80
  constructor(baseUrl: string, token: string);
81
+ private fetchWithTimeout;
75
82
  private request;
76
83
  loadTracks(identifier: string): Promise<LoadResult>;
77
- prewarm(encoded: string[], guildId?: number): Promise<any>;
78
- createPlayer(guildId: number, voice: Record<string, unknown>, opts?: {
84
+ prewarm(encoded: string[], guildId?: bigint): Promise<any>;
85
+ createPlayer(guildId: bigint, voice: Record<string, unknown>, opts?: {
79
86
  track?: string;
80
87
  volume?: number;
81
88
  paused?: boolean;
82
89
  audioGraph?: string;
83
90
  }): Promise<any>;
84
- destroyPlayer(guildId: number): Promise<any>;
85
- getState(guildId: number): Promise<PlayerState>;
86
- play(guildId: number, track: string, audioGraph?: string): Promise<any>;
87
- stop(guildId: number): Promise<any>;
88
- setPaused(guildId: number, paused: boolean): Promise<any>;
89
- setVolume(guildId: number, volume: number): Promise<any>;
90
- seek(guildId: number, positionMs: number): Promise<any>;
91
- setFilters(guildId: number, audioGraph: string | null): Promise<any>;
91
+ destroyPlayer(guildId: bigint): Promise<any>;
92
+ getState(guildId: bigint): Promise<PlayerState>;
93
+ play(guildId: bigint, track: string, audioGraph?: string): Promise<any>;
94
+ stop(guildId: bigint): Promise<any>;
95
+ setPaused(guildId: bigint, paused: boolean): Promise<any>;
96
+ setVolume(guildId: bigint, volume: number): Promise<any>;
97
+ seek(guildId: bigint, positionMs: number): Promise<any>;
98
+ setFilters(guildId: bigint, audioGraph: string | null): Promise<any>;
92
99
  }
93
100
 
94
101
  /** Per-guild player: the client-side queue brain.
@@ -108,12 +115,11 @@ declare class REST {
108
115
  interface PlayerHost {
109
116
  rest: REST;
110
117
  emit(event: string, ...args: unknown[]): void;
111
- dropPlayer(guildId: number): void;
118
+ dropPlayer(guildId: bigint): void;
112
119
  }
113
120
  type AutoplayHook = (player: Player) => Promise<Track | null>;
114
121
  declare class Player {
115
122
  private host;
116
- guildId: number;
117
123
  queue: Track[];
118
124
  history: Track[];
119
125
  current: Track | null;
@@ -126,13 +132,42 @@ declare class Player {
126
132
  state: PlayerState;
127
133
  /** Optional async hook used when the queue and loop are exhausted. */
128
134
  autoplay: AutoplayHook | null;
135
+ /** When a non-stream track "finishes" without ever streaming (dead source:
136
+ * geo/age-restricted, throttled, removed), search for and play an alternate
137
+ * upload instead of silently draining the queue. */
138
+ recoverDeadStreams: boolean;
139
+ /** A track whose furthest observed position never passed this many ms is
140
+ * treated as never-streamed (used by {@link recoverDeadStreams}). */
141
+ deadStreamThresholdMs: number;
142
+ /** Give up recovering a single dead track after this many alternate attempts. */
143
+ deadStreamRecoveryLimit: number;
144
+ /** Auto-skip a track the node reports as `TRACK_STUCK` instead of stalling. */
145
+ advanceOnStuck: boolean;
146
+ /** On a remote `VOICE_CLOSED`, reset so the next voice material re-creates the
147
+ * player and resumes the current track (the bot must re-send its op-4 join). */
148
+ rejoinOnVoiceClosed: boolean;
149
+ /** The guild this player drives. Always a `bigint` so 64-bit ids stay exact. */
150
+ readonly guildId: bigint;
129
151
  private created;
130
152
  private voiceServer;
131
153
  private voiceState;
132
- constructor(host: PlayerHost, guildId: number);
154
+ /** Furthest position seen for the current track — reset on every track start. */
155
+ private maxPositionMs;
156
+ /** Alternate-upload attempts spent on the current dead track. */
157
+ private recoverAttempts;
158
+ constructor(host: PlayerHost, guildId: Snowflake);
133
159
  handleVoiceServerUpdate(data: VoiceServerUpdate): void;
134
160
  handleVoiceStateUpdate(data: VoiceStateUpdate): void;
135
161
  get connected(): boolean;
162
+ /** Called by the client on `PLAYER_UPDATE` (and reconnect resync). Tracks the
163
+ * furthest position reached so a "finished but never streamed" track can be
164
+ * spotted, and clears the recovery counter once a track is genuinely playing. */
165
+ applyState(state: PlayerState): void;
166
+ /** Discord terminated the voice session (remote `VOICE_CLOSED`). The node has
167
+ * dropped our player, so reset local voice/created state and requeue the
168
+ * current track; once the bot re-sends its op-4 join, fresh voice material
169
+ * re-creates the player and resumes. Bot-initiated closes don't call this. */
170
+ handleVoiceClosed(): void;
136
171
  private voicePayload;
137
172
  private maybeStart;
138
173
  play(query: string | Track, requester?: number): Promise<Track[]>;
@@ -173,8 +208,19 @@ declare class Player {
173
208
  private advance;
174
209
  private playTrack;
175
210
  private setPausedInternal;
176
- /** Called by the client's WS loop when a TRACK_END for this guild arrives. */
211
+ /** Called by the client's WS loop when a TRACK_END for this guild arrives.
212
+ * `advances` is true for FINISHED / LOAD_FAILED. Before draining the queue we
213
+ * check for a dead source and try to recover it with an alternate upload. */
177
214
  onTrackEnd(advances: boolean): Promise<void>;
215
+ /** Called by the client on `TRACK_STUCK`: skip the stalled track. */
216
+ onTrackStuck(): Promise<void>;
217
+ /** A non-stream track that "finished" without its position ever advancing
218
+ * almost certainly never streamed. Genuinely short tracks (length at most a
219
+ * few × the threshold) are excluded so we don't loop on real short songs. */
220
+ private isDeadStream;
221
+ /** Search for an alternate upload of the current (dead) track and play it.
222
+ * Returns true when an alternate started; false to let the caller advance. */
223
+ private tryRecover;
178
224
  }
179
225
 
180
226
  /** The top-level client: owns the REST wrapper, the WebSocket event loop, the
@@ -190,14 +236,23 @@ interface ClientEvents {
190
236
  resumed: [];
191
237
  disconnect: [];
192
238
  error: [err: unknown];
193
- trackStart: [player: Player | number, data: any];
194
- trackEnd: [player: Player | number, data: any];
195
- trackException: [player: Player | number, data: any];
196
- trackStuck: [player: Player | number, data: any];
197
- playerUpdate: [player: Player | number, data: any];
198
- voiceClosed: [player: Player | number, data: any];
199
- stats: [player: Player | number | null, data: any];
239
+ trackStart: [player: Player | bigint, data: any];
240
+ trackEnd: [player: Player | bigint, data: any];
241
+ trackException: [player: Player | bigint, data: any];
242
+ trackStuck: [player: Player | bigint, data: any];
243
+ playerUpdate: [player: Player | bigint, data: any];
244
+ voiceClosed: [player: Player | bigint, data: any];
245
+ stats: [player: Player | bigint | null, data: any];
200
246
  queueEnd: [player: Player];
247
+ /** A dead source could not be recovered — the bot should tell the user. */
248
+ trackStreamDead: [player: Player, data: {
249
+ failed: Track;
250
+ }];
251
+ /** A dead source was auto-recovered by playing an alternate upload. */
252
+ trackRecovered: [player: Player, data: {
253
+ failed: Track;
254
+ alternate: Track;
255
+ }];
201
256
  }
202
257
  interface Client {
203
258
  on<K extends keyof ClientEvents>(event: K, listener: (...args: ClientEvents[K]) => void): this;
@@ -229,9 +284,9 @@ declare class Client extends EventEmitter implements PlayerHost {
229
284
  /** Emit "error" without the Node EventEmitter footgun: an unlistened "error"
230
285
  * throws and would crash the host process, so fall back to a console warning. */
231
286
  private safeError;
232
- getPlayer(guildId: number): Player;
287
+ getPlayer(guildId: Snowflake): Player;
233
288
  players(): Player[];
234
- dropPlayer(guildId: number): void;
289
+ dropPlayer(guildId: bigint): void;
235
290
  loadTracks(identifier: string): Promise<LoadResult>;
236
291
  /** `ytsearch:`-prefix a bare query and return the hits (empty on miss). */
237
292
  search(query: string): Promise<Track[]>;
@@ -268,4 +323,4 @@ declare class NotConnected extends AuralisError {
268
323
  declare class LoadError extends AuralisError {
269
324
  }
270
325
 
271
- 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 Track, type VoiceServerUpdate, type VoiceStateUpdate, endReasonAdvances };
326
+ 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 };
package/dist/index.js CHANGED
@@ -27,6 +27,13 @@ var LoadError = class extends AuralisError {
27
27
  };
28
28
 
29
29
  // src/types.ts
30
+ var BIG_TAG = "__BIG__";
31
+ function snowflakeStringify(obj) {
32
+ return JSON.stringify(
33
+ obj,
34
+ (_k, v) => typeof v === "bigint" ? `${BIG_TAG}${v}${BIG_TAG}` : v
35
+ ).replace(new RegExp(`"${BIG_TAG}(\\d+)${BIG_TAG}"`, "g"), "$1");
36
+ }
30
37
  var LoopMode = /* @__PURE__ */ ((LoopMode2) => {
31
38
  LoopMode2["Off"] = "off";
32
39
  LoopMode2["Track"] = "track";
@@ -90,10 +97,9 @@ function playerStateFromJson(d) {
90
97
  var Player = class {
91
98
  constructor(host, guildId) {
92
99
  this.host = host;
93
- this.guildId = guildId;
100
+ this.guildId = BigInt(guildId);
94
101
  }
95
102
  host;
96
- guildId;
97
103
  queue = [];
98
104
  history = [];
99
105
  current = null;
@@ -112,9 +118,29 @@ var Player = class {
112
118
  };
113
119
  /** Optional async hook used when the queue and loop are exhausted. */
114
120
  autoplay = null;
121
+ /** When a non-stream track "finishes" without ever streaming (dead source:
122
+ * geo/age-restricted, throttled, removed), search for and play an alternate
123
+ * upload instead of silently draining the queue. */
124
+ recoverDeadStreams = true;
125
+ /** A track whose furthest observed position never passed this many ms is
126
+ * treated as never-streamed (used by {@link recoverDeadStreams}). */
127
+ deadStreamThresholdMs = 1500;
128
+ /** Give up recovering a single dead track after this many alternate attempts. */
129
+ deadStreamRecoveryLimit = 2;
130
+ /** Auto-skip a track the node reports as `TRACK_STUCK` instead of stalling. */
131
+ advanceOnStuck = true;
132
+ /** On a remote `VOICE_CLOSED`, reset so the next voice material re-creates the
133
+ * player and resumes the current track (the bot must re-send its op-4 join). */
134
+ rejoinOnVoiceClosed = true;
135
+ /** The guild this player drives. Always a `bigint` so 64-bit ids stay exact. */
136
+ guildId;
115
137
  created = false;
116
138
  voiceServer = null;
117
139
  voiceState = null;
140
+ /** Furthest position seen for the current track — reset on every track start. */
141
+ maxPositionMs = 0;
142
+ /** Alternate-upload attempts spent on the current dead track. */
143
+ recoverAttempts = 0;
118
144
  // --- voice plumbing ---------------------------------------------------
119
145
  handleVoiceServerUpdate(data) {
120
146
  this.voiceServer = { token: data.token, endpoint: data.endpoint };
@@ -127,14 +153,35 @@ var Player = class {
127
153
  }
128
154
  this.voiceState = {
129
155
  session_id: data.session_id,
130
- channel_id: Number(data.channel_id),
131
- user_id: Number(data.user_id)
156
+ channel_id: BigInt(data.channel_id),
157
+ user_id: BigInt(data.user_id)
132
158
  };
133
159
  void this.maybeStart();
134
160
  }
135
161
  get connected() {
136
162
  return this.voiceServer !== null && this.voiceState !== null;
137
163
  }
164
+ /** Called by the client on `PLAYER_UPDATE` (and reconnect resync). Tracks the
165
+ * furthest position reached so a "finished but never streamed" track can be
166
+ * spotted, and clears the recovery counter once a track is genuinely playing. */
167
+ applyState(state) {
168
+ this.state = state;
169
+ if (state.positionMs > this.maxPositionMs) this.maxPositionMs = state.positionMs;
170
+ if (this.maxPositionMs > this.deadStreamThresholdMs) this.recoverAttempts = 0;
171
+ }
172
+ /** Discord terminated the voice session (remote `VOICE_CLOSED`). The node has
173
+ * dropped our player, so reset local voice/created state and requeue the
174
+ * current track; once the bot re-sends its op-4 join, fresh voice material
175
+ * re-creates the player and resumes. Bot-initiated closes don't call this. */
176
+ handleVoiceClosed() {
177
+ if (this.current !== null) {
178
+ this.queue.unshift(this.current);
179
+ this.current = null;
180
+ }
181
+ this.created = false;
182
+ this.voiceServer = null;
183
+ this.voiceState = null;
184
+ }
138
185
  voicePayload() {
139
186
  return { ...this.voiceServer, ...this.voiceState, guild_id: this.guildId };
140
187
  }
@@ -317,6 +364,7 @@ var Player = class {
317
364
  async playTrack(track) {
318
365
  if (!this.connected) throw new NotConnected(`guild ${this.guildId} has no voice connection`);
319
366
  this.current = track;
367
+ this.maxPositionMs = 0;
320
368
  if (!this.created) {
321
369
  await this.host.rest.createPlayer(this.guildId, this.voicePayload(), {
322
370
  track: track.encoded,
@@ -332,16 +380,67 @@ var Player = class {
332
380
  this.paused = paused;
333
381
  if (this.created) await this.host.rest.setPaused(this.guildId, paused);
334
382
  }
335
- /** Called by the client's WS loop when a TRACK_END for this guild arrives. */
383
+ /** Called by the client's WS loop when a TRACK_END for this guild arrives.
384
+ * `advances` is true for FINISHED / LOAD_FAILED. Before draining the queue we
385
+ * check for a dead source and try to recover it with an alternate upload. */
336
386
  async onTrackEnd(advances) {
337
- if (advances) await this.advance();
387
+ if (!advances) return;
388
+ if (this.recoverDeadStreams && this.loop !== "track" /* Track */ && this.isDeadStream()) {
389
+ if (await this.tryRecover()) return;
390
+ }
391
+ await this.advance();
392
+ }
393
+ /** Called by the client on `TRACK_STUCK`: skip the stalled track. */
394
+ async onTrackStuck() {
395
+ if (this.advanceOnStuck) await this.advance(true);
396
+ }
397
+ /** A non-stream track that "finished" without its position ever advancing
398
+ * almost certainly never streamed. Genuinely short tracks (length at most a
399
+ * few × the threshold) are excluded so we don't loop on real short songs. */
400
+ isDeadStream() {
401
+ const t = this.current;
402
+ if (t === null || t.isStream) return false;
403
+ if (this.maxPositionMs > this.deadStreamThresholdMs) return false;
404
+ return t.lengthMs === 0 || t.lengthMs > this.deadStreamThresholdMs * 4;
405
+ }
406
+ /** Search for an alternate upload of the current (dead) track and play it.
407
+ * Returns true when an alternate started; false to let the caller advance. */
408
+ async tryRecover() {
409
+ const failed = this.current;
410
+ if (failed === null) return false;
411
+ this.recoverAttempts++;
412
+ if (this.recoverAttempts > this.deadStreamRecoveryLimit) {
413
+ this.recoverAttempts = 0;
414
+ this.host.emit("trackStreamDead", this, { failed });
415
+ return false;
416
+ }
417
+ const query = [failed.title, failed.author].filter(Boolean).join(" ").trim();
418
+ if (query === "") return false;
419
+ let tracks;
420
+ try {
421
+ tracks = (await this.host.rest.loadTracks(`ytsearch:${query}`)).tracks;
422
+ } catch {
423
+ return false;
424
+ }
425
+ const alt = tracks.find((t) => t.encoded && t.encoded !== failed.encoded);
426
+ if (!alt) return false;
427
+ alt.extras = { ...alt.extras, ...failed.extras };
428
+ await this.playTrack(alt);
429
+ this.host.emit("trackRecovered", this, { failed, alternate: alt });
430
+ return true;
338
431
  }
339
432
  };
340
433
 
341
434
  // src/rest.ts
435
+ var DEFAULT_TIMEOUT_MS = 8e3;
436
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([502, 503]);
437
+ var BACKOFF_MS = [200, 500];
438
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
342
439
  var REST = class {
343
440
  base;
344
441
  headers;
442
+ /** Per-request timeout (ms). Exposed so a slow-network deployment can raise it. */
443
+ timeoutMs = DEFAULT_TIMEOUT_MS;
345
444
  constructor(baseUrl, token) {
346
445
  this.base = baseUrl.replace(/\/$/, "");
347
446
  this.headers = {
@@ -349,20 +448,56 @@ var REST = class {
349
448
  "Content-Type": "application/json"
350
449
  };
351
450
  }
451
+ async fetchWithTimeout(method, path, body) {
452
+ const controller = new AbortController();
453
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
454
+ try {
455
+ return await fetch(`${this.base}${path}`, {
456
+ method,
457
+ headers: this.headers,
458
+ // snowflakeStringify keeps 64-bit voice ids (channel_id/user_id) exact —
459
+ // the control plane parses them as u64 bare numbers.
460
+ body: body === void 0 ? void 0 : snowflakeStringify(body),
461
+ signal: controller.signal
462
+ });
463
+ } finally {
464
+ clearTimeout(timer);
465
+ }
466
+ }
467
+ // Retry policy: GET is idempotent, so retry it on both transient 502/503 and
468
+ // network/timeout errors. Writes only retry on 502/503 (a network error might
469
+ // mean the write half-applied). 401/403/429/other 4xx are terminal — no retry.
352
470
  async request(method, path, body) {
353
- const res = await fetch(`${this.base}${path}`, {
354
- method,
355
- headers: this.headers,
356
- body: body === void 0 ? void 0 : JSON.stringify(body)
357
- });
358
- if (res.status === 401 || res.status === 403) throw new AuthError(await res.text());
359
- if (res.status === 429) {
360
- const retry = res.headers.get("Retry-After");
361
- throw new RateLimited(await res.text(), retry ? Number(retry) : void 0);
471
+ const idempotent = method === "GET";
472
+ const maxAttempts = idempotent ? BACKOFF_MS.length + 1 : 2;
473
+ let lastErr;
474
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
475
+ const isLast = attempt === maxAttempts - 1;
476
+ try {
477
+ const res = await this.fetchWithTimeout(method, path, body);
478
+ if (res.status === 401 || res.status === 403) throw new AuthError(await res.text());
479
+ if (res.status === 429) {
480
+ const retry = res.headers.get("Retry-After");
481
+ throw new RateLimited(await res.text(), retry ? Number(retry) : void 0);
482
+ }
483
+ if (RETRYABLE_STATUS.has(res.status) && !isLast) {
484
+ await sleep(BACKOFF_MS[attempt]);
485
+ continue;
486
+ }
487
+ if (res.status >= 400) throw new APIError(res.status, await res.text());
488
+ const ct = res.headers.get("Content-Type") ?? "";
489
+ return ct.includes("application/json") ? res.json() : res.text();
490
+ } catch (err) {
491
+ if (err instanceof AuralisError) throw err;
492
+ lastErr = err;
493
+ if (idempotent && !isLast) {
494
+ await sleep(BACKOFF_MS[attempt]);
495
+ continue;
496
+ }
497
+ throw err;
498
+ }
362
499
  }
363
- if (res.status >= 400) throw new APIError(res.status, await res.text());
364
- const ct = res.headers.get("Content-Type") ?? "";
365
- return ct.includes("application/json") ? res.json() : res.text();
500
+ throw lastErr;
366
501
  }
367
502
  // --- metadata ---------------------------------------------------------
368
503
  async loadTracks(identifier) {
@@ -480,10 +615,11 @@ var Client = class extends EventEmitter {
480
615
  }
481
616
  // --- players ----------------------------------------------------------
482
617
  getPlayer(guildId) {
483
- let p = this.playersMap.get(guildId);
618
+ const id = BigInt(guildId);
619
+ let p = this.playersMap.get(id);
484
620
  if (!p) {
485
- p = new Player(this, guildId);
486
- this.playersMap.set(guildId, p);
621
+ p = new Player(this, id);
622
+ this.playersMap.set(id, p);
487
623
  }
488
624
  return p;
489
625
  }
@@ -546,7 +682,7 @@ var Client = class extends EventEmitter {
546
682
  await Promise.all(
547
683
  this.players().map(async (p) => {
548
684
  try {
549
- p.state = await this.rest.getState(p.guildId);
685
+ p.applyState(await this.rest.getState(p.guildId));
550
686
  } catch {
551
687
  }
552
688
  })
@@ -596,12 +732,16 @@ var Client = class extends EventEmitter {
596
732
  if (op === "STATS") {
597
733
  this.lastStats = d;
598
734
  }
599
- const guildId = d.guild_id ? Number(d.guild_id) : null;
735
+ const guildId = d.guild_id != null ? BigInt(d.guild_id) : null;
600
736
  const player = guildId !== null ? this.playersMap.get(guildId) : void 0;
601
737
  if (op === "TRACK_END" && player) {
602
738
  await player.onTrackEnd(endReasonAdvances(d.reason ?? "FINISHED"));
603
739
  } else if (op === "PLAYER_UPDATE" && player) {
604
- player.state = playerStateFromJson(d.state ?? {});
740
+ player.applyState(playerStateFromJson(d.state ?? {}));
741
+ } else if (op === "TRACK_STUCK" && player) {
742
+ await player.onTrackStuck();
743
+ } else if (op === "VOICE_CLOSED" && player && d.by_remote && player.rejoinOnVoiceClosed) {
744
+ player.handleVoiceClosed();
605
745
  }
606
746
  const eventName = EVENT_OPS[op];
607
747
  if (eventName) this.emit(eventName, player ?? guildId, d);