Writing Custom Plugins

Kombiner’s plugin system is open. Every import source, enrichment provider, and download target in the app is a plugin — a small Rhai script paired with a plugin.toml manifest. You can write your own plugins for any data source or workflow that matters to you, install them locally, and share them with other Kombiner users.

This guide walks through the plugin format, the host API, and step-by-step examples for each pipeline stage.


Plugin Anatomy

Every plugin is a folder containing exactly two files:

my-plugin/
├── plugin.toml     # Metadata, stage declaration, and settings schema
└── main.rhai       # Plugin logic in the Rhai scripting language

Place the folder in Kombiner’s plugins directory, then go to Settings → Refresh and activate it.


plugin.toml

The manifest declares who you are, what stage you implement, and what settings your plugin requires.

[plugin]
name = "my-enrich"         # Unique identifier (kebab-case) — namespaces DB records
version = "0.1.0"
stage = "enrich"           # One of: import | enrich | acquire
description = "Does something useful"
browser = false            # true if the plugin uses a headless browser

[settings]
# Each entry becomes a field in the plugin's settings form.
# Supported types: string, password, number
api_key = { type = "string", label = "API Key", required = true }
threshold = { type = "number", label = "Match threshold", default = 80 }

The stage field tells Kombiner which variables to inject into main.rhai when the plugin runs.


Pipeline Stages

Scripts in main.rhai run as top-level code — there is no entry point function to define. Kombiner executes the entire file each time the plugin is invoked, with stage-specific variables pre-injected into the script’s scope:

StageInjected variablesScript runs…
import(none)On a schedule; pull and upsert tracks from your source
enrichtrack — map of the current trackOnce per track in the enrich queue
acquiretrack — map of the current trackOnce per track in the acquire cart

Host API Reference

These functions are provided by Kombiner and can be called from any main.rhai script:

FunctionAvailable inDescription
db_upsert_track(track_map)importInsert or update a track by title+artist
db_update_track(id, patch_map)enrichWrite specific fields (bpm, key, genre, label, year) back to a track
track_add_enrichment(id, result_map)enrichStore the enrichment result for this plugin
browser_save(track)acquireNavigate to the track source and trigger a file download
track_report_purchasable(id, url, price, notes)acquireMark track as needing purchase with a URL and price
get_cursor(key)importRetrieve a saved cursor value by key (returns () if not set)
set_cursor(key, value)importSave a cursor value for resumption on the next run
execute_with_permutations(track, fn)anyCall fn(title, artists_variant) with artist name permutations; returns first non-() result
browser_prompt()browser pluginsShow the headless browser to the user for manual interaction
log_info(msg)anyWrite an info message to the run log
log_error(msg)anyWrite an error message to the run log
get_setting(key)anyRead a value from the plugin’s settings (returns () if not set)

Writing an Import Plugin

Import plugins discover tracks from an external source and add them to Kombiner’s library. They use a cursor to track progress so that resumption after interruption is cheap.

// Read cursor — returns () if this is the first run
let cursor = get_cursor("page");
let page = if cursor == () { 0 } else { cursor.parse_int() };

loop {
    // Fetch a page of results from your source
    let results = fetch_page(page);  // your own helper fn

    if results.len() == 0 {
        break;  // No more results
    }

    for item in results {
        // Build a track map with whatever fields you have
        let track = #{
            source_plugin: "my-import",
            source_id:     item.id,
            title:         item.title,
            artist:        item.artist,
            bpm:           item.bpm,
            genre:         item.genre,
        };
        let saved = db_upsert_track(track);
        track_add_link(saved["id"], "my_source", item.url);
    }

    page += 1;
    // Save cursor after each page so a restart picks up here
    set_cursor("page", page.to_string());
}

log_info("Import complete");

Key points:

  • Call set_cursor after each page, not just at the end. If the run is interrupted mid-way, the next run will resume from the last saved page.
  • db_upsert_track deduplicates by normalized title+artist. Pass as many fields as you have. It returns a map — use saved["id"] to get the track’s internal ID.
  • The cursor takes a key name and a string value — store whatever helps you resume: a page number, a timestamp, a pagination token.

Writing an Enrich Plugin

Enrich plugins receive one track at a time (via the injected track variable), look up data from an external source, write fields back to the track, and record a result.

let api_key = get_setting("api_key");
let artist = if track["artist"] != () { track["artist"] } else { "" };
let title  = track["title"];

// execute_with_permutations retries the closure with common artist-name
// variants (&, comma, feat. …). The closure receives (title, artist_variant).
let result = execute_with_permutations(track, |t, artists| {
    let query = artists + " " + t;
    let data = my_api_search(query, api_key);  // your helper fn
    if data == () {
        return ();  // Signal: try the next permutation
    }
    data
});

if result == () {
    track_add_enrichment(track["id"], #{ status: "not_found" });
    return;
}

// Write fields back to the track record
try {
    db_update_track(track["id"], #{
        bpm: result.bpm,
        key: result.key,
        genre: result.genre
    });

    // Store the enrichment result (accessible in the UI)
    track_add_enrichment(track["id"], #{
        status: "done",
        fields_updated: ["bpm", "key", "genre"]
    });
} catch(err) {
    log_error("Failed to update track " + track["id"] + ": " + err);
    track_add_enrichment(track["id"], #{ status: "error" });
}

Key points:

  • Always call track_add_enrichment exactly once per track — with done, not_found, no_data, or error. This is how Kombiner tracks enrichment progress.
  • db_update_track accepts a partial map — include only the fields you have data for.
  • execute_with_permutations is especially valuable for tracks with featured artists. The closure receives (title, artists_variant)title is the track title and artists_variant is a permuted form of the artist string. Return () to try the next permutation.
  • Wrap external calls in try/catch. Browser pages and HTTP requests can fail unexpectedly.

Writing an Acquire Plugin

Acquire plugins receive the current track via the injected track variable and either download the file or mark it as needing purchase.

// Check for a stored link from an enrich plugin
let url = track["links"]["my_source"];

if url == () {
    log_error("No source link for track: " + track["title"]);
    return;
}

try {
    // Option A: Download directly
    browser_save(track);

    // Option B: Report as purchasable (user needs to buy first)
    // track_report_purchasable(track["id"], url, 1.99, "");
} catch(err) {
    log_error("Download failed for " + track["title"] + ": " + err);
}

Key points:

  • browser_save(track) tells Kombiner to navigate the headless browser to the track’s source URL and save the resulting file. The browser must already be on the correct page, or the track map must contain enough information for the plugin to navigate there.
  • track_report_purchasable(id, url, price, notes) adds the track to the Kombiner cart as a “needs purchase” item. price is a float; notes is a string (pass "" if unused).
  • The browser session persists across all tracks in the same run — you log in once and reuse the session.

Rhai Language Quick Reference

Rhai is a dynamically typed scripting language with a Rust-like syntax. Here are the essentials:

// Variables
let x = 42;
let name = "hello";
let flag = true;

// Maps (equivalent to objects)
let track = #{
    title: "Strobe",
    artist: "deadmau5",
    bpm: 128
};
let bpm = track.bpm;           // field access
let bpm2 = track["bpm"];       // bracket access (same result)

// Arrays
let genres = ["Techno", "House", "Trance"];
let first = genres[0];

// Functions
fn greet(name) {
    "Hello, " + name   // last expression is the return value
}

// Conditionals
if x > 10 {
    log_info("big");
} else {
    log_info("small");
}

// Loops
for item in items {
    log_info(item.title);
}

// Error handling
try {
    risky_operation();
} catch(err) {
    log_error("Something went wrong: " + err);
}

// Null / missing value
let val = get_setting("optional_key");
if val == () {
    // () is Rhai's unit/null — always check before using
}

Installing Your Plugin

  1. Create your plugin folder with plugin.toml and main.rhai as described above.
  2. Place the folder inside Kombiner’s plugins directory (shown in Settings → Plugins folder).
  3. Click Settings → Refresh to reload the plugins list.
  4. The plugin appears in the plugins list. Configure any required settings via the gear icon.
  5. To test it, add a few tracks to the relevant queue (library for enrich, cart for acquire) and run the plugin from the pipeline controls.

Common Pitfalls

  • Always check for () before using a value returned by get_setting, get_cursor, or field access on a track map. Accessing a field that does not exist returns (), and passing () to a string operation will cause a runtime error. Prefer bracket notation (track["artist"]) over dot notation for track fields.
  • Wrap browser calls in try/catch. Browser pages change unexpectedly. A robust plugin handles errors gracefully rather than crashing the entire run.
  • Use execute_with_permutations for cross-platform matching. Different platforms style artist names differently (& vs , vs feat.). The closure receives (title, artists_variant) — return () to signal that the current permutation produced no result and the next one should be tried.
  • Call track_add_enrichment exactly once per track in enrich plugins, even on error. If the call is skipped, the track stays in a pending state forever and will be re-queued on every run.
  • Don’t store secrets in main.rhai. Use the [settings] block in plugin.toml with type = "password" — Kombiner encrypts these values at rest. Never hardcode credentials in your script.