@ziplayer/plugin 0.1.41 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/AttachmentsPlugin.d.ts +192 -0
- package/dist/AttachmentsPlugin.d.ts.map +1 -0
- package/dist/AttachmentsPlugin.js +481 -0
- package/dist/AttachmentsPlugin.js.map +1 -0
- package/dist/TTSPlugin.d.ts +17 -0
- package/dist/TTSPlugin.d.ts.map +1 -1
- package/dist/TTSPlugin.js +68 -7
- package/dist/TTSPlugin.js.map +1 -1
- package/dist/index.d.ts +27 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/AttachmentsPlugin.ts +523 -0
- package/src/TTSPlugin.ts +79 -7
- package/src/index.ts +28 -5
- package/src/utils/stream-converter.ts +79 -79
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { BasePlugin, Track, SearchResult, StreamInfo } from "ziplayer";
|
|
2
|
+
import { Readable } from "stream";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import { parseBuffer } from "music-metadata";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration options for the AttachmentsPlugin.
|
|
8
|
+
*/
|
|
9
|
+
export interface AttachmentsPluginOptions {
|
|
10
|
+
/** Maximum file size in bytes (default: 25MB) */
|
|
11
|
+
maxFileSize?: number;
|
|
12
|
+
/** Allowed audio file extensions */
|
|
13
|
+
allowedExtensions?: string[];
|
|
14
|
+
/** Whether to enable debug logging */
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A plugin for handling Discord attachment URLs and local audio files.
|
|
20
|
+
*
|
|
21
|
+
* This plugin provides support for:
|
|
22
|
+
* - Discord attachment URLs (cdn.discordapp.com, media.discordapp.net)
|
|
23
|
+
* - Direct audio file URLs
|
|
24
|
+
* - Local file paths (if accessible)
|
|
25
|
+
* - Various audio formats (mp3, wav, ogg, m4a, flac, etc.)
|
|
26
|
+
* - File size validation
|
|
27
|
+
* - Audio metadata analysis (duration, title, artist, album, etc.)
|
|
28
|
+
* - Stream extraction from URLs
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* const attachmentsPlugin = new AttachmentsPlugin({
|
|
32
|
+
* maxFileSize: 25 * 1024 * 1024, // 25MB
|
|
33
|
+
* allowedExtensions: ['mp3', 'wav', 'ogg', 'm4a', 'flac']
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* // Add to PlayerManager
|
|
37
|
+
* const manager = new PlayerManager({
|
|
38
|
+
* plugins: [attachmentsPlugin]
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* // Search for attachment content
|
|
42
|
+
* const result = await attachmentsPlugin.search(
|
|
43
|
+
* "https://cdn.discordapp.com/attachments/123/456/audio.mp3",
|
|
44
|
+
* "user123"
|
|
45
|
+
* );
|
|
46
|
+
* const stream = await attachmentsPlugin.getStream(result.tracks[0]);
|
|
47
|
+
*
|
|
48
|
+
* @since 1.0.0
|
|
49
|
+
*/
|
|
50
|
+
export class AttachmentsPlugin extends BasePlugin {
|
|
51
|
+
name = "attachments";
|
|
52
|
+
version = "1.0.0";
|
|
53
|
+
|
|
54
|
+
private opts: AttachmentsPluginOptions;
|
|
55
|
+
private readonly defaultAllowedExtensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma", "opus", "webm"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates a new AttachmentsPlugin instance.
|
|
59
|
+
*
|
|
60
|
+
* @param opts - Configuration options for the attachments plugin
|
|
61
|
+
* @param opts.maxFileSize - Maximum file size in bytes (default: 25MB)
|
|
62
|
+
* @param opts.allowedExtensions - Allowed audio file extensions (default: common audio formats)
|
|
63
|
+
* @param opts.debug - Whether to enable debug logging (default: false)
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // Basic attachments plugin
|
|
67
|
+
* const attachmentsPlugin = new AttachmentsPlugin();
|
|
68
|
+
*
|
|
69
|
+
* // Custom configuration
|
|
70
|
+
* const customPlugin = new AttachmentsPlugin({
|
|
71
|
+
* maxFileSize: 50 * 1024 * 1024, // 50MB
|
|
72
|
+
* allowedExtensions: ['mp3', 'wav', 'ogg'],
|
|
73
|
+
* debug: true
|
|
74
|
+
* });
|
|
75
|
+
*/
|
|
76
|
+
constructor(opts?: AttachmentsPluginOptions) {
|
|
77
|
+
super();
|
|
78
|
+
this.opts = {
|
|
79
|
+
maxFileSize: opts?.maxFileSize || 25 * 1024 * 1024, // 25MB default
|
|
80
|
+
allowedExtensions: opts?.allowedExtensions || this.defaultAllowedExtensions,
|
|
81
|
+
debug: opts?.debug || false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Determines if this plugin can handle the given query.
|
|
87
|
+
*
|
|
88
|
+
* @param query - The URL or file path to check
|
|
89
|
+
* @returns `true` if the query is a Discord attachment URL or audio file URL, `false` otherwise
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* plugin.canHandle("https://cdn.discordapp.com/attachments/123/456/audio.mp3"); // true
|
|
93
|
+
* plugin.canHandle("https://example.com/song.wav"); // true
|
|
94
|
+
* plugin.canHandle("youtube.com/watch?v=123"); // false
|
|
95
|
+
*/
|
|
96
|
+
canHandle(query: string): boolean {
|
|
97
|
+
if (!query) return false;
|
|
98
|
+
|
|
99
|
+
const q = query.trim();
|
|
100
|
+
|
|
101
|
+
// Check if it's a URL
|
|
102
|
+
if (q.startsWith("http://") || q.startsWith("https://")) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(q);
|
|
105
|
+
|
|
106
|
+
// Discord attachment URLs
|
|
107
|
+
if (url.hostname === "cdn.discordapp.com" || url.hostname === "media.discordapp.net") {
|
|
108
|
+
return this.isAudioFile(q);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Any other URL - check if it's an audio file
|
|
112
|
+
return this.isAudioFile(q);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check if it's a local file path (basic check)
|
|
119
|
+
if (q.includes("/") || q.includes("\\")) {
|
|
120
|
+
return this.isAudioFile(q);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validates if a URL is a valid Discord attachment URL or audio file URL.
|
|
128
|
+
*
|
|
129
|
+
* @param url - The URL to validate
|
|
130
|
+
* @returns `true` if the URL is valid and points to an audio file, `false` otherwise
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* plugin.validate("https://cdn.discordapp.com/attachments/123/456/audio.mp3"); // true
|
|
134
|
+
* plugin.validate("https://example.com/song.wav"); // true
|
|
135
|
+
* plugin.validate("https://example.com/image.jpg"); // false
|
|
136
|
+
*/
|
|
137
|
+
validate(url: string): boolean {
|
|
138
|
+
return this.canHandle(url) && this.isAudioFile(url);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Creates a track from an attachment URL or file path.
|
|
143
|
+
*
|
|
144
|
+
* This method handles both Discord attachment URLs and direct audio file URLs.
|
|
145
|
+
* It extracts metadata from the URL and creates a track that can be played.
|
|
146
|
+
*
|
|
147
|
+
* @param query - The attachment URL or file path
|
|
148
|
+
* @param requestedBy - The user ID who requested the track
|
|
149
|
+
* @returns A SearchResult containing a single track
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* // Discord attachment
|
|
153
|
+
* const result = await plugin.search(
|
|
154
|
+
* "https://cdn.discordapp.com/attachments/123/456/audio.mp3",
|
|
155
|
+
* "user123"
|
|
156
|
+
* );
|
|
157
|
+
*
|
|
158
|
+
* // Direct audio file URL
|
|
159
|
+
* const result2 = await plugin.search(
|
|
160
|
+
* "https://example.com/song.wav",
|
|
161
|
+
* "user123"
|
|
162
|
+
* );
|
|
163
|
+
*/
|
|
164
|
+
async search(query: string, requestedBy: string): Promise<SearchResult> {
|
|
165
|
+
if (!this.canHandle(query)) {
|
|
166
|
+
return { tracks: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const filename = this.extractFilename(query);
|
|
171
|
+
const fileExtension = this.getFileExtension(filename);
|
|
172
|
+
let title = filename || `Audio File (${fileExtension})`;
|
|
173
|
+
|
|
174
|
+
// Get file size if it's a URL
|
|
175
|
+
let fileSize = 0;
|
|
176
|
+
let duration = 0;
|
|
177
|
+
let audioMetadata: any = {};
|
|
178
|
+
|
|
179
|
+
if (query.startsWith("http://") || query.startsWith("https://")) {
|
|
180
|
+
try {
|
|
181
|
+
const headResponse = await axios.head(query, { timeout: 5000 });
|
|
182
|
+
const contentLength = headResponse.headers["content-length"];
|
|
183
|
+
if (contentLength) {
|
|
184
|
+
fileSize = parseInt(contentLength, 10);
|
|
185
|
+
|
|
186
|
+
// Check file size limit
|
|
187
|
+
if (fileSize > this.opts.maxFileSize!) {
|
|
188
|
+
throw new Error(`File too large: ${this.formatBytes(fileSize)} (max: ${this.formatBytes(this.opts.maxFileSize!)})`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.debug("Could not get file size:", error);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Analyze audio metadata to get duration and other info
|
|
196
|
+
try {
|
|
197
|
+
const analysisResult = await this.analyzeAudioMetadata(query);
|
|
198
|
+
duration = analysisResult.duration;
|
|
199
|
+
audioMetadata = analysisResult.metadata || {};
|
|
200
|
+
|
|
201
|
+
// Use metadata title if available
|
|
202
|
+
if (audioMetadata.title && audioMetadata.title.trim()) {
|
|
203
|
+
const artist = audioMetadata.artist ? ` - ${audioMetadata.artist}` : "";
|
|
204
|
+
const album = audioMetadata.album ? ` (${audioMetadata.album})` : "";
|
|
205
|
+
const finalTitle = `${audioMetadata.title}${artist}${album}`;
|
|
206
|
+
if (finalTitle.trim()) {
|
|
207
|
+
title = finalTitle;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.debug("Could not analyze audio metadata:", error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const track: Track = {
|
|
216
|
+
id: this.generateTrackId(query),
|
|
217
|
+
title,
|
|
218
|
+
url: query,
|
|
219
|
+
duration,
|
|
220
|
+
requestedBy,
|
|
221
|
+
source: this.name,
|
|
222
|
+
metadata: {
|
|
223
|
+
filename,
|
|
224
|
+
extension: fileExtension,
|
|
225
|
+
fileSize,
|
|
226
|
+
isDiscordAttachment: this.isDiscordAttachment(query),
|
|
227
|
+
...audioMetadata, // Include all audio metadata
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return { tracks: [track] };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.debug("Error creating track:", error);
|
|
234
|
+
return { tracks: [] };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Retrieves the audio stream from an attachment URL or file path.
|
|
240
|
+
*
|
|
241
|
+
* This method downloads the audio file from the URL and returns it as a stream.
|
|
242
|
+
* It handles various audio formats and provides proper error handling.
|
|
243
|
+
*
|
|
244
|
+
* @param track - The Track object to get the stream for
|
|
245
|
+
* @returns A StreamInfo object containing the audio stream
|
|
246
|
+
* @throws {Error} If the URL is invalid, file is too large, or download fails
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* const track = {
|
|
250
|
+
* id: "attachment-123",
|
|
251
|
+
* title: "audio.mp3",
|
|
252
|
+
* url: "https://cdn.discordapp.com/attachments/123/456/audio.mp3",
|
|
253
|
+
* ...
|
|
254
|
+
* };
|
|
255
|
+
* const streamInfo = await plugin.getStream(track);
|
|
256
|
+
* console.log(streamInfo.type); // "arbitrary"
|
|
257
|
+
* console.log(streamInfo.stream); // Readable stream
|
|
258
|
+
*/
|
|
259
|
+
async getStream(track: Track): Promise<StreamInfo> {
|
|
260
|
+
if (track.source !== this.name) {
|
|
261
|
+
throw new Error("Track is not from AttachmentsPlugin");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const url = track.url;
|
|
265
|
+
if (!url) {
|
|
266
|
+
throw new Error("No URL provided for track");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
this.debug("Downloading audio from:", url);
|
|
271
|
+
|
|
272
|
+
// Download the file
|
|
273
|
+
const response = await axios.get(url, {
|
|
274
|
+
responseType: "stream",
|
|
275
|
+
timeout: 30000, // 30 second timeout
|
|
276
|
+
maxContentLength: this.opts.maxFileSize,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const stream = response.data as Readable;
|
|
280
|
+
const contentType = response.headers["content-type"] || "";
|
|
281
|
+
const contentLength = response.headers["content-length"];
|
|
282
|
+
|
|
283
|
+
this.debug("Download successful:", {
|
|
284
|
+
contentType,
|
|
285
|
+
contentLength: contentLength ? parseInt(contentLength, 10) : "unknown",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Determine stream type based on content type or file extension
|
|
289
|
+
const streamType = this.getStreamType(contentType, track.metadata?.extension);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
stream,
|
|
293
|
+
type: streamType,
|
|
294
|
+
metadata: {
|
|
295
|
+
...track.metadata,
|
|
296
|
+
contentType,
|
|
297
|
+
contentLength: contentLength ? parseInt(contentLength, 10) : undefined,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
} catch (error: any) {
|
|
301
|
+
if (error.code === "ECONNABORTED") {
|
|
302
|
+
throw new Error(`Download timeout for ${url}`);
|
|
303
|
+
}
|
|
304
|
+
if (error.response?.status === 404) {
|
|
305
|
+
throw new Error(`File not found: ${url}`);
|
|
306
|
+
}
|
|
307
|
+
if (error.response?.status === 403) {
|
|
308
|
+
throw new Error(`Access denied: ${url}`);
|
|
309
|
+
}
|
|
310
|
+
if (error.message?.includes("maxContentLength")) {
|
|
311
|
+
throw new Error(`File too large: ${url}`);
|
|
312
|
+
}
|
|
313
|
+
throw new Error(`Failed to download audio: ${error.message || error}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Provides a fallback by attempting to re-download the file.
|
|
319
|
+
*
|
|
320
|
+
* @param track - The Track object to get a fallback stream for
|
|
321
|
+
* @returns A StreamInfo object containing the fallback audio stream
|
|
322
|
+
* @throws {Error} If fallback download fails
|
|
323
|
+
*/
|
|
324
|
+
async getFallback(track: Track): Promise<StreamInfo> {
|
|
325
|
+
this.debug("Attempting fallback for track:", track.title);
|
|
326
|
+
return await this.getStream(track);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Checks if a file path or URL is an audio file based on extension.
|
|
331
|
+
*/
|
|
332
|
+
private isAudioFile(path: string): boolean {
|
|
333
|
+
const extension = this.getFileExtension(path);
|
|
334
|
+
return this.opts.allowedExtensions!.includes(extension.toLowerCase());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Extracts the file extension from a path or URL.
|
|
339
|
+
*/
|
|
340
|
+
private getFileExtension(path: string): string {
|
|
341
|
+
const lastDot = path.lastIndexOf(".");
|
|
342
|
+
if (lastDot === -1) return "";
|
|
343
|
+
|
|
344
|
+
const extension = path.slice(lastDot + 1);
|
|
345
|
+
// Remove query parameters if present
|
|
346
|
+
const questionMark = extension.indexOf("?");
|
|
347
|
+
return questionMark === -1 ? extension : extension.slice(0, questionMark);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Extracts filename from a URL or path.
|
|
352
|
+
*/
|
|
353
|
+
private extractFilename(path: string): string {
|
|
354
|
+
try {
|
|
355
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
356
|
+
const url = new URL(path);
|
|
357
|
+
const pathname = url.pathname;
|
|
358
|
+
const lastSlash = pathname.lastIndexOf("/");
|
|
359
|
+
return lastSlash === -1 ? pathname : pathname.slice(lastSlash + 1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Local file path
|
|
363
|
+
const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
364
|
+
return lastSlash === -1 ? path : path.slice(lastSlash + 1);
|
|
365
|
+
} catch {
|
|
366
|
+
return "Unknown File";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Checks if a URL is a Discord attachment URL.
|
|
372
|
+
*/
|
|
373
|
+
private isDiscordAttachment(url: string): boolean {
|
|
374
|
+
try {
|
|
375
|
+
const urlObj = new URL(url);
|
|
376
|
+
return urlObj.hostname === "cdn.discordapp.com" || urlObj.hostname === "media.discordapp.net";
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Generates a unique track ID for a given URL.
|
|
384
|
+
*/
|
|
385
|
+
private generateTrackId(url: string): string {
|
|
386
|
+
// Create a hash-like ID from the URL
|
|
387
|
+
const hash = this.simpleHash(url);
|
|
388
|
+
return `attachment-${hash}-${Date.now()}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Simple hash function for generating IDs.
|
|
393
|
+
*/
|
|
394
|
+
private simpleHash(str: string): string {
|
|
395
|
+
let hash = 0;
|
|
396
|
+
for (let i = 0; i < str.length; i++) {
|
|
397
|
+
const char = str.charCodeAt(i);
|
|
398
|
+
hash = (hash << 5) - hash + char;
|
|
399
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
400
|
+
}
|
|
401
|
+
return Math.abs(hash).toString(36);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Determines the appropriate stream type based on content type and file extension.
|
|
406
|
+
*/
|
|
407
|
+
private getStreamType(contentType: string, extension?: string): StreamInfo["type"] {
|
|
408
|
+
const type = contentType.toLowerCase();
|
|
409
|
+
const ext = extension?.toLowerCase() || "";
|
|
410
|
+
|
|
411
|
+
// Check content type first
|
|
412
|
+
if (type.includes("webm") && type.includes("opus")) return "webm/opus";
|
|
413
|
+
if (type.includes("ogg") && type.includes("opus")) return "ogg/opus";
|
|
414
|
+
|
|
415
|
+
// Fallback to extension
|
|
416
|
+
if (ext === "webm") return "webm/opus";
|
|
417
|
+
if (ext === "ogg") return "ogg/opus";
|
|
418
|
+
|
|
419
|
+
// Default to arbitrary for all other types (mp3, wav, flac, mp4, etc.)
|
|
420
|
+
return "arbitrary";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Formats bytes into a human-readable string.
|
|
425
|
+
*/
|
|
426
|
+
private formatBytes(bytes: number): string {
|
|
427
|
+
if (bytes === 0) return "0 Bytes";
|
|
428
|
+
const k = 1024;
|
|
429
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
430
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
431
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Analyzes audio metadata to extract duration and other information.
|
|
436
|
+
*
|
|
437
|
+
* @param url - The URL to analyze
|
|
438
|
+
* @returns Promise containing duration in seconds and metadata
|
|
439
|
+
*/
|
|
440
|
+
private async analyzeAudioMetadata(url: string): Promise<{ duration: number; metadata?: any }> {
|
|
441
|
+
try {
|
|
442
|
+
this.debug("Analyzing audio metadata for:", url);
|
|
443
|
+
|
|
444
|
+
// Download a small portion of the file to analyze metadata
|
|
445
|
+
const response = await axios.get(url, {
|
|
446
|
+
responseType: "arraybuffer",
|
|
447
|
+
timeout: 10000, // 10 second timeout for metadata analysis
|
|
448
|
+
maxContentLength: 1024 * 1024, // Only download first 1MB for metadata
|
|
449
|
+
headers: {
|
|
450
|
+
Range: "bytes=0-1048575", // Request first 1MB
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const buffer = Buffer.from(response.data);
|
|
455
|
+
const metadata = await parseBuffer(buffer);
|
|
456
|
+
|
|
457
|
+
const duration = metadata.format.duration || 0;
|
|
458
|
+
this.debug("Audio metadata analysis result:", {
|
|
459
|
+
duration: `${duration}s`,
|
|
460
|
+
format: metadata.format.container,
|
|
461
|
+
codec: metadata.format.codec,
|
|
462
|
+
bitrate: metadata.format.bitrate,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
duration: Math.round(duration),
|
|
467
|
+
metadata: {
|
|
468
|
+
format: metadata.format.container,
|
|
469
|
+
codec: metadata.format.codec,
|
|
470
|
+
bitrate: metadata.format.bitrate,
|
|
471
|
+
sampleRate: metadata.format.sampleRate,
|
|
472
|
+
channels: metadata.format.numberOfChannels,
|
|
473
|
+
title: metadata.common.title,
|
|
474
|
+
artist: metadata.common.artist,
|
|
475
|
+
album: metadata.common.album,
|
|
476
|
+
year: metadata.common.year,
|
|
477
|
+
genre: metadata.common.genre,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
} catch (error: any) {
|
|
481
|
+
this.debug("Failed to analyze audio metadata:", error.message);
|
|
482
|
+
|
|
483
|
+
// Fallback: try without Range header for some servers that don't support it
|
|
484
|
+
try {
|
|
485
|
+
const response = await axios.get(url, {
|
|
486
|
+
responseType: "arraybuffer",
|
|
487
|
+
timeout: 10000,
|
|
488
|
+
maxContentLength: 1024 * 1024, // 1MB limit
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const buffer = Buffer.from(response.data);
|
|
492
|
+
const metadata = await parseBuffer(buffer);
|
|
493
|
+
|
|
494
|
+
const duration = metadata.format.duration || 0;
|
|
495
|
+
this.debug("Audio metadata analysis (fallback) result:", {
|
|
496
|
+
duration: `${duration}s`,
|
|
497
|
+
format: metadata.format.container,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
duration: Math.round(duration),
|
|
502
|
+
metadata: {
|
|
503
|
+
format: metadata.format.container,
|
|
504
|
+
codec: metadata.format.codec,
|
|
505
|
+
bitrate: metadata.format.bitrate,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
} catch (fallbackError: any) {
|
|
509
|
+
this.debug("Fallback metadata analysis also failed:", fallbackError.message);
|
|
510
|
+
return { duration: 0 };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Debug logging helper.
|
|
517
|
+
*/
|
|
518
|
+
private debug(message: string, ...args: any[]): void {
|
|
519
|
+
if (this.opts.debug) {
|
|
520
|
+
console.log(`[AttachmentsPlugin] ${message}`, ...args);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
package/src/TTSPlugin.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Readable } from "stream";
|
|
3
3
|
import { getTTSUrls } from "@zibot/zitts";
|
|
4
4
|
import axios from "axios";
|
|
5
|
+
import { parseBuffer } from "music-metadata";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Configuration options for the TTSPlugin.
|
|
@@ -58,6 +59,7 @@ interface TTSConfig {
|
|
|
58
59
|
* - Multiple language support
|
|
59
60
|
* - Configurable speech rate (normal/slow)
|
|
60
61
|
* - TTS query parsing with language and speed options
|
|
62
|
+
* - Accurate duration analysis by generating sample audio
|
|
61
63
|
*
|
|
62
64
|
* @example
|
|
63
65
|
* const ttsPlugin = new TTSPlugin({
|
|
@@ -174,21 +176,94 @@ export class TTSPlugin extends BasePlugin {
|
|
|
174
176
|
const config: TTSConfig = { text, lang, slow };
|
|
175
177
|
const url = this.encodeConfig(config);
|
|
176
178
|
const title = `TTS (${lang}${slow ? ", slow" : ""}): ${text.slice(0, 64)}${text.length > 64 ? "…" : ""}`;
|
|
177
|
-
|
|
179
|
+
|
|
180
|
+
// Analyze actual TTS duration
|
|
181
|
+
let duration: number;
|
|
182
|
+
try {
|
|
183
|
+
duration = await this.analyzeTTSDuration(text, lang, slow);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// Fallback to estimation if analysis fails
|
|
186
|
+
duration = this.estimateDuration(text);
|
|
187
|
+
}
|
|
178
188
|
|
|
179
189
|
const track: Track = {
|
|
180
190
|
id: `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
181
191
|
title,
|
|
182
192
|
url,
|
|
183
|
-
duration
|
|
193
|
+
duration,
|
|
184
194
|
requestedBy,
|
|
185
195
|
source: this.name,
|
|
186
|
-
metadata: {
|
|
196
|
+
metadata: {
|
|
197
|
+
tts: config,
|
|
198
|
+
analyzedDuration: true,
|
|
199
|
+
textLength: text.length,
|
|
200
|
+
language: lang,
|
|
201
|
+
slowMode: slow,
|
|
202
|
+
},
|
|
187
203
|
};
|
|
188
204
|
|
|
189
205
|
return { tracks: [track] };
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Analyzes TTS audio duration by generating a small sample and measuring its length.
|
|
210
|
+
*
|
|
211
|
+
* @param text - The text to analyze
|
|
212
|
+
* @param lang - The language code
|
|
213
|
+
* @param slow - Whether to use slow speech rate
|
|
214
|
+
* @returns Promise containing duration in seconds
|
|
215
|
+
*/
|
|
216
|
+
private async analyzeTTSDuration(text: string, lang: string, slow: boolean): Promise<number> {
|
|
217
|
+
try {
|
|
218
|
+
// Use a shorter sample text for duration analysis to minimize processing time
|
|
219
|
+
const sampleText = text.length > 50 ? text.slice(0, 50) + "..." : text;
|
|
220
|
+
|
|
221
|
+
const urls = getTTSUrls(sampleText, { lang, slow });
|
|
222
|
+
if (!urls || urls.length === 0) {
|
|
223
|
+
return this.estimateDuration(text);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Download the sample audio
|
|
227
|
+
const parts = await Promise.all(
|
|
228
|
+
urls.map((u) => axios.get<ArrayBuffer>(u, { responseType: "arraybuffer" }).then((r) => Buffer.from(r.data))),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const merged = Buffer.concat(parts);
|
|
232
|
+
|
|
233
|
+
// Parse metadata to get actual duration
|
|
234
|
+
const metadata = await parseBuffer(merged);
|
|
235
|
+
const actualDuration = metadata.format.duration || 0;
|
|
236
|
+
|
|
237
|
+
// Calculate ratio and apply to full text
|
|
238
|
+
const sampleRatio = sampleText.length / text.length;
|
|
239
|
+
const estimatedDuration = actualDuration / sampleRatio;
|
|
240
|
+
|
|
241
|
+
return Math.round(estimatedDuration);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// Fallback to text-based estimation
|
|
244
|
+
return this.estimateDuration(text);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Estimates TTS duration based on text length and language.
|
|
250
|
+
*
|
|
251
|
+
* @param text - The text to estimate duration for
|
|
252
|
+
* @returns Estimated duration in seconds
|
|
253
|
+
*/
|
|
254
|
+
private estimateDuration(text: string): number {
|
|
255
|
+
// Base estimation: ~12 characters per second for normal speech
|
|
256
|
+
// Adjust based on text complexity and language
|
|
257
|
+
const baseRate = 12; // characters per second
|
|
258
|
+
const wordCount = text.split(/\s+/).length;
|
|
259
|
+
const charCount = text.length;
|
|
260
|
+
|
|
261
|
+
// Use word count for better estimation
|
|
262
|
+
const estimatedSeconds = Math.max(1, Math.min(300, Math.ceil(wordCount / 2.5)));
|
|
263
|
+
|
|
264
|
+
return estimatedSeconds;
|
|
265
|
+
}
|
|
266
|
+
|
|
192
267
|
/**
|
|
193
268
|
* Generates an audio stream for a TTS track.
|
|
194
269
|
*
|
|
@@ -243,10 +318,7 @@ export class TTSPlugin extends BasePlugin {
|
|
|
243
318
|
const urlStr = o.url.toString();
|
|
244
319
|
try {
|
|
245
320
|
type =
|
|
246
|
-
normType(o.type) ||
|
|
247
|
-
(urlStr.endsWith(".webm") ? "webm/opus"
|
|
248
|
-
: urlStr.endsWith(".ogg") ? "ogg/opus"
|
|
249
|
-
: undefined);
|
|
321
|
+
normType(o.type) || (urlStr.endsWith(".webm") ? "webm/opus" : urlStr.endsWith(".ogg") ? "ogg/opus" : undefined);
|
|
250
322
|
const res = await axios.get(urlStr, { responseType: "stream" });
|
|
251
323
|
stream = res.data as unknown as Readable;
|
|
252
324
|
metadata = o.metadata;
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* const youtubePlugin = new YouTubePlugin();
|
|
34
34
|
* const result = await youtubePlugin.search("Never Gonna Give You Up", "user123");
|
|
35
35
|
*/
|
|
36
|
-
export
|
|
36
|
+
export * from "./YouTubePlugin";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* SoundCloud plugin for handling SoundCloud tracks, playlists, and search.
|
|
@@ -49,7 +49,7 @@ export { YouTubePlugin } from "./YouTubePlugin";
|
|
|
49
49
|
* const soundcloudPlugin = new SoundCloudPlugin();
|
|
50
50
|
* const result = await soundcloudPlugin.search("chill music", "user123");
|
|
51
51
|
*/
|
|
52
|
-
export
|
|
52
|
+
export * from "./SoundCloudPlugin";
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Spotify plugin for metadata extraction and display purposes.
|
|
@@ -67,7 +67,7 @@ export { SoundCloudPlugin } from "./SoundCloudPlugin";
|
|
|
67
67
|
* const spotifyPlugin = new SpotifyPlugin();
|
|
68
68
|
* const result = await spotifyPlugin.search("spotify:track:4iV5W9uYEdYUVa79Axb7Rh", "user123");
|
|
69
69
|
*/
|
|
70
|
-
export
|
|
70
|
+
export * from "./SpotifyPlugin";
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Text-to-Speech (TTS) plugin for converting text to audio.
|
|
@@ -83,7 +83,7 @@ export { SpotifyPlugin } from "./SpotifyPlugin";
|
|
|
83
83
|
* const ttsPlugin = new TTSPlugin({ defaultLang: "en" });
|
|
84
84
|
* const result = await ttsPlugin.search("tts:Hello world", "user123");
|
|
85
85
|
*/
|
|
86
|
-
export
|
|
86
|
+
export * from "./TTSPlugin";
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
89
|
* YTSR plugin for advanced YouTube search without streaming.
|
|
@@ -100,4 +100,27 @@ export { TTSPlugin } from "./TTSPlugin";
|
|
|
100
100
|
* const result = await ytsrPlugin.search("Never Gonna Give You Up", "user123");
|
|
101
101
|
* const playlistResult = await ytsrPlugin.searchPlaylist("chill music", "user123");
|
|
102
102
|
*/
|
|
103
|
-
export
|
|
103
|
+
export * from "./YTSRPlugin";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Attachments plugin for handling Discord attachment URLs and audio files.
|
|
107
|
+
*
|
|
108
|
+
* Provides comprehensive support for attachment content including:
|
|
109
|
+
* - Discord attachment URLs (cdn.discordapp.com, media.discordapp.net)
|
|
110
|
+
* - Direct audio file URLs
|
|
111
|
+
* - Local file paths (if accessible)
|
|
112
|
+
* - Various audio formats (mp3, wav, ogg, m4a, flac, etc.)
|
|
113
|
+
* - File size validation and stream extraction
|
|
114
|
+
* - Proper error handling and fallback support
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const attachmentsPlugin = new AttachmentsPlugin({
|
|
118
|
+
* maxFileSize: 25 * 1024 * 1024, // 25MB
|
|
119
|
+
* allowedExtensions: ['mp3', 'wav', 'ogg', 'm4a', 'flac']
|
|
120
|
+
* });
|
|
121
|
+
* const result = await attachmentsPlugin.search(
|
|
122
|
+
* "https://cdn.discordapp.com/attachments/123/456/audio.mp3",
|
|
123
|
+
* "user123"
|
|
124
|
+
* );
|
|
125
|
+
*/
|
|
126
|
+
export * from "./AttachmentsPlugin";
|