TuneCamp Plugin System β
TuneCamp uses a modular, provider-based architecture inspired by projects like Nuclear. This allows developers to extend the platform's functionality without modifying the core codebase.
Overview β
There are 8 provider types you can implement. All are auto-detected at load time by duck-typing the methods your class exposes:
- MetadataProvider β new sources for track/album info (e.g. MusicBrainz, Discogs). Detected by:
searchRelease. - StreamingProvider β external audio fallbacks (e.g. YouTube, Bandcamp). Detected by:
getStreamUrl. - DownloadProvider β new ways to acquire music (e.g. Soulseek, BitTorrent). Detected by:
search+download+isAvailable. - ScannerProvider β new library sources (e.g. IPFS, S3). Detected by:
scan. - StorageProvider β upload backends (e.g. Google Drive, Dropbox). Detected by:
upload+getUrl. - PlaylistProvider β external playlist import (e.g. Deezer, YouTube Music). Detected by:
canHandlePlaylist+fetchPlaylistByUrl. - ScrobbleProvider β listening history export (e.g. Last.fm, ListenBrainz). Detected by:
scrobble+isConfigured. - AIProvider β LLM backends for metadata enrichment (e.g. Ollama, OpenAI). Detected by:
enrichMetadata+complete.
Note: ActivityPub federation and P2P Peer Sharing are handled internally by the platform's core modules and are not exposed as external plugin types.
Creating a Plugin β
To create a plugin, simply create a .js (ESM) file in the plugins/ directory of your TuneCamp installation.
Example: Custom Metadata Provider β
// plugins/my-custom-metadata.js
export default class MyCustomMetadataProvider {
constructor() {
this.id = 'my-custom-source';
this.name = 'My Custom Source';
this.version = '1.0.0';
this.description = 'Fetches metadata from my private API';
}
async isAvailable() {
return true;
}
// `searchRelease` is the method the loader duck-types on to register a
// MetadataProvider β it MUST exist or the plugin is ignored.
async searchRelease(query) {
// Return an array of MetadataResult objects (album/release level)
return [
{ id: 'rel-1', title: 'Album Title', artist: 'Artist Name', date: '2024-01-01', source: this.id }
];
}
async searchRecording(query) {
// Return an array of MetadataResult objects (track level)
return [
{ id: 'abc-123', title: 'Song Title', artist: 'Artist Name', date: '2024-01-01', source: this.id }
];
}
async getCoverUrl(id) {
return 'https://example.com/cover.jpg';
}
}Example: YouTube Streaming Provider (using external library) β
If your plugin needs external dependencies, you can install them in the TuneCamp root or bundle your plugin.
import play from 'play-dl';
export default class MyYouTubeProvider {
constructor() {
this.id = 'custom-youtube';
this.name = 'Custom YouTube';
this.version = '1.0.0';
}
async getStreamUrl(title, artist) {
const results = await play.search(`${artist} - ${title}`, { limit: 1 });
if (results.length > 0) {
const info = await play.video_info(results[0].url);
return info.format.find(f => f.mimeType.includes('audio')).url;
}
return null;
}
}Provider Contracts β
The loader duck-types on the methods listed under Detected by above β a class is registered against every registry whose required methods it implements (so one plugin can be, say, both a DownloadProvider and a StorageProvider). Below are the full method contracts for each type. Plugins are plain JS; the signatures are shown in TypeScript for clarity, taken from src/server/core/provider.ts.
Every provider also carries the base fields: id, name, version, description?, and optional onEnable() / onDisable() (see Lifecycle Hooks below).
3. DownloadProvider β
Detected by: isAvailable + search + download.
interface DownloadResult {
id: string;
title: string;
artist?: string;
filename: string;
sizeBytes: number;
bitrate?: number;
source: string;
meta?: any; // provider-specific data, passed back to download()
}
isAvailable(): Promise<boolean>; // is the source connected/reachable?
search(query: string): Promise<DownloadResult[]>; // e.g. "Artist - Title"
download(result: DownloadResult): Promise<string>; // returns the LOCAL file path// plugins/my-download.js
export default class MyDownloadProvider {
id = 'my-dl'; name = 'My Downloader'; version = '1.0.0';
async isAvailable() { return true; }
async search(query) {
return [{ id: 'x1', title: query, filename: 'x1.flac', sizeBytes: 0, source: this.id, meta: {} }];
}
async download(result) {
const dest = `/tmp/${result.filename}`;
// ...fetch bytes to `dest`...
return dest;
}
}4. ScannerProvider β
Detected by: scan.
scan(): Promise<string[]>; // list of paths/identifiers in the source
getMetadata(path: string): Promise<any>; // tags for one entry
getFileStream(path: string): Promise<NodeJS.ReadableStream>; // readable for ingest// plugins/s3-scanner.js
export default class S3Scanner {
id = 's3'; name = 'S3 Scanner'; version = '1.0.0';
async scan() { return ['bucket/track1.flac', 'bucket/track2.flac']; }
async getMetadata(p) { return { title: p.split('/').pop() }; }
async getFileStream(p) { /* return a Readable */ }
}5. StorageProvider β
Detected by: upload + getUrl.
upload(localPath: string, remotePath: string): Promise<string>; // returns remote ref/key
download(remotePath: string, localPath: string): Promise<void>;
getUrl(remotePath: string): Promise<string | null>; // public/signed URL
exists(remotePath: string): Promise<boolean>;
delete(remotePath: string): Promise<void>;// plugins/dropbox-storage.js
export default class DropboxStorage {
id = 'dropbox'; name = 'Dropbox'; version = '1.0.0';
async upload(localPath, remotePath) { /* upload */ return remotePath; }
async download(remotePath, localPath) { /* download */ }
async getUrl(remotePath) { return `https://dropbox.example/${remotePath}`; }
async exists(remotePath) { return true; }
async delete(remotePath) { /* delete */ }
}6. PlaylistProvider β
Detected by: canHandlePlaylist + fetchPlaylistByUrl.
interface PlaylistTrack { title: string; artist: string; album?: string; duration?: number; sourceId: string; provider: string; }
interface ExternalPlaylist { id: string; title: string; description?: string; thumbnail?: string; tracks: PlaylistTrack[]; }
canHandlePlaylist(url: string): boolean; // can this provider parse the URL?
fetchPlaylistByUrl(url: string): Promise<ExternalPlaylist>;// plugins/deezer-playlist.js
export default class DeezerPlaylist {
id = 'deezer-pl'; name = 'Deezer Playlists'; version = '1.0.0';
canHandlePlaylist(url) { return url.includes('deezer.com/playlist'); }
async fetchPlaylistByUrl(url) {
return { id: 'pl1', title: 'My Playlist', tracks: [
{ title: 'Song', artist: 'Artist', sourceId: 's1', provider: this.id }
]};
}
}7. ScrobbleProvider β
Detected by: scrobble + isConfigured.
scrobble(track: { artist: string; title: string; album?: string; duration?: number }): Promise<void>;
nowPlaying?(track: { artist: string; title: string; album?: string }): Promise<void>;
isConfigured(): Promise<boolean>; // credentials present and ready?// plugins/maloja-scrobble.js
export default class MalojaScrobble {
id = 'maloja'; name = 'Maloja'; version = '1.0.0';
async isConfigured() { return !!process.env.MALOJA_KEY; }
async scrobble(track) { /* POST to Maloja */ }
async nowPlaying(track) { /* optional */ }
}8. AIProvider β
Detected by: enrichMetadata + complete.
enrichMetadata(context: { title: string; artist?: string; album?: string; genre?: string }):
Promise<Partial<{ description: string; genre: string; tags: string[]; mood: string }>>;
complete(prompt: string): Promise<string | null>;
isAvailable(): Promise<boolean>;// plugins/ollama-ai.js
export default class OllamaAI {
id = 'ollama'; name = 'Ollama (local)'; version = '1.0.0';
async isAvailable() { return true; }
async complete(prompt) { /* call local Ollama */ return 'response'; }
async enrichMetadata(ctx) {
return { description: `A track by ${ctx.artist}`, tags: ['demo'] };
}
}How it Works β
- Detection: At startup, TuneCamp scans the
plugins/folder. - Registration: It dynamically imports each file and checks which methods are implemented.
- Injection: The plugin is automatically registered into the appropriate singleton service (e.g.,
MetadataService). - Execution: When a user performs an action (like searching or streaming), TuneCamp iterates through all registered providers in parallel or in order of registration.
Advanced: Accessing Internal Services β
While plugins are designed to be decoupled, you can occasionally access internal services via the singleton exports if needed (though not recommended for maximum portability).
import { database } from '../dist/server/core/database.js'; // Use with cautionLifecycle Hooks β
A provider may optionally implement onEnable() and onDisable():
async onEnable() { /* open connections, warm caches, etc. */ }
async onDisable() { /* clean up */ }These run when the plugin is enabled/disabled β at load time (honoring the last persisted state) and whenever an admin flips the toggle in the Admin Panel. A plugin disabled by an admin stays disabled across restarts.
Admin Panel β
You can see all loaded providers, their versions and enabled status β and toggle them on/off β in the Admin Panel β Integrations section of the TuneCamp web interface (root admin only).
Docker / Production deployment β
How plugins are included in the image β
The plugins/ directory is copied into the production Docker image at /app/plugins. The env variable TUNECAMP_PLUGINS_DIR=/app/plugins is set automatically.
Built-in plugins (e.g. demo-provider.js) are always present. Custom plugins survive image rebuilds via a named Docker volume.
Adding a custom plugin with docker-compose β
The default docker-compose.yml mounts a named volume at /app/plugins:
volumes:
- tunecamp_plugins:/app/pluginsOn first run, Docker copies the image's plugins/ content into the volume. After that, add your plugin:
# Copy your plugin file into the running container
docker cp my-plugin.js tunecamp:/app/plugins/
# Restart to load it
docker compose restart tunecampUsing a local folder instead β
To manage plugins as files on the host (easier for development):
# docker-compose.yml
volumes:
- /path/to/your/music:/music
- tunecamp_data:/data
- ./plugins:/app/plugins # β bind mount to host folderPlace your .js files in ./plugins/ and restart the container.
Verifying plugins are loaded β
docker compose logs tunecamp | grep -i pluginYou should see lines like:
[PluginLoader] Loaded plugin: My Custom Source v1.0.0