ziplayer 0.2.7 → 0.3.1
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/AI-Guide.md +624 -956
- package/README.md +277 -10
- package/dist/extensions/BaseExtension.d.ts +1 -0
- package/dist/extensions/BaseExtension.d.ts.map +1 -1
- package/dist/extensions/BaseExtension.js.map +1 -1
- package/dist/extensions/index.d.ts +38 -3
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +259 -41
- package/dist/extensions/index.js.map +1 -1
- package/dist/persistence/PersistenceManager.d.ts +95 -0
- package/dist/persistence/PersistenceManager.d.ts.map +1 -0
- package/dist/persistence/PersistenceManager.js +975 -0
- package/dist/persistence/PersistenceManager.js.map +1 -0
- package/dist/plugins/BasePlugin.js +1 -1
- package/dist/plugins/BasePlugin.js.map +1 -1
- package/dist/plugins/index.d.ts +74 -8
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +657 -116
- package/dist/plugins/index.js.map +1 -1
- package/dist/structures/FilterManager.js +3 -3
- package/dist/structures/FilterManager.js.map +1 -1
- package/dist/structures/PersistenceManager.d.ts +96 -0
- package/dist/structures/PersistenceManager.d.ts.map +1 -0
- package/dist/structures/PersistenceManager.js +1008 -0
- package/dist/structures/PersistenceManager.js.map +1 -0
- package/dist/structures/Player.d.ts +158 -14
- package/dist/structures/Player.d.ts.map +1 -1
- package/dist/structures/Player.js +1175 -188
- package/dist/structures/Player.js.map +1 -1
- package/dist/structures/PlayerManager.d.ts +106 -91
- package/dist/structures/PlayerManager.d.ts.map +1 -1
- package/dist/structures/PlayerManager.js +365 -124
- package/dist/structures/PlayerManager.js.map +1 -1
- package/dist/structures/Queue.d.ts +136 -31
- package/dist/structures/Queue.d.ts.map +1 -1
- package/dist/structures/Queue.js +265 -46
- package/dist/structures/Queue.js.map +1 -1
- package/dist/structures/StreamManager.d.ts +137 -0
- package/dist/structures/StreamManager.d.ts.map +1 -0
- package/dist/structures/StreamManager.js +420 -0
- package/dist/structures/StreamManager.js.map +1 -0
- package/dist/types/index.d.ts +181 -8
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/persistence.d.ts +77 -0
- package/dist/types/persistence.d.ts.map +1 -0
- package/dist/types/persistence.js +3 -0
- package/dist/types/persistence.js.map +1 -0
- package/package.json +3 -2
- package/src/extensions/BaseExtension.ts +1 -0
- package/src/extensions/index.ts +320 -37
- package/src/plugins/BasePlugin.ts +1 -1
- package/src/plugins/index.ts +809 -139
- package/src/structures/FilterManager.ts +3 -3
- package/src/structures/Player.ts +2810 -1693
- package/src/structures/PlayerManager.ts +438 -129
- package/src/structures/Queue.ts +300 -55
- package/src/structures/StreamManager.ts +524 -0
- package/src/types/extension.ts +129 -129
- package/src/types/fillter.ts +264 -264
- package/src/types/index.ts +187 -12
- package/src/types/plugin.ts +59 -59
- package/tsconfig.json +0 -1
|
@@ -3,11 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Player = void 0;
|
|
4
4
|
const events_1 = require("events");
|
|
5
5
|
const voice_1 = require("@discordjs/voice");
|
|
6
|
+
const lru_cache_1 = require("lru-cache");
|
|
6
7
|
const Queue_1 = require("./Queue");
|
|
7
8
|
const plugins_1 = require("../plugins");
|
|
8
9
|
const extensions_1 = require("../extensions");
|
|
9
|
-
const timeout_1 = require("../utils/timeout");
|
|
10
10
|
const FilterManager_1 = require("./FilterManager");
|
|
11
|
+
const StreamManager_1 = require("./StreamManager");
|
|
11
12
|
/**
|
|
12
13
|
* Represents a music player for a specific Discord guild.
|
|
13
14
|
*
|
|
@@ -51,15 +52,79 @@ class Player extends events_1.EventEmitter {
|
|
|
51
52
|
this.volume = 100;
|
|
52
53
|
this.isPlaying = false;
|
|
53
54
|
this.isPaused = false;
|
|
55
|
+
this._lastActivity = Date.now();
|
|
54
56
|
this.leaveTimeout = null;
|
|
55
57
|
this.currentResource = null;
|
|
56
58
|
this.volumeInterval = null;
|
|
59
|
+
this.stuckTimer = null;
|
|
57
60
|
this.skipLoop = false;
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
this.refreshLock = false;
|
|
62
|
+
//preloaded resource
|
|
63
|
+
this.preloadState = {
|
|
64
|
+
resource: null,
|
|
65
|
+
track: null,
|
|
66
|
+
abortController: null,
|
|
67
|
+
timeoutId: null,
|
|
68
|
+
isValid: false,
|
|
69
|
+
isBeingUsed: false,
|
|
70
|
+
};
|
|
71
|
+
this.isPreloading = false;
|
|
72
|
+
this.currentSlot = {
|
|
73
|
+
resource: null,
|
|
74
|
+
track: null,
|
|
75
|
+
streamId: null,
|
|
76
|
+
abortController: null,
|
|
77
|
+
isValid: false,
|
|
78
|
+
isLoading: false,
|
|
79
|
+
loadPromise: null,
|
|
80
|
+
};
|
|
81
|
+
this.preloadSlot = {
|
|
82
|
+
resource: null,
|
|
83
|
+
track: null,
|
|
84
|
+
streamId: null,
|
|
85
|
+
abortController: null,
|
|
86
|
+
isValid: false,
|
|
87
|
+
isLoading: false,
|
|
88
|
+
loadPromise: null,
|
|
89
|
+
};
|
|
90
|
+
this.preloadLock = false;
|
|
91
|
+
this.preloadEnabled = true;
|
|
92
|
+
this.crossfadeEnabled = true;
|
|
93
|
+
this.crossfadeDurationMs = 500;
|
|
94
|
+
this.lowPerformanceMode = false;
|
|
95
|
+
this.crossfadeTransitionLock = false;
|
|
96
|
+
this.smartTransitionEnabled = true;
|
|
97
|
+
this.smartTransitionGenreAware = true;
|
|
98
|
+
this.smartTransitionBeatAlign = true;
|
|
99
|
+
this.smartTransitionBaseMs = 800;
|
|
100
|
+
this.smartTransitionMinMs = 120;
|
|
101
|
+
this.smartTransitionMaxMs = 8000;
|
|
102
|
+
this.smartTransitionGenreDurations = {
|
|
103
|
+
chill: 700,
|
|
104
|
+
ambient: 750,
|
|
105
|
+
lofi: 650,
|
|
106
|
+
pop: 450,
|
|
107
|
+
rock: 350,
|
|
108
|
+
edm: 220,
|
|
109
|
+
house: 250,
|
|
110
|
+
techno: 200,
|
|
111
|
+
};
|
|
112
|
+
this.smartTransitionBeatAlignMaxWaitMs = 180;
|
|
113
|
+
this.antiStuckEnabled = true;
|
|
114
|
+
this.antiStuckMaxRetries = 2;
|
|
115
|
+
this.antiStuckRetryDelayMs = 900;
|
|
116
|
+
this.antiStuckReusePreloadFirst = true;
|
|
117
|
+
this.antiStuckReduceQualityOnRetry = true;
|
|
118
|
+
this.antiStuckControlledSkipThreshold = 3;
|
|
119
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
120
|
+
this.loudnessNormalizationEnabled = false;
|
|
121
|
+
this.loudnessTargetLUFS = -14;
|
|
122
|
+
this.loudnessMaxBoostDb = 8;
|
|
123
|
+
this.loudnessMaxCutDb = 10;
|
|
124
|
+
this.loudnessLimiterCeiling = 0.95;
|
|
60
125
|
this.SEARCH_CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
61
|
-
this.searchCacheTimestamps = new Map();
|
|
62
126
|
this.ttsPlayer = null;
|
|
127
|
+
this.lastDuration = 0;
|
|
63
128
|
this.debug(`[Player] Constructor called for guildId: ${guildId}`);
|
|
64
129
|
this.guildId = guildId;
|
|
65
130
|
this.queue = new Queue_1.Queue();
|
|
@@ -84,17 +149,81 @@ class Player extends events_1.EventEmitter {
|
|
|
84
149
|
createPlayer: false,
|
|
85
150
|
interrupt: true,
|
|
86
151
|
volume: 100,
|
|
87
|
-
|
|
152
|
+
maxTimeTts: 60_000,
|
|
88
153
|
...(options?.tts || {}),
|
|
89
154
|
},
|
|
90
155
|
};
|
|
156
|
+
this.lowPerformanceMode = this.options.lowPerformance ?? this.options.quality === "low";
|
|
157
|
+
const preloadOptions = this.options.preload || {};
|
|
158
|
+
const preloadAutoDisable = preloadOptions.autoDisableInLowPerformance ?? true;
|
|
159
|
+
this.preloadEnabled = preloadOptions.enabled ?? true;
|
|
160
|
+
if (this.lowPerformanceMode && preloadAutoDisable) {
|
|
161
|
+
this.preloadEnabled = false;
|
|
162
|
+
}
|
|
163
|
+
const crossfadeOptions = this.options.crossfade || {};
|
|
164
|
+
const crossfadeAutoEnable = crossfadeOptions.autoEnable ?? true;
|
|
165
|
+
const crossfadeAutoDisable = crossfadeOptions.autoDisableInLowPerformance ?? true;
|
|
166
|
+
this.crossfadeDurationMs = Math.max(0, crossfadeOptions.durationMs ?? 5000);
|
|
167
|
+
if (typeof crossfadeOptions.enabled === "boolean") {
|
|
168
|
+
this.crossfadeEnabled = crossfadeOptions.enabled;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
this.crossfadeEnabled = crossfadeAutoEnable;
|
|
172
|
+
}
|
|
173
|
+
if (this.lowPerformanceMode && crossfadeAutoDisable) {
|
|
174
|
+
this.crossfadeEnabled = false;
|
|
175
|
+
}
|
|
176
|
+
const smartTransitionOptions = this.options.smartTransition || {};
|
|
177
|
+
this.smartTransitionEnabled = smartTransitionOptions.enabled ?? true;
|
|
178
|
+
this.smartTransitionGenreAware = smartTransitionOptions.genreAware ?? true;
|
|
179
|
+
this.smartTransitionBeatAlign = smartTransitionOptions.beatAlign ?? true;
|
|
180
|
+
this.smartTransitionBaseMs = Math.max(0, smartTransitionOptions.baseDurationMs ?? this.crossfadeDurationMs);
|
|
181
|
+
this.smartTransitionMinMs = Math.max(0, smartTransitionOptions.minDurationMs ?? 1200);
|
|
182
|
+
this.smartTransitionMaxMs = Math.max(this.smartTransitionMinMs, smartTransitionOptions.maxDurationMs ?? 8000);
|
|
183
|
+
this.smartTransitionBeatAlignMaxWaitMs = Math.max(0, smartTransitionOptions.beatAlignMaxWaitMs ?? 1200);
|
|
184
|
+
this.smartTransitionGenreDurations = {
|
|
185
|
+
...this.smartTransitionGenreDurations,
|
|
186
|
+
...(smartTransitionOptions.genreDurations || {}),
|
|
187
|
+
};
|
|
188
|
+
const antiStuckOptions = this.options.antiStuck || {};
|
|
189
|
+
this.antiStuckEnabled = antiStuckOptions.enabled ?? true;
|
|
190
|
+
this.antiStuckMaxRetries = Math.max(0, antiStuckOptions.maxRetries ?? 2);
|
|
191
|
+
this.antiStuckRetryDelayMs = Math.max(0, antiStuckOptions.retryDelayMs ?? 900);
|
|
192
|
+
this.antiStuckReusePreloadFirst = antiStuckOptions.reusePreloadFirst ?? true;
|
|
193
|
+
this.antiStuckReduceQualityOnRetry = antiStuckOptions.reduceQualityOnRetry ?? true;
|
|
194
|
+
this.antiStuckControlledSkipThreshold = Math.max(1, antiStuckOptions.controlledSkipThreshold ?? 3);
|
|
195
|
+
const loudnessOptions = this.options.loudnessNormalization || {};
|
|
196
|
+
this.loudnessNormalizationEnabled = loudnessOptions.enabled ?? false;
|
|
197
|
+
this.loudnessTargetLUFS = loudnessOptions.targetLUFS ?? -14;
|
|
198
|
+
this.loudnessMaxBoostDb = Math.max(0, loudnessOptions.maxBoostDb ?? 8);
|
|
199
|
+
this.loudnessMaxCutDb = Math.max(0, loudnessOptions.maxCutDb ?? 10);
|
|
200
|
+
this.loudnessLimiterCeiling = Math.min(1, Math.max(0.1, loudnessOptions.limiterCeiling ?? 0.95));
|
|
201
|
+
this.debug(`[Player] Runtime options resolved: lowPerformance=${this.lowPerformanceMode}, preload=${this.preloadEnabled}, crossfade=${this.crossfadeEnabled} (${this.crossfadeDurationMs}ms), smartTransition=${this.smartTransitionEnabled}, antiStuck=${this.antiStuckEnabled}, loudnessNormalization=${this.loudnessNormalizationEnabled}`);
|
|
91
202
|
this.filter = new FilterManager_1.FilterManager(this, this.manager);
|
|
92
203
|
this.extensionManager = new extensions_1.ExtensionManager(this, this.manager);
|
|
93
204
|
this.pluginManager = new plugins_1.PluginManager(this, this.manager, {
|
|
94
205
|
extractorTimeout: this.options.extractorTimeout,
|
|
95
206
|
});
|
|
207
|
+
this.streamManager = new StreamManager_1.StreamManager({
|
|
208
|
+
maxConcurrentStreams: 20,
|
|
209
|
+
streamTimeout: 5 * 60 * 1000,
|
|
210
|
+
maxListenersPerStream: 15,
|
|
211
|
+
enableMetrics: true,
|
|
212
|
+
autoDestroy: true,
|
|
213
|
+
});
|
|
96
214
|
this.volume = this.options.volume || 100;
|
|
97
215
|
this.userdata = this.options.userdata;
|
|
216
|
+
this.searchCache = new lru_cache_1.LRUCache({
|
|
217
|
+
max: 200,
|
|
218
|
+
ttl: this.SEARCH_CACHE_TTL,
|
|
219
|
+
dispose: (value, key, reason) => {
|
|
220
|
+
if (this.listenerCount("debug") > 0) {
|
|
221
|
+
this.debug(`[SearchCache] Disposed cache entry: ${key}, reason: ${reason}`);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
allowStale: false,
|
|
225
|
+
updateAgeOnGet: true,
|
|
226
|
+
});
|
|
98
227
|
this.setupEventListeners();
|
|
99
228
|
// Initialize filters from options
|
|
100
229
|
if (this.options.filters && this.options.filters.length > 0) {
|
|
@@ -114,11 +243,12 @@ class Player extends events_1.EventEmitter {
|
|
|
114
243
|
* @private
|
|
115
244
|
*/
|
|
116
245
|
destroyCurrentStream() {
|
|
246
|
+
this.audioPlayer.stop(true);
|
|
117
247
|
if (!this.currentResource)
|
|
118
248
|
return;
|
|
119
249
|
const stream = this.currentResource?.metadata?.stream ?? this.currentResource?.stream;
|
|
120
|
-
if (stream
|
|
121
|
-
stream.destroy();
|
|
250
|
+
if (stream && typeof stream.destroy === "function") {
|
|
251
|
+
stream.destroy().catch((e) => this.debug("Stream destroy error:", e));
|
|
122
252
|
}
|
|
123
253
|
this.currentResource = null;
|
|
124
254
|
}
|
|
@@ -135,12 +265,7 @@ class Player extends events_1.EventEmitter {
|
|
|
135
265
|
*/
|
|
136
266
|
async search(query, requestedBy) {
|
|
137
267
|
this.debug(`[Player] Search called with query: ${query}, requestedBy: ${requestedBy}`);
|
|
138
|
-
//
|
|
139
|
-
if (Math.random() < 0.1) {
|
|
140
|
-
// 10% chance to clean cache
|
|
141
|
-
this.clearExpiredSearchCache();
|
|
142
|
-
}
|
|
143
|
-
// Check cache first
|
|
268
|
+
// Check player cache first (LRU)
|
|
144
269
|
const cachedResult = this.getCachedSearchResult(query);
|
|
145
270
|
if (cachedResult) {
|
|
146
271
|
return cachedResult;
|
|
@@ -152,44 +277,18 @@ class Player extends events_1.EventEmitter {
|
|
|
152
277
|
this.cacheSearchResult(query, extensionResult);
|
|
153
278
|
return extensionResult;
|
|
154
279
|
}
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
this.debug(`[Player]
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
return true;
|
|
164
|
-
});
|
|
165
|
-
this.debug(`[Player] Using ${plugins.length} plugins for search (filtered from ${allPlugins.length})`);
|
|
166
|
-
let lastError = null;
|
|
167
|
-
let searchAttempts = 0;
|
|
168
|
-
for (const p of plugins) {
|
|
169
|
-
searchAttempts++;
|
|
170
|
-
try {
|
|
171
|
-
this.debug(`[Player] Trying plugin for search: ${p.name} (attempt ${searchAttempts}/${plugins.length})`);
|
|
172
|
-
const startTime = Date.now();
|
|
173
|
-
const res = await (0, timeout_1.withTimeout)(p.search(query, requestedBy), this.options.extractorTimeout ?? 15000, `Search operation timed out for ${p.name}`);
|
|
174
|
-
const duration = Date.now() - startTime;
|
|
175
|
-
if (res && Array.isArray(res.tracks) && res.tracks.length > 0) {
|
|
176
|
-
this.debug(`[Player] Plugin '${p.name}' returned ${res.tracks.length} tracks in ${duration}ms`);
|
|
177
|
-
this.cacheSearchResult(query, res);
|
|
178
|
-
return res;
|
|
179
|
-
}
|
|
180
|
-
this.debug(`[Player] Plugin '${p.name}' returned no tracks in ${duration}ms`);
|
|
181
|
-
}
|
|
182
|
-
catch (error) {
|
|
183
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
184
|
-
this.debug(`[Player] Search via plugin '${p.name}' failed: ${errorMessage}`);
|
|
185
|
-
lastError = error;
|
|
186
|
-
// Continue to next plugin
|
|
280
|
+
// Use PluginManager for search with deduplication and evaluation
|
|
281
|
+
const pluginResult = await this.pluginManager.search(query, requestedBy);
|
|
282
|
+
if (pluginResult && pluginResult.tracks.length > 0) {
|
|
283
|
+
this.debug(`[Player] Plugin search returned ${pluginResult.tracks.length} tracks (score: ${pluginResult.score?.score}%)`);
|
|
284
|
+
if (pluginResult.score) {
|
|
285
|
+
this.debug(`[Player] Search evaluation - ${pluginResult.score.reason}`);
|
|
187
286
|
}
|
|
287
|
+
this.cacheSearchResult(query, pluginResult);
|
|
288
|
+
return pluginResult;
|
|
188
289
|
}
|
|
189
|
-
this.debug(`[Player] No
|
|
190
|
-
|
|
191
|
-
this.emit("playerError", lastError);
|
|
192
|
-
throw new Error(`No plugin found to handle: ${query}`);
|
|
290
|
+
this.debug(`[Player] No search results for query: ${query}`);
|
|
291
|
+
throw new Error(`No results found for: ${query}`);
|
|
193
292
|
}
|
|
194
293
|
/**
|
|
195
294
|
* Get cached search result or null if not found/expired
|
|
@@ -198,14 +297,10 @@ class Player extends events_1.EventEmitter {
|
|
|
198
297
|
*/
|
|
199
298
|
getCachedSearchResult(query) {
|
|
200
299
|
const cacheKey = query.toLowerCase().trim();
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (cachedResult) {
|
|
206
|
-
this.debug(`[SearchCache] Using cached search result for: ${query}`);
|
|
207
|
-
return cachedResult;
|
|
208
|
-
}
|
|
300
|
+
const cached = this.searchCache.get(cacheKey);
|
|
301
|
+
if (cached) {
|
|
302
|
+
this.debug(`[SearchCache] Using cached search result for: ${query}`);
|
|
303
|
+
return cached;
|
|
209
304
|
}
|
|
210
305
|
return null;
|
|
211
306
|
}
|
|
@@ -216,23 +311,15 @@ class Player extends events_1.EventEmitter {
|
|
|
216
311
|
*/
|
|
217
312
|
cacheSearchResult(query, result) {
|
|
218
313
|
const cacheKey = query.toLowerCase().trim();
|
|
219
|
-
const now = Date.now();
|
|
220
314
|
this.searchCache.set(cacheKey, result);
|
|
221
|
-
this.searchCacheTimestamps.set(cacheKey, now);
|
|
222
315
|
this.debug(`[SearchCache] Cached search result for: ${query} (${result.tracks.length} tracks)`);
|
|
223
316
|
}
|
|
224
317
|
/**
|
|
225
318
|
* Clear expired search cache entries
|
|
226
319
|
*/
|
|
227
320
|
clearExpiredSearchCache() {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (now - timestamp >= this.SEARCH_CACHE_TTL) {
|
|
231
|
-
this.searchCache.delete(key);
|
|
232
|
-
this.searchCacheTimestamps.delete(key);
|
|
233
|
-
this.debug(`[SearchCache] Cleared expired cache entry: ${key}`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
321
|
+
this.searchCache.purgeStale();
|
|
322
|
+
this.debug(`[SearchCache] Purged stale search cache entries`);
|
|
236
323
|
}
|
|
237
324
|
/**
|
|
238
325
|
* Clear all search cache entries
|
|
@@ -242,7 +329,6 @@ class Player extends events_1.EventEmitter {
|
|
|
242
329
|
clearSearchCache() {
|
|
243
330
|
const cacheSize = this.searchCache.size;
|
|
244
331
|
this.searchCache.clear();
|
|
245
|
-
this.searchCacheTimestamps.clear();
|
|
246
332
|
this.debug(`[SearchCache] Cleared all ${cacheSize} search cache entries`);
|
|
247
333
|
}
|
|
248
334
|
/**
|
|
@@ -252,9 +338,8 @@ class Player extends events_1.EventEmitter {
|
|
|
252
338
|
*/
|
|
253
339
|
debugSearchQuery(query) {
|
|
254
340
|
const cacheKey = query.toLowerCase().trim();
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
const isCached = cachedTimestamp && now - cachedTimestamp < this.SEARCH_CACHE_TTL;
|
|
341
|
+
const cached = this.searchCache.get(cacheKey);
|
|
342
|
+
const isCached = !!cached;
|
|
258
343
|
const allPlugins = this.pluginManager.getAll();
|
|
259
344
|
const plugins = allPlugins.filter((p) => {
|
|
260
345
|
if (p.name.toLowerCase() === "tts" && !query.toLowerCase().startsWith("tts:")) {
|
|
@@ -263,8 +348,8 @@ class Player extends events_1.EventEmitter {
|
|
|
263
348
|
return true;
|
|
264
349
|
});
|
|
265
350
|
return {
|
|
266
|
-
isCached
|
|
267
|
-
cacheAge:
|
|
351
|
+
isCached,
|
|
352
|
+
cacheAge: undefined,
|
|
268
353
|
pluginCount: plugins.length,
|
|
269
354
|
ttsFiltered: allPlugins.length > plugins.length,
|
|
270
355
|
};
|
|
@@ -334,7 +419,7 @@ class Player extends events_1.EventEmitter {
|
|
|
334
419
|
}
|
|
335
420
|
else {
|
|
336
421
|
// Handle other types (string, Track)
|
|
337
|
-
const hookOutcome = await this.extensionManager.
|
|
422
|
+
const hookOutcome = await this.extensionManager.beforePlayHooks(effectiveRequest);
|
|
338
423
|
effectiveRequest = hookOutcome.request;
|
|
339
424
|
hookResponse = hookOutcome.response;
|
|
340
425
|
if (effectiveRequest.requestedBy === undefined) {
|
|
@@ -350,7 +435,7 @@ class Player extends events_1.EventEmitter {
|
|
|
350
435
|
isPlaylist: hookResponse.isPlaylist ?? false,
|
|
351
436
|
error: hookResponse.error,
|
|
352
437
|
};
|
|
353
|
-
await this.extensionManager.
|
|
438
|
+
await this.extensionManager.afterPlayHooks(handledPayload);
|
|
354
439
|
if (hookResponse.error) {
|
|
355
440
|
this.emit("playerError", hookResponse.error);
|
|
356
441
|
}
|
|
@@ -393,7 +478,7 @@ class Player extends events_1.EventEmitter {
|
|
|
393
478
|
(isTTS(tracksToAdd[0]) || queryLooksTTS)) {
|
|
394
479
|
this.debug(`[Player] Interrupting with TTS: ${tracksToAdd[0].title}`);
|
|
395
480
|
await this.interruptWithTTSTrack(tracksToAdd[0]);
|
|
396
|
-
await this.extensionManager.
|
|
481
|
+
await this.extensionManager.afterPlayHooks({
|
|
397
482
|
success: true,
|
|
398
483
|
query: effectiveRequest.query,
|
|
399
484
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -411,7 +496,7 @@ class Player extends events_1.EventEmitter {
|
|
|
411
496
|
this.emit("queueAdd", tracksToAdd[0]);
|
|
412
497
|
}
|
|
413
498
|
const started = !this.isPlaying ? await this.playNext() : true;
|
|
414
|
-
await this.extensionManager.
|
|
499
|
+
await this.extensionManager.afterPlayHooks({
|
|
415
500
|
success: started,
|
|
416
501
|
query: effectiveRequest.query,
|
|
417
502
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -421,7 +506,7 @@ class Player extends events_1.EventEmitter {
|
|
|
421
506
|
return started;
|
|
422
507
|
}
|
|
423
508
|
catch (error) {
|
|
424
|
-
await this.extensionManager.
|
|
509
|
+
await this.extensionManager.afterPlayHooks({
|
|
425
510
|
success: false,
|
|
426
511
|
query: effectiveRequest.query,
|
|
427
512
|
requestedBy: effectiveRequest.requestedBy,
|
|
@@ -434,6 +519,537 @@ class Player extends events_1.EventEmitter {
|
|
|
434
519
|
return false;
|
|
435
520
|
}
|
|
436
521
|
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region Preload
|
|
524
|
+
/**
|
|
525
|
+
* Main preload method - only one at a time
|
|
526
|
+
*/
|
|
527
|
+
async preloadNextTrack() {
|
|
528
|
+
if (!this.preloadEnabled) {
|
|
529
|
+
this.debug(`[Preload] Disabled by options/runtime profile`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Prevent concurrent preloads
|
|
533
|
+
if (this.preloadLock) {
|
|
534
|
+
this.debug(`[Preload] Already preloading, skipping`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const nextTrack = this.queue.nextTrack;
|
|
538
|
+
if (!nextTrack) {
|
|
539
|
+
this.debug(`[Preload] No next track to preload`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
// Check if already preloaded correctly
|
|
543
|
+
if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
|
|
544
|
+
this.debug(`[Preload] Already have valid preload for: ${nextTrack.title}`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Check if currently loading the same track
|
|
548
|
+
if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
|
|
549
|
+
this.debug(`[Preload] Currently loading same track, waiting...`);
|
|
550
|
+
if (this.preloadSlot.loadPromise) {
|
|
551
|
+
await this.preloadSlot.loadPromise;
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// Cancel old preload if different track
|
|
556
|
+
if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
|
|
557
|
+
this.debug(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
|
|
558
|
+
await this.safeCancelPreload();
|
|
559
|
+
}
|
|
560
|
+
this.preloadLock = true;
|
|
561
|
+
// Create new abort controller
|
|
562
|
+
const abortController = new AbortController();
|
|
563
|
+
// Setup preload slot
|
|
564
|
+
this.preloadSlot.track = nextTrack;
|
|
565
|
+
this.preloadSlot.abortController = abortController;
|
|
566
|
+
this.preloadSlot.isLoading = true;
|
|
567
|
+
// Create load promise
|
|
568
|
+
const loadPromise = this.executePreload(nextTrack, abortController);
|
|
569
|
+
this.preloadSlot.loadPromise = loadPromise;
|
|
570
|
+
try {
|
|
571
|
+
await loadPromise;
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
|
|
575
|
+
this.debug(`[Preload] Cancelled for ${nextTrack.title}`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
this.debug(`[Preload] Failed for ${nextTrack.title}:`, err);
|
|
579
|
+
}
|
|
580
|
+
this.clearSlot(this.preloadSlot);
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
this.preloadLock = false;
|
|
584
|
+
this.preloadSlot.isLoading = false;
|
|
585
|
+
this.preloadSlot.loadPromise = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Execute actual preload
|
|
590
|
+
*/
|
|
591
|
+
async executePreload(track, abortController) {
|
|
592
|
+
this.debug(`[Preload] Starting preload for: ${track.title}`);
|
|
593
|
+
// Check for cancellation
|
|
594
|
+
if (abortController.signal.aborted) {
|
|
595
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
596
|
+
}
|
|
597
|
+
// Check if track still relevant
|
|
598
|
+
if (this.queue.nextTrack?.id !== track.id) {
|
|
599
|
+
this.debug(`[Preload] Track changed, cancelling`);
|
|
600
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
// Get stream with abort support - NO TIMEOUT
|
|
604
|
+
const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
|
|
605
|
+
// Check cancellation
|
|
606
|
+
if (abortController.signal.aborted) {
|
|
607
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
608
|
+
}
|
|
609
|
+
// Check track relevance again
|
|
610
|
+
if (this.queue.nextTrack?.id !== track.id) {
|
|
611
|
+
this.debug(`[Preload] Track changed after stream fetch`);
|
|
612
|
+
throw new Error("PRELOAD_CANCELLED");
|
|
613
|
+
}
|
|
614
|
+
if (!streamInfo?.stream) {
|
|
615
|
+
throw new Error(`No stream available`);
|
|
616
|
+
}
|
|
617
|
+
// Register with StreamManager as preload
|
|
618
|
+
const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
|
|
619
|
+
source: track.source || "preload",
|
|
620
|
+
isPreload: true,
|
|
621
|
+
priority: 5,
|
|
622
|
+
});
|
|
623
|
+
// Create resource
|
|
624
|
+
const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
|
|
625
|
+
inlineVolume: true,
|
|
626
|
+
metadata: { ...track, preloaded: true },
|
|
627
|
+
});
|
|
628
|
+
// Verify resource is valid
|
|
629
|
+
if (!resource.playStream || resource.playStream.readable === false) {
|
|
630
|
+
throw new Error("Resource not readable");
|
|
631
|
+
}
|
|
632
|
+
// Update preload slot
|
|
633
|
+
this.preloadSlot.resource = resource;
|
|
634
|
+
this.preloadSlot.streamId = streamId;
|
|
635
|
+
this.preloadSlot.isValid = true;
|
|
636
|
+
this.preloadSlot.track = track;
|
|
637
|
+
this.debug(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
this.debug(`[Preload] Error during preload:`, err);
|
|
644
|
+
throw err;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Safe cancel preload - doesn't throw
|
|
649
|
+
*/
|
|
650
|
+
async safeCancelPreload() {
|
|
651
|
+
if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
this.debug(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
|
|
655
|
+
// Abort the operation
|
|
656
|
+
if (this.preloadSlot.abortController) {
|
|
657
|
+
this.preloadSlot.abortController.abort();
|
|
658
|
+
this.preloadSlot.abortController = null;
|
|
659
|
+
}
|
|
660
|
+
// Clean up stream
|
|
661
|
+
if (this.preloadSlot.streamId && this.streamManager) {
|
|
662
|
+
this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
|
|
663
|
+
}
|
|
664
|
+
// Clean up resource
|
|
665
|
+
if (this.preloadSlot.resource) {
|
|
666
|
+
try {
|
|
667
|
+
const stream = this.preloadSlot.resource.playStream;
|
|
668
|
+
if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
|
|
669
|
+
stream.destroy();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
// Ignore destroy errors
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Clear slot
|
|
677
|
+
this.clearSlot(this.preloadSlot);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Get stream with proper cancellation
|
|
681
|
+
*/
|
|
682
|
+
async getStreamWithCancel(track, signal) {
|
|
683
|
+
// Create abort promise
|
|
684
|
+
const abortPromise = new Promise((_, reject) => {
|
|
685
|
+
if (signal.aborted) {
|
|
686
|
+
reject(new Error("PRELOAD_CANCELLED"));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const handler = () => {
|
|
690
|
+
signal.removeEventListener("abort", handler);
|
|
691
|
+
reject(new Error("PRELOAD_CANCELLED"));
|
|
692
|
+
};
|
|
693
|
+
signal.addEventListener("abort", handler);
|
|
694
|
+
});
|
|
695
|
+
try {
|
|
696
|
+
// Check if stream already exists and is valid
|
|
697
|
+
const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
|
|
698
|
+
if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
|
|
699
|
+
this.debug(`[Stream] Using existing stream for preload: ${track.title}`);
|
|
700
|
+
return { stream: existingStream, type: "arbitrary" };
|
|
701
|
+
}
|
|
702
|
+
// Race between stream fetch and abort
|
|
703
|
+
const streamPromise = this.getStream(track);
|
|
704
|
+
const result = await Promise.race([streamPromise, abortPromise]);
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
711
|
+
throw err;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Preload next track with proper error handling and cleanup
|
|
716
|
+
*/
|
|
717
|
+
async preloadNext() {
|
|
718
|
+
if (!this.preloadEnabled) {
|
|
719
|
+
this.debug(`[Preload] Disabled by options/runtime profile`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
this.cancelPreload();
|
|
723
|
+
const next = this.queue.nextTrack;
|
|
724
|
+
if (!next || this.isPreloading) {
|
|
725
|
+
this.debug(`[Preload] Skipped - ${!next ? "no next track" : "already preloading"}`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.isPreloading = true;
|
|
729
|
+
// Create new AbortController
|
|
730
|
+
const abortController = new AbortController();
|
|
731
|
+
const timeoutId = setTimeout(() => {
|
|
732
|
+
// this.debug(`[Preload] Timeout for track: ${next.title}`);
|
|
733
|
+
// abortController.abort();
|
|
734
|
+
}, 30000);
|
|
735
|
+
this.preloadState.abortController = abortController;
|
|
736
|
+
this.preloadState.timeoutId = timeoutId;
|
|
737
|
+
try {
|
|
738
|
+
this.debug(`[Preload] Starting preload for: ${next.title}`);
|
|
739
|
+
// Check if already aborted
|
|
740
|
+
if (abortController.signal.aborted) {
|
|
741
|
+
throw new Error("Preload aborted before start");
|
|
742
|
+
}
|
|
743
|
+
// Check if this track is still the next one
|
|
744
|
+
if (this.queue.nextTrack?.id !== next.id) {
|
|
745
|
+
this.debug(`[Preload] Track changed, cancelling preload`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const streamInfo = await this.getStreamWithCancel(next, abortController.signal);
|
|
749
|
+
// Double check
|
|
750
|
+
if (abortController.signal.aborted) {
|
|
751
|
+
throw new Error("Preload aborted after stream fetch");
|
|
752
|
+
}
|
|
753
|
+
if (this.queue.nextTrack?.id !== next.id) {
|
|
754
|
+
this.debug(`[Preload] Track changed after stream fetch`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (!streamInfo?.stream) {
|
|
758
|
+
throw new Error(`No stream available`);
|
|
759
|
+
}
|
|
760
|
+
// Register with StreamManager
|
|
761
|
+
const streamId = this.streamManager.registerStream(streamInfo.stream, next, {
|
|
762
|
+
source: next.source || "preload",
|
|
763
|
+
isPreload: true,
|
|
764
|
+
priority: 8,
|
|
765
|
+
});
|
|
766
|
+
// Create resource
|
|
767
|
+
const resource = (0, voice_1.createAudioResource)(streamInfo.stream, {
|
|
768
|
+
inlineVolume: true,
|
|
769
|
+
metadata: { ...next, preloaded: true },
|
|
770
|
+
});
|
|
771
|
+
// Store preload state
|
|
772
|
+
this.preloadState = {
|
|
773
|
+
resource,
|
|
774
|
+
track: next,
|
|
775
|
+
abortController,
|
|
776
|
+
timeoutId,
|
|
777
|
+
isValid: true,
|
|
778
|
+
isBeingUsed: false,
|
|
779
|
+
streamId,
|
|
780
|
+
};
|
|
781
|
+
this.debug(`[Preload] Successfully preloaded: ${next.title} (Stream ID: ${streamId})`);
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
if (err instanceof Error && err.message.includes("aborted")) {
|
|
785
|
+
this.debug(`[Preload] Cancelled for ${next.title}`);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
this.debug(`[Preload] Failed for ${next?.title}:`, err);
|
|
789
|
+
}
|
|
790
|
+
this.cancelPreload();
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
this.isPreloading = false;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async fadeResourceVolume(resource, from, to, durationMs) {
|
|
797
|
+
if (!resource.volume)
|
|
798
|
+
return;
|
|
799
|
+
const safeDuration = Math.max(0, durationMs);
|
|
800
|
+
if (safeDuration === 0) {
|
|
801
|
+
resource.volume.setVolume(to);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const steps = Math.max(1, Math.floor(safeDuration / 50));
|
|
805
|
+
const stepDuration = Math.max(20, Math.floor(safeDuration / steps));
|
|
806
|
+
const delta = (to - from) / steps;
|
|
807
|
+
resource.volume.setVolume(from);
|
|
808
|
+
for (let i = 1; i <= steps; i++) {
|
|
809
|
+
await new Promise((resolve) => setTimeout(resolve, stepDuration));
|
|
810
|
+
resource.volume.setVolume(from + delta * i);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async applyCrossfadeIn(resource, track) {
|
|
814
|
+
if (!this.crossfadeEnabled || !resource.volume)
|
|
815
|
+
return;
|
|
816
|
+
const targetVolume = this.getTrackTargetVolume(track);
|
|
817
|
+
const transitionMs = this.resolveSmartTransitionDuration(track);
|
|
818
|
+
await this.fadeResourceVolume(resource, 0, targetVolume, transitionMs);
|
|
819
|
+
}
|
|
820
|
+
async applyCrossfadeOutCurrent() {
|
|
821
|
+
if (!this.crossfadeEnabled)
|
|
822
|
+
return;
|
|
823
|
+
const current = this.currentSlot.resource || this.currentResource;
|
|
824
|
+
if (!current?.volume)
|
|
825
|
+
return;
|
|
826
|
+
const currentVolume = current.volume.volume ?? this.volume / 100;
|
|
827
|
+
const currentTrack = this.queue.currentTrack;
|
|
828
|
+
const transitionMs = currentTrack ? this.resolveSmartTransitionDuration(currentTrack) : this.resolveSmartTransitionDuration({});
|
|
829
|
+
await this.fadeResourceVolume(current, currentVolume, 0, transitionMs);
|
|
830
|
+
}
|
|
831
|
+
async crossfadeSkipAndStop() {
|
|
832
|
+
if (!this.crossfadeEnabled) {
|
|
833
|
+
this.audioPlayer.stop();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (this.crossfadeTransitionLock) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
this.crossfadeTransitionLock = true;
|
|
840
|
+
try {
|
|
841
|
+
await this.applyCrossfadeOutCurrent();
|
|
842
|
+
this.audioPlayer.stop();
|
|
843
|
+
}
|
|
844
|
+
finally {
|
|
845
|
+
this.crossfadeTransitionLock = false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
getTrackMetadataValue(track, key) {
|
|
849
|
+
const md = track?.metadata;
|
|
850
|
+
if (!md)
|
|
851
|
+
return undefined;
|
|
852
|
+
return md[key];
|
|
853
|
+
}
|
|
854
|
+
resolveSmartTransitionDuration(track) {
|
|
855
|
+
if (!this.smartTransitionEnabled) {
|
|
856
|
+
return this.crossfadeDurationMs;
|
|
857
|
+
}
|
|
858
|
+
let duration = this.smartTransitionBaseMs;
|
|
859
|
+
if (this.smartTransitionGenreAware) {
|
|
860
|
+
const rawGenre = this.getTrackMetadataValue(track, "genre");
|
|
861
|
+
const genre = typeof rawGenre === "string" ? rawGenre.toLowerCase().trim() : "";
|
|
862
|
+
if (genre && this.smartTransitionGenreDurations[genre] !== undefined) {
|
|
863
|
+
duration = this.smartTransitionGenreDurations[genre];
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return Math.min(this.smartTransitionMaxMs, Math.max(this.smartTransitionMinMs, duration));
|
|
867
|
+
}
|
|
868
|
+
async maybeAlignToBeatBoundary() {
|
|
869
|
+
if (!this.smartTransitionEnabled || !this.smartTransitionBeatAlign)
|
|
870
|
+
return;
|
|
871
|
+
const currentTrack = this.queue.currentTrack;
|
|
872
|
+
if (!currentTrack || !this.currentResource)
|
|
873
|
+
return;
|
|
874
|
+
const bpmRaw = this.getTrackMetadataValue(currentTrack, "bpm");
|
|
875
|
+
const bpm = typeof bpmRaw === "number" ? bpmRaw : Number(bpmRaw);
|
|
876
|
+
if (!Number.isFinite(bpm) || bpm <= 0)
|
|
877
|
+
return;
|
|
878
|
+
const beatMs = 60000 / bpm;
|
|
879
|
+
const positionMs = this.currentResource.playbackDuration;
|
|
880
|
+
const remainder = positionMs % beatMs;
|
|
881
|
+
const waitMs = beatMs - remainder;
|
|
882
|
+
if (waitMs > 0 && waitMs <= this.smartTransitionBeatAlignMaxWaitMs) {
|
|
883
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
getTrackTargetVolume(track) {
|
|
887
|
+
const base = this.volume / 100;
|
|
888
|
+
if (!this.loudnessNormalizationEnabled) {
|
|
889
|
+
return base;
|
|
890
|
+
}
|
|
891
|
+
const lufsRaw = this.getTrackMetadataValue(track, "lufs");
|
|
892
|
+
const trackLufs = typeof lufsRaw === "number" ? lufsRaw : Number(lufsRaw);
|
|
893
|
+
if (!Number.isFinite(trackLufs)) {
|
|
894
|
+
return Math.min(base, this.loudnessLimiterCeiling);
|
|
895
|
+
}
|
|
896
|
+
const deltaDbRaw = this.loudnessTargetLUFS - trackLufs;
|
|
897
|
+
const maxBoost = this.loudnessMaxBoostDb;
|
|
898
|
+
const maxCut = this.loudnessMaxCutDb;
|
|
899
|
+
const deltaDb = Math.max(-maxCut, Math.min(maxBoost, deltaDbRaw));
|
|
900
|
+
const multiplier = Math.pow(10, deltaDb / 20);
|
|
901
|
+
const adjusted = base * multiplier;
|
|
902
|
+
return Math.min(this.loudnessLimiterCeiling, Math.max(0, adjusted));
|
|
903
|
+
}
|
|
904
|
+
async attemptTrackRecovery(track, reason) {
|
|
905
|
+
if (!this.antiStuckEnabled)
|
|
906
|
+
return false;
|
|
907
|
+
this.debug(`[AntiStuck] Recovery started for: ${track.title}`, reason);
|
|
908
|
+
const originalQuality = this.options.quality;
|
|
909
|
+
let attempted = 0;
|
|
910
|
+
while (attempted < this.antiStuckMaxRetries) {
|
|
911
|
+
attempted++;
|
|
912
|
+
if (this.antiStuckReduceQualityOnRetry) {
|
|
913
|
+
this.options.quality = "low";
|
|
914
|
+
}
|
|
915
|
+
if (this.antiStuckRetryDelayMs > 0) {
|
|
916
|
+
await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
if (this.antiStuckReusePreloadFirst && this.preloadSlot.isValid && this.preloadSlot.track?.id === track.id) {
|
|
920
|
+
const startedFromPreload = await this.startTrack(track);
|
|
921
|
+
if (startedFromPreload) {
|
|
922
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
923
|
+
this.options.quality = originalQuality;
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const started = await this.loadFreshStream(track);
|
|
928
|
+
if (started) {
|
|
929
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
930
|
+
this.options.quality = originalQuality;
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
this.debug(`[AntiStuck] Retry ${attempted} failed for ${track.title}:`, error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
this.options.quality = originalQuality;
|
|
939
|
+
this.antiStuckConsecutiveFailures++;
|
|
940
|
+
if (this.antiStuckConsecutiveFailures >= this.antiStuckControlledSkipThreshold) {
|
|
941
|
+
this.debug(`[AntiStuck] Controlled skip threshold reached for ${track.title}`);
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
// Avoid hard skip storm by leaving track for next natural retry window.
|
|
945
|
+
this.debug(`[AntiStuck] Keeping track for controlled retry window: ${track.title}`);
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Clear preloaded resource with proper cleanup
|
|
950
|
+
*/
|
|
951
|
+
clearPreload() {
|
|
952
|
+
// Abort ongoing preload
|
|
953
|
+
if (this.preloadState.abortController) {
|
|
954
|
+
this.preloadState.abortController.abort();
|
|
955
|
+
this.preloadState.abortController = null;
|
|
956
|
+
}
|
|
957
|
+
// Clean up stream
|
|
958
|
+
const stream = this.preloadState.stream;
|
|
959
|
+
if (stream && typeof stream.destroy === "function") {
|
|
960
|
+
try {
|
|
961
|
+
stream.destroy();
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
this.debug(`[Preload] Error destroying stream:`, err);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Clean up resource
|
|
968
|
+
if (this.preloadState.resource) {
|
|
969
|
+
try {
|
|
970
|
+
const playStream = this.preloadState.resource.playStream;
|
|
971
|
+
if (playStream && typeof playStream.destroy === "function") {
|
|
972
|
+
playStream.destroy();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
this.debug(`[Preload] Error destroying resource:`, err);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
this.preloadState = {
|
|
980
|
+
resource: null,
|
|
981
|
+
track: null,
|
|
982
|
+
abortController: null,
|
|
983
|
+
timeoutId: null,
|
|
984
|
+
isValid: false,
|
|
985
|
+
isBeingUsed: false,
|
|
986
|
+
streamId: undefined,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Cancel preload (when skipping or stopping)
|
|
991
|
+
*/
|
|
992
|
+
cancelPreload() {
|
|
993
|
+
if (this.preloadSlot.abortController) {
|
|
994
|
+
this.debug(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
|
|
995
|
+
this.preloadSlot.abortController.abort();
|
|
996
|
+
}
|
|
997
|
+
if (this.preloadSlot.streamId && this.streamManager) {
|
|
998
|
+
this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
|
|
999
|
+
}
|
|
1000
|
+
this.clearSlot(this.preloadSlot);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Clear a stream slot
|
|
1004
|
+
*/
|
|
1005
|
+
clearSlot(slot) {
|
|
1006
|
+
if (slot.resource) {
|
|
1007
|
+
try {
|
|
1008
|
+
const stream = slot.resource.playStream;
|
|
1009
|
+
if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
|
|
1010
|
+
stream.destroy();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
// Ignore
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (slot.streamId && this.streamManager) {
|
|
1018
|
+
// Don't wait for unregister
|
|
1019
|
+
this.streamManager.unregisterStream(slot.streamId, true);
|
|
1020
|
+
}
|
|
1021
|
+
slot.resource = null;
|
|
1022
|
+
slot.track = null;
|
|
1023
|
+
slot.streamId = null;
|
|
1024
|
+
slot.abortController = null;
|
|
1025
|
+
slot.isValid = false;
|
|
1026
|
+
slot.isLoading = false;
|
|
1027
|
+
slot.loadPromise = null;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Promote preload slot to current slot without destroying promoted stream.
|
|
1031
|
+
*/
|
|
1032
|
+
promotePreloadToCurrent(track) {
|
|
1033
|
+
const promotedResource = this.preloadSlot.resource;
|
|
1034
|
+
const promotedStreamId = this.preloadSlot.streamId;
|
|
1035
|
+
// Move ownership to current slot.
|
|
1036
|
+
this.currentSlot.resource = promotedResource;
|
|
1037
|
+
this.currentSlot.track = track;
|
|
1038
|
+
this.currentSlot.streamId = promotedStreamId;
|
|
1039
|
+
this.currentSlot.abortController = null;
|
|
1040
|
+
this.currentSlot.isValid = !!promotedResource;
|
|
1041
|
+
this.currentSlot.isLoading = false;
|
|
1042
|
+
this.currentSlot.loadPromise = null;
|
|
1043
|
+
this.currentResource = promotedResource;
|
|
1044
|
+
// Reset preload slot only (do not destroy promoted resource/stream).
|
|
1045
|
+
this.preloadSlot.resource = null;
|
|
1046
|
+
this.preloadSlot.track = null;
|
|
1047
|
+
this.preloadSlot.streamId = null;
|
|
1048
|
+
this.preloadSlot.abortController = null;
|
|
1049
|
+
this.preloadSlot.isValid = false;
|
|
1050
|
+
this.preloadSlot.isLoading = false;
|
|
1051
|
+
this.preloadSlot.loadPromise = null;
|
|
1052
|
+
}
|
|
437
1053
|
/**
|
|
438
1054
|
* Create AudioResource with filters and seek applied
|
|
439
1055
|
*
|
|
@@ -482,72 +1098,97 @@ class Player extends events_1.EventEmitter {
|
|
|
482
1098
|
}
|
|
483
1099
|
}
|
|
484
1100
|
async getStream(track) {
|
|
1101
|
+
const trackId = track.id || track.url || track.title;
|
|
1102
|
+
const existingStream = this.streamManager.getStreamByTrack(trackId);
|
|
1103
|
+
if (existingStream && !existingStream.destroyed) {
|
|
1104
|
+
this.debug(`[Stream] Using existing stream from manager for: ${track.title}`);
|
|
1105
|
+
return { stream: existingStream, type: "arbitrary" };
|
|
1106
|
+
}
|
|
485
1107
|
let stream = await this.extensionManager.provideStream(track);
|
|
486
|
-
if (stream?.stream)
|
|
1108
|
+
if (stream?.stream) {
|
|
1109
|
+
// Register with StreamManager
|
|
1110
|
+
const streamId = this.streamManager.registerStream(stream.stream, track, {
|
|
1111
|
+
source: "extension",
|
|
1112
|
+
isPreload: false,
|
|
1113
|
+
priority: 10,
|
|
1114
|
+
});
|
|
1115
|
+
this.debug(`[Stream] Extension stream registered with ID: ${streamId}`);
|
|
487
1116
|
return stream;
|
|
1117
|
+
}
|
|
488
1118
|
stream = await this.pluginManager.getStream(track);
|
|
489
|
-
if (stream?.stream)
|
|
1119
|
+
if (stream?.stream) {
|
|
1120
|
+
const existingAgain = this.streamManager.getStreamByTrack(trackId);
|
|
1121
|
+
if (existingAgain && !existingAgain.destroyed) {
|
|
1122
|
+
if (stream.stream.destroy)
|
|
1123
|
+
stream.stream.destroy();
|
|
1124
|
+
return { stream: existingAgain, type: "arbitrary" };
|
|
1125
|
+
}
|
|
1126
|
+
// Register with StreamManager
|
|
1127
|
+
const streamId = this.streamManager.registerStream(stream.stream, track, {
|
|
1128
|
+
source: track.source || "plugin",
|
|
1129
|
+
isPreload: false,
|
|
1130
|
+
priority: 5,
|
|
1131
|
+
});
|
|
1132
|
+
this.debug(`[Stream] Plugin stream registered with ID: ${streamId}`);
|
|
490
1133
|
return stream;
|
|
1134
|
+
}
|
|
1135
|
+
if (!this.pluginManager.hasStreamCandidate(track)) {
|
|
1136
|
+
throw new Error(`UNRECOVERABLE_NO_PLUGIN:${track.title}`);
|
|
1137
|
+
}
|
|
491
1138
|
throw new Error(`No stream available for track: ${track.title}`);
|
|
492
1139
|
}
|
|
1140
|
+
isUnrecoverableStreamError(error) {
|
|
1141
|
+
if (!(error instanceof Error))
|
|
1142
|
+
return false;
|
|
1143
|
+
return error.message.startsWith("UNRECOVERABLE_NO_PLUGIN:");
|
|
1144
|
+
}
|
|
493
1145
|
/**
|
|
494
1146
|
* Start playing a specific track immediately, replacing the current resource.
|
|
495
1147
|
*/
|
|
496
1148
|
async startTrack(track) {
|
|
497
1149
|
try {
|
|
498
|
-
|
|
499
|
-
this.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return true;
|
|
1150
|
+
// Try to use preloaded resource
|
|
1151
|
+
if (this.preloadSlot.isValid &&
|
|
1152
|
+
this.preloadSlot.track?.id === track.id &&
|
|
1153
|
+
this.preloadSlot.resource &&
|
|
1154
|
+
this.preloadSlot.resource.playStream?.readable !== false) {
|
|
1155
|
+
this.debug(`[Player] Using preloaded stream for: ${track.title}`);
|
|
1156
|
+
// Stop current playback
|
|
1157
|
+
this.audioPlayer.stop(true);
|
|
1158
|
+
// Clean up old current stream (but delay to be safe)
|
|
1159
|
+
const oldStreamId = this.currentSlot.streamId;
|
|
1160
|
+
if (oldStreamId && this.streamManager) {
|
|
1161
|
+
setTimeout(() => {
|
|
1162
|
+
if (this.currentSlot.streamId === oldStreamId) {
|
|
1163
|
+
this.streamManager.unregisterStream(oldStreamId, true);
|
|
1164
|
+
}
|
|
1165
|
+
}, 3000);
|
|
515
1166
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const fallbackResource = (0, voice_1.createAudioResource)(streamInfo.stream, {
|
|
522
|
-
metadata: track,
|
|
523
|
-
inputType: streamInfo.type === "webm/opus" ? voice_1.StreamType.WebmOpus
|
|
524
|
-
: streamInfo.type === "ogg/opus" ? voice_1.StreamType.OggOpus
|
|
525
|
-
: voice_1.StreamType.Arbitrary,
|
|
526
|
-
inlineVolume: true,
|
|
527
|
-
});
|
|
528
|
-
this.currentResource = fallbackResource;
|
|
529
|
-
this.currentResource.volume?.setVolume(this.volume / 100);
|
|
530
|
-
this.audioPlayer.play(this.currentResource);
|
|
531
|
-
await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 5_000);
|
|
532
|
-
return true;
|
|
533
|
-
}
|
|
534
|
-
catch (fallbackError) {
|
|
535
|
-
this.debug(`[Player] Fallback also failed:`, fallbackError);
|
|
536
|
-
throw fallbackError;
|
|
537
|
-
}
|
|
1167
|
+
// Set current slot from preload
|
|
1168
|
+
this.promotePreloadToCurrent(track);
|
|
1169
|
+
const currentResource = this.currentSlot.resource;
|
|
1170
|
+
if (!currentResource) {
|
|
1171
|
+
return false;
|
|
538
1172
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
this.
|
|
1173
|
+
const targetVolume = this.getTrackTargetVolume(track);
|
|
1174
|
+
// Apply volume
|
|
1175
|
+
if (currentResource.volume) {
|
|
1176
|
+
currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
|
|
1177
|
+
}
|
|
1178
|
+
// Play
|
|
1179
|
+
await this.maybeAlignToBeatBoundary();
|
|
1180
|
+
this.audioPlayer.play(currentResource);
|
|
1181
|
+
await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
|
|
1182
|
+
await this.applyCrossfadeIn(currentResource, track);
|
|
1183
|
+
// Start preloading next track (async, don't await)
|
|
1184
|
+
this.preloadNextTrack().catch((err) => {
|
|
1185
|
+
this.debug(`[Player] Preload error:`, err);
|
|
1186
|
+
});
|
|
546
1187
|
return true;
|
|
547
1188
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1189
|
+
// No valid preload, load fresh
|
|
1190
|
+
this.debug(`[Player] No preload available, loading fresh: ${track.title}`);
|
|
1191
|
+
return await this.loadFreshStream(track);
|
|
551
1192
|
}
|
|
552
1193
|
catch (error) {
|
|
553
1194
|
this.debug(`[Player] startTrack error:`, error);
|
|
@@ -555,8 +1196,113 @@ class Player extends events_1.EventEmitter {
|
|
|
555
1196
|
return false;
|
|
556
1197
|
}
|
|
557
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Swap preload slot to current slot
|
|
1201
|
+
*/
|
|
1202
|
+
async swapToCurrent(track) {
|
|
1203
|
+
// Store preload resource
|
|
1204
|
+
const newResource = this.preloadSlot.resource;
|
|
1205
|
+
const oldStreamId = this.currentSlot.streamId;
|
|
1206
|
+
if (!newResource) {
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
// Stop current playback
|
|
1210
|
+
this.audioPlayer.stop(true);
|
|
1211
|
+
// Clean up old current stream (but keep it for a moment)
|
|
1212
|
+
if (oldStreamId && this.streamManager) {
|
|
1213
|
+
// Delay cleanup to avoid destroying if still needed
|
|
1214
|
+
setTimeout(() => {
|
|
1215
|
+
if (this.currentSlot.streamId === oldStreamId) {
|
|
1216
|
+
this.streamManager.unregisterStream(oldStreamId, true);
|
|
1217
|
+
}
|
|
1218
|
+
}, 5000);
|
|
1219
|
+
}
|
|
1220
|
+
// Set new current
|
|
1221
|
+
this.promotePreloadToCurrent(track);
|
|
1222
|
+
const currentResource = this.currentSlot.resource;
|
|
1223
|
+
if (!currentResource) {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
const targetVolume = this.getTrackTargetVolume(track);
|
|
1227
|
+
// Apply volume
|
|
1228
|
+
if (currentResource.volume) {
|
|
1229
|
+
currentResource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
|
|
1230
|
+
}
|
|
1231
|
+
// Play
|
|
1232
|
+
await this.maybeAlignToBeatBoundary();
|
|
1233
|
+
this.audioPlayer.play(currentResource);
|
|
1234
|
+
try {
|
|
1235
|
+
await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
|
|
1236
|
+
await this.applyCrossfadeIn(currentResource, track);
|
|
1237
|
+
// Start preloading next track
|
|
1238
|
+
this.preloadNextTrack().catch((err) => {
|
|
1239
|
+
this.debug(`[Player] Preload error:`, err);
|
|
1240
|
+
});
|
|
1241
|
+
return true;
|
|
1242
|
+
}
|
|
1243
|
+
catch (err) {
|
|
1244
|
+
this.debug(`[Player] Failed to play swapped track:`, err);
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Load fresh stream when no preload available
|
|
1250
|
+
*/
|
|
1251
|
+
async loadFreshStream(track) {
|
|
1252
|
+
// Cancel preload to free resources
|
|
1253
|
+
await this.safeCancelPreload();
|
|
1254
|
+
try {
|
|
1255
|
+
const streamInfo = await this.getStream(track);
|
|
1256
|
+
if (!streamInfo?.stream) {
|
|
1257
|
+
throw new Error(`No stream available`);
|
|
1258
|
+
}
|
|
1259
|
+
// Register with StreamManager
|
|
1260
|
+
const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
|
|
1261
|
+
source: track.source || "stream",
|
|
1262
|
+
isPreload: false,
|
|
1263
|
+
priority: 10,
|
|
1264
|
+
});
|
|
1265
|
+
// Create resource
|
|
1266
|
+
const resource = await this.createResource(streamInfo, track, 0);
|
|
1267
|
+
// Clean up old current
|
|
1268
|
+
if (this.currentSlot.streamId && this.currentSlot.streamId !== streamId) {
|
|
1269
|
+
this.streamManager.unregisterStream(this.currentSlot.streamId, true);
|
|
1270
|
+
}
|
|
1271
|
+
// Set current slot
|
|
1272
|
+
this.currentSlot.resource = resource;
|
|
1273
|
+
this.currentSlot.track = track;
|
|
1274
|
+
this.currentSlot.streamId = streamId;
|
|
1275
|
+
this.currentSlot.isValid = true;
|
|
1276
|
+
this.currentResource = resource;
|
|
1277
|
+
// Apply volume
|
|
1278
|
+
const targetVolume = this.getTrackTargetVolume(track);
|
|
1279
|
+
if (resource.volume) {
|
|
1280
|
+
resource.volume.setVolume(this.crossfadeEnabled ? 0 : targetVolume);
|
|
1281
|
+
}
|
|
1282
|
+
// Play
|
|
1283
|
+
await this.maybeAlignToBeatBoundary();
|
|
1284
|
+
this.audioPlayer.stop(true);
|
|
1285
|
+
this.audioPlayer.play(resource);
|
|
1286
|
+
await (0, voice_1.entersState)(this.audioPlayer, voice_1.AudioPlayerStatus.Playing, 10_000);
|
|
1287
|
+
await this.applyCrossfadeIn(resource, track);
|
|
1288
|
+
// Preload next (async)
|
|
1289
|
+
this.preloadNextTrack().catch((err) => {
|
|
1290
|
+
this.debug(`[Player] Preload error:`, err);
|
|
1291
|
+
});
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
catch (error) {
|
|
1295
|
+
this.debug(`[Player] loadFreshStream error:`, error);
|
|
1296
|
+
throw error;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Play the next track in the queue, handling errors and edge cases gracefully
|
|
1301
|
+
*/
|
|
558
1302
|
async playNext() {
|
|
559
|
-
this.debug(
|
|
1303
|
+
this.debug("[Player] playNext called");
|
|
1304
|
+
// Don't cancel preload here unless absolutely necessary
|
|
1305
|
+
// Let startTrack handle it
|
|
560
1306
|
while (true) {
|
|
561
1307
|
const track = this.queue.next(this.skipLoop);
|
|
562
1308
|
this.skipLoop = false;
|
|
@@ -571,20 +1317,51 @@ class Player extends events_1.EventEmitter {
|
|
|
571
1317
|
this.debug(`[Player] No next track in queue`);
|
|
572
1318
|
this.isPlaying = false;
|
|
573
1319
|
this.emit("queueEnd");
|
|
1320
|
+
// Clean up both slots when queue is empty
|
|
1321
|
+
this.clearSlot(this.currentSlot);
|
|
1322
|
+
await this.safeCancelPreload();
|
|
574
1323
|
if (this.options.leaveOnEnd) {
|
|
575
1324
|
this.scheduleLeave();
|
|
576
1325
|
}
|
|
577
1326
|
return false;
|
|
578
1327
|
}
|
|
579
|
-
this.generateWillNext();
|
|
1328
|
+
this.generateWillNext().catch((err) => this.debug("[Player] generateWillNext error:", err));
|
|
580
1329
|
this.clearLeaveTimeout();
|
|
581
1330
|
this.debug(`[Player] playNext called for track: ${track.title}`);
|
|
582
1331
|
try {
|
|
583
|
-
|
|
1332
|
+
const started = await this.startTrack(track);
|
|
1333
|
+
if (started) {
|
|
1334
|
+
this.antiStuckConsecutiveFailures = 0;
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
const recovered = await this.attemptTrackRecovery(track, new Error("TRACK_START_RETURNED_FALSE"));
|
|
1338
|
+
if (recovered) {
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
|
|
1342
|
+
this.queue.insert(track, 0);
|
|
1343
|
+
if (this.antiStuckRetryDelayMs > 0) {
|
|
1344
|
+
await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
584
1347
|
}
|
|
585
1348
|
catch (err) {
|
|
586
1349
|
this.debug(`[Player] playNext error:`, err);
|
|
587
1350
|
this.emit("playerError", err, track);
|
|
1351
|
+
if (this.isUnrecoverableStreamError(err)) {
|
|
1352
|
+
this.debug(`[Player] Skipping unrecoverable track (no plugin): ${track.title}`);
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
const recovered = await this.attemptTrackRecovery(track, err);
|
|
1356
|
+
if (recovered) {
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
if (this.antiStuckEnabled && this.antiStuckConsecutiveFailures < this.antiStuckControlledSkipThreshold) {
|
|
1360
|
+
this.queue.insert(track, 0);
|
|
1361
|
+
if (this.antiStuckRetryDelayMs > 0) {
|
|
1362
|
+
await new Promise((resolve) => setTimeout(resolve, this.antiStuckRetryDelayMs));
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
588
1365
|
continue;
|
|
589
1366
|
}
|
|
590
1367
|
}
|
|
@@ -624,12 +1401,12 @@ class Player extends events_1.EventEmitter {
|
|
|
624
1401
|
// Build resource from plugin stream
|
|
625
1402
|
const streamInfo = await this.pluginManager.getStream(track);
|
|
626
1403
|
if (!streamInfo) {
|
|
627
|
-
throw new Error(
|
|
1404
|
+
throw new Error(`No stream available for track: ${track.title}`);
|
|
628
1405
|
}
|
|
629
1406
|
ttsStream = streamInfo.stream;
|
|
630
1407
|
const resource = await this.createResource(streamInfo, track);
|
|
631
1408
|
if (!resource) {
|
|
632
|
-
throw new Error(
|
|
1409
|
+
throw new Error(`No resource available for track: ${track.title}`);
|
|
633
1410
|
}
|
|
634
1411
|
ttsResource = resource;
|
|
635
1412
|
if (resource.volume) {
|
|
@@ -656,7 +1433,7 @@ class Player extends events_1.EventEmitter {
|
|
|
656
1433
|
declared
|
|
657
1434
|
: declared * 1000
|
|
658
1435
|
: undefined;
|
|
659
|
-
const cap = this.options?.tts?.
|
|
1436
|
+
const cap = this.options?.tts?.maxTimeTts ?? 60_000;
|
|
660
1437
|
const idleTimeout = declaredMs ? Math.min(cap, Math.max(1_000, declaredMs + 1_500)) : cap;
|
|
661
1438
|
await (0, voice_1.entersState)(ttsPlayer, voice_1.AudioPlayerStatus.Idle, idleTimeout).catch(() => null);
|
|
662
1439
|
// Swap back and resume if needed
|
|
@@ -707,9 +1484,21 @@ class Player extends events_1.EventEmitter {
|
|
|
707
1484
|
});
|
|
708
1485
|
await (0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Ready, 50_000);
|
|
709
1486
|
this.connection = connection;
|
|
710
|
-
connection.on(voice_1.VoiceConnectionStatus.Disconnected, () => {
|
|
711
|
-
|
|
712
|
-
|
|
1487
|
+
connection.on(voice_1.VoiceConnectionStatus.Disconnected, async () => {
|
|
1488
|
+
try {
|
|
1489
|
+
// move channel
|
|
1490
|
+
await Promise.race([
|
|
1491
|
+
(0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Signalling, 5_000),
|
|
1492
|
+
(0, voice_1.entersState)(connection, voice_1.VoiceConnectionStatus.Connecting, 5_000),
|
|
1493
|
+
]);
|
|
1494
|
+
// Signalling/Connecting → reconnect
|
|
1495
|
+
this.debug(`[Player] Reconnecting after channel move...`);
|
|
1496
|
+
}
|
|
1497
|
+
catch {
|
|
1498
|
+
// no reconnect in 5 giây → disconnect
|
|
1499
|
+
this.debug(`[Player] Truly disconnected, destroying player`);
|
|
1500
|
+
this.destroy();
|
|
1501
|
+
}
|
|
713
1502
|
});
|
|
714
1503
|
connection.on("error", (error) => {
|
|
715
1504
|
this.debug(`[Player] Voice connection error:`, error);
|
|
@@ -757,7 +1546,7 @@ class Player extends events_1.EventEmitter {
|
|
|
757
1546
|
const track = this.queue.currentTrack;
|
|
758
1547
|
if (track) {
|
|
759
1548
|
this.debug(`[Player] Player resumed on track: ${track.title}`);
|
|
760
|
-
this.emit("playerResume", track);
|
|
1549
|
+
// this.emit("playerResume", track); //đã có trong stateChange
|
|
761
1550
|
}
|
|
762
1551
|
}
|
|
763
1552
|
return result;
|
|
@@ -774,6 +1563,8 @@ class Player extends events_1.EventEmitter {
|
|
|
774
1563
|
*/
|
|
775
1564
|
stop() {
|
|
776
1565
|
this.debug(`[Player] stop called`);
|
|
1566
|
+
// Cancel preload when stopping
|
|
1567
|
+
this.cancelPreload();
|
|
777
1568
|
this.queue.clear();
|
|
778
1569
|
const result = this.audioPlayer.stop();
|
|
779
1570
|
this.destroyCurrentStream();
|
|
@@ -808,12 +1599,7 @@ class Player extends events_1.EventEmitter {
|
|
|
808
1599
|
this.debug(`[Player] Invalid seek position: ${position}ms (track duration: ${totalDuration}ms)`);
|
|
809
1600
|
return false;
|
|
810
1601
|
}
|
|
811
|
-
|
|
812
|
-
if (!streaminfo?.stream) {
|
|
813
|
-
this.debug(`[Player] No stream to seek`);
|
|
814
|
-
return false;
|
|
815
|
-
}
|
|
816
|
-
await this.refeshPlayerResource(true, position);
|
|
1602
|
+
await this.refreshPlayerResource(true, position);
|
|
817
1603
|
return true;
|
|
818
1604
|
}
|
|
819
1605
|
/**
|
|
@@ -830,26 +1616,23 @@ class Player extends events_1.EventEmitter {
|
|
|
830
1616
|
this.debug(`[Player] skip called with index: ${index}`);
|
|
831
1617
|
try {
|
|
832
1618
|
if (typeof index === "number" && index >= 0) {
|
|
833
|
-
// Skip to specific index
|
|
834
1619
|
const targetTrack = this.queue.getTrack(index);
|
|
835
1620
|
if (!targetTrack) {
|
|
836
1621
|
this.debug(`[Player] No track found at index ${index}`);
|
|
837
1622
|
return false;
|
|
838
1623
|
}
|
|
839
|
-
// Remove tracks from 0 to index-1
|
|
840
1624
|
for (let i = 0; i < index; i++) {
|
|
841
1625
|
this.queue.remove(0);
|
|
842
1626
|
}
|
|
843
1627
|
this.debug(`[Player] Skipped to track at index ${index}: ${targetTrack.title}`);
|
|
844
|
-
if (this.isPlaying || this.isPaused) {
|
|
845
|
-
this.skipLoop = true;
|
|
846
|
-
return this.audioPlayer.stop();
|
|
847
|
-
}
|
|
848
|
-
return true;
|
|
849
1628
|
}
|
|
850
1629
|
if (this.isPlaying || this.isPaused) {
|
|
851
1630
|
this.skipLoop = true;
|
|
852
|
-
|
|
1631
|
+
void this.crossfadeSkipAndStop().catch((error) => {
|
|
1632
|
+
this.debug(`[Player] crossfade skip error:`, error);
|
|
1633
|
+
this.audioPlayer.stop();
|
|
1634
|
+
});
|
|
1635
|
+
return true;
|
|
853
1636
|
}
|
|
854
1637
|
return true;
|
|
855
1638
|
}
|
|
@@ -919,7 +1702,7 @@ class Player extends events_1.EventEmitter {
|
|
|
919
1702
|
saveOptions = options;
|
|
920
1703
|
}
|
|
921
1704
|
try {
|
|
922
|
-
//
|
|
1705
|
+
// Skip extension manager for saving - we want the raw stream without filters/seek applied, and extensions may not support this
|
|
923
1706
|
let streamInfo = await this.pluginManager.getStream(track);
|
|
924
1707
|
if (!streamInfo || !streamInfo.stream) {
|
|
925
1708
|
throw new Error(`No save stream available for track: ${track.title}`);
|
|
@@ -1133,21 +1916,96 @@ class Player extends events_1.EventEmitter {
|
|
|
1133
1916
|
* @example
|
|
1134
1917
|
* const progressBar = player.getProgressBar();
|
|
1135
1918
|
* console.log(`Progress bar: ${progressBar}`);
|
|
1919
|
+
*
|
|
1920
|
+
* // Custom options
|
|
1921
|
+
* const customBar = player.getProgressBar({
|
|
1922
|
+
* size: 30,
|
|
1923
|
+
* barChar: "─",
|
|
1924
|
+
* progressChar: "●",
|
|
1925
|
+
* timeFormat: "compact" // "compact" = 1:22:12, "full" = 01:22:12
|
|
1926
|
+
* });
|
|
1136
1927
|
*/
|
|
1137
1928
|
getProgressBar(options = {}) {
|
|
1138
|
-
const { size = 20, barChar = "▬", progressChar = "🔘"
|
|
1929
|
+
const { size = 20, barChar = "▬", progressChar = "🔘", timeFormat = "compact", // "compact" or "full"
|
|
1930
|
+
showPercentage = false, showTime = true, } = options;
|
|
1139
1931
|
const track = this.queue.currentTrack;
|
|
1140
1932
|
const resource = this.currentResource;
|
|
1141
|
-
|
|
1933
|
+
// Handle live stream
|
|
1934
|
+
if (this.isLive || !track || !resource) {
|
|
1935
|
+
if (this.isLive)
|
|
1936
|
+
return "🔴 LIVE";
|
|
1142
1937
|
return "";
|
|
1938
|
+
}
|
|
1143
1939
|
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
1144
1940
|
if (!total)
|
|
1145
|
-
return this.
|
|
1941
|
+
return this.formatTimeCompact(resource.playbackDuration);
|
|
1146
1942
|
const current = resource.playbackDuration;
|
|
1147
|
-
const ratio = Math.min(current / total, 1);
|
|
1943
|
+
const ratio = Math.min(Math.max(current / total, 0), 1);
|
|
1148
1944
|
const progress = Math.round(ratio * size);
|
|
1149
|
-
|
|
1150
|
-
|
|
1945
|
+
// Build progress bar
|
|
1946
|
+
let bar = "";
|
|
1947
|
+
if (progressChar === "none" || options.hideProgressChar) {
|
|
1948
|
+
// Continuous bar without separator
|
|
1949
|
+
const filled = barChar.repeat(progress);
|
|
1950
|
+
const empty = barChar.repeat(size - progress);
|
|
1951
|
+
bar = filled + empty;
|
|
1952
|
+
}
|
|
1953
|
+
else {
|
|
1954
|
+
// Bar with progress character
|
|
1955
|
+
const filled = barChar.repeat(progress);
|
|
1956
|
+
const empty = barChar.repeat(Math.max(0, size - progress));
|
|
1957
|
+
bar = filled + progressChar + empty;
|
|
1958
|
+
}
|
|
1959
|
+
// Format time based on option
|
|
1960
|
+
const formatTimeFn = timeFormat === "compact" ? this.formatTimeCompact.bind(this) : this.formatTime.bind(this);
|
|
1961
|
+
const currentTimeStr = formatTimeFn(current);
|
|
1962
|
+
const totalTimeStr = formatTimeFn(total);
|
|
1963
|
+
// Build result
|
|
1964
|
+
let result = "";
|
|
1965
|
+
if (showTime) {
|
|
1966
|
+
result = `${currentTimeStr} ${bar} ${totalTimeStr}`;
|
|
1967
|
+
}
|
|
1968
|
+
else {
|
|
1969
|
+
result = bar;
|
|
1970
|
+
}
|
|
1971
|
+
// Add percentage if requested
|
|
1972
|
+
if (showPercentage) {
|
|
1973
|
+
const percent = Math.round(ratio * 100);
|
|
1974
|
+
result += ` (${percent}%)`;
|
|
1975
|
+
}
|
|
1976
|
+
return result;
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Format time with leading zeros (00:00 or 00:00:00)
|
|
1980
|
+
* @param ms - Time in milliseconds
|
|
1981
|
+
* @returns Formatted time string with leading zeros
|
|
1982
|
+
*/
|
|
1983
|
+
formatTime(ms) {
|
|
1984
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
1985
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1986
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1987
|
+
const seconds = totalSeconds % 60;
|
|
1988
|
+
const parts = [];
|
|
1989
|
+
if (hours > 0)
|
|
1990
|
+
parts.push(String(hours).padStart(2, "0"));
|
|
1991
|
+
parts.push(String(minutes).padStart(2, "0"));
|
|
1992
|
+
parts.push(String(seconds).padStart(2, "0"));
|
|
1993
|
+
return parts.join(":");
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Format time without leading zeros for hours (1:22:12 or 3:45)
|
|
1997
|
+
* @param ms - Time in milliseconds
|
|
1998
|
+
* @returns Compact formatted time string
|
|
1999
|
+
*/
|
|
2000
|
+
formatTimeCompact(ms) {
|
|
2001
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
2002
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
2003
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
2004
|
+
const seconds = totalSeconds % 60;
|
|
2005
|
+
if (hours > 0) {
|
|
2006
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
2007
|
+
}
|
|
2008
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
1151
2009
|
}
|
|
1152
2010
|
/**
|
|
1153
2011
|
* Get the time of the current track
|
|
@@ -1156,44 +2014,44 @@ class Player extends events_1.EventEmitter {
|
|
|
1156
2014
|
* @example
|
|
1157
2015
|
* const time = player.getTime();
|
|
1158
2016
|
* console.log(`Time: ${time.current}`);
|
|
2017
|
+
* console.log(`Formatted: ${time.formatted.current}`); // "1:22:12" or "3:45"
|
|
1159
2018
|
*/
|
|
1160
2019
|
getTime() {
|
|
2020
|
+
if (this.isLive)
|
|
2021
|
+
return {
|
|
2022
|
+
current: 0,
|
|
2023
|
+
total: 0,
|
|
2024
|
+
format: "LIVE",
|
|
2025
|
+
formatted: {
|
|
2026
|
+
current: "LIVE",
|
|
2027
|
+
total: "LIVE",
|
|
2028
|
+
},
|
|
2029
|
+
};
|
|
1161
2030
|
const resource = this.currentResource;
|
|
1162
2031
|
const track = this.queue.currentTrack;
|
|
1163
|
-
if (!track || !resource)
|
|
2032
|
+
if (!track || !resource) {
|
|
1164
2033
|
return {
|
|
1165
2034
|
current: 0,
|
|
1166
2035
|
total: 0,
|
|
1167
2036
|
format: "00:00",
|
|
2037
|
+
formatted: {
|
|
2038
|
+
current: "00:00",
|
|
2039
|
+
total: "00:00",
|
|
2040
|
+
},
|
|
1168
2041
|
};
|
|
2042
|
+
}
|
|
1169
2043
|
const total = track.duration > 1000 ? track.duration : track.duration * 1000;
|
|
2044
|
+
const current = resource.playbackDuration;
|
|
1170
2045
|
return {
|
|
1171
|
-
current:
|
|
2046
|
+
current: current,
|
|
1172
2047
|
total: total,
|
|
1173
|
-
format: this.formatTime(
|
|
2048
|
+
format: this.formatTime(current),
|
|
2049
|
+
formatted: {
|
|
2050
|
+
current: this.formatTimeCompact(current),
|
|
2051
|
+
total: this.formatTimeCompact(total),
|
|
2052
|
+
},
|
|
1174
2053
|
};
|
|
1175
2054
|
}
|
|
1176
|
-
/**
|
|
1177
|
-
* Format the time in the format of HH:MM:SS
|
|
1178
|
-
*
|
|
1179
|
-
* @param {number} ms - The time in milliseconds
|
|
1180
|
-
* @returns {string} The formatted time
|
|
1181
|
-
* @example
|
|
1182
|
-
* const formattedTime = player.formatTime(1000);
|
|
1183
|
-
* console.log(`Formatted time: ${formattedTime}`);
|
|
1184
|
-
*/
|
|
1185
|
-
formatTime(ms) {
|
|
1186
|
-
const totalSeconds = Math.floor(ms / 1000);
|
|
1187
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
1188
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1189
|
-
const seconds = totalSeconds % 60;
|
|
1190
|
-
const parts = [];
|
|
1191
|
-
if (hours > 0)
|
|
1192
|
-
parts.push(String(hours).padStart(2, "0"));
|
|
1193
|
-
parts.push(String(minutes).padStart(2, "0"));
|
|
1194
|
-
parts.push(String(seconds).padStart(2, "0"));
|
|
1195
|
-
return parts.join(":");
|
|
1196
|
-
}
|
|
1197
2055
|
/**
|
|
1198
2056
|
* Destroy the player
|
|
1199
2057
|
*
|
|
@@ -1207,10 +2065,14 @@ class Player extends events_1.EventEmitter {
|
|
|
1207
2065
|
clearTimeout(this.leaveTimeout);
|
|
1208
2066
|
this.leaveTimeout = null;
|
|
1209
2067
|
}
|
|
2068
|
+
this.streamManager.destroyAll(true);
|
|
1210
2069
|
// Destroy current stream before stopping audio
|
|
1211
2070
|
this.destroyCurrentStream();
|
|
2071
|
+
this.clearSlot(this.currentSlot);
|
|
2072
|
+
this.clearSlot(this.preloadSlot);
|
|
1212
2073
|
this.audioPlayer.removeAllListeners();
|
|
1213
2074
|
this.audioPlayer.stop(true);
|
|
2075
|
+
this.clearPreload();
|
|
1214
2076
|
if (this.ttsPlayer) {
|
|
1215
2077
|
try {
|
|
1216
2078
|
this.ttsPlayer.stop(true);
|
|
@@ -1243,7 +2105,7 @@ class Player extends events_1.EventEmitter {
|
|
|
1243
2105
|
if (this.leaveTimeout) {
|
|
1244
2106
|
clearTimeout(this.leaveTimeout);
|
|
1245
2107
|
}
|
|
1246
|
-
if (this.options.
|
|
2108
|
+
if (this.options.leaveOnEnd && this.options.leaveTimeout) {
|
|
1247
2109
|
this.leaveTimeout = setTimeout(() => {
|
|
1248
2110
|
this.debug(`[Player] Leaving voice channel after timeoutMs`);
|
|
1249
2111
|
this.destroy();
|
|
@@ -1257,13 +2119,16 @@ class Player extends events_1.EventEmitter {
|
|
|
1257
2119
|
* @param {number} position - Position to seek to in milliseconds
|
|
1258
2120
|
* @returns {Promise<boolean>}
|
|
1259
2121
|
* @example
|
|
1260
|
-
* const refreshed = await player.
|
|
2122
|
+
* const refreshed = await player.refreshPlayerResource(true, 1000);
|
|
1261
2123
|
* console.log(`Refreshed: ${refreshed}`);
|
|
1262
2124
|
*/
|
|
1263
|
-
async
|
|
2125
|
+
async refreshPlayerResource(applyToCurrent = true, position = -1) {
|
|
1264
2126
|
if (!applyToCurrent || !this.queue.currentTrack || !(this.isPlaying || this.isPaused)) {
|
|
1265
2127
|
return false;
|
|
1266
2128
|
}
|
|
2129
|
+
if (this.refreshLock)
|
|
2130
|
+
return false;
|
|
2131
|
+
this.refreshLock = true;
|
|
1267
2132
|
try {
|
|
1268
2133
|
const track = this.queue.currentTrack;
|
|
1269
2134
|
this.debug(`[Player] Refreshing player resource for track: ${track.title}`);
|
|
@@ -1290,7 +2155,10 @@ class Player extends events_1.EventEmitter {
|
|
|
1290
2155
|
}
|
|
1291
2156
|
}
|
|
1292
2157
|
catch (error) {
|
|
1293
|
-
this.debug(`[Player] Error destroying old stream in
|
|
2158
|
+
this.debug(`[Player] Error destroying old stream in refreshPlayerResource:`, error);
|
|
2159
|
+
}
|
|
2160
|
+
finally {
|
|
2161
|
+
this.refreshLock = false;
|
|
1294
2162
|
}
|
|
1295
2163
|
this.currentResource = resource;
|
|
1296
2164
|
// Subscribe to new resource
|
|
@@ -1407,11 +2275,42 @@ class Player extends events_1.EventEmitter {
|
|
|
1407
2275
|
}
|
|
1408
2276
|
else if (newState.status === voice_1.AudioPlayerStatus.Buffering) {
|
|
1409
2277
|
this.debug(`[Player] AudioPlayerStatus.Buffering`);
|
|
2278
|
+
this.lastDuration = this.currentResource?.playbackDuration || 0;
|
|
2279
|
+
this.stuckTimer = setTimeout(() => {
|
|
2280
|
+
if (this.currentResource?.playbackDuration === this.lastDuration) {
|
|
2281
|
+
this.emit("trackStuck", this.currentTrack);
|
|
2282
|
+
const stuckTrack = this.currentTrack;
|
|
2283
|
+
if (stuckTrack && this.antiStuckEnabled) {
|
|
2284
|
+
void this.attemptTrackRecovery(stuckTrack, new Error("TRACK_STUCK")).then((recovered) => {
|
|
2285
|
+
if (!recovered) {
|
|
2286
|
+
this.skip();
|
|
2287
|
+
}
|
|
2288
|
+
});
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
this.skip();
|
|
2292
|
+
}
|
|
2293
|
+
}, 10000);
|
|
2294
|
+
}
|
|
2295
|
+
else {
|
|
2296
|
+
if (this.stuckTimer) {
|
|
2297
|
+
clearTimeout(this.stuckTimer);
|
|
2298
|
+
this.stuckTimer = null;
|
|
2299
|
+
}
|
|
1410
2300
|
}
|
|
1411
2301
|
});
|
|
1412
2302
|
this.audioPlayer.on("error", (error) => {
|
|
1413
2303
|
this.debug(`[Player] AudioPlayer error:`, error);
|
|
1414
2304
|
this.emit("playerError", error, this.queue.currentTrack || undefined);
|
|
2305
|
+
const track = this.queue.currentTrack;
|
|
2306
|
+
if (track && this.antiStuckEnabled) {
|
|
2307
|
+
void this.attemptTrackRecovery(track, error).then((recovered) => {
|
|
2308
|
+
if (!recovered) {
|
|
2309
|
+
this.playNext();
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
1415
2314
|
this.playNext();
|
|
1416
2315
|
});
|
|
1417
2316
|
this.audioPlayer.on("debug", (...args) => {
|
|
@@ -1419,6 +2318,20 @@ class Player extends events_1.EventEmitter {
|
|
|
1419
2318
|
this.emit("debug", ...args);
|
|
1420
2319
|
}
|
|
1421
2320
|
});
|
|
2321
|
+
//stream Manager events
|
|
2322
|
+
this.streamManager.on("streamError", ({ streamId, error }) => {
|
|
2323
|
+
this.debug(`[StreamManager] Error for stream ${streamId}:`, error);
|
|
2324
|
+
this.emit("streamError", error, this.queue.currentTrack || null);
|
|
2325
|
+
});
|
|
2326
|
+
this.streamManager.on("streamRegistered", ({ streamId, track, metadata }) => {
|
|
2327
|
+
this.debug(`[StreamManager] Stream registered: ${track.title} (preload: ${metadata.isPreload})`);
|
|
2328
|
+
});
|
|
2329
|
+
this.streamManager.on("streamUnregistered", ({ streamId, track, reason }) => {
|
|
2330
|
+
this.debug(`[StreamManager] Stream unregistered: ${track.title} (reason: ${reason})`);
|
|
2331
|
+
});
|
|
2332
|
+
this.streamManager.on("debug", (...args) => {
|
|
2333
|
+
this.debug(...args);
|
|
2334
|
+
});
|
|
1422
2335
|
}
|
|
1423
2336
|
addPlugin(plugin) {
|
|
1424
2337
|
this.debug(`[Player] Adding plugin: ${plugin.name}`);
|
|
@@ -1428,6 +2341,77 @@ class Player extends events_1.EventEmitter {
|
|
|
1428
2341
|
this.debug(`[Player] Removing plugin: ${name}`);
|
|
1429
2342
|
return this.pluginManager.unregister(name);
|
|
1430
2343
|
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Save the current session of the player, including queue, current track, position, volume, loop mode, auto-play mode, and active extensions/plugins.
|
|
2346
|
+
*
|
|
2347
|
+
* @returns {PlayerSession} The saved session data
|
|
2348
|
+
*/
|
|
2349
|
+
saveSession() {
|
|
2350
|
+
return {
|
|
2351
|
+
guildId: this.guildId,
|
|
2352
|
+
currentTrack: this.currentTrack,
|
|
2353
|
+
position: this.currentResource?.playbackDuration || null,
|
|
2354
|
+
volume: this.volume,
|
|
2355
|
+
queue: this.queue.getTracks(),
|
|
2356
|
+
loopMode: this.queue.loop(),
|
|
2357
|
+
autoPlay: this.queue.autoPlay(),
|
|
2358
|
+
extensions: this.extensionManager.getAll().map((ext) => ext.name),
|
|
2359
|
+
plugins: this.pluginManager.getAll().map((plugin) => plugin.name),
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Get serializable state (for manual persistence)
|
|
2364
|
+
*/
|
|
2365
|
+
getSerializableState() {
|
|
2366
|
+
return {
|
|
2367
|
+
guildId: this.guildId,
|
|
2368
|
+
queue: this.queue.getTracks(),
|
|
2369
|
+
currentTrack: this.currentTrack,
|
|
2370
|
+
volume: this.volume,
|
|
2371
|
+
isPlaying: this.isPlaying,
|
|
2372
|
+
isPaused: this.isPaused,
|
|
2373
|
+
loopMode: this.queue.loop(),
|
|
2374
|
+
autoPlay: this.queue.autoPlay(),
|
|
2375
|
+
filters: this.filter.getFilterString(),
|
|
2376
|
+
timestamp: Date.now(),
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Restore from saved state
|
|
2381
|
+
*/
|
|
2382
|
+
async restoreState(state) {
|
|
2383
|
+
try {
|
|
2384
|
+
if (state.volume)
|
|
2385
|
+
this.setVolume(state.volume);
|
|
2386
|
+
if (state.loopMode)
|
|
2387
|
+
this.queue.loop(state.loopMode);
|
|
2388
|
+
if (typeof state.autoPlay === "boolean")
|
|
2389
|
+
this.queue.autoPlay(state.autoPlay);
|
|
2390
|
+
if (state.filters)
|
|
2391
|
+
await this.filter.applyFilters(state.filters.split(","));
|
|
2392
|
+
// Restore queue
|
|
2393
|
+
if (state.queue && Array.isArray(state.queue)) {
|
|
2394
|
+
this.queue.clear();
|
|
2395
|
+
this.queue.addMultiple(state.queue);
|
|
2396
|
+
}
|
|
2397
|
+
this.debug("[Player] State restored");
|
|
2398
|
+
return true;
|
|
2399
|
+
}
|
|
2400
|
+
catch (error) {
|
|
2401
|
+
this.debug("[Player] Failed to restore state:", error);
|
|
2402
|
+
return false;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Get stream manager stats
|
|
2407
|
+
*/
|
|
2408
|
+
getStreamManagerStats() {
|
|
2409
|
+
return {
|
|
2410
|
+
metrics: this.streamManager.getMetrics(),
|
|
2411
|
+
stats: this.streamManager.getStats(),
|
|
2412
|
+
totalStreams: this.streamManager.getStreamCount(),
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
1431
2415
|
//#endregion
|
|
1432
2416
|
//#region Getters
|
|
1433
2417
|
/**
|
|
@@ -1507,6 +2491,9 @@ class Player extends events_1.EventEmitter {
|
|
|
1507
2491
|
get relatedTracks() {
|
|
1508
2492
|
return this.queue.relatedTracks();
|
|
1509
2493
|
}
|
|
2494
|
+
get isLive() {
|
|
2495
|
+
return this.currentTrack?.isLive === true;
|
|
2496
|
+
}
|
|
1510
2497
|
}
|
|
1511
2498
|
exports.Player = Player;
|
|
1512
2499
|
//# sourceMappingURL=Player.js.map
|