Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84a115e32 | ||
|
|
ac20d12b13 | ||
|
|
2b90b2775f | ||
|
|
00875123f5 |
No files matched your search
@@ -18,7 +18,7 @@ Create an [issue](https://git.lechner-systems.at/FrauJulian/Discord-Audio-Stream
|
||||
|
||||
## Installation
|
||||
|
||||
Node.js `>=24.17.0` and `<25` is required.
|
||||
Node.js `>=22.12.0` and `<25` is required.
|
||||
|
||||
```bash
|
||||
npm install discord-audio-stream @discordjs/voice @discordjs/opus
|
||||
@@ -114,6 +114,7 @@ type AudioManagerOptions = {
|
||||
};
|
||||
source?: { type: 'url'; url: string } | { type: 'file'; path: string };
|
||||
renewIntervalMs?: number | false;
|
||||
reconnectAttempts?: number;
|
||||
connectTimeoutMs?: number;
|
||||
onError?: (error: Error) => void;
|
||||
volume?: {
|
||||
@@ -124,16 +125,18 @@ type AudioManagerOptions = {
|
||||
```
|
||||
|
||||
`connectTimeoutMs` and numeric `renewIntervalMs` values must be whole milliseconds from `1` through `2_147_483_647`.
|
||||
`reconnectAttempts` must be a non-negative safe integer. URL sources must use `http:` or `https:`.
|
||||
`volume.initialPercent` must be a finite number from `0` through `100` and requires `volume.enabled: true`.
|
||||
|
||||
### Defaults
|
||||
|
||||
| Option | Default |
|
||||
| ------------------ | ---------- |
|
||||
| `ffmpeg.mode` | `'native'` |
|
||||
| `connectTimeoutMs` | `20_000` |
|
||||
| `renewIntervalMs` | `false` |
|
||||
| `volume.enabled` | `false` |
|
||||
| Option | Default |
|
||||
| ------------------- | ---------- |
|
||||
| `ffmpeg.mode` | `'native'` |
|
||||
| `connectTimeoutMs` | `20_000` |
|
||||
| `renewIntervalMs` | `false` |
|
||||
| `reconnectAttempts` | `0` |
|
||||
| `volume.enabled` | `false` |
|
||||
|
||||
### Methods
|
||||
|
||||
@@ -188,6 +191,9 @@ Connection renewal is disabled by default because `@discordjs/voice` handles rec
|
||||
`renewIntervalMs` only when an application has a measured need for periodic restarts. `stop()` and `dispose()` always
|
||||
clear the renewal timer.
|
||||
|
||||
After an unrecoverable disconnect, playback stops by default. Set `reconnectAttempts` to retry joining the configured
|
||||
voice channel and restarting the current source.
|
||||
|
||||
## Errors
|
||||
|
||||
The package exports these error classes:
|
||||
|
||||
Generated
+1
-1
@@ -31,7 +31,7 @@
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0 <25"
|
||||
"node": ">=22.12.0 <25"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/FrauJulian"
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@
|
||||
"esbuild": "^0.28.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0 <25"
|
||||
"node": ">=22.12.0 <25"
|
||||
},
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
|
||||
+35
-1
@@ -48,6 +48,7 @@ export default class AudioManager implements Disposable {
|
||||
private connectAttempt: AbortController | undefined;
|
||||
private renewTimer: NodeJS.Timeout | undefined;
|
||||
private playbackState: PlaybackState = 'idle';
|
||||
private reconnectGeneration = 0;
|
||||
private connectionOptions: VoiceConnectionOptions | undefined;
|
||||
private audioSource: AudioSource | undefined;
|
||||
private readonly options: Required<Pick<AudioManagerOptions, 'connectTimeoutMs'>> &
|
||||
@@ -61,6 +62,13 @@ export default class AudioManager implements Disposable {
|
||||
assertValidTimerDelay(options.renewIntervalMs, 'renewIntervalMs');
|
||||
}
|
||||
|
||||
if (
|
||||
options.reconnectAttempts !== undefined &&
|
||||
(!Number.isSafeInteger(options.reconnectAttempts) || options.reconnectAttempts < 0)
|
||||
) {
|
||||
throw new AudioManagerConfigError('reconnectAttempts must be a non-negative safe integer.');
|
||||
}
|
||||
|
||||
if (options.volume?.initialPercent !== undefined) {
|
||||
if (options.volume.enabled !== true) {
|
||||
throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.');
|
||||
@@ -254,6 +262,7 @@ export default class AudioManager implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectGeneration += 1;
|
||||
this.cancelConnectAttempt();
|
||||
this.clearRenewTimer();
|
||||
this.stopCurrentPlayback();
|
||||
@@ -285,6 +294,7 @@ export default class AudioManager implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectGeneration += 1;
|
||||
this.cancelConnectAttempt();
|
||||
this.clearRenewTimer();
|
||||
this.stopCurrentPlayback();
|
||||
@@ -307,8 +317,13 @@ export default class AudioManager implements Disposable {
|
||||
|
||||
if (this.audioSource.type === 'url') {
|
||||
try {
|
||||
const url = new URL(this.audioSource.url);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('Unsupported audio source protocol.');
|
||||
}
|
||||
|
||||
return {
|
||||
input: new URL(this.audioSource.url).toString(),
|
||||
input: url.toString(),
|
||||
source: this.audioSource,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -361,6 +376,9 @@ export default class AudioManager implements Disposable {
|
||||
}
|
||||
|
||||
private async handleDisconnectedConnection(connection: VoiceConnection): Promise<void> {
|
||||
const shouldReconnect = this.resource !== undefined && (this.options.reconnectAttempts ?? 0) > 0;
|
||||
const reconnectGeneration = this.reconnectGeneration;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS),
|
||||
@@ -371,6 +389,22 @@ export default class AudioManager implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldReconnect) {
|
||||
this.clearRenewTimer();
|
||||
for (let attempt = 0; attempt < (this.options.reconnectAttempts ?? 0); attempt += 1) {
|
||||
try {
|
||||
await this.start();
|
||||
return;
|
||||
} catch (reconnectError) {
|
||||
error = reconnectError;
|
||||
}
|
||||
|
||||
if (this.reconnectGeneration !== reconnectGeneration || this.playbackState === 'disposed') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.clearRenewTimer();
|
||||
this.stopCurrentPlayback();
|
||||
this.audioPlayer.stop(true);
|
||||
|
||||
@@ -150,6 +150,12 @@ export type AudioManagerOptions = {
|
||||
*/
|
||||
renewIntervalMs?: number | false;
|
||||
|
||||
/**
|
||||
* Number of full reconnect and playback restart attempts after an unrecoverable disconnect.
|
||||
* Disabled by default. Must be a non-negative safe integer.
|
||||
*/
|
||||
reconnectAttempts?: number;
|
||||
|
||||
/**
|
||||
* Maximum milliseconds to wait for the Discord voice connection to become ready.
|
||||
* Must be an integer from `1` through `2_147_483_647`.
|
||||
|
||||
@@ -235,6 +235,28 @@ describe('AudioManager', () => {
|
||||
expect(manager.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('reconnects and restarts playback when configured', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
reconnectAttempts: 1,
|
||||
});
|
||||
await manager.start();
|
||||
jest.mocked(entersState)
|
||||
.mockRejectedValueOnce(new Error('disconnect'))
|
||||
.mockRejectedValueOnce(new Error('disconnect'));
|
||||
jest.mocked(joinVoiceChannel).mockReturnValueOnce(mockSecondVoiceConnection);
|
||||
mockConnection.state.status = VoiceConnectionStatus.Disconnected;
|
||||
|
||||
await listenerFor(connectionListeners, VoiceConnectionStatus.Disconnected)();
|
||||
|
||||
expect(joinVoiceChannel).toHaveBeenCalledTimes(2);
|
||||
expect(startFfmpeg).toHaveBeenCalledTimes(2);
|
||||
expect(manager.state).toBe('playing');
|
||||
expect(manager.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up when connection startup fails', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -599,6 +621,18 @@ describe('AudioManager', () => {
|
||||
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
|
||||
});
|
||||
|
||||
it.each(['file:///etc/passwd', 'ftp://example.com/audio.mp3'])('rejects non-HTTP URL source %s', async (url) => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: { type: 'url', url },
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.connect();
|
||||
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
|
||||
expect(startFfmpeg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies initial volume only when volume support is enabled', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
|
||||
Reference in new issue
Block a user