Compare commits

...
12 Commits
Author SHA1 Message Date
fraujulian fdaa30af1a ci: parallelize Gitea validation jobs
CI / format (push) Successful in 1m45s
CI / lint (push) Successful in 1m44s
CI / typecheck (push) Successful in 58s
CI / test-and-build (push) Successful in 1m25s
2026-10-02 13:17:12 +02:00
fraujulian 94bb33308a ci: label release workflow as CD 2026-10-02 13:16:20 +02:00
fraujulian a5b17dfd2a chore(deps): update dependencies
Build Validation / build (push) Successful in 37s
2026-10-02 12:44:00 +02:00
fraujulian f84a115e32 test(audio): cover URL protocols and reconnects
Build Validation / build (push) Successful in 47s
2026-09-28 13:39:29 +02:00
fraujulian ac20d12b13 feat(voice): retry unrecoverable disconnects
Build Validation / build (push) Successful in 48s
2026-09-28 13:38:14 +02:00
fraujulian 2b90b2775f chore(runtime): support Node.js 22.12 and newer
Build Validation / build (push) Successful in 47s
2026-09-28 13:37:31 +02:00
fraujulian 00875123f5 fix(security): restrict audio URLs to HTTP(S)
Build Validation / build (push) Successful in 1m37s
2026-09-28 13:36:44 +02:00
fraujulian 04eba4de45 ci(whoami): delete unnecessary task
Build Validation / build (push) Failing after 0s
2026-08-24 16:05:11 +02:00
fraujulian 7bd03b5e5f ci(release): verify npm authentication 2026-08-24 16:03:24 +02:00
fraujulian fefad30ad5 refactor(version): define release line statically 2026-08-24 16:02:24 +02:00
fraujulian 9b14d8e9f0 fix(version): preserve package formatting 2026-08-24 16:01:16 +02:00
fraujulian 2430f6e667 chore(license): align package metadata with AGPL 2026-08-24 15:59:12 +02:00
9 changed files with 2017 additions and 1719 deletions

No files matched your search

+3 -7
View File
@@ -1,4 +1,4 @@
name: Release and Deploy Build name: CD
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,11 +6,6 @@ on:
permissions: permissions:
contents: write contents: write
env:
VERSION_MAJOR: '1'
VERSION_MINOR: '1'
VERSION_PATCH: ${{ gitea.run_number }}
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -33,8 +28,9 @@ jobs:
run: npm ci run: npm ci
- name: Set Release Version - name: Set Release Version
id: version
run: npm run version:place run: npm run version:place
env:
VERSION_PATCH: ${{ gitea.run_number }}
- name: Export Release Version - name: Export Release Version
id: release_version id: release_version
+58 -5
View File
@@ -1,4 +1,4 @@
name: Build Validation name: CI
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,10 +6,9 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
types: [opened, synchronize, reopened]
jobs: jobs:
build: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
@@ -23,8 +22,62 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Run Checks - name: Check Version
run: npm run check run: npm run version:check
- name: Check Formatting
run: npm run format:check
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Use Node.js 24.17.0
uses: actions/setup-node@v4
with:
node-version: '24.17.0'
- name: Install Dependencies
run: npm ci
- name: Run Lint
run: npm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Use Node.js 24.17.0
uses: actions/setup-node@v4
with:
node-version: '24.17.0'
- name: Install Dependencies
run: npm ci
- name: Run Type Check
run: npm run typecheck
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Use Node.js 24.17.0
uses: actions/setup-node@v4
with:
node-version: '24.17.0'
- name: Install Dependencies
run: npm ci
- name: Run Tests
run: npm test -- --runInBand
- name: Run Build - name: Run Build
run: npm run build run: npm run build
+8 -2
View File
@@ -18,7 +18,7 @@ Create an [issue](https://git.lechner-systems.at/FrauJulian/Discord-Audio-Stream
## Installation ## Installation
Node.js `>=24.17.0` and `<25` is required. Node.js `>=22.12.0` and `<25` is required.
```bash ```bash
npm install discord-audio-stream @discordjs/voice @discordjs/opus npm install discord-audio-stream @discordjs/voice @discordjs/opus
@@ -114,6 +114,7 @@ type AudioManagerOptions = {
}; };
source?: { type: 'url'; url: string } | { type: 'file'; path: string }; source?: { type: 'url'; url: string } | { type: 'file'; path: string };
renewIntervalMs?: number | false; renewIntervalMs?: number | false;
reconnectAttempts?: number;
connectTimeoutMs?: number; connectTimeoutMs?: number;
onError?: (error: Error) => void; onError?: (error: Error) => void;
volume?: { volume?: {
@@ -124,15 +125,17 @@ type AudioManagerOptions = {
``` ```
`connectTimeoutMs` and numeric `renewIntervalMs` values must be whole milliseconds from `1` through `2_147_483_647`. `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`. `volume.initialPercent` must be a finite number from `0` through `100` and requires `volume.enabled: true`.
### Defaults ### Defaults
| Option | Default | | Option | Default |
| ------------------ | ---------- | | ------------------- | ---------- |
| `ffmpeg.mode` | `'native'` | | `ffmpeg.mode` | `'native'` |
| `connectTimeoutMs` | `20_000` | | `connectTimeoutMs` | `20_000` |
| `renewIntervalMs` | `false` | | `renewIntervalMs` | `false` |
| `reconnectAttempts` | `0` |
| `volume.enabled` | `false` | | `volume.enabled` | `false` |
### Methods ### 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 `renewIntervalMs` only when an application has a measured need for periodic restarts. `stop()` and `dispose()` always
clear the renewal timer. 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 ## Errors
The package exports these error classes: The package exports these error classes:
+1857 -1685
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "discord-audio-stream", "name": "discord-audio-stream",
"version": "x.x.x", "version": "x.x.x",
"license": "LGPL-3.0-only", "license": "AGPL-3.0-only",
"description": "A small Discord voice audio streaming library with managed ffmpeg playback.", "description": "A small Discord voice audio streaming library with managed ffmpeg playback.",
"exports": { "exports": {
".": { ".": {
@@ -70,22 +70,22 @@
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@jest/globals": "^30.4.1", "@jest/globals": "^30.4.1",
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
"@types/node": "^24.0.0", "@types/node": "^24.19.1",
"@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.67.0", "@typescript-eslint/parser": "^8.71.0",
"@typescript/native": "npm:typescript@^7.0.2",
"eslint": "^9.39.5", "eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.5", "eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-n": "^18.3.0", "eslint-plugin-n": "^18.4.0",
"eslint-plugin-promise": "^7.3.0", "eslint-plugin-promise": "^7.3.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^30.4.2", "jest": "^30.5.2",
"prettier": "^3.9.6", "prettier": "^3.9.9",
"ts-jest": "^29.4.12", "ts-jest": "^29.4.14",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2" "typescript": "npm:@typescript/typescript6@^6.0.2"
}, },
"peerDependencies": { "peerDependencies": {
@@ -112,7 +112,7 @@
"esbuild": "^0.28.2" "esbuild": "^0.28.2"
}, },
"engines": { "engines": {
"node": ">=24.17.0 <25" "node": ">=22.12.0 <25"
}, },
"private": false, "private": false,
"publishConfig": { "publishConfig": {
+6 -9
View File
@@ -3,6 +3,8 @@ const fs = require('fs');
const lockFilePath = 'package-lock.json'; const lockFilePath = 'package-lock.json';
const packageFilePath = 'package.json'; const packageFilePath = 'package.json';
const placeholderVersion = 'x.x.x'; const placeholderVersion = 'x.x.x';
const versionMajor = 1;
const versionMinor = 2;
const command = process.argv[2]; const command = process.argv[2];
function readJson(path) { function readJson(path) {
@@ -24,7 +26,7 @@ function getLockFile() {
function writeVersions(version) { function writeVersions(version) {
const pkg = readJson(packageFilePath); const pkg = readJson(packageFilePath);
pkg.version = version; pkg.version = version;
writeJson(packageFilePath, pkg, 2); writeJson(packageFilePath, pkg, 4);
const lock = getLockFile(); const lock = getLockFile();
@@ -74,19 +76,14 @@ function fixPlaceholder() {
} }
function getCiVersion() { function getCiVersion() {
const major = process.env.VERSION_MAJOR;
const minor = process.env.VERSION_MINOR;
const patch = process.env.VERSION_PATCH; const patch = process.env.VERSION_PATCH;
const segments = { major, minor, patch };
for (const [name, value] of Object.entries(segments)) { if (!/^\d+$/.test(patch ?? '')) {
if (!/^\d+$/.test(value ?? '')) { console.error(`Missing or invalid patch version segment: "${patch ?? ''}"`);
console.error(`Missing or invalid ${name} version segment: "${value ?? ''}"`);
process.exit(1); process.exit(1);
} }
}
return `${major}.${minor}.${patch}`; return `${versionMajor}.${versionMinor}.${patch}`;
} }
if (command === 'check-placeholder') { if (command === 'check-placeholder') {
+35 -1
View File
@@ -48,6 +48,7 @@ export default class AudioManager implements Disposable {
private connectAttempt: AbortController | undefined; private connectAttempt: AbortController | undefined;
private renewTimer: NodeJS.Timeout | undefined; private renewTimer: NodeJS.Timeout | undefined;
private playbackState: PlaybackState = 'idle'; private playbackState: PlaybackState = 'idle';
private reconnectGeneration = 0;
private connectionOptions: VoiceConnectionOptions | undefined; private connectionOptions: VoiceConnectionOptions | undefined;
private audioSource: AudioSource | undefined; private audioSource: AudioSource | undefined;
private readonly options: Required<Pick<AudioManagerOptions, 'connectTimeoutMs'>> & private readonly options: Required<Pick<AudioManagerOptions, 'connectTimeoutMs'>> &
@@ -61,6 +62,13 @@ export default class AudioManager implements Disposable {
assertValidTimerDelay(options.renewIntervalMs, 'renewIntervalMs'); 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?.initialPercent !== undefined) {
if (options.volume.enabled !== true) { if (options.volume.enabled !== true) {
throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.'); throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.');
@@ -254,6 +262,7 @@ export default class AudioManager implements Disposable {
return; return;
} }
this.reconnectGeneration += 1;
this.cancelConnectAttempt(); this.cancelConnectAttempt();
this.clearRenewTimer(); this.clearRenewTimer();
this.stopCurrentPlayback(); this.stopCurrentPlayback();
@@ -285,6 +294,7 @@ export default class AudioManager implements Disposable {
return; return;
} }
this.reconnectGeneration += 1;
this.cancelConnectAttempt(); this.cancelConnectAttempt();
this.clearRenewTimer(); this.clearRenewTimer();
this.stopCurrentPlayback(); this.stopCurrentPlayback();
@@ -307,8 +317,13 @@ export default class AudioManager implements Disposable {
if (this.audioSource.type === 'url') { if (this.audioSource.type === 'url') {
try { try {
const url = new URL(this.audioSource.url);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported audio source protocol.');
}
return { return {
input: new URL(this.audioSource.url).toString(), input: url.toString(),
source: this.audioSource, source: this.audioSource,
}; };
} catch (error) { } catch (error) {
@@ -361,6 +376,9 @@ export default class AudioManager implements Disposable {
} }
private async handleDisconnectedConnection(connection: VoiceConnection): Promise<void> { private async handleDisconnectedConnection(connection: VoiceConnection): Promise<void> {
const shouldReconnect = this.resource !== undefined && (this.options.reconnectAttempts ?? 0) > 0;
const reconnectGeneration = this.reconnectGeneration;
try { try {
await Promise.race([ await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS), entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS),
@@ -371,6 +389,22 @@ export default class AudioManager implements Disposable {
return; 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.clearRenewTimer();
this.stopCurrentPlayback(); this.stopCurrentPlayback();
this.audioPlayer.stop(true); this.audioPlayer.stop(true);
+6
View File
@@ -150,6 +150,12 @@ export type AudioManagerOptions = {
*/ */
renewIntervalMs?: number | false; 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. * Maximum milliseconds to wait for the Discord voice connection to become ready.
* Must be an integer from `1` through `2_147_483_647`. * Must be an integer from `1` through `2_147_483_647`.
+34
View File
@@ -235,6 +235,28 @@ describe('AudioManager', () => {
expect(manager.isConnected).toBe(false); 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 () => { it('cleans up when connection startup fails', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@@ -599,6 +621,18 @@ describe('AudioManager', () => {
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError); 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 () => { it('applies initial volume only when volume support is enabled', async () => {
const manager = new AudioManager({ const manager = new AudioManager({
connection: connectionOptions, connection: connectionOptions,