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/README.md +18 -1
- package/dist/index.cjs +164 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -26
- package/dist/index.d.ts +81 -26
- package/dist/index.js +164 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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:
|
|
65
|
-
user_id:
|
|
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?:
|
|
78
|
-
createPlayer(guildId:
|
|
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:
|
|
85
|
-
getState(guildId:
|
|
86
|
-
play(guildId:
|
|
87
|
-
stop(guildId:
|
|
88
|
-
setPaused(guildId:
|
|
89
|
-
setVolume(guildId:
|
|
90
|
-
seek(guildId:
|
|
91
|
-
setFilters(guildId:
|
|
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:
|
|
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
|
-
|
|
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 |
|
|
194
|
-
trackEnd: [player: Player |
|
|
195
|
-
trackException: [player: Player |
|
|
196
|
-
trackStuck: [player: Player |
|
|
197
|
-
playerUpdate: [player: Player |
|
|
198
|
-
voiceClosed: [player: Player |
|
|
199
|
-
stats: [player: Player |
|
|
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:
|
|
287
|
+
getPlayer(guildId: Snowflake): Player;
|
|
233
288
|
players(): Player[];
|
|
234
|
-
dropPlayer(guildId:
|
|
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:
|
|
65
|
-
user_id:
|
|
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?:
|
|
78
|
-
createPlayer(guildId:
|
|
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:
|
|
85
|
-
getState(guildId:
|
|
86
|
-
play(guildId:
|
|
87
|
-
stop(guildId:
|
|
88
|
-
setPaused(guildId:
|
|
89
|
-
setVolume(guildId:
|
|
90
|
-
seek(guildId:
|
|
91
|
-
setFilters(guildId:
|
|
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:
|
|
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
|
-
|
|
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 |
|
|
194
|
-
trackEnd: [player: Player |
|
|
195
|
-
trackException: [player: Player |
|
|
196
|
-
trackStuck: [player: Player |
|
|
197
|
-
playerUpdate: [player: Player |
|
|
198
|
-
voiceClosed: [player: Player |
|
|
199
|
-
stats: [player: Player |
|
|
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:
|
|
287
|
+
getPlayer(guildId: Snowflake): Player;
|
|
233
288
|
players(): Player[];
|
|
234
|
-
dropPlayer(guildId:
|
|
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:
|
|
131
|
-
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)
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
+
const id = BigInt(guildId);
|
|
619
|
+
let p = this.playersMap.get(id);
|
|
484
620
|
if (!p) {
|
|
485
|
-
p = new Player(this,
|
|
486
|
-
this.playersMap.set(
|
|
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.
|
|
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 ?
|
|
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.
|
|
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);
|