A Vibo plugin is two files of plain JavaScript — no build step, no framework. If you can write a fetch call, you can put lyrics, artwork, streaming sources, or whole views inside the app.
A plugin is a folder with a manifest and a script. That's the whole toolchain.
my-plugin/ ├── manifest.json metadata + what the plugin contributes └── index.js the code, plain JavaScript
The manifest declares who you are and what the plugin adds to the app. This one contributes an information section to artist detail pages:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Fun facts about the artist you're playing",
"minAppVersion": "0.9.5",
"contributes": {
"informationTypes": [
{
"id": "artist_facts",
"name": "Facts",
"entity": "artist",
"displayKind": "rich_text",
"ttl": 604800
}
]
}
}
The script receives the api object, registers handlers in activate, and returns its exports:
function activate(api) {
api.informationTypes.onFetch("artist_facts", function (entity) {
var url = "https://api.example.com/facts?artist="
+ encodeURIComponent(entity.name);
return api.network.fetch(url).then(function (resp) {
if (resp.status !== 200) {
return { status: "error", message: "HTTP " + resp.status };
}
return resp.json().then(function (data) {
if (!data.summary) return { status: "not_found" };
return { status: "ok", value: { summary: data.summary } };
});
});
});
}
function deactivate() {}
return { activate: activate, deactivate: deactivate };
Fetch handlers resolve to { status: "ok", value }, { status: "not_found" }, or { status: "error", message? } — the app takes care of caching (per-type ttl, in seconds), retries, and rendering. That's a complete, publishable plugin.
src-tauri/plugins
in the app repo — lyrics providers, artwork sources, Last.fm scrobbling, and more, all in the same two-file format.
Point the app at your working folder — no packaging needed while you develop.
id.index.js or manifest.json, then hit Reload to pick up the changes.Plugin console.log output lands in the webview devtools, and api.log(level, message) writes to the app's persistent log. Installed plugins live in the app data directory under plugins/, but for development the dev folder is all you need.
Everything a plugin contributes is declared under contributes in the manifest, then wired up with a matching api handler in activate.
| Manifest key | What it adds |
|---|---|
| informationTypes | Metadata sections on artist, album, track, and tag detail pages — bios, lyrics, stats, similar artists, reviews. |
| imageProviders | Artist and album artwork sources, tried in user-configurable priority order. |
| streamResolvers | Playback fallbacks — given a track's metadata, return a playable URL when no local source exists. |
| downloadProviders | Download sources for the unified download modal: by URI scheme, by metadata, or interactive search. |
| webSearchProviders | A web search other plugins and AI assistants can run, in the order the user picks in Settings → Providers → Web search. See api.webSearch. |
| contextMenuItems | Right-click actions on tracks, albums, artists, playlists, and multi-track selections — on every surface. |
| sidebarItems | Full custom views in the sidebar, rendered from declarative view data (lists, cards, charts, settings rows…). |
| viewHeader (top level) | The header strip the app draws on your views: a subtitle, your own logo, or a banner image. See Designing your view. |
| homeShelves | Horizontal shelves on the Home page — album cards, artist cards, playlist cards, or track rows. |
| settingsPanel | A tab of your own inside the app's Settings. |
| eventHooks | Reactions to app events: track:started, track:scrobbled, track:added, track:removed, scan:complete. |
Shelves, context-menu items, and now-playing info items can also be registered at runtime (api.home.registerShelf, api.contextMenu.registerItem, api.nowPlayingInfo.registerItem) when the set depends on user state.
The app draws a header at the top of every plugin view: your plugin's icon and name, taken from the manifest. You can add to it, but the look always comes from the user's skin, so your view looks right on all of them.
Set defaults in the manifest. viewHeader applies to all your views, and a sidebar item's own header overrides it for that view.
"viewHeader": {
"subtitle": "YouTube, SoundCloud, Bandcamp and 1,000+ sites",
"logo": "assets/logo-dark.png",
"logoLight": "assets/logo-light.png",
"banner": "assets/banner.jpg"
},
"contributes": {
"sidebarItems": [
{ "id": "search", "label": "Search", "icon": "search" },
{ "id": "deck", "label": "Deck", "icon": "music", "header": { "hidden": true } }
]
}
Change it while your plugin runs with api.ui.setViewHeader. Fields you leave out keep the manifest value, and null puts the manifest back.
if (typeof api.ui.setViewHeader === "function") {
api.ui.setViewHeader("browse", {
subtitle: "Connected as " + user,
status: { variant: "success", label: "Ready" },
actions: [{ label: "Refresh", action: "refresh" }]
});
}
success, warning, error or muted, in the skin's own colours.api.ui.onAction handlers, like buttons inside your view.data:image/… URI, or a file you saved with api.storage.files.logoLight if your logo disappears on light skins."hidden": true only when your view draws a large header of its own.layout row with className: "ds-banner ds-banner--warning" (or --error). Set a sidebar dot with setBadge so it shows outside your view too.showNotification is for the result of something the user just did ("Added 12 tracks"). A popup at every launch because something isn't running is noise, and it's gone before anyone can act on it.The api object passed to activate is the plugin's entire world — every capability below is available to any plugin, and nothing else is. The canonical TypeScript definitions live in src/types/plugin.ts.
Host information and logging.
| appVersion | The running app version (semver string) — for feature-gating beyond the manifest's minAppVersion. |
| log(level, message, section?) | Write to the app's persistent log ("error" / "warn" / "info"). Prefer this over console.log for diagnostics. |
Read the user's library, search it, and react to library events.
| getTrackCount() | Total track count across enabled collections. |
| getTracks(opts?) | Tracks, filtered by artistId / albumId / tagId with limit / offset. |
| ftsTracks(query, opts?) | Full-text search over tracks; ftsArtists / ftsAlbums / ftsTags do the same per entity. |
| getArtists / getAlbums / getTags(opts?) | Paginated entity listings (getAlbums accepts artistId). |
| getTrackById(id) … | Single-entity lookups: getArtistById, getAlbumById, getTagById. |
| getHistory / getMostPlayed / getMostPlayedArtists(opts?) | Play history and most-played charts; opts.days switches to a rolling window. |
| getHistoryPlayCount() / getHistoryPlaysPage(opts?) | Total play count and cheap keyset-paginated raw plays — use these to stream long histories. |
| recordHistoryPlaysBatch(plays) | Batch-import scrobbles; returns { imported, skipped }. |
| applyTags(trackId, tagNames) | Tag a single track (database only). |
| applyTagsBulk(assignments) | Tag many tracks in one call: Array<[trackId, tagNames]>. |
| bulkUpdateTracks(trackIds, fields) | Bulk-edit artist / album / year / tags, written through to file metadata. |
| findDuplicates(opts?) | Duplicate track groups by normalized title + artist, keeper-first. |
| getTrackLikeStates(tracks) | Read persisted like states for { title, artistName? }[]; parallel number[] (1 liked, -1 disliked, 0 neither). |
| setTrackLikesBatch(tracks) | Persist a batch of track likes/dislikes (e.g. imported loved tracks); newer-wins merge, idempotent. Returns rows applied. |
| onTrackAdded / onTrackRemoved / onScanComplete(handler) | Library events. onTrackAdded fires for one track added outside a scan (a download, an upgrade, a saved copy); a scan or sync reports once through onScanComplete, so handle both. |
| onLikeChanged(handler) | Every like, dislike or un-like the user makes on a track, artist, album or tag, after it is saved: { kind, name, artistName, albumTitle, albumArtistName, liked, previous }, ratings -1/0/1. Derive "became liked" (liked === 1 && previous !== 1) and "stopped being liked" (previous === 1 && liked !== 1) from the pair — liked → disliked is an un-like. Not sent for setTrackLikesBatch imports. Needs library:read. The one like event; onTrackLiked was removed. |
Control playback, feed the queue, and resolve streams. Event handlers receive metadata-only tracks (title, artist, album, duration) — no database ids.
| getCurrentTrack() / isPlaying() / getPosition() | Current playback state (synchronous). |
| playTrack(track) | Play a single track. |
| playTracks(tracks, startIndex?, context?) | Play a list; context ({ name, coverUrl?, source? }) gives the queue its banner. |
| insertTrack(track, position) / insertTracks(tracks, position) | Insert into the current queue (-1 = end). |
| setMarkers(trackKey, markers) | Draw ticks on the playing track's seek bar, in both bars and fullscreen: [{ at, label? }] (seconds; the label shows when the pointer is over the tick). trackKey is the current track's key; markers for any other track are ignored, and the app clears them on every track change. At most 200. Needs playback:markers. Feature-detect it; older apps don't have it. |
| playWithBackfill({ head, context?, resolveTail, tailErrorMessage? }) | Play the opening track(s) you already know now, and append the rest when your slow work finishes — no loading modal. head can be metadata-only (the stream chain resolves it); resolveTail() returns the full list and the host strips a leading duplicate. Prefer this over playTracks + insertTracks: the host drops the tail if the user changed the queue while you were resolving, and the queue panel shows its own "filling in" row for the whole wait. Resolves with the number of tracks actually appended (0 = nothing landed) — announce success from that, not from your resolved list. |
| onLevel(handler) | Live output level of the playing track, for a meter or a visualizer: handler({ peakDb, rmsDb } | null), one value per channel in dBFS (silence is -90), about ten times a second while it plays and only when a value moves. It is measured after the equalizer and before ReplayGain and the volume, so neither moves it. null means the reading stopped being true (paused, stopped, next track): drop what you're showing. Each reading is the latest chunk of audio, not every sample, so draw it with some decay. The app measures only while someone is subscribed, so unsubscribe when you stop drawing. Needs playback:read. Feature-detect it; older apps don't have it. |
| onTrackStarted / onTrackScrobbled(handler) | Playback events (likes are api.library.onLikeChanged, which reports every rating change with what it replaced); the scrobble threshold is 50% or 4 minutes. A plugin that subscribes to onTrackScrobbled is listed in Settings → Scrobbling, where the user picks whether it gets audio plays, video plays or both — the host filters, so the plugin needs no on/off or media-type setting of its own. The onTrackScrobbled handler may return (or resolve with) { status: "sent" | "queued" | "skipped" | "failed", message?, pending? }, which is shown under the plugin's row and decides the bar's checkmark; return skipped with a message when signed out. Add "scrobbler": { "defaultEnabled": false } under contributes if the plugin sends plays off the machine, so it starts off until the user turns it on. |
| reportScrobble(report) | Report a later outcome — a queued batch landing or failing — in the same shape. Only heard from a plugin subscribed to onTrackScrobbled. Feature-detect. |
| onStreamResolve(providerId, handler) | Register a stream fallback: (title, artist, album, duration, opts?) → { url, label, video? } | null. opts.preferVideo is an advisory hint (the user enabled “Prefer video”): return a video stream and set video: true to have the host play it in the theater, or ignore it to keep returning audio. The hint never overrides source priority. |
| onResolveStreamByUri(scheme, handler) | Resolve a custom URL scheme (e.g. myservice://id): (id, quality?, opts?) → string | { candidates: StreamCandidate[] } | null. Return a bare URL for one self-contained stream, or a candidate list when the source splits into separate video/audio streams (e.g. YouTube ≥720p). opts.externalAudio is set when the app will render this as video and can merge a separate audio track — return the split candidates then, else a muxed URL. StreamCandidate: { url, kind: "muxed"|"video"|"audio", height?, container?, vcodec?, acodec?, tbr? }; the host picks: a split video + audio pair for video when there is one, else the best muxed stream. |
| onResolveStoryboard(scheme, handler) | Supply seek-preview thumbnails for a custom URL scheme: (id) → Storyboard | null. The host shows one tile in the seek bar's hover bubble. Prefer the source's own published storyboard over extracting frames — YouTube serves sprite sheets (~58 KB for 200 thumbnails), so nothing is decoded and the video is never re-streamed. Cache the sheet bytes via api.storage.files.download, not the URLs: signed media URLs expire within hours while the images are permanent. 10 s host timeout; null simply means no preview. Storyboard: { sheets: string[], cols, rows, count, tileW, tileH, startSecs, intervalSecs } — tiles row-major per sheet, sheets consecutive in time, count = tiles that actually carry a frame. |
Shelves on the Home page. Fetch handlers have a 5-second budget — serve from cached state, not live network calls.
| onFetchShelf(shelfId, handler) | Resolve a shelf's items: (limit) → { status: "ok", items } | { status: "empty" } | { status: "error" }. |
| registerShelf(descriptor) | Add a shelf at runtime ({ id, title, displayKind, limit?, icon? }). |
| unregisterShelf(shelfId) | Remove a runtime-registered shelf. |
| onItemClick(shelfId, handler) | Take over card clicks — e.g. navigate into your own view instead of playing. |
| onResolvePlay(shelfId, handler) | Resolve a playlist card's tracks lazily when the user presses play. Ship the track the list starts with plus partial: true on the item and it plays instantly while your resolver fills in the rest — no loading modal. |
Items for the cycling info line in the mini player (the line under the track title).
| registerItem({ id, label, priority? }) | Add an item; the user toggles it in the mini player's context menu. |
| unregisterItem(id) | Remove a registered item. |
| onFetch(id, handler) | Resolve the item's text for the current track: (track) → { status: "ok", text } | { status: "empty" } | { status: "error" }. |
Render sidebar views and talk to the host UI. Views are declarative — you send data (lists, cards, charts, inputs, settings rows), the app renders native, skin-aware components.
| setViewData(viewId, data, opts?) | Render or update a plugin view; opts.scrollKey gives it per-view scroll memory. |
| showNotification(message, options?) | Show a toast for the result of something the user just did. options.action: { label, id } adds one button that runs your onAction(id) handler. options.kind ("info", "success", "warning" or "error") sets its colour. The app shows your plugin's name above the message, so don't repeat it. An error or a button the user doesn't get to stays in the notifications list behind the bell in the title bar, so keep "error" for real failures. Not for ongoing states; see Designing your view. |
| setViewHeader(viewId, header) | Change a view's header at runtime: { title?, subtitle?, status?, actions?, logo?, logoLight?, banner?, hidden? }, or null for the manifest defaults. Feature-detect it; older apps don't have it. |
| navigateToView(viewId) | Navigate to one of your views. |
| navigateToEntity(kind, { name, artistName?, albumTitle? }, { tab? }) | Open Vibo's own page for a "track", "album" or "artist", by name, optionally on a given tab (an information type id, yours or another plugin's). Feature-detect it. |
| requestAction(action, payload) | Request a host-level action (play with context, navigate to an entity, downloads…). |
| onAction(actionId, handler) | Handle button / toggle / input events fired from your views. |
| setBadge(viewId, badge) | Dot or count badge on your sidebar item (null to clear). |
Persistent state, scoped to your plugin. Key-value pairs live in the app database; files live in the plugin's own data directory.
| get(key) / set(key, value) / delete(key) | JSON key-value store. |
| cacheFile(subdir, filename, url, init?) | Download a URL into the plugin cache; returns the local path. init.headers as on fetch. |
| getCachePath / listCacheDirs / deleteCacheDir | Inspect and prune the flat file cache. |
| files.writeJson / readJson / writeText / readText | Nested file I/O; paths are string arrays of segments. |
| files.download(path, url, init?) | Fetch a URL through the host and write it to disk. init.headers as on fetch. |
| files.getPath / exists / list / remove / copy / move | File management inside the plugin's directory. |
All network access goes through the host — no CORS restrictions, no direct fetch.
| fetch(url, init?) | HTTP request proxied through Rust; returns { status, text(), json() }. The host's default User-Agent is browser-style; a User-Agent in init.headers replaces it, and a plugin calling an API should send its own (the first-party plugins send "Vibo"). |
| openUrl(url) | Open a URL in the system browser. |
| onDeepLink(handler) | Receive deep links (e.g. OAuth callbacks) delivered to the app. |
| openBrowseWindow(url, opts?) | Embedded browser window with eval / messaging — for services without an API. |
| getLocalCollections() | The user's local-folder collections as [{ id, name, path }] — e.g. as download destinations. |
| requestAdd(source) | Offer a music source: opens the app's own Add Server dialog ({ kind: "subsonic", url, name?, username?, password? }) or Add Music Source confirmation ({ kind: "manifest", url, name? }), filled in. The user confirms there, so it needs no permission. Resolves once the dialog is shown. |
| get(title, artistName?) / list() | The user's Now Playing cue sheets — one per song, keyed by title + artist (accent- and case-insensitive), so a sheet plays over any copy of the song. Needs cues:read. |
| set(title, artistName, sheet, meta?) | Store a sheet through the app's own validator — what it resolves with is what will play — and refresh an open Now Playing view. meta: { author?, albumName?, durationSecs? }. Needs cues:write. |
| delete(title, artistName?) | Remove a sheet; resolves whether one existed. Needs cues:write. |
| save(data) / list() / delete(id) / getTracks(id) | Saved playlists — source-aware and image-aware. |
Provide values for the information sections you declare in the manifest, and read the shared cache. Values are keyed by entity name, so metadata is shared across libraries. Display kinds include rich_text, lyrics, entity_list, stat_grid, tag_list, ranked_list, image_gallery, and more. plugin_view is an interactive tab: its value is a view tree (the same nodes setViewData takes), and its buttons and inputs reach your api.ui.onAction handlers with the page's entity added to the payload.
| onFetch(infoTypeId, handler) | Provide a value: (entity) → { status: "ok", value } | { status: "not_found" } | { status: "error" }. |
| searchValues(query, opts?) | Substring-search across all cached values (any type — lyrics, bios, reviews…), with optional type / field / entity filters. |
| getValuesForEntity(entity) | Every cached value for one entity. |
| getValue(typeId, entity) | One cached value, or null. |
| setSectionData(typeId, entity, data) | Replace what one of your types shows for an entity, at once and without a refetch — e.g. redraw an interactive tab after a like. data is what onFetch would return as value. Pass entity exactly as onFetch or the action payload gave it to you: the value is stored under that entity's exact name and artist, so a respelled or rebuilt one lands where no page looks. Feature-detect it. |
| onFetch(entity, handler) | Provide artwork for "artist" or "album": (name, artistName?) → { status: "ok", url } (or base64 data); the host downloads, caches, and falls through the provider chain on miss. |
Plug into the unified downloader — queueing, progress, tagging, and cover embedding are handled by the host.
| enqueue(request) | Queue a download ({ title, artistName?, uri?, format?, provider?, … }); returns its id. |
| onResolveByUri(providerId, handler) | Resolve a download for a URI scheme you own. |
| onResolveByMetadata(providerId, handler) | Resolve by (title, artist, album, duration, format) — automatic fallback downloads. |
| onInteractiveSearch(providerId, handler) | Power the manual search inside the download modal. |
| onInteractiveResolve(providerId, handler) | Resolve the search result the user picked. |
| onGetQualities(providerId, handler) | Supply the quality / format options ({ value, label, video? }[]) the modal offers for your provider. Mark a video option with video: true and the modal defaults to it when the track being downloaded is a video. |
Your resolve handlers return { url, headers?, metadata?, ext? }. Always return a concrete ext when you know the container ("mp3", "flac", "m4a", …) — naming the file is the provider's job. ext: "auto" asks the host to detect the format from the downloaded bytes, but that only happens on the background queue (batch) path; the single-track download window resolves the extension before the bytes arrive and never sniffs, so there "auto" falls back to a default (and to a neutral .bin if you also declared no onGetQualities). Don't rely on "auto" for single-track downloads.
Web search as a shared service: provide one, or run the user's. Results are { status: "ok", results: [{ title, url, snippet?, site? }] }, { status: "empty" } or { status: "error", message? }. The user orders and switches providers in Settings → Providers → Web search; a switched-off one is never asked. Running a search needs the plugins:call permission. Guard for older apps: typeof api.webSearch?.query === "function".
| onQuery(providerId, handler) | Answer searches for a provider you declared in contributes.webSearchProviders. handler(query, limit) returns a result; a refusal from the engine (a captcha, a rate limit) is error with a message, never empty. |
| registerProvider({ id, name }) / unregisterProvider(id) | Offer a provider at runtime, for one that can only sometimes search. |
| listProviders() | The switched-on providers, in the user's order: { key, pluginId, providerId, name }[]. |
| query(query, { limit?, provider? }) | Run a search. Without provider the user's providers are tried in order and the first that answers wins; one that errors hands over to the next. Resolves with the result plus provider / providerName. A provider's failure is a result, not a rejection. |
Periodic background tasks, persisted across restarts.
| register(taskId, intervalMs) / unregister(taskId) | Schedule / remove a recurring task. |
| onDue(taskId, handler) | Run when the task comes due. |
| complete(taskId) | Mark the run finished so the next interval starts. |
Subprocesses are allow-listed by the host — currently yt-dlp and ffmpeg. Declare what you use via binaryDependencies in the manifest; plugins cannot add new binaries.
| exec(program, args?, opts?) | Run an allow-listed binary; returns { exitCode, stdout, stderr }. |
| getDependency(name) | The host's cached status for a binary: { installed, version, origin, latest } — never hits the network yourself. A cold cache may trigger one local version probe, so the first call of a session can take seconds. |
| get(key) | Read an allow-listed build-time environment variable (e.g. an API key baked into a build), or null. |
Plugins run inside the app with a deliberately small surface — everything flows through api.
window, document, or fetch. You get console, timers, and the standard built-ins (JSON, Math, Promise, …); HTTP goes through api.network.fetch, files through api.storage.api.system.exec only runs binaries the host registers (currently yt-dlp, ffmpeg and rqbit).api.system.programs downloads, unpacks and runs a program inside your plugin's own storage (permission programs:run, worker runtime only). Users are always asked for it, even for signed plugins, and an unsigned plugin gets a second, warned confirmation. Every download is confirmed by the user too, and Vibo only runs what such a download brought: run and start refuse a file you wrote yourself, and one that has changed since it was installed. If your plugin also works with a program the user installs themselves, list programs:run under optionalPermissions: it is then asked for only when they choose the install. Pin the checksum of the build you download.permissionReasons manifest field maps a permission to one sentence ({ "network:api.example.com": "Look up artist bios" }). It's shown under that permission everywhere the user is asked — the install dialog, the update prompt and the plugin's Extensions page. Keys must match your permissions exactly; it grants nothing. The older apiUsage field is no longer shown.minAppVersion blocks install and update on older apps. Bumping your plugin's version clears its cached information values, forcing a clean re-fetch."stability": "experimental" badges the plugin and keeps it out of onboarding recommendations; debugOnly: true hides it outside debug mode.Plugins live in their own GitHub repos. The gallery is just an index that points at your releases — you ship updates by publishing a release, and installed copies auto-update within a day.
my-plugin.zip with manifest.json and index.js at the zip root (no wrapper folder), plus any images your manifest names, at the same paths.update.json next to the zip in the same release:{
"version": "1.0.0",
"minAppVersion": "0.9.5",
"file": "https://github.com/you/my-plugin/releases/download/v1.0.0/my-plugin.zip",
"changelog": "First release"
}
…/releases/latest/download/update.json is what the app checks (about every 24 hours) for updates.update.json URL into the gallery submission form. A bot validates your release and opens a PR; a maintainer merges it, and your plugin appears in the gallery and in the app's Extensions view.Two files, an idea, and the gallery does the rest.