Creating a Custom Extractor
Custom extractors allow you to integrate any music source with Discord Player. You can create extractors for:- Private streaming services
- Custom APIs
- Self-hosted media servers
- Niche music platforms
- Audio processing pipelines
BaseExtractor API
All extractors must extend theBaseExtractor class:
import { BaseExtractor } from 'discord-player';
export class MyCustomExtractor extends BaseExtractor {
public static identifier = 'com.mycompany.mycustomextractor';
// Your implementation here
}
Required Properties
identifier
A unique identifier for your extractor:public static identifier = 'com.mycompany.mycustomextractor';
Use reverse domain notation (e.g.,
com.company.extractor) to avoid conflicts with other extractors.Optional Properties
priority
Execution priority (default: 1). Higher values execute first:public priority = 10; // Will be executed before priority 1
protocols
Custom search protocols for your extractor:public protocols = ['mysearch', 'mycustomprotocol'];
// Users can then search with:
// await player.search('mysearch:query');
Core Methods
constructor()
Initialize your extractor with options:interface MyExtractorOptions {
apiKey: string;
baseUrl?: string;
}
export class MyCustomExtractor extends BaseExtractor<MyExtractorOptions> {
constructor(
public context: ExtractorExecutionContext,
public options: MyExtractorOptions
) {
super(context, options);
}
}
activate()
Called when the extractor is registered. Set up resources here:public async activate(): Promise<void> {
// Set custom protocols
this.protocols = ['mysearch', 'myservice'];
// Initialize API clients
this.apiClient = new MyAPIClient(this.options.apiKey);
// Validate credentials
await this.apiClient.authenticate();
this.debug('My custom extractor activated!');
}
deactivate()
Called when the extractor is unregistered. Clean up resources:public async deactivate(): Promise<void> {
// Clean up resources
this.protocols = [];
this.apiClient?.disconnect();
this.debug('My custom extractor deactivated!');
}
validate()
Determine if your extractor can handle a query:public async validate(
query: string,
type?: SearchQueryType | null
): Promise<boolean> {
// Check URL pattern
if (query.includes('myservice.com')) return true;
// Check query type
if (type === 'myCustomType') return true;
// Check protocol
if (query.startsWith('mysearch:')) return true;
return false;
}
handle()
Extract track metadata from a query:import {
BaseExtractor,
ExtractorInfo,
ExtractorSearchContext,
Track,
Playlist,
Util
} from 'discord-player';
public async handle(
query: string,
context: ExtractorSearchContext
): Promise<ExtractorInfo> {
// Fetch data from your API
const data = await this.apiClient.search(query);
if (!data) {
return this.createResponse(null, []);
}
// Create Track objects
const tracks = data.results.map(item => {
const track = new Track(this.context.player, {
title: item.title,
author: item.artist,
url: item.url,
duration: Util.buildTimeCode(Util.parseMS(item.duration)),
thumbnail: item.thumbnail,
description: item.description || `${item.title} by ${item.artist}`,
views: item.playCount || 0,
requestedBy: context.requestedBy,
source: 'myservice',
queryType: 'myCustomType',
metadata: item,
requestMetadata: async () => item
});
track.extractor = this;
return track;
});
return this.createResponse(null, tracks);
}
stream()
Return a streamable source for a track:import { ExtractorStreamable } from 'discord-player';
import { Readable } from 'stream';
public async stream(info: Track): Promise<ExtractorStreamable> {
// Option 1: Return a URL string
const streamUrl = await this.apiClient.getStreamUrl(info.url);
return streamUrl;
// Option 2: Return a Readable stream
const stream = await this.apiClient.getStream(info.url);
return stream;
// Option 3: Return stream with format hint (for raw formats)
return {
stream: rawOpusStream,
$fmt: 'opus' // Format hint for Discord Player
};
}
Discord Player accepts three return types:
string- Direct URL to audioReadable- Node.js stream{ stream: Readable, $fmt: string }- Stream with format hint for raw audio
Optional Methods
getRelatedTracks()
Provide related tracks for autoplay:import { GuildQueueHistory } from 'discord-player';
public async getRelatedTracks(
track: Track,
history: GuildQueueHistory
): Promise<ExtractorInfo> {
// Fetch related tracks from your API
const related = await this.apiClient.getRelated(track.url);
// Filter out tracks already in history
const unique = related.filter(
item => !history.tracks.some(h => h.url === item.url)
);
// Convert to Track objects
const tracks = unique.map(item => this.createTrack(item));
return this.createResponse(null, tracks);
}
bridge()
Provide streaming for tracks from other extractors:public async bridge(
track: Track,
sourceExtractor: BaseExtractor | null
): Promise<ExtractorStreamable | null> {
// Don't bridge our own tracks
if (sourceExtractor?.identifier === this.identifier) {
return this.stream(track);
}
// Build search query from track metadata
const query = sourceExtractor?.createBridgeQuery(track) ??
`${track.author} - ${track.title}`;
// Search on our platform
const result = await this.handle(query, {
requestedBy: track.requestedBy,
type: 'myCustomSearch'
});
if (!result.tracks.length) return null;
// Stream the first result
const stream = await this.stream(result.tracks[0]);
// Update track metadata
track.bridgedTrack = result.tracks[0];
track.bridgedExtractor = this;
return stream;
}
createBridgeQuery()
Customize how bridge queries are generated:public createBridgeQuery = (track: Track) => {
// Default implementation
return `${track.title} by ${track.author} official audio`;
// Custom implementation
// return `${track.author} ${track.title} ${track.album || ''}`;
};
handlePostStream()
Middleware to modify streams before playback:import { Readable } from 'stream';
import { NextFunction } from 'discord-player';
public handlePostStream(stream: Readable, next: NextFunction) {
// Add transformations to the stream
const transformed = stream.pipe(myTransformStream());
// Pass to next middleware
return next(null, transformed);
// Or report an error
// return next(new Error('Stream failed'));
}
reconfigure()
Update extractor options at runtime:// Called internally, usually not overridden
public async reconfigure(options: MyExtractorOptions) {
this.options = options;
await this.deactivate();
await this.activate();
}
// Usage
await myExtractor.reconfigure({
apiKey: 'new-api-key',
baseUrl: 'https://new-api.example.com'
});
Helper Methods
createResponse()
Helper to createExtractorInfo responses:
// Return tracks without playlist
return this.createResponse(null, tracks);
// Return tracks with playlist
const playlist = new Playlist(this.context.player, { /* ... */ });
return this.createResponse(playlist, tracks);
// Return empty response
return this.createResponse();
debug()
Log debug messages (only shown if player has debugger enabled):this.debug('Fetching track metadata...');
this.debug(`Found ${tracks.length} tracks`);
emit()
Dispatch events to the player:this.emit('debug', 'Custom debug message');
this.emit('playerError', this.context.player, error);
Complete Example
Here’s a complete custom extractor implementation:import {
BaseExtractor,
ExtractorInfo,
ExtractorSearchContext,
ExtractorStreamable,
Track,
Playlist,
Util,
QueryType,
SearchQueryType,
GuildQueueHistory
} from 'discord-player';
import { Readable } from 'stream';
interface MyServiceOptions {
apiKey: string;
baseUrl?: string;
}
export class MyServiceExtractor extends BaseExtractor<MyServiceOptions> {
public static identifier = 'com.example.myserviceextractor';
public priority = 5;
private apiClient: MyAPIClient | null = null;
public async activate(): Promise<void> {
this.protocols = ['mysearch'];
this.apiClient = new MyAPIClient({
apiKey: this.options.apiKey,
baseUrl: this.options.baseUrl || 'https://api.myservice.com'
});
await this.apiClient.authenticate();
this.debug('MyService extractor activated!');
}
public async deactivate(): Promise<void> {
this.protocols = [];
this.apiClient?.disconnect();
this.apiClient = null;
this.debug('MyService extractor deactivated!');
}
public async validate(
query: string,
type?: SearchQueryType | null
): Promise<boolean> {
// Support our domain
if (query.includes('myservice.com')) return true;
// Support custom protocol
if (query.startsWith('mysearch:')) return true;
// Support custom query types
const supportedTypes = [
'myServiceTrack',
'myServicePlaylist',
'myServiceSearch',
QueryType.AUTO,
QueryType.AUTO_SEARCH
] as SearchQueryType[];
return supportedTypes.includes(type!);
}
public async handle(
query: string,
context: ExtractorSearchContext
): Promise<ExtractorInfo> {
// Handle custom protocol
if (context.protocol === 'mysearch') {
query = query.replace('mysearch:', '');
}
// Determine query type
const isTrack = query.includes('/track/');
const isPlaylist = query.includes('/playlist/');
if (isTrack) {
return this.handleTrack(query, context);
} else if (isPlaylist) {
return this.handlePlaylist(query, context);
} else {
return this.handleSearch(query, context);
}
}
private async handleTrack(
query: string,
context: ExtractorSearchContext
): Promise<ExtractorInfo> {
const data = await this.apiClient!.getTrack(query);
if (!data) return this.createResponse();
const track = new Track(this.context.player, {
title: data.title,
author: data.artist,
url: data.url,
duration: Util.buildTimeCode(Util.parseMS(data.duration)),
thumbnail: data.thumbnail,
description: `${data.title} by ${data.artist}`,
views: data.playCount,
requestedBy: context.requestedBy,
source: 'myservice',
queryType: 'myServiceTrack',
metadata: data,
requestMetadata: async () => data
});
track.extractor = this;
return this.createResponse(null, [track]);
}
private async handlePlaylist(
query: string,
context: ExtractorSearchContext
): Promise<ExtractorInfo> {
const data = await this.apiClient!.getPlaylist(query);
if (!data) return this.createResponse();
const playlist = new Playlist(this.context.player, {
title: data.title,
description: data.description,
thumbnail: data.thumbnail,
type: 'playlist',
source: 'myservice',
author: {
name: data.creator,
url: data.creatorUrl
},
tracks: [],
id: data.id,
url: data.url,
rawPlaylist: data
});
playlist.tracks = data.tracks.map(item => {
const track = new Track(this.context.player, {
title: item.title,
author: item.artist,
url: item.url,
duration: Util.buildTimeCode(Util.parseMS(item.duration)),
thumbnail: item.thumbnail,
description: `${item.title} by ${item.artist}`,
views: item.playCount,
requestedBy: context.requestedBy,
source: 'myservice',
queryType: 'myServiceTrack',
metadata: item,
requestMetadata: async () => item,
playlist
});
track.extractor = this;
return track;
});
return this.createResponse(playlist, playlist.tracks);
}
private async handleSearch(
query: string,
context: ExtractorSearchContext
): Promise<ExtractorInfo> {
const data = await this.apiClient!.search(query);
if (!data || !data.results.length) return this.createResponse();
const tracks = data.results.map(item => {
const track = new Track(this.context.player, {
title: item.title,
author: item.artist,
url: item.url,
duration: Util.buildTimeCode(Util.parseMS(item.duration)),
thumbnail: item.thumbnail,
description: `${item.title} by ${item.artist}`,
views: item.playCount,
requestedBy: context.requestedBy,
source: 'myservice',
queryType: 'myServiceTrack',
metadata: item,
requestMetadata: async () => item
});
track.extractor = this;
return track;
});
return this.createResponse(null, tracks);
}
public async stream(info: Track): Promise<ExtractorStreamable> {
const streamUrl = await this.apiClient!.getStreamUrl(info.url);
if (!streamUrl) {
throw new Error('Could not extract stream from this track');
}
return streamUrl;
}
public async getRelatedTracks(
track: Track,
history: GuildQueueHistory
): Promise<ExtractorInfo> {
const related = await this.apiClient!.getRelated(track.url);
const unique = related.filter(
item => !history.tracks.some(h => h.url === item.url)
);
const tracks = unique.map(item => {
const track = new Track(this.context.player, {
title: item.title,
author: item.artist,
url: item.url,
duration: Util.buildTimeCode(Util.parseMS(item.duration)),
thumbnail: item.thumbnail,
description: `${item.title} by ${item.artist}`,
views: item.playCount,
requestedBy: track.requestedBy,
source: 'myservice',
queryType: 'myServiceTrack',
metadata: item,
requestMetadata: async () => item
});
track.extractor = this;
return track;
});
return this.createResponse(null, tracks);
}
public async bridge(
track: Track,
sourceExtractor: BaseExtractor | null
): Promise<ExtractorStreamable | null> {
if (sourceExtractor?.identifier === this.identifier) {
return this.stream(track);
}
const query = sourceExtractor?.createBridgeQuery(track) ??
`${track.author} - ${track.title}`;
const result = await this.handle(query, {
requestedBy: track.requestedBy,
type: 'myServiceSearch'
});
if (!result.tracks.length) return null;
const stream = await this.stream(result.tracks[0]);
track.bridgedTrack = result.tracks[0];
track.bridgedExtractor = this;
return stream;
}
}
// Mock API client for example purposes
class MyAPIClient {
constructor(private config: { apiKey: string; baseUrl: string }) {}
async authenticate() {
// Authenticate with API
}
async getTrack(url: string) {
// Fetch track data
return null as any;
}
async getPlaylist(url: string) {
// Fetch playlist data
return null as any;
}
async search(query: string) {
// Search for tracks
return null as any;
}
async getStreamUrl(url: string) {
// Get stream URL
return '';
}
async getRelated(url: string) {
// Get related tracks
return [] as any[];
}
disconnect() {
// Clean up connection
}
}
Registering Your Extractor
import { MyServiceExtractor } from './extractors/MyServiceExtractor';
const extractor = await player.extractors.register(
MyServiceExtractor,
{
apiKey: process.env.MY_SERVICE_API_KEY!,
baseUrl: 'https://api.myservice.com'
}
);
if (extractor) {
console.log('MyService extractor registered successfully!');
} else {
console.error('Failed to register MyService extractor');
}
Testing Your Extractor
// Test validation
const canHandle = await extractor.validate(
'https://myservice.com/track/123',
'myServiceTrack'
);
console.log('Can handle:', canHandle);
// Test search
const searchResult = await player.search('mysearch:test query', {
requestedBy: interaction.user
});
console.log('Found tracks:', searchResult.tracks.length);
// Test streaming
if (searchResult.tracks[0]) {
const stream = await searchResult.tracks[0].extractor?.stream(
searchResult.tracks[0]
);
console.log('Stream:', stream);
}
Best Practices
Handle errors gracefully
Always catch API errors and return empty responses instead of throwing:
public async handle(query: string, context: ExtractorSearchContext) {
try {
const data = await this.apiClient.search(query);
// ... process data
} catch (error) {
this.debug(`Error in handle: ${error}`);
return this.createResponse();
}
}
Use debug messages
Help users troubleshoot issues with debug logging:
this.debug('Searching for: ' + query);
this.debug(`Found ${tracks.length} tracks`);
this.debug('Failed to fetch stream URL');
Implement activate/deactivate
Properly initialize and clean up resources:
public async activate() {
this.apiClient = new APIClient(this.options.apiKey);
await this.apiClient.connect();
}
public async deactivate() {
this.apiClient?.disconnect();
this.apiClient = null;
}
Set appropriate priority
Use higher priority for more reliable sources:
// High priority for primary sources
public priority = 10;
// Default priority for standard sources
public priority = 1;
// Low priority for fallback sources
public priority = 0;
Validate credentials early
Check API credentials in
activate() to fail fast:public async activate() {
try {
await this.apiClient.authenticate();
} catch (error) {
throw new Error('Invalid API credentials');
}
}
TypeScript Types
import type {
BaseExtractor,
ExtractorInfo,
ExtractorSearchContext,
ExtractorStreamable,
ExtractorExecutionContext,
SearchQueryType,
GuildQueueHistory,
Track,
Playlist,
NextFunction
} from 'discord-player';
import type { Readable } from 'stream';
import type { RequestOptions } from 'http';
Next Steps
Extractors Overview
Learn about extractor architecture and concepts
Default Extractors
Explore built-in extractors for reference